🎴
issue/PRのURLをmarkdown形式で綺麗にコピーするブックマークレット作った
概要
GitHubのIssue、Pull Request、DiscussionのタイトルとリンクをMarkdown形式で一発コピーできるブックマークレットです。
忙しい人のためのまとめ
これ↓をブックマークレットにしてクリックしたらissueTitle[#nnn](URL)
でコピーされます。
javascript:(()=>{const SELECTORS={TITLE:['[data-test-selector="issue-title"]','[data-test-selector="pull-request-title"]','[data-test-selector="discussion-title"]','.js-issue-title','h1 .js-issue-title'],NUMBER:'.gh-header-number',LINKS:['a[data-hovercard-type="issue"]','a[data-hovercard-type="pull_request"]','a[data-hovercard-type="discussion"]','a[href*="/issues/"]','a[href*="/pull/"]','a[href*="/discussions/"]'].join(','),CONTAINERS:['[role="dialog"]','.details-dialog','.Overlay','[data-overlay-container="true"]','[data-testid*="pane"]','.project-pane'].join(',')};const GITHUB_ISSUE_REGEX=/^(?:https?:\/\/github\.com)?\/[^\/]+\/[^\/]+\/(issues|pull|discussions)\/(\d+)(?:$|[/?#])/;const utils={getTextContent:(selectors,root=document)=>{if(typeof selectors==='string')selectors=[selectors];for(const selector of selectors){const element=root.querySelector(selector);if(element)return element.textContent.trim()}return ""},absoluteUrl:(url)=>new URL(url,location.origin).href,copyToClipboard:async(text)=>{if(navigator.clipboard&&window.isSecureContext){return navigator.clipboard.writeText(text)}else{const textarea=document.createElement("textarea");textarea.value=text;textarea.style.position="fixed";textarea.style.top="-1000px";document.body.appendChild(textarea);textarea.focus();textarea.select();try{document.execCommand("copy");document.body.removeChild(textarea);return Promise.resolve()}catch(error){document.body.removeChild(textarea);return Promise.reject(error)}}},showError:(message,details="")=>{console.error("Bookmarklet Error:",message,details);alert(`エラー: ${message}${details?'\n詳細: '+details:''}`)}};const parseIssueFromUrl=(url,html)=>{const doc=new DOMParser().parseFromString(html,"text/html");const title=utils.getTextContent(SELECTORS.TITLE,doc);const match=url.match(/\/(issues|pull|discussions)\/(\d+)/);const issueNum=match?"#"+match[2]:utils.getTextContent(SELECTORS.NUMBER,doc);return{title,issueNum}};const scoreLink=(link)=>{let score=0;if(link.dataset.hovercardType)score+=5;if(/^#\d+$/.test((link.textContent||"").trim()))score+=3;if(link.closest(SELECTORS.CONTAINERS))score+=2;return score};const findBestIssueLink=()=>{const links=Array.from(document.querySelectorAll(SELECTORS.LINKS));const processedLinks=links.map(link=>{const hrefAttr=link.getAttribute("href")||"";const fullHref=link.href||hrefAttr;const match=hrefAttr.match(GITHUB_ISSUE_REGEX)||(fullHref&&fullHref.match(/^https?:\/\/github\.com\/[^\/]+\/[^\/]+\/(issues|pull|discussions)\/(\d+)/));if(!match)return null;return{link,href:utils.absoluteUrl(hrefAttr||fullHref),num:match[2],score:scoreLink(link)}}).filter(Boolean).sort((a,b)=>b.score-a.score);return processedLinks.length>0?processedLinks[0]:null};const extractCurrentPageInfo=()=>{const match=location.pathname.match(/\/(issues|pull|discussions)\/(\d+)/);const num=match?match[2]:"";let title=utils.getTextContent(SELECTORS.TITLE);const issueNum=num?`#${num}`:utils.getTextContent(SELECTORS.NUMBER);if(!title){const docTitle=document.title;if(docTitle){const leftPart=docTitle.split("·")[0].trim();title=leftPart.replace(/\s+by\s+[^·]+$/,"").trim()}}return{url:location.href,title,issueNum,num}};const extractFromBestLink=async()=>{const bestLink=findBestIssueLink();if(!bestLink)return null;const container=bestLink.link.closest(SELECTORS.CONTAINERS)||document;let title=utils.getTextContent(SELECTORS.TITLE,container)||utils.getTextContent(['h1','h2'],container);if(!title){try{const response=await fetch(bestLink.href,{credentials:"include"});const html=await response.text();const parsed=parseIssueFromUrl(bestLink.href,html);title=parsed.title}catch(error){console.error("Failed to fetch title:",error);throw new Error("タイトルの取得に失敗しました")}}return{url:bestLink.href,title,issueNum:`#${bestLink.num}`,num:bestLink.num}};const formatMarkdown=(info)=>{const cleanTitle=info.title.replace(/#\d+$/,"").trim();return `${cleanTitle} [${info.issueNum}](${info.url})`};const main=async()=>{try{let issueInfo;const isOnCanonicalPage=GITHUB_ISSUE_REGEX.test(location.pathname);if(isOnCanonicalPage){issueInfo=extractCurrentPageInfo()}else{issueInfo=await extractFromBestLink();if(!issueInfo){throw new Error("Issue/PR/Discussionのリンクが見つかりませんでした")}}if(!issueInfo.url||!issueInfo.title||!issueInfo.issueNum){throw new Error("タイトル/番号/URLを取得できませんでした(Projectのドラフト項目かもしれません)")}const markdown=formatMarkdown(issueInfo);await utils.copyToClipboard(markdown);alert("コピーしました!\n\n"+markdown)}catch(error){utils.showError(error.message)}};main()})();
(忙しくない人へ。この後同じコードが出てきます。混乱しないでください🙇)
はじめに
- このブックマークレットはGitHubのDOM構造に依存しているため、将来的なサイトのアップデートで動作しなくなる可能性があります。ご了承ください。ご利用は自己責任でお願いします。
- chromeでしか動作確認してません。
使用例
実行すると、以下のような形式でクリップボードにコピーされます:
Issue/PR/Discussion title [#1234](https://github.com/company/project/issues/1234)
SlackやNotionなどにそのまま貼り付けて使えます!
タイトルの前に##
が付いたり、変に改行されたりしません!
こういうところで使えます。
- PRレビュー依頼をSlackで共有
- Issue報告をNotionやJira、Confluenceに転記
- 進捗報告での関連Issue/PR引用
- ドキュメントへのGitHub参照リンク追加
対応ページ
- ✅ 通常のIssue/PR/Discussionページ
- ✅ GitHubプロジェクトボード
- ✅ Issue一覧ページ(最適なリンクを自動選択)
- ✅ PR一覧ページ
- ✅ ダイアログ内のIssue/PR
導入方法
コードの内容についての解説は後述します。
- 以下のコードをコピー
- ブラウザでブックマークを新規作成
- 名前:「GitHub Issue Copy」など
- URL:コピーしたコードを貼り付け
- 保存
javascript:(()=>{const SELECTORS={TITLE:['[data-test-selector="issue-title"]','[data-test-selector="pull-request-title"]','[data-test-selector="discussion-title"]','.js-issue-title','h1 .js-issue-title'],NUMBER:'.gh-header-number',LINKS:['a[data-hovercard-type="issue"]','a[data-hovercard-type="pull_request"]','a[data-hovercard-type="discussion"]','a[href*="/issues/"]','a[href*="/pull/"]','a[href*="/discussions/"]'].join(','),CONTAINERS:['[role="dialog"]','.details-dialog','.Overlay','[data-overlay-container="true"]','[data-testid*="pane"]','.project-pane'].join(',')};const GITHUB_ISSUE_REGEX=/^(?:https?:\/\/github\.com)?\/[^\/]+\/[^\/]+\/(issues|pull|discussions)\/(\d+)(?:$|[/?#])/;const utils={getTextContent:(selectors,root=document)=>{if(typeof selectors==='string')selectors=[selectors];for(const selector of selectors){const element=root.querySelector(selector);if(element)return element.textContent.trim()}return ""},absoluteUrl:(url)=>new URL(url,location.origin).href,copyToClipboard:async(text)=>{if(navigator.clipboard&&window.isSecureContext){return navigator.clipboard.writeText(text)}else{const textarea=document.createElement("textarea");textarea.value=text;textarea.style.position="fixed";textarea.style.top="-1000px";document.body.appendChild(textarea);textarea.focus();textarea.select();try{document.execCommand("copy");document.body.removeChild(textarea);return Promise.resolve()}catch(error){document.body.removeChild(textarea);return Promise.reject(error)}}},showError:(message,details="")=>{console.error("Bookmarklet Error:",message,details);alert(`エラー: ${message}${details?'\n詳細: '+details:''}`)}};const parseIssueFromUrl=(url,html)=>{const doc=new DOMParser().parseFromString(html,"text/html");const title=utils.getTextContent(SELECTORS.TITLE,doc);const match=url.match(/\/(issues|pull|discussions)\/(\d+)/);const issueNum=match?"#"+match[2]:utils.getTextContent(SELECTORS.NUMBER,doc);return{title,issueNum}};const scoreLink=(link)=>{let score=0;if(link.dataset.hovercardType)score+=5;if(/^#\d+$/.test((link.textContent||"").trim()))score+=3;if(link.closest(SELECTORS.CONTAINERS))score+=2;return score};const findBestIssueLink=()=>{const links=Array.from(document.querySelectorAll(SELECTORS.LINKS));const processedLinks=links.map(link=>{const hrefAttr=link.getAttribute("href")||"";const fullHref=link.href||hrefAttr;const match=hrefAttr.match(GITHUB_ISSUE_REGEX)||(fullHref&&fullHref.match(/^https?:\/\/github\.com\/[^\/]+\/[^\/]+\/(issues|pull|discussions)\/(\d+)/));if(!match)return null;return{link,href:utils.absoluteUrl(hrefAttr||fullHref),num:match[2],score:scoreLink(link)}}).filter(Boolean).sort((a,b)=>b.score-a.score);return processedLinks.length>0?processedLinks[0]:null};const extractCurrentPageInfo=()=>{const match=location.pathname.match(/\/(issues|pull|discussions)\/(\d+)/);const num=match?match[2]:"";let title=utils.getTextContent(SELECTORS.TITLE);const issueNum=num?`#${num}`:utils.getTextContent(SELECTORS.NUMBER);if(!title){const docTitle=document.title;if(docTitle){const leftPart=docTitle.split("·")[0].trim();title=leftPart.replace(/\s+by\s+[^·]+$/,"").trim()}}return{url:location.href,title,issueNum,num}};const extractFromBestLink=async()=>{const bestLink=findBestIssueLink();if(!bestLink)return null;const container=bestLink.link.closest(SELECTORS.CONTAINERS)||document;let title=utils.getTextContent(SELECTORS.TITLE,container)||utils.getTextContent(['h1','h2'],container);if(!title){try{const response=await fetch(bestLink.href,{credentials:"include"});const html=await response.text();const parsed=parseIssueFromUrl(bestLink.href,html);title=parsed.title}catch(error){console.error("Failed to fetch title:",error);throw new Error("タイトルの取得に失敗しました")}}return{url:bestLink.href,title,issueNum:`#${bestLink.num}`,num:bestLink.num}};const formatMarkdown=(info)=>{const cleanTitle=info.title.replace(/#\d+$/,"").trim();return `${cleanTitle} [${info.issueNum}](${info.url})`};const main=async()=>{try{let issueInfo;const isOnCanonicalPage=GITHUB_ISSUE_REGEX.test(location.pathname);if(isOnCanonicalPage){issueInfo=extractCurrentPageInfo()}else{issueInfo=await extractFromBestLink();if(!issueInfo){throw new Error("Issue/PR/Discussionのリンクが見つかりませんでした")}}if(!issueInfo.url||!issueInfo.title||!issueInfo.issueNum){throw new Error("タイトル/番号/URLを取得できませんでした(Projectのドラフト項目かもしれません)")}const markdown=formatMarkdown(issueInfo);await utils.copyToClipboard(markdown);alert("コピーしました!\n\n"+markdown)}catch(error){utils.showError(error.message)}};main()})();
使い方
- GitHubのIssue/PR/Discussionページ、またはそれらのリンクがあるページでブックマークをクリック
- 「コピーしました!」のアラートが表示されたら完了
- SlackやNotionなどに貼り付け
技術的な仕組み
主要な処理フロー
1.ページ判定
現在のページがGitHubのIssue/PR/Discussionページかを正規表現で判定
const GITHUB_ISSUE_REGEX = /^(?:https?:\/\/github\.com)?\/[^\/]+\/[^\/]+\/(issues|pull|discussions)\/(\d+)(?:$|[/?#])/;
const isOnCanonicalPage = GITHUB_ISSUE_REGEX.test(location.pathname);
2.情報抽出
直接ページ: DOMからタイトル・番号・URLを抽出
const extractCurrentPageInfo = () => {
const match = location.pathname.match(/\/(issues|pull|discussions)\/(\d+)/);
const num = match ? match[2] : "";
let title = utils.getTextContent(SELECTORS.TITLE);
const issueNum = num ? `#${num}` : utils.getTextContent(SELECTORS.NUMBER);
// document.titleからの抽出もフォールバック
return { url: location.href, title, issueNum, num };
};
間接ページ: リンクを収集してスコアリング→最適なリンクを選択
const findBestIssueLink = () => {
const links = Array.from(document.querySelectorAll(SELECTORS.LINKS));
const processedLinks = links.map(link => {
// URLマッチング処理
return {
link, href: utils.absoluteUrl(hrefAttr || fullHref),
num: match[2], score: scoreLink(link)
};
}).filter(Boolean).sort((a, b) => b.score - a.score);
return processedLinks.length > 0 ? processedLinks[0] : null;
};
3.スコアリングロジック
リンクの重要度を以下で判定
-
data-hovercard-type
属性の有無(+5点) - リンクテキストが
#数字
形式(+3点) - ダイアログ・パネル内のリンク(+2点)
const scoreLink = (link) => {
let score = 0;
// data-hovercard-type属性の有無(+5点)
if (link.dataset.hovercardType) score += 5;
// リンクテキストが#数字形式(+3点)
if (/^#\d+$/.test((link.textContent || "").trim())) score += 3;
// ダイアログ・パネル内のリンク(+2点)
if (link.closest(SELECTORS.CONTAINERS)) score += 2;
return score;
};
4.フォールバック処理
タイトルが見つからない場合はfetch
でページを取得して解析
const extractFromBestLink = async () => {
const bestLink = findBestIssueLink();
// コンテナ内でタイトル検索
let title = utils.getTextContent(SELECTORS.TITLE, container);
if (!title) {
// fetchで外部ページから取得
const response = await fetch(bestLink.href, { credentials: "include" });
const html = await response.text();
const parsed = parseIssueFromUrl(bestLink.href, html);
title = parsed.title;
}
return { url: bestLink.href, title, issueNum: `#${bestLink.num}` };
};
5.クリップボード操作
モダンAPI(navigator.clipboard
)と従来手法の両方に対応
const copyToClipboard = async (text) => {
if (navigator.clipboard && window.isSecureContext) {
// モダンAPI(HTTPS環境)
return navigator.clipboard.writeText(text);
} else {
// フォールバック(HTTP環境など)
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.top = "-1000px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
document.body.removeChild(textarea);
return Promise.resolve();
} catch (error) {
document.body.removeChild(textarea);
return Promise.reject(error);
}
}
};
使用している技術
-
DOM操作:
querySelector
によるマルチセレクター対応 - 正規表現: GitHubのURL形式を厳密に判定
-
非同期処理:
async/await
でのfetch処理 - DOMParser: HTMLパースによる外部ページからの情報抽出
- フォールバック: セキュアコンテキスト外でのクリップボード操作
セキュリティ考慮
- XSS対策: DOMParserを使用してHTMLを安全に解析
- エラーハンドリング: try-catch文による適切な例外処理
- コンソールログ: デバッグ用の詳細ログ出力
Discussion