🎴

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

導入方法

コードの内容についての解説は後述します。

  1. 以下のコードをコピー
  2. ブラウザでブックマークを新規作成
  3. 名前:「GitHub Issue Copy」など
  4. URL:コピーしたコードを貼り付け
  5. 保存
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()})();

使い方

  1. GitHubのIssue/PR/Discussionページ、またはそれらのリンクがあるページでブックマークをクリック
  2. 「コピーしました!」のアラートが表示されたら完了
  3. 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