[{"data":1,"prerenderedAt":397},["ShallowReactive",2],{"content-/x-video-download-button-fix":3,"all-pages-for-dir":395,"og-image-/x-video-download-button-fix":396},{"id":4,"title":5,"body":6,"category":376,"concepts":377,"description":378,"extension":379,"meta":380,"navigation":381,"ogImage":377,"path":382,"project_name":383,"published":384,"publishedAt":385,"seo":386,"stem":387,"tags":388,"todo":377,"unpublished":384,"updatedAt":385,"__hash__":394},"pages/2026-07/2026-07-05/x-video-download-button-fix.md","自作Chrome拡張のXの動画ダウンロードボタンが消えた——DOM構造の変化を追って復活させた",{"type":7,"value":8,"toc":367},"minimark",[9,13,17,21,41,48,51,54,57,67,74,78,81,94,104,107,113,122,251,268,272,275,278,281,288,297,321,331,334,363],[10,11,5],"h1",{"id":12},"自作chrome拡張のxの動画ダウンロードボタンが消えたdom構造の変化を追って復活させた",[14,15,16],"h2",{"id":16},"結論",[18,19,20],"p",{},"前に自分で作ったXの動画ダウンロードボタンが、いつの間にか画面から消えていた。\nXのタイムラインを開いても、動画ツイートのアクションバーに出るはずのボタンがどこにもない。",[18,22,23,24,33,34,37,38,40],{},"Claude Code に調べさせたら、原因は拡張機能ではなくX側にあった。\n",[25,26,27,28,32],"strong",{},"Xが動画の描画方式を変えて、再生を始めるまで ",[29,30,31],"code",{},"\u003Cvideo>"," 要素をDOMに置かなくなっていた。","\n拡張の動画判定（",[29,35,36],{},"hasVideo","）は ",[29,39,31],{}," の有無を頼りにしていたので、\n「動画なし」と判定し続けてボタンを付けなくなっていた、というのが真相だった。",[18,42,43,44,47],{},"直し方はシンプルで、動画判定に別の手がかりを足すこと。\n再生前の状態でも残っている再生ボタンやサムネイル画像を ",[29,45,46],{},"querySelector"," で拾うフォールバックを\n3つ追加して、ボタンを復活させた。変更後にセキュリティレビューもかけて、\nDOM判定を広げただけで新しい穴は増えていないことを確認してからコミットした。",[14,49,50],{"id":50},"何が起きたか",[18,52,53],{},"きっかけは「そういえば動画のダウンロードボタン、最近見てないな」という違和感だった。\nChrome DevToolsでXを開いて確かめてもらったら、やはり動画ツイートにボタンが付いていない。",[18,55,56],{},"この手のバグは、原因が2つに割れる。",[58,59,60,64],"ol",{},[61,62,63],"li",{},"拡張機能そのものが壊れている（エラーで死んでいる、無効化されている）",[61,65,66],{},"拡張は生きているが、対象サイト側のDOM構造が変わって判定が空振りしている",[18,68,69,70,73],{},"まず1を潰すために、",[29,71,72],{},"chrome://extensions"," をDevTools経由で開いて、\nShadow DOMを掘って拡張の状態を確認させた。有効で、エラーもなし。拡張は生きていた。\nとなると2が濃厚で、Xの画面のDOMを直接見にいくことにした。",[14,75,77],{"id":76},"devtoolsで一緒にdomを追った","DevToolsで一緒にDOMを追った",[18,79,80],{},"ここからは、自分が「どこを見てほしいか」を指示して、\nClaude Code に実際のX画面のDOMを掘らせる作業になった。\nブックマーク画面と検索フィードを開いて、動画ツイートのDOMツリーを覗いていく。",[18,82,83,84,90,91,93],{},"分かったのは、",[25,85,86,87,89],{},"再生前の動画ツイートには ",[29,88,31],{}," 要素が存在しない","という事実だった。\n以前は動画ツイートを画面に出した時点で ",[29,92,31],{}," がDOMに入っていたはずだが、\n今は再生ボタンを押すまでプレースホルダー（サムネイル＋再生ボタン）しか置かれていない。",[18,95,96,97,100,101,103],{},"拡張の ",[29,98,99],{},"content.js"," は、ツイート要素の中に ",[29,102,31],{}," があるかどうかで動画判定していた。\nつまり、Xが遅延ロードに切り替えた瞬間から、判定が全部「動画なし」に倒れていたわけだ。\nボタンが消えたのは拡張のバグではなく、判定ロジックが前提にしていた世界が変わったから、\nという気持ちのいい切り分けができた。",[14,105,106],{"id":106},"動画判定にフォールバックを足して復活させた",[18,108,109,110,112],{},"原因が分かれば直しは早い。",[29,111,31],{}," が無くても「これは動画ツイートだ」と言える手がかりを、\n再生前のDOMから拾えばいい。再生ボタン・プレビューの覆い・サムネイル画像あたりが候補になる。",[18,114,115,116,118,119,121],{},"Claude Code に、",[29,117,36],{}," 判定へ次のような ",[29,120,46],{}," のフォールバックを足させた。",[123,124,129],"pre",{"className":125,"code":126,"language":127,"meta":128,"style":128},"language-js shiki shiki-themes vitesse-light vitesse-light","// \u003Cvideo> が無くても、再生前の手がかりで動画ツイートと判定する\nconst hasVideo =\n  el.querySelector('video') ||\n  el.querySelector('[data-testid=\"playButton\"]') ||\n  el.querySelector('[data-testid=\"previewInterstitial\"]') ||\n  el.querySelector('img[src*=\"video_thumb\"]')\n","js","",[29,130,131,140,155,186,208,230],{"__ignoreMap":128},[132,133,136],"span",{"class":134,"line":135},"line",1,[132,137,139],{"class":138},"sxvE3","// \u003Cvideo> が無くても、再生前の手がかりで動画ツイートと判定する\n",[132,141,143,147,151],{"class":134,"line":142},2,[132,144,146],{"class":145},"stQ0i","const",[132,148,150],{"class":149},"s4oTP"," hasVideo",[132,152,154],{"class":153},"shFtX"," =\n",[132,156,158,161,164,167,170,174,178,180,183],{"class":134,"line":157},3,[132,159,160],{"class":149},"  el",[132,162,163],{"class":153},".",[132,165,46],{"class":166},"senZ8",[132,168,169],{"class":153},"(",[132,171,173],{"class":172},"sMJiu","'",[132,175,177],{"class":176},"sdGka","video",[132,179,173],{"class":172},[132,181,182],{"class":153},")",[132,184,185],{"class":145}," ||\n",[132,187,189,191,193,195,197,199,202,204,206],{"class":134,"line":188},4,[132,190,160],{"class":149},[132,192,163],{"class":153},[132,194,46],{"class":166},[132,196,169],{"class":153},[132,198,173],{"class":172},[132,200,201],{"class":176},"[data-testid=\"playButton\"]",[132,203,173],{"class":172},[132,205,182],{"class":153},[132,207,185],{"class":145},[132,209,211,213,215,217,219,221,224,226,228],{"class":134,"line":210},5,[132,212,160],{"class":149},[132,214,163],{"class":153},[132,216,46],{"class":166},[132,218,169],{"class":153},[132,220,173],{"class":172},[132,222,223],{"class":176},"[data-testid=\"previewInterstitial\"]",[132,225,173],{"class":172},[132,227,182],{"class":153},[132,229,185],{"class":145},[132,231,233,235,237,239,241,243,246,248],{"class":134,"line":232},6,[132,234,160],{"class":149},[132,236,163],{"class":153},[132,238,46],{"class":166},[132,240,169],{"class":153},[132,242,173],{"class":172},[132,244,245],{"class":176},"img[src*=\"video_thumb\"]",[132,247,173],{"class":172},[132,249,250],{"class":153},")\n",[18,252,253,256,257,260,261,264,265,267],{},[29,254,255],{},"playButton"," は再生ボタン、",[29,258,259],{},"previewInterstitial"," は再生前にサムネイルを覆う要素、\n",[29,262,263],{},"video_thumb"," を含む画像は動画のサムネイルだ。どれか1つでも当たれば動画とみなす。\n",[29,266,31],{}," を先頭に残したのは、再生後のツイートも従来どおり拾えるようにするため。",[14,269,271],{"id":270},"目視で確認するまでは直ったと言わない","目視で確認するまでは「直った」と言わない",[18,273,274],{},"判定を広げた程度の修正でも、実際に画面でボタンが出るまでは信用しないことにしている。\n拡張をリロードして、検索フィードとブックマーク画面の両方を開き直してもらった。",[18,276,277],{},"検索フィードでは動画ツイート7件すべてに、再生前の状態でダウンロードボタンが付いた。\nブックマーク画面でも動画ツイート3件すべてで、サムネイルと再生ボタンが並ぶアクションバーに\nボタンが表示された。スクリーンショットで見た目が崩れていないところまで確認して、\nようやく「復活した」と判断した。",[14,279,280],{"id":280},"セキュリティレビューをかけてからコミットした",[18,282,283,284,287],{},"DOM判定を触っただけとはいえ、拡張機能はXのページに注入されて動くコードなので、\n念のためセキュリティレビュー（",[29,285,286],{},"/security-review"," 相当）を走らせた。",[18,289,290,291,293,294,296],{},"結果は指摘ゼロ。今回の差分は ",[29,292,36],{}," というクライアント側のDOMヒューリスティックに\n",[29,295,46],{}," のフォールバックを3つ足しただけで、",[298,299,300,303,306],"ul",{},[61,301,302],{},"新しいデータの出口（sink）は増えていない",[61,304,305],{},"新しいデータフローも生まれていない",[61,307,308,309,312,313,316,317,320],{},"既存の ",[29,310,311],{},"tweetUrl"," の組み立て、",[29,314,315],{},"chrome.runtime.sendMessage"," で送るペイロード、\nトーストの ",[29,318,319],{},"innerHTML"," 補間には一切手を付けていない",[18,322,323,324,326,327,330],{},"最悪ケースでも「動画じゃないツイートに誤ってボタンが出る」というUIの誤検知どまりで、\nこれまで越えられなかったセキュリティ境界を新たに越えるものではない、という整理だった。\n安心して ",[29,325,99],{}," の変更だけをコミットした（",[29,328,329],{},"background.js"," などの以前からの\n未コミット変更は今回の話とは別なので、そのまま残してある）。",[14,332,333],{"id":333},"学び",[298,335,336,342,351,357],{},[61,337,338,341],{},[25,339,340],{},"ボタンが消えた＝自分のコードが壊れた、とは限らない。"," 対象サイトのDOMは黙って変わる。\n拡張が生きているかを先に潰してから、相手のDOMを疑う順序で切り分けると早い",[61,343,344,347,348,350],{},[25,345,346],{},"DOM判定は「今ある要素」に賭けると脆い。"," ",[29,349,31],{}," 一本足打法だと、遅延ロードに\n切り替えられた瞬間に全滅する。再生ボタン・サムネイルなど、複数の手がかりでORを取ると壊れにくい",[61,352,353,356],{},[25,354,355],{},"人間は違和感を拾う係、AIは掘る係。"," 「ボタン見ないな」という肌感覚から始めて、\nDOMを実際に掘って原因を突き止め、修正して画面で確認するところまでは道具に回させると速い",[61,358,359,362],{},[25,360,361],{},"小さな差分でもセキュリティレビューは効く。"," 「DOM判定を広げただけ」を、\nsink・データフロー・既存の危険箇所に触れていないかという観点で言語化してもらえると、\n自分の中で「これは安全な変更だ」と腹落ちしてからコミットできる",[364,365,366],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":128,"searchDepth":142,"depth":142,"links":368},[369,370,371,372,373,374,375],{"id":16,"depth":142,"text":16},{"id":50,"depth":142,"text":50},{"id":76,"depth":142,"text":77},{"id":106,"depth":142,"text":106},{"id":270,"depth":142,"text":271},{"id":280,"depth":142,"text":280},{"id":333,"depth":142,"text":333},"dev",null,"以前作ったXの動画ダウンロードボタンが、ある日消えていた。拡張は壊れていなくて、X側が動画の描画方式を変えていたのが真因。DevToolsで一緒にDOMを追い、動画判定に querySelector のフォールバックを足して復活させた記録。","md",{},true,"/x-video-download-button-fix","chrome-extension-x",false,"2026-07-05T00:00:00.000Z",{"title":5,"description":378},"2026-07/2026-07-05/x-video-download-button-fix",[389,390,391,392,393],"Chrome拡張","X","DOM","デバッグ","DevTools","z2YRhTeVgrjB3YEKH5wrjfOfgwJRRXzez_F-s_3boDM",[],"https://log.eurekapu.com/og/blog/x-video-download-button-fix.png?v=2026-07-05T00%3A00%3A00.000Z&title=%E8%87%AA%E4%BD%9CChrome%E6%8B%A1%E5%BC%B5%E3%81%AEX%E3%81%AE%E5%8B%95%E7%94%BB%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89%E3%83%9C%E3%82%BF%E3%83%B3%E3%81%8C%E6%B6%88%E3%81%88%E3%81%9F%E2%80%94%E2%80%94DOM%E6%A7%8B%E9%80%A0%E3%81%AE%E5%A4%89%E5%8C%96%E3%82%92%E8%BF%BD%E3%81%A3%E3%81%A6%E5%BE%A9%E6%B4%BB%E3%81%95%E3%81%9B%E3%81%9F&author=Kei%20Komatsu&sig=4e20e40e1b43b472",1783483676887]