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ツールをブラウザから呼び出したいケースにも応用できる。