📥

Twitterの画像にユーザーID、ポストIDを合成して保存するChrome拡張

に公開

使用例

左下にちっちゃく入っている黒いのがユーザーIDとポストID。

配布

GitHubにあげた。
https://github.com/NettoNeon/TweetImageMark
特にライセンスは付けてないのでご自由にどうぞ。

設計

大まかな処理の流れは以下

  1. URLをマッチング(形式に合わなければエラー)
  2. コンテキストメニューが起動された画像URLを取得し、HTMLから同一のURLを持つimgタグを探す
    (画像のサイズを取得するためにimgタグが必要)
  3. 画像サイズと同じ大きさのHTMLCanvasを作成
  4. 画像をCanvasに貼り付け
  5. URLから取得したユーザーIDとポストIDをCanvasに追記
  6. aタグを作成し自動保存

コード

manifest.json
{
  "manifest_version": 3,
  "name": "Twitter Image Downloader",
  "version": "1.0",
  "description": "画像に文字を入れて保存",
  "permissions": ["contextMenus", "scripting", "activeTab"],
  "host_permissions": ["*://twitter.com/*", "*://x.com/*"],
  "background": {
    "service_worker": "background.js"
  }
}
background.js
// 拡張機能がインストールされたときにメニュー作成
chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: "save-image-with-text",
    title: "TweetImageMark(文字入り画像を保存)",
    contexts: ["image"],
  });
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === "save-image-with-text") {
    chrome.scripting.executeScript({
      target: { tabId: tab.id },
      func: runContentScript,
      args: [info.srcUrl], // ← 画像URLを渡す
    });
  }
});

// contentScript を関数として注入(argsあり)
function runContentScript(imageUrl) {
  function notify(message) {
    alert(`[Twitter Image DL]\n${message}`);
  }

  const url = location.href;
  const urlPattern = /https:\/\/x\.com\/([^\/]+)\/status\/([^\/]+)/;
  const match = url.match(urlPattern);
  if (!match) {
    notify("画像ページではないか、URL形式が不正です。");
    return;
  }

  const userID = match[1];
  const tweetID = match[2];

  // srcURL に一致する <img> 要素を探す
  const img = [...document.images].find((i) => i.src.startsWith(imageUrl));
  if (!img) {
    notify("画像要素が見つかりませんでした。");
    return;
  }

  function drawRoundedRect(ctx, x, y, width, height, radius) {
    ctx.beginPath();
    ctx.moveTo(x + radius, y);
    ctx.lineTo(x + width - radius, y);
    ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
    ctx.lineTo(x + width, y + height - radius);
    ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
    ctx.lineTo(x + radius, y + height);
    ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
    ctx.lineTo(x, y + radius);
    ctx.quadraticCurveTo(x, y, x + radius, y);
    ctx.closePath();
    ctx.fill();
  }

  function drawMultilineTextWithBox(ctx, lines, x, y, fontSize = 20, letterSpacingEm = 0.05, lineHeight = 1.4) {
    const padding = 8;
    const borderRadius = 4;

    ctx.font = `${fontSize}px 'Century Gothic'`;
    ctx.textBaseline = "top";

    const letterSpacing = fontSize * letterSpacingEm;
    const lineHeightPx = fontSize * lineHeight;

    // 計算:幅(最大行の長さ)と高さ(行数)
    let maxLineWidth = 0;
    lines.forEach((line) => {
      let width = 0;
      for (const char of line) {
        width += ctx.measureText(char).width + letterSpacing;
      }
      maxLineWidth = Math.max(maxLineWidth, width);
    });

    const totalHeight = lines.length * lineHeightPx;

    // 背景描画
    ctx.fillStyle = "rgba(0, 0, 0, 0.8)";
    drawRoundedRect(ctx, x - padding, y - padding, maxLineWidth + padding * 2, totalHeight + padding, borderRadius);

    // テキスト描画
    ctx.fillStyle = "white";
    lines.forEach((line, lineIndex) => {
      let cursorX = x;
      const cursorY = y + lineIndex * lineHeightPx;

      for (let i = 0; i < line.length; i++) {
        const char = line[i];
        ctx.fillText(char, cursorX, cursorY);
        cursorX += ctx.measureText(char).width + letterSpacing;
      }
    });
  }

  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  canvas.width = img.naturalWidth;
  canvas.height = img.naturalHeight;

  const image = new Image();
  image.crossOrigin = "Anonymous";
  image.src = img.src;
  image.onload = () => {
    ctx.drawImage(image, 0, 0);
    const lines = [`userID: @${userID}`, `tweetID: ${tweetID}`];
    drawMultilineTextWithBox(ctx, lines, 20, canvas.height - 48 * 1.4);
    // ========== ダウンロード ==========
    const link = document.createElement("a");
    link.download = `image_${tweetID}.png`;
    link.href = canvas.toDataURL();
    link.click();
  };
}

Discussion