• #Chrome拡張機能
  • #yt-dlp
  • #Twitter
  • #Native Host
  • #Cookie
  • #動画ダウンロード
開発chrome-extension-xメモ

Chrome拡張機能でX/Twitterの動画をダウンロードする - yt-dlp連携の修正記録

X(旧Twitter)の動画をダウンロードするChrome拡張機能が動かなくなっていた。原因を追っていくと、yt-dlpのバージョン問題、ChromeのCookieデータベースロック、ファイルパス判定のバグと、複数の問題が絡み合っていた。それぞれの問題と解決策を記録する。

問題1: yt-dlpが古くてX/Twitterの動画を取得できない

症状

yt-dlpでX/Twitterの動画URLを渡すと、認証エラーやパース失敗で動画を取得できない。

原因

インストール済みのyt-dlpのバージョンが 2025.06.25 だった。X/Twitterは頻繁にAPI仕様を変更するため、半年以上前のバージョンでは対応できない。

対応

2026.1.31にアップデートした。

# バージョン確認
yt-dlp --version
# 2025.06.25

# アップデート
yt-dlp -U
# もしくは
pip install -U yt-dlp

# アップデート後
yt-dlp --version
# 2026.01.31

yt-dlpはX/Twitterに限らず、サイト側の変更に追従するために頻繁にアップデートが入る。定期的にバージョンを上げる運用が必要。

問題2: --cookies-from-browser chrome が使えない

症状

yt-dlpにはブラウザのCookieを直接読み取るオプション --cookies-from-browser chrome がある。X/Twitterの動画はログイン状態でないと取得できないものが多いため、このオプションを使おうとした。

しかし、Chromeが起動している状態ではこのオプションが失敗する。

ERROR: could not read cookies from chrome:
  database is locked

原因(構造的な問題)

ChromeはCookieをSQLiteデータベースに保存している。Chrome起動中はこのデータベースにロックがかかっており、外部プロセスからの読み取りができない。

%LOCALAPPDATA%\Google\Chrome\User Data\Default\Network\Cookies

これはSQLiteの排他ロックによる制限で、yt-dlp側では対処できない。Chromeを閉じた状態なら読み取れるが、「動画を見ているブラウザを閉じてからダウンロードする」というのは実用的ではない。

代替策: Chrome拡張機能の chrome.cookies APIを使う

Chrome拡張機能にはブラウザのCookieにアクセスするための chrome.cookies APIが用意されている。これはChromeの内部APIなので、データベースロックの問題を回避できる。

方式を以下のように変更した。

[Chrome拡張機能]
  background.js: chrome.cookies APIでTwitterのCookieを取得
       ↓
  Native Messaging で native_host.py に送信
       ↓
[native_host.py]
  Cookieをnetscape形式のファイルに書き出し
       ↓
  yt-dlp --cookies <cookieファイル> で動画をダウンロード

実装: background.jsでのCookie取得

Chrome拡張機能の background.js(Service Worker)で、X/TwitterドメインのCookieを取得してNative Hostに送信する。

manifest.jsonの権限設定

{
  "permissions": [
    "cookies",
    "nativeMessaging"
  ],
  "host_permissions": [
    "https://x.com/*",
    "https://twitter.com/*"
  ]
}

cookies パーミッションと、対象ドメインへの host_permissions が両方必要。

Cookie取得ロジック

// background.js

async function getTwitterCookies() {
  const domains = [".x.com", ".twitter.com"];
  const allCookies = [];

  for (const domain of domains) {
    const cookies = await chrome.cookies.getAll({ domain });
    allCookies.push(...cookies);
  }

  // 重複排除(name + domain + path で一意)
  const unique = new Map();
  for (const c of allCookies) {
    const key = `${c.name}|${c.domain}|${c.path}`;
    unique.set(key, c);
  }

  return Array.from(unique.values());
}

chrome.cookies.getAll() はPromiseを返す(Manifest V3)。.x.com.twitter.com の両方のドメインからCookieを取得する。X/Twitterは両ドメインを併用しているため、片方だけだと認証に必要なCookieが揃わないことがある。

Netscape形式への変換

yt-dlpが受け付けるCookieファイルはNetscape形式。chrome.cookies APIが返すオブジェクトをこの形式に変換する。

function toNetscapeFormat(cookies) {
  const lines = [
    "# Netscape HTTP Cookie File",
    "# This file is generated by Chrome extension",
    ""
  ];

  for (const c of cookies) {
    const domain = c.domain.startsWith(".") ? c.domain : `.${c.domain}`;
    const includeSubdomains = domain.startsWith(".") ? "TRUE" : "FALSE";
    const secure = c.secure ? "TRUE" : "FALSE";
    // expirationDate が未設定の場合はセッションCookie(0を設定)
    const expiry = c.expirationDate ? Math.floor(c.expirationDate) : 0;
    const line = `${domain}\t${includeSubdomains}\t${c.path}\t${secure}\t${expiry}\t${c.name}\t${c.value}`;
    lines.push(line);
  }

  return lines.join("\n");
}

Netscape形式の各フィールドはタブ区切りで、以下の順序になる。

domain  includeSubdomains  path  secure  expiry  name  value

Native Hostへの送信

async function downloadVideo(url) {
  const cookies = await getTwitterCookies();
  const cookieText = toNetscapeFormat(cookies);

  // Native Hostにメッセージを送信
  chrome.runtime.sendNativeMessage(
    "com.example.ytdlp_host",
    {
      action: "download",
      url: url,
      cookies: cookieText
    },
    (response) => {
      if (chrome.runtime.lastError) {
        console.error("Native Host error:", chrome.runtime.lastError.message);
        return;
      }
      console.log("Download response:", response);
    }
  );
}

実装: native_host.pyでのダウンロード処理

Native HostはPythonスクリプトで実装した。Chromeから受け取ったCookieテキストを一時ファイルに書き出し、yt-dlpに渡す。

Native Messagingのプロトコル

ChromeのNative Messagingは独自のバイナリプロトコルを使う。メッセージの先頭4バイトがメッセージ長(リトルエンディアン)、続いてJSON本体が来る。

import struct
import sys
import json
import subprocess
import tempfile
import os

def read_message():
    """標準入力からNative Messageを読み取る"""
    raw_length = sys.stdin.buffer.read(4)
    if not raw_length:
        return None
    length = struct.unpack('<I', raw_length)[0]
    message = sys.stdin.buffer.read(length)
    return json.loads(message.decode('utf-8'))

def send_message(data):
    """標準出力にNative Messageを書き出す"""
    encoded = json.dumps(data).encode('utf-8')
    sys.stdout.buffer.write(struct.pack('<I', len(encoded)))
    sys.stdout.buffer.write(encoded)
    sys.stdout.buffer.flush()

Cookieファイル書き出しとyt-dlp実行

def download_video(url, cookie_text, output_dir):
    """Cookieファイルを書き出してyt-dlpでダウンロード"""
    # 一時ファイルにCookieを書き出し
    cookie_file = os.path.join(tempfile.gettempdir(), "ytdlp_cookies.txt")
    with open(cookie_file, "w", encoding="utf-8") as f:
        f.write(cookie_text)

    # ファイル名テンプレート
    output_template = os.path.join(output_dir, "%(title)s [%(id)s].%(ext)s")

    try:
        result = subprocess.run(
            [
                "yt-dlp",
                "--cookies", cookie_file,
                "-o", output_template,
                url
            ],
            capture_output=True,
            text=True,
            timeout=300
        )

        if result.returncode == 0:
            # テンプレートから実際のファイルパスを構築
            filepath = find_downloaded_file(output_dir, url)
            return {"status": "success", "filepath": filepath}
        else:
            return {"status": "error", "message": result.stderr}
    finally:
        # Cookieファイルを削除
        if os.path.exists(cookie_file):
            os.remove(cookie_file)

メインループ

def main():
    message = read_message()
    if message is None:
        return

    action = message.get("action")
    if action == "download":
        url = message.get("url", "")
        cookies = message.get("cookies", "")
        output_dir = message.get("output_dir", os.path.expanduser("~/Downloads"))
        result = download_video(url, cookies, output_dir)
        send_message(result)

if __name__ == "__main__":
    main()

問題3: ダウンロード完了後のファイル存在チェックが誤判定する

症状

yt-dlpのダウンロード自体は成功しているのに、ダウンロード後の「ファイルが存在するか」のチェックが失敗する。存在するはずのファイルが「見つからない」と判定される。

原因1: --print after_move:filepath の出力にBOMや余分な空白が含まれる

当初、ダウンロード後のファイルパスを取得するために yt-dlp の --print after_move:filepath オプションを使っていた。

yt-dlp --print after_move:filepath --cookies cookies.txt "https://x.com/..."

このオプションはダウンロード・変換・移動が完了した後のファイルパスを標準出力に出力する。しかし、環境によっては出力にBOM(Byte Order Mark)や末尾の空白・改行が含まれることがあり、そのままファイルパスとして使うと os.path.exists()False を返す。

# NG: BOMや空白が残っている
filepath = result.stdout.strip()
# filepath = "\ufeffC:\\Users\\numbe\\Downloads\\video.mp4"
os.path.exists(filepath)  # False(BOMのせい)

原因2: ファイルパスに空白が含まれる場合の処理

動画タイトルに空白が含まれる場合、ファイルパスにも空白が入る。--print の出力を行単位で読み取る際に、パスの途中で分割されてしまうケースがあった。

解決策: --print after_move:filepath を使わない

--print after_move:filepath を削除し、ファイル名テンプレートから直接パスを構築するように変更した。

def find_downloaded_file(output_dir, url):
    """
    yt-dlpのテンプレートと同じ命名規則でファイルを探す。
    --print after_move:filepath に頼らない方式。
    """
    # URLからツイートIDを抽出
    # https://x.com/user/status/1234567890 -> 1234567890
    tweet_id = url.rstrip("/").split("/")[-1]

    # output_dir内で tweet_id を含むファイルを検索
    for filename in os.listdir(output_dir):
        if tweet_id in filename:
            return os.path.join(output_dir, filename)

    return None

ツイートIDはURL内で一意なので、ダウンロード先ディレクトリ内でIDを含むファイルを検索すれば確実に見つかる。--print の出力パースに依存しないため、BOMや空白の問題が発生しない。

問題4: content.jsの file:/// ローカルリソースエラー

症状

ダウンロード完了後、content script(content.js)でダウンロード済みファイルのサムネイルやプレビューを表示しようとした際に、以下のエラーが出る。

Not allowed to load local resource: file:///C:/Users/numbe/Downloads/video.mp4

原因

Chrome拡張機能のcontent scriptはWebページのコンテキストで実行される。Webページから file:/// プロトコルのローカルファイルにアクセスすることは、Chromeのセキュリティポリシーで禁止されている。これは拡張機能であっても例外ではない。

対応

ローカルファイルへの直接参照を諦め、ダウンロード完了の通知のみを表示する方式にした。ファイルを開きたい場合は、Chromeのダウンロードバーか、ファイルマネージャから開く。

// content.js
// ダウンロード完了を通知するだけ(file:/// は使わない)
function showDownloadComplete(filename) {
  const notification = document.createElement("div");
  notification.textContent = `Downloaded: ${filename}`;
  notification.style.cssText = `
    position: fixed; bottom: 20px; right: 20px;
    background: #1da1f2; color: white;
    padding: 12px 20px; border-radius: 8px;
    z-index: 10000; font-size: 14px;
  `;
  document.body.appendChild(notification);
  setTimeout(() => notification.remove(), 5000);
}

もしcontent script内でダウンロード済みファイルの内容を表示したい場合は、background scriptで chrome.downloads APIを使ってBlobURLを生成し、メッセージパッシングでcontent scriptに渡すという方法がある。ただし今回はそこまでの必要性がなかったため見送った。

まとめ

今回の修正で対処した問題の一覧。

問題原因対応
X/Twitterの動画が取得できないyt-dlpが2025.06.25と古い2026.01.31にアップデート
--cookies-from-browser chrome が失敗Chrome起動中はCookie DBがロックされるchrome.cookies API + Native Host方式に変更
ファイル存在チェックが誤判定--print after_move:filepath の出力にBOM/空白テンプレートから直接パスを構築
content.jsで file:/// エラーChromeのセキュリティポリシー通知表示のみに変更

Chrome拡張機能とNative Hostの組み合わせは、ブラウザの制約を超えてローカルツールと連携するのに使いやすい。yt-dlpに限らず、ffmpegやImageMagickなどローカルのCLIツールをブラウザから呼び出したいケースにも応用できる。