X でこの人の投稿する画像はよく保存する、このキャラのタグがついた画像はよく保存するといったことないでしょうか。投稿者やハッシュタグの条件で自動で画像が開いたりダウンロードできたりしたら便利だと思い Chrome 拡張を作ってみました。

何ができるか

タイムラインに流れてくるツイートを監視し、条件に合う画像付きツイートを見つけたら画像を自動で処理する。動作モードは 2 つ。

  • 別タブで開く — 画像を原寸(name=orig)で非アクティブタブに開く
  • ダウンロード — 指定フォルダに保存する(重複チェックつき)

キーワードを設定するとそのキーワードを含むツイートだけが対象になる。空欄なら画像付きツイートすべてが対象。設定はポップアップ UI から切り替えられる。

MutationObserver でタイムラインを監視する

X は SPA なので、スクロールに応じて DOM にツイートが追加される。ページ読み込み時に全ツイートが揃っているわけではないため、MutationObserver で DOM の変更を検知した。

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType !== Node.ELEMENT_NODE) continue;

      // 追加されたノード自体がツイートの場合
      if (node.matches?.('[data-testid="tweet"]')) {
        checkTweet(node);
      }
      // 追加されたコンテナの子孫にツイートがある場合
      for (const t of node.querySelectorAll?.('[data-testid="tweet"]') ?? []) {
        checkTweet(t);
      }
    }
  }
});

// body 全体を subtree ごと監視してスクロールで追加されるツイートを拾う
observer.observe(document.body, { childList: true, subtree: true });

document.bodychildList: true, subtree: true で監視し、追加されたノードから [data-testid="tweet"] にマッチする要素を探す。ここで 2 パターンの拾い方をしている。追加されたノード自体が [data-testid="tweet"] である場合と、追加されたコンテナの子孫にツイートがある場合だ。X はタイムラインの描画タイミングによって両方のケースが起きるので、node.matchesnode.querySelectorAll の両方で探索した。

ツイートの判定と画像取得

ツイートを見つけたら、キーワード照合と処理済みチェックを行う。

function checkTweet(tweet) {
  // 処理済みフラグで同じツイートの二重処理を防ぐ
  if (tweet.dataset.imageOpenerProcessed) return;
  if (!enabled) return;

  // キーワードが設定されていれば本文に含まれるか照合
  if (keyword) {
    const textEl = tweet.querySelector('[data-testid="tweetText"]');
    if (!textEl || !textEl.textContent.includes(keyword)) return;
  }

  tweet.dataset.imageOpenerProcessed = 'true';
  tryOpenImages(tweet, 5);
}

dataset.imageOpenerProcessed で同じツイートの二重処理を防いだ。X は DOM の再利用や再描画が多く、このフラグがないと同じ画像が何度も開かれる。

画像の取得では、ツイートが DOM に追加された直後だと [data-testid="tweetPhoto"] img がまだ存在しないことがあった。500ms 間隔で最大 5 回リトライする形で対処した。500ms も 5 回も適当な決め打ちで、回線が遅い環境だと足りないかもしれない。

function tryOpenImages(tweet, retries) {
  const images = tweet.querySelectorAll('[data-testid="tweetPhoto"] img');

  // 画像要素がまだ読み込まれていなければリトライ(500ms × 最大5回)
  if (images.length === 0) {
    if (retries > 0) {
      setTimeout(() => tryOpenImages(tweet, retries - 1), 500);
    }
    return;
  }

  for (const img of images) {
    // 動画プレーヤー内のサムネイルは除外
    if (img.closest('[data-testid="videoPlayer"]')) continue;

    // &name=small や &name=medium を &name=orig に置き換えて原寸取得
    let url = img.src;
    if (url.includes('&name=')) {
      url = url.replace(/&name=[^&]+/, '&name=orig');
    }

    // content script からはタブ操作・DL できないので background へ委譲
    if (mode === 'download') {
      // URL からファイルID と拡張子を抽出してファイル名を組み立てる
      const match = url.match(/\/media\/([^?]+)\?format=(\w+)/);
      const filename = match
        ? match[1] + '.' + match[2]
        : 'image_' + Date.now() + '.jpg';
      chrome.runtime.sendMessage({ action: 'downloadImage', url, filename, downloadFolder });
    } else {
      chrome.runtime.sendMessage({ action: 'openImage', url });
    }
  }
}

画像 URL の原寸指定

X の画像 URL には &name=small&name=medium といったサイズ指定のパラメータがつく。これを &name=orig に差し替えると原寸画像が取れる。正規表現で &name= 以降を一括置換した。

動画のサムネイルを誤って開かないよう img.closest('[data-testid="videoPlayer"]') で動画プレーヤー内の img は除外した。

content script から別タブを開く

Manifest V3 の content script には chrome.tabs API の権限がない。別タブを開くには chrome.runtime.sendMessage で background の service worker にメッセージを送り、そちらで chrome.tabs.create を呼ぶ必要があった。

if (message.action === 'openImage') {
  // active: false でバックグラウンドタブとして開く(フォーカスを奪わない)
  chrome.tabs.create({ url: message.url, active: false });
}

active: false を指定することで、タブが非アクティブ(バックグラウンド)で開かれる。これがないと画像を開くたびにフォーカスがそちらに移ってしまい、タイムラインのスクロールが中断される。タイムラインを流し見しながら裏でタブを溜めていく用途では必須だった。

chrome.downloads API でダウンロードする

ダウンロードモードでは chrome.downloads.download を使った。content script からは呼べないため、こちらも background 経由になる。

if (message.action === 'downloadImage') {
  // フォルダ名があればスラッシュ区切りでパスにする(DLフォルダ内にサブフォルダが作られる)
  const filename = message.downloadFolder
    ? message.downloadFolder + '/' + message.filename
    : message.filename;

  chrome.downloads.download({ url: message.url, filename });
}

filename にスラッシュ区切りのパスを渡すと、Chrome のダウンロードフォルダ内にサブフォルダを作ってそこに保存してくれる。ポップアップ UI でフォルダ名を指定できるようにした。

ファイル名の組み立て

X の画像 URL は https://pbs.twimg.com/media/XXXXX?format=jpg&name=orig のような形式になっている。ここから /media/ 以降のファイル ID と format パラメータを正規表現で取り出し、XXXXX.jpg のようなファイル名を組み立てた。

// /media/XXXXX?format=jpg から ID と拡張子を取り出す
const match = url.match(/\/media\/([^?]+)\?format=(\w+)/);
const filename = match
  ? match[1] + '.' + match[2]       // 例: AbCdEfG.jpg
  : 'image_' + Date.now() + '.jpg'; // URL が想定外ならタイムスタンプで代替

URL の形式が想定外だった場合は image_ + タイムスタンプでフォールバックした。

chrome.downloads.search で重複を除去する

タイムラインを行き来すると同じツイートが再表示されることがある。content script 側の dataset フラグは DOM が再構築されるとリセットされるため、同じ画像を二重にダウンロードしてしまう。

これを防ぐために chrome.downloads.search を使った。URL を検索キーにして過去のダウンロード履歴を引き、state === 'complete' のものがあればスキップする。

// 同じURLを過去にDL済みか Chrome のダウンロード履歴から検索
chrome.downloads.search({ url: message.url }, (results) => {
  const alreadyDownloaded = results && results.some((r) => r.state === 'complete');
  if (alreadyDownloaded) return; // 完了済みがあればスキップ
  chrome.downloads.download({ url: message.url, filename });
});

chrome.downloads.search は URL の完全一致で検索できる。ダウンロード履歴は Chrome が管理しているので、拡張機能側で別途リストを持つ必要がない。ブラウザを再起動しても履歴は残っているため、セッションをまたいだ重複除去にもなった。

つまずいたところ

画像の遅延読み込みが一番手こずった。ツイートの DOM ノードが追加された時点では [data-testid="tweetPhoto"] img がまだ存在せず querySelectorAll で 0 件になる。最初はタイミングの問題だと気づかず「セレクタが間違っているのでは」と探し回った。

もうひとつは data-testid への依存。検索保存の拡張を作ったときも同じ課題にぶつかったが、X が DOM 構造を変えると一斉に動かなくなる。対象のセレクタを一か所にまとめておけば修正は楽だが、根本的にはどうしようもない。

全ソースコード

以下がファイル構成と各ファイルの全体。このままコピーすれば chrome://extensions/ から読み込んで使える。

twitter-image-opener/
├── manifest.json
├── content_script.js
├── background.js
├── popup.html
└── popup.js

manifest.json

{
  "manifest_version": 3,
  "name": "Twitter Image Opener",
  "version": "1.3",
  "description": "指定キーワードと画像を含むツイートの画像を自動で別タブに開く/ダウンロードする",
  "permissions": ["storage", "downloads"],
  "action": {
    "default_popup": "popup.html"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["https://twitter.com/*", "https://x.com/*"],
      "js": ["content_script.js"],
      "run_at": "document_end"
    }
  ]
}

content_script.js

let keyword = '';
let enabled = true;
let mode = 'tab';
let downloadFolder = '';

chrome.storage.local.get(['keyword', 'enabled', 'mode', 'downloadFolder'], (result) => {
  keyword = result.keyword || '';
  enabled = result.enabled !== false;
  mode = result.mode || 'tab';
  downloadFolder = result.downloadFolder || '';
});

chrome.storage.onChanged.addListener((changes) => {
  if (changes.keyword) keyword = changes.keyword.newValue || '';
  if (changes.enabled) enabled = changes.enabled.newValue !== false;
  if (changes.mode) mode = changes.mode.newValue || 'tab';
  if (changes.downloadFolder) downloadFolder = changes.downloadFolder.newValue || '';
});

function tryOpenImages(tweet, retries) {
  const images = tweet.querySelectorAll('[data-testid="tweetPhoto"] img');

  if (images.length === 0) {
    if (retries > 0) {
      setTimeout(() => tryOpenImages(tweet, retries - 1), 500);
    }
    return;
  }

  for (const img of images) {
    if (img.closest('[data-testid="videoPlayer"]')) continue;

    let url = img.src;
    if (url.includes('&name=')) {
      url = url.replace(/&name=[^&]+/, '&name=orig');
    }

    if (mode === 'download') {
      const match = url.match(/\/media\/([^?]+)\?format=(\w+)/);
      const filename = match
        ? match[1] + '.' + match[2]
        : 'image_' + Date.now() + '.jpg';
      chrome.runtime.sendMessage({ action: 'downloadImage', url, filename, downloadFolder });
    } else {
      chrome.runtime.sendMessage({ action: 'openImage', url });
    }
  }
}

function checkTweet(tweet) {
  if (tweet.dataset.imageOpenerProcessed) return;
  if (!enabled) return;

  if (keyword) {
    const textEl = tweet.querySelector('[data-testid="tweetText"]');
    if (!textEl || !textEl.textContent.includes(keyword)) return;
  }

  tweet.dataset.imageOpenerProcessed = 'true';
  tryOpenImages(tweet, 5);
}

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType !== Node.ELEMENT_NODE) continue;

      if (node.matches?.('[data-testid="tweet"]')) {
        checkTweet(node);
      }
      for (const t of node.querySelectorAll?.('[data-testid="tweet"]') ?? []) {
        checkTweet(t);
      }
    }
  }
});

observer.observe(document.body, { childList: true, subtree: true });

background.js

chrome.runtime.onMessage.addListener((message) => {
  if (message.action === 'openImage') {
    chrome.tabs.create({ url: message.url, active: false });
  }
  if (message.action === 'downloadImage') {
    const filename = message.downloadFolder
      ? message.downloadFolder + '/' + message.filename
      : message.filename;

    chrome.downloads.search({ url: message.url }, (results) => {
      const alreadyDownloaded = results && results.some((r) => r.state === 'complete');
      if (alreadyDownloaded) {
        console.log('[ImageOpener:BG] ダウンロード済みスキップ:', message.filename);
        return;
      }
      chrome.downloads.download({ url: message.url, filename });
    });
  }
});

popup.html

ポップアップの HTML はスタイルを含むため長いが、やっていることは有効/無効トグル・動作モード選択・キーワード入力・保存ボタンの 4 つ。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    body { width: 300px; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; }
    h1 { font-size: 15px; margin: 0 0 12px; }
    .toggle-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
    .toggle-label { font-weight: bold; }
    .toggle { position: relative; width: 44px; height: 24px; }
    .toggle input { opacity: 0; width: 0; height: 0; }
    .toggle .slider { position: absolute; inset: 0; background: #ccc; border-radius: 12px; cursor: pointer; transition: background 0.2s; }
    .toggle .slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: transform 0.2s; }
    .toggle input:checked + .slider { background: #1d9bf0; }
    .toggle input:checked + .slider::before { transform: translateX(20px); }
    label.field { display: block; margin-bottom: 6px; font-weight: bold; }
    input[type="text"], select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; box-sizing: border-box; }
    select { margin-bottom: 12px; }
    .folder-section { margin-bottom: 12px; display: none; }
    .folder-section.visible { display: block; }
    .folder-hint { font-size: 11px; color: #888; margin-top: 4px; }
    button { margin-top: 10px; width: 100%; padding: 8px; background: #1d9bf0; color: #fff; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; }
    button:hover { background: #1a8cd8; }
    .status { margin-top: 8px; color: #16a34a; font-size: 12px; min-height: 18px; }
  </style>
</head>
<body>
  <h1>Twitter Image Opener</h1>
  <div class="toggle-row">
    <span class="toggle-label">有効</span>
    <label class="toggle">
      <input type="checkbox" id="enabled">
      <span class="slider"></span>
    </label>
  </div>
  <label class="field">動作モード</label>
  <select id="mode">
    <option value="tab">別タブで開く</option>
    <option value="download">ダウンロード</option>
  </select>
  <div class="folder-section" id="folderSection">
    <label class="field">保存先フォルダ</label>
    <input type="text" id="downloadFolder" placeholder="例: twitter_images">
    <div class="folder-hint">ダウンロードフォルダ内のサブフォルダ名</div>
  </div>
  <label class="field">キーワード</label>
  <input type="text" id="keyword" placeholder="空欄で全ツイート対象">
  <div class="folder-hint">空欄にすると画像付きツイートすべてが対象になります</div>
  <button id="save">保存</button>
  <div class="status" id="status"></div>
  <script src="popup.js"></script>
</body>
</html>

popup.js

const input = document.getElementById('keyword');
const toggle = document.getElementById('enabled');
const modeSelect = document.getElementById('mode');
const folderSection = document.getElementById('folderSection');
const folderInput = document.getElementById('downloadFolder');
const status = document.getElementById('status');

function updateFolderVisibility() {
  folderSection.classList.toggle('visible', modeSelect.value === 'download');
}

chrome.storage.local.get(['keyword', 'enabled', 'mode', 'downloadFolder'], (result) => {
  input.value = result.keyword || '';
  toggle.checked = result.enabled !== false;
  modeSelect.value = result.mode || 'tab';
  folderInput.value = result.downloadFolder || '';
  updateFolderVisibility();
});

toggle.addEventListener('change', () => {
  chrome.storage.local.set({ enabled: toggle.checked });
});

modeSelect.addEventListener('change', updateFolderVisibility);

document.getElementById('save').addEventListener('click', () => {
  chrome.storage.local.set({
    keyword: input.value,
    mode: modeSelect.value,
    downloadFolder: folderInput.value.trim(),
  }, () => {
    status.textContent = '保存しました';
    setTimeout(() => { status.textContent = ''; }, 2000);
  });
});