0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

YouTubeの文字起こしを開くだけでMarkdown保存するTampermonkeyスクリプトを作った(Obsidian向けプロパティ付き)

0
Last updated at Posted at 2026-10-15

はじめに

YouTubeの動画をしっかり見たい時は、文字起こしを見ながら、そこにメモや画面キャプチャーを自分なりに張り付けたりして、記録を残したいことがあります。
そこで見ている動画の文字起こしを自動保存するTampermonkey scriptを作りました。
後はそれをエディターでメモしながら動画を閲覧する訳です。
この動画メモはObsidianで記録していくのをイメージしています。
使い続けると閲覧した動画のスクラップブックが作れるので学習系動画の閲覧の助けになるんじゃないかと思います。
以下の解説文とコードはAIに作ってもらいました。

解説

YouTube で技術系の動画や解説動画を見たあと、「あの話、どこで言ってたっけ?」と探し直すことがよくあります。動画は検索しにくいので、文字起こし(字幕)をテキストで手元に残しておき、Obsidian などのノートアプリで全文検索したり、生成AIに要約させたりしたいと考えました。

YouTube には動画の概要欄から開ける「文字起こしを表示」パネルがありますが、ここからテキストをコピーするのは意外と面倒です。

  • パネルを開いて、全部選択して、コピーして、エディタに貼って、ファイル名を付けて保存…を毎回やる
  • タイムスタンプと本文が交互に並ぶので、そのままでは読みにくい
  • 動画タイトル・チャンネル名・URL・公開日などのメタ情報は別途手で書く必要がある

そこで、文字起こしパネルが開いたら、その内容を Markdown ファイルとして自動保存する Tampermonkey のユーザースクリプトを作りました。この記事では、使い方と、作る過程でハマったポイント(YouTube の内部 API が使えなくなった件、3 種類ある文字起こしパネル、SPA 遷移、GM_download の設定など)をまとめます。

保存したテキストは個人のメモ・学習用途にとどめてください。字幕・文字起こしの著作権は動画の制作者にあります。再配布や公開は行わないでください。

できること

機能 内容
自動保存 動画ページを開くと、スクリプトが「文字起こしを表示」ボタンを自動で押し、内容を保存してパネルを閉じる(ON/OFF 切替可)
手動保存 自分で文字起こしパネルを開いても保存される。画面右下の常駐ボタンでも保存できる
本文のみモード タイムスタンプを外し、約200文字ごとに文末で段落を区切った読みやすい本文にする(既定)
メタ情報 タイトル・チャンネル・URL・動画ID・公開日(JST)・長さ・再生回数・高評価数・高評価率・カテゴリ・言語を YAML フロントマターに書く
ファイル名 取得日_タイトル.md。Windows と Obsidian で問題になる文字は _ に置換
保存先 ダウンロードフォルダ配下の YouTubeTranscripts サブフォルダ(ジャンクションで RAM ディスク等へ振り向け可能)
出力形式 md(既定)/ txt / srt

動作の流れは次のとおりです。

動作検証環境

  • Windows 11
  • Google Chrome(Chrome 138 以降で確認)
  • Tampermonkey(Manifest V3 版)
  • 2026年10月時点の YouTube(PC 版 https://www.youtube.com/)

YouTube の画面構成(DOM)は予告なく変わります。この記事の内容は 2026年10月時点のものです。動かなくなったら、後述の「動かないときの確認順」とセレクタの調査方法を参考にしてください。

導入手順

1. Tampermonkey を入れて「ユーザースクリプトを許可」する

Chrome ウェブストアから Tampermonkey をインストールします。

Chrome 138 以降では、拡張機能側で ユーザースクリプトの実行を明示的に許可する必要があります。

  1. chrome://extensions を開く
  2. Tampermonkey の「詳細」を開く
  3. 「ユーザースクリプトを許可」をオンにする

(それより前のバージョンの Chrome では、拡張機能画面右上の「デベロッパーモード」をオンにします。)

2. Tampermonkey のダウンロード設定を変える

このスクリプトは GM_download でサブフォルダにファイルを保存します。既定の設定のままだと保存に失敗するので、次の設定を変更します。

  1. Tampermonkey のダッシュボード →「設定」タブ
  2. 一番上の「設定モード」を「上級者」にする
  3. 「ダウンロード BETA」の「ダウンロード方法」を「ブラウザー API」にする(サブフォルダ指定に必須)
  4. 同じ欄の「ファイル拡張子のホワイトリスト」に、次の行を 1 行ずつ 追加する
.md
.srt

ホワイトリストに /\.(md|txt|srt)$/ のような正規表現の行を書いたところ、日本語環境でバックスラッシュが ¥ に化けるなどして効かないことがありました。単純な .md の形で 1 行ずつ書くのが確実です。失敗時は not_whitelisted というエラーになります。

3. Chrome のダウンロード設定

  • chrome://settings/downloads の「ダウンロード前に各ファイルの保存場所を確認する」をオフにする(オンだと毎回ダイアログが出る)
  • 初回保存時に「このサイトが複数のファイルを自動でダウンロードしようとしています」と出たら「許可」する

4. スクリプトを登録する

Tampermonkey のダッシュボードで「+(新規スクリプト)」を開き、エディタの中身を全部消してから、記事末尾のスクリプト全文を貼り付けて保存(Ctrl+S)します。

その後、YouTube のタブを再読み込みしてください。画面右下に「⬇ 文字起こし」ボタンが表示されれば、スクリプトが動いています。

使い方

自動保存(既定:ON)

動画ページを開くと、約3秒後にスクリプトが概要欄の「文字起こしを表示」ボタンを押し、パネルの内容を保存してパネルを閉じます。60秒未満の短い動画(ショートなど)は自動保存の対象外です。

手動保存

  • YouTube の「文字起こしを表示」ボタンを自分で押してパネルを開くだけで保存されます
  • 画面右下の常駐ボタンを押しても保存できます(パネルが閉じていれば開いてから保存)

常駐ボタンは状態を色と文字で表します。

表示 色 意味
⬇ 文字起こし 濃い灰色 未保存
⏳ 保存中… 橙 保存処理中
✔ 保存済み(再保存) 緑 保存済み。押すともう一度保存

メニュー

Chrome のツールバーの Tampermonkey アイコンから、次の操作ができます。

  • 今の動画の文字起こしを保存
  • 自動で開いて保存 ON/OFF(OFF でも、パネルを手で開けば保存されます)

出力例

既定(format: 'md'、textOnly: true)では、次のような Markdown が保存されます。ファイル名は 2026-10-12_動画タイトル.md のようになります。

2026-10-12_動画タイトル.md
---
format: "Open Knowledge Format (YouTube Transcript) v1"
title: "動画タイトル"
channel: "チャンネル名"
url: https://www.youtube.com/watch?v=XXXXXXXXXXX
video-id: XXXXXXXXXXX
published: 2026-10-06
published-at: "2026-10-06T05:23:55-07:00"
duration: "00:12:10"
views: 7122
likes: 35
like-rate: "0.49%"
stats-as-of: 2026-10-12
category: "Science & Technology"
language: ""
source-type: "YouTube 文字起こし(字幕パネル)"
source-tool: "YouTube Transcript Auto Downloader v2.5.2"
verification: "未実施(自動字幕の可能性あり)"
downloaded: 2026-10-12
last-updated: 2026-10-12
tags: [youtube, transcript]
---

# 動画タイトル

> チャンネル名 / 公開日: 2026-10-06 / 再生回数: 7,122 / 高評価: 35(0.49%)

(ここに文字起こしの本文。約200文字ごとに、文末で段落が区切られる)

フロントマターは、Obsidian の保管庫(Vault)で使っている独自の共通ヘッダー形式(format: 行で文書の種類を示す)に合わせています。文字列は "..." で囲み、数値と日付(YYYY-MM-DD)は囲まないようにしているので、Obsidian のプロパティでは views や likes が数値型、published が日付型として扱われ、並べ替えや Dataview / Bases での集計に使えます。build() 関数の fm 配列を書き換えれば、好きな形式にできます。重要パラメータは like-rate でこの数字が高いと人気コンテンツと考えられます。

設定項目

スクリプト冒頭の CONFIG で挙動を変えられます。

項目 既定値 説明
format 'md' 'md' / 'txt' / 'srt'。txt でも拡張子は .md
textOnly true タイムスタンプを付けず段落にまとめる
paragraphChars 200 本文のみモードで、この文字数を超えたら文末で改段落
timestamps true textOnly: false のとき行頭にタイムスタンプを付ける
mdTimeLinks false textOnly: false のとき、タイムスタンプを該当秒へのリンクにする
subfolder 'YouTubeTranscripts' 既定のダウンロードフォルダからの相対パス
autoOpenDelayMs 3000 画面遷移後、パネルを自動で開くまでの待ち時間
minDurationSec 60 これより短い動画は自動保存しない
closePanelAfterAuto true 自動で開いたパネルを保存後に閉じる
maxTitleChars 100 ファイル名に使うタイトルの最大文字数
fullwidthToUnderscore false 全角の :/? なども _ に置換する

保存先を RAM ディスクにする(任意)

GM_download の保存先(name)には、Chrome の既定ダウンロードフォルダからの相対パスしか指定できません。R:\... のような絶対パスは書けないので、RAM ディスクなど別のドライブに保存したい場合は、ダウンロードフォルダ内のサブフォルダをジャンクションにして振り向けます。他のダウンロードには影響しません。

一般ユーザーのコマンドプロンプトで実行します(管理者権限は不要です)。

一般ユーザーのコマンドプロンプト
mklink /J "%USERPROFILE%\Downloads\YouTubeTranscripts" "R:\YouTubeTranscripts"

PowerShell で行う場合も一般ユーザーで実行できます。

一般ユーザーの PowerShell
New-Item -ItemType Junction -Path "$env:USERPROFILE\Downloads\YouTubeTranscripts" -Target "R:\YouTubeTranscripts"
  • 同名の普通のフォルダが既にあると失敗するので、中身を退避して削除してから作ります
  • dir で <JUNCTION> と表示されれば成功です
  • RAM ディスクは再起動で中身が消えるので、リンク先のフォルダ(R:\YouTubeTranscripts)は起動時に作るようにします(RAM ディスクソフトの起動時フォルダ作成機能や、スタートアップのバッチで mkdir)

私は「まず RAM ディスクに落とし、残す価値のあるものだけ手で Obsidian の保管庫に移す」という運用にしています。見ただけの動画のファイルがノートに溜まり続けないのが利点です。

実装のポイント・ハマりどころ

ここからは、作る過程で分かったことをまとめます。

1. 内部 API(get_transcript)を直接叩くのはやめた

最初のバージョン(v1)は、YouTube の内部 API(InnerTube)である POST /youtubei/v1/get_transcript をページ内から直接呼び出して文字起こしを取得していました。ところが 2026年10月時点では、この API を呼ぶと 400 FAILED_PRECONDITION が返るようになっていました。

そこで v2 からは方針を変え、YouTube 自身が画面に描画した文字起こしパネルの DOM を読み取るようにしました。この方式には次の利点があります。

  • 非公開 API のパラメータ(params のエンコードなど)の仕様変更に振り回されない
  • ユーザーが手でパネルを開いた場合も、同じ処理で保存できる
  • 「パネルに表示されている内容=保存される内容」なので、結果が分かりやすい

2. 文字起こしパネルは 3 種類ある

調べてみると、文字起こしパネルには少なくとも 3 種類あり、動画(やアカウント)によってどれが出るかが変わります。

種類 パネルの target-id 1 行の要素 タイムスタンプ 本文
旧UI engagement-panel-searchable-transcript ytd-transcript-segment-renderer .segment-timestamp .segment-text
新UI(「文字起こし」見出し+検索欄) PAmodern_transcript_view transcript-segment-view-model .ytwTranscriptSegmentViewModelTimestamp span.ytAttributedStringHost
「動画の関連情報」(チャプター/文字起こしタブ) 無し transcript-segment-view-model 同上 同上

3 つ目のパネルには target-id が無いため、target-id でパネルを特定する方法は使えません。そこで、開いているパネル(visibility="ENGAGEMENT_PANEL_VISIBILITY_EXPANDED")の中に、文字起こしの行要素があるかどうかで判定するようにしました。

開いているパネルの判定
const EXPANDED = 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED';

function activePanel() {
  const open = [...document.querySelectorAll('ytd-engagement-panel-section-list-renderer')]
    .filter((el) => el.getAttribute('visibility') === EXPANDED);
  // 中身(行要素)があるパネルを優先
  for (const el of open) for (const def of PANELS) if (el.querySelector(def.seg)) return { def, el };
  for (const el of open) for (const def of PANELS) if (el.matches(def.sel)) return { def, el };
  return null;
}

新UIのタイムスタンプの隣には、スクリーンリーダー用の ...TimestampA11yLabel(「23 秒」のようなテキスト)があります。本文の抽出に混ざらないよう、セレクタは本文用の span.ytAttributedStringHost に絞っています。

3. SPA 遷移と「前の動画の内容」の誤保存

YouTube は SPA(Single Page Application)なので、関連動画をクリックしてもページ全体は再読み込みされません。画面遷移は YouTube 独自のイベント yt-navigate-finish で検知できます。

document.addEventListener('yt-navigate-finish', onNavigate);
onNavigate(); // 初回ロード時にも呼ぶ

厄介なのは、遷移直後にはパネルに前の動画の文字起こしが残っていることです。そのまま MutationObserver で拾うと、新しい動画のタイトルで古い内容を保存してしまいます。

そこで、遷移時にパネルの内容の「署名」(行数+先頭行+末尾行)を記録しておき、保存時に同じ署名なら古い内容とみなしてスキップするようにしました。

const signature = (segs) =>
  (segs.length ? `${segs.length}|${segs[0].text}|${segs[segs.length - 1].text}` : null);

// 別の動画から遷移してきたときだけ、残っている内容を「古い」とみなす
state.staleFirstText = prevId ? signature(readSegments()) : null;

署名を取るのは別の動画から遷移してきたときだけにしています。初回表示でも署名を取ってしまうと、最初に開いた動画の正しい内容まで弾いてしまうためです。

4. 描画待ちは MutationObserver+行数の安定待ち

パネルの行は少しずつ描画されるので、開いた直後に読むと途中までしか取れないことがあります。決め打ちの sleep だけに頼らず、

  1. MutationObserver でパネルが開いて行が現れたことを検知し、
  2. 400ms 間隔で行数を数え、前回と同じ行数になったら(安定したら) 読み取る

という組み合わせにしています。リストが二重に描画されることもあったので、「時刻+本文」をキーに重複を除いてから時刻順に並べ直しています。

また、新UIは仮想化リスト(表示範囲だけ描画)の可能性があったため、最後の行が動画の終わりから 90 秒以上手前のときだけ、パネルを 1 画面ずつスクロールして全行を集める処理を入れています(最長 15 秒で打ち切り)。実際には 23 分の動画でも描画済みの行で末尾まで取れることがほとんどで、スクロールが走ることは稀でした。

5. 背景タブのタイマー抑制に注意

Chrome は、画面に表示されていないタブの setTimeout を間引きます(長く隠れているタブでは 1 分に 1 回程度まで遅くなります)。最初は sleep を何十回も回すループを書いていたため、背景タブで「保存中…」のまま止まったように見える問題がありました。

対策として、

  • 必要のない処理は早めに打ち切る
  • ループには必ず Date.now() による時間の上限を付ける
  • 処理中フラグ saving に開始時刻を持たせ、45 秒を超えたら固まったとみなして解除する(ウォッチドッグ)

ようにしました。また、背景タブではそもそも文字起こしパネルの読み込みが進まないことがあるので、「タブが表示された時点で MutationObserver が拾えばよい」という作りにしています。

6. メタ情報はプレイヤー API から取る

タイトルやチャンネル名を DOM から取ると、SPA 遷移直後に前の動画の値が残っていることがあります。確実なのは、ページの #movie_player 要素が持つプレイヤー API を使う方法です。

const player = unsafeWindow.document.getElementById('movie_player');
const vd = player.getVideoData();      // { video_id, title, author, ... }
const dur = player.getDuration();      // 秒
const pr = player.getPlayerResponse(); // videoDetails / microformat を含む
  • getVideoData():タイトル(title)、チャンネル名(author)、動画ID(video_id)
  • getPlayerResponse():
    • videoDetails.viewCount(再生回数)、videoDetails.lengthSeconds(長さ)
    • microformat.playerMicroformatRenderer.publishDate / uploadDate(公開日)、likeCount(高評価数)、category

getPlayerResponse() は SPA 遷移後も新しい動画の値に更新されます。一方、よく紹介されている window.ytInitialPlayerResponse は初回ロード時の値のままなので、遷移後には使えません。いずれの場合も、取得した値の動画ID が対象の動画ID と一致するかを必ず確認しています。

@grant を指定したユーザースクリプトはサンドボックス内で動くため、ページ側のオブジェクト(プレイヤー API など)には unsafeWindow 経由でアクセスします。

likeCount が取れなかった場合の予備として、高評価ボタンの aria-label(「他 12,345 人とともに…」)から数字を拾っています。ただし「1.2万」「12K」のような丸めた表記しか無い場合は不正確になるので採用しません。

7. 日付は JST で作る

new Date().toISOString().slice(0, 10) は UTC の日付なので、日本時間の 0〜9 時に実行すると前日の日付になります。sv-SE(スウェーデン語)ロケールが YYYY-MM-DD 形式を返すことを利用して、JST の日付を作っています。

const jstDate = (d = new Date()) =>
  new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Tokyo' }).format(d);

YouTube が返す公開日(publishDate)は 2022-09-15T11:03:16-07:00 のように米国太平洋時間のオフセット付きなので、これも JST の日付に直して published に入れ、元の値は published-at に残しています。

8. ファイル名の無害化

動画タイトルにはさまざまな記号が含まれるので、ファイル名に使う部分だけ次のルールで無害化しています(Markdown 本文やフロントマターの title は元のまま)。

  1. Windows の禁止文字 \ / : * ? " < > | と制御文字 → _
  2. Obsidian のリンク・見出し・ブロック参照を壊す # ^ [ ] → _
  3. ゼロ幅スペース・方向制御文字・BOM(U+200B〜200F、U+202A〜202E、U+2060〜2069、U+FEFF)は削除
  4. _ や空白の連続は 1 つにまとめ、先頭・末尾の空白・ドット・_ を除去(Windows と Chrome は末尾のドットを拒否する)
  5. CON PRN AUX NUL COM1〜9 LPT1〜9 などの予約名は先頭に _ を付ける
  6. 長さは Array.from(s).slice(0, N) でコードポイント単位に切る(絵文字の途中で切らない)
  7. 全角の :?/ などは Windows でも使えるので既定では残す(設定で _ にできる)

当初はファイル名に [動画ID] を付けていましたが、Obsidian では [ ] がリンク記法と衝突するため、動画ID はファイル名から外し、フロントマターの video-id に残すようにしました。

9. GM_download には data URL を渡す、MIME は拡張子に合わせる

GM_download に blob: URL を渡すと、環境によって失敗することがありました。テキストを UTF-8 のバイト列にして base64 化した data URL を渡すと安定しました。

function toDataUrl(text) {
  const bytes = new TextEncoder().encode(text);
  let bin = '';
  for (let i = 0; i < bytes.length; i += 0x8000) {
    bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
  }
  return 'data:text/markdown;charset=utf-8;base64,' + btoa(bin);
}

GM_download({
  url: toDataUrl(markdown),
  name: `YouTubeTranscripts/${filename}`, // 既定ダウンロードフォルダからの相対パス
  saveAs: false,
  conflictAction: 'uniquify',             // 同名ファイルは (1) 付きで保存
  onerror: (e) => fallback(e?.error),
});

ポイントは 2 つです。

  • MIME を拡張子に合わせる:最初は text/plain にしていたところ、Chrome がファイル名の拡張子を .md から .txt に付け替えてしまうことがありました。text/markdown にしたら直りました
  • 失敗時のフォールバック:onerror では <a download> によるダウンロードに切り替え、トーストで失敗理由を表示します(この経路ではサブフォルダは使えず、既定フォルダ直下に保存されます)

GM_download が返すエラーコードと主な原因は次のとおりです。

エラー 原因
not_enabled ダウンロード機能が無効
not_whitelisted 拡張子がホワイトリストに無い
not_permitted ダウンロード権限が許可されていない
not_supported ダウンロード方法が Native / Disabled になっている
download_error 書き込み失敗(保存先フォルダやジャンクション先の有無を確認)

10. 「動いているか」が一目で分かる常駐ボタン

ユーザースクリプトは「反応しない」ときに、そもそも動いていないのか、途中で失敗しているのかが分かりにくいのが難点です。そこで、

  • 画面右下に常駐ボタンを置き、状態を色と文字で表す
  • 起動時に console.log('[YT-Transcript] loaded v2.5.2, auto = true') を出す

ようにしました。ボタンが見えなければスクリプト自体が動いていない、ボタンはあるが保存されないなら途中で失敗している、とすぐに切り分けられます。赤は「エラー・押せない」に見えるので通常状態には使わず、opacity: 1 にして「押せる」ことが分かるようにしています。また、処理中の表示は try/finally で必ず元に戻し、例外で「保存中…」のまま残らないようにしています。

なお、YouTube は Trusted Types を有効にしているため innerHTML が使えません。ボタンやトーストは createElement と textContent で組み立てています。

動かないときの確認順

  1. Chrome の拡張機能の詳細画面で、Tampermonkey の「ユーザースクリプトを許可」(Chrome 138 以降)またはデベロッパーモードがオンになっているか
  2. Tampermonkey のダッシュボードでスクリプトが有効か、@match が URL に合っているか、同じスクリプトが 2 つ入っていないか
  3. スクリプトを貼り替えたあと、YouTube のタブを再読み込みしたか(常駐ボタンや loaded v2.5.2 のログで新しい版が動いているか確認)
  4. 開発者ツール(F12)のコンソールで [YT-Transcript] を検索してログを見る
  5. 保存に失敗する:拡張子のホワイトリスト、ダウンロード方法(ブラウザー API)、Chrome の複数ダウンロードのブロック
  6. 「保存中…」のまま止まる:背景タブのタイマー抑制。タブを前面に出して少し待つ
  7. それでも駄目なら YouTube 側の仕様変更を疑い、DOM を調べ直す

YouTube の DOM を調べるときは、開発者ツールのコンソールで次のように実行すると、開いているパネルの種類と行数を確認できます。

開発者ツールのコンソールで実行
[...document.querySelectorAll('ytd-engagement-panel-section-list-renderer')]
  .filter((el) => el.getAttribute('visibility') === 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED')
  .map((el) => ({
    targetId: el.getAttribute('target-id'),
    classic: el.querySelectorAll('ytd-transcript-segment-renderer').length,
    modern: el.querySelectorAll('transcript-segment-view-model').length,
  }));

行が 0 件なら、行要素のタグ名が変わった可能性があります。文字起こしの本文の一部をテキストで探し、親要素をたどって構造を確認すると早いです。

活用例

私は保存した Markdown を次のように使っています。

  • Obsidian で全文検索:「あの動画で言っていた設定値」を後から探せる
  • プロパティで整理:views や published が数値・日付型になるので、Bases / Dataview で「チャンネル別」「公開日順」の一覧を作れる
  • 生成AIで要約・構造化:文字起こしの原文を残したまま、AI に見出し・要点・表を付けて読みやすいノートにしてもらう。フロントマターの verification を「未実施(自動字幕の可能性あり)」にしているのは、自動字幕の誤変換をそのまま事実として扱わないための目印です

おわりに

非公開 API に頼る方法から「YouTube 自身が描画した画面を読む」方法に切り替えたことで、仕様変更への耐性と、手動・自動の両対応を同時に得られました。一方で、DOM を読む方式は画面構成の変更に弱いので、セレクタは配列(PANELS)にまとめ、追加・差し替えしやすい形にしています。

同じように「YouTube の情報を手元のノートに貯めたい」方の参考になれば幸いです。

スクリプト全文

youtube-transcript-auto-downloader.user.js(v2.5.2)
youtube-transcript-auto-downloader.user.js
// ==UserScript==
// @name         YouTube Transcript Auto Downloader
// @namespace    https://github.com/nemo-kaz
// @version      2.5.2
// @description  YouTube の文字起こしパネルが開いたら(手動・自動どちらでも)内容をファイルに保存する
// @author       nemo-kaz
// @match        https://www.youtube.com/*
// @run-at       document-idle
// @noframes
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// ==/UserScript==

/*
 * v2 の変更点
 *  - InnerTube get_transcript API の直叩きを廃止(2026-10 時点で 400 FAILED_PRECONDITION が返るため)
 *  - YouTube 自身が描画した文字起こしパネル(ytd-transcript-segment-renderer)を監視して取得
 *      → YouTube の「文字起こしを表示」ボタンを手で押しても保存される
 *      → auto=ON なら、スクリプトがボタンを自動で押す
 *  - 画面右下に「⬇ 文字起こし」ボタンを常駐(スクリプトが動いているかの目印も兼ねる)
 *  - GM_download は data: URL で渡す(blob: URL だと環境により失敗するため)
 *
 * v2.5.0 の変更点
 *  - md のヘッダー(YAML プロパティ)を Open Knowledge Format (OKF) v1 形式に変更
 *  - 動画の公開日(JST)・再生回数・高評価数・高評価率・長さ・カテゴリを追加
 *    取得元は movie_player.getPlayerResponse() の videoDetails / microformat
 *    (SPA 遷移後も最新の動画に更新されることを 2026-10-10 に実ページで確認)
 *  - downloaded(取得日)を UTC ではなく JST の日付に修正
 *
 * v2.5.1 の変更点
 *  - ファイル名のタイトル部分の無害化を強化(Windows 禁止文字・Obsidian で問題になる # ^ [ ]・
 *    不可視文字・末尾ドット・予約名・文字単位の長さ制限)
 *  - ファイル名の動画 ID を [ID] → (ID) に変更(Obsidian のリンク対策)
 *
 * v2.5.2 の変更点
 *  - ファイル名から動画 ID を削除(「取得日_タイトル.md」。ID は md の video-id プロパティに残る)
 *  - 拡張子を常に .md に(ダウンロード時の MIME を text/plain → text/markdown に変更し、
 *    Chrome による .txt への付け替えを防ぐ)
 */

(function () {
  'use strict';

  // ===================== 設定 =====================
  const CONFIG = {
    format: 'md',            // 'md' | 'txt' | 'srt'(txt でも拡張子は .md で保存する)
    // 本文のみモード:タイムスタンプ・リンクを付けず、行をつないで段落にする
    textOnly: true,
    paragraphChars: 200,     // 本文のみモードで、この文字数を超えたら文末で改段落
    timestamps: true,        // textOnly=false のとき md/txt でタイムスタンプを付ける
    mdTimeLinks: false,      // textOnly=false のとき md のタイムスタンプを該当秒へのリンクにする
    // Chrome の既定ダウンロードフォルダからの相対パス(絶対パスは指定不可)
    // RAMディスクへはジャンクション or Chrome の保存先変更で振り向ける(README 参照)
    subfolder: 'YouTubeTranscripts',
    autoOpenDelayMs: 3000,   // 遷移後、自動でパネルを開くまでの待ち
    minDurationSec: 60,      // これより短い動画は自動取得しない(手動ボタンは対象)
    closePanelAfterAuto: true, // 自動で開いたパネルは保存後に閉じる
    maxTitleChars: 100,      // ファイル名に使うタイトルの最大文字数(パス長 260 文字制限への余裕)
    fullwidthToUnderscore: false, // true なら全角の :/? なども _ に置換(全角は Windows で使えるので既定は残す)
  };
  // ================================================

  const W = unsafeWindow;
  const TAG = '[YT-Transcript]';
  const LOG = (...a) => console.log(TAG, ...a);
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  // YouTube には旧UI・新UIの2種類の文字起こしパネルがあり、動画やアカウントでどちらが出るか変わる
  const PANELS = [
    { // 旧UI
      name: 'classic',
      sel: 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"]',
      seg: 'ytd-transcript-segment-renderer',
      ts: '.segment-timestamp',
      tx: '.segment-text',
    },
    { // 新UI(「文字起こし」見出し+「文字音声変換を検索」)
      name: 'modern',
      sel: 'ytd-engagement-panel-section-list-renderer[target-id="PAmodern_transcript_view"]',
      seg: 'transcript-segment-view-model',
      ts: '.ytwTranscriptSegmentViewModelTimestamp',
      tx: 'span.ytAttributedStringHost',
    },
  ];
  const EXPANDED = 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED';
  const ANY_SEG_SEL = PANELS.map((d) => d.seg).join(', ');

  const state = {
    videoId: null,
    savedThisView: false,  // このページ表示で保存済みか
    autoOpened: false,     // スクリプトがパネルを開いたか
    staleFirstText: null,  // 遷移前パネル内容の署名(古い内容の誤保存防止)
  };

  const isAuto = () => GM_getValue('auto', true);

  // ---------- ユーティリティ ----------
  const getVideoIdFromUrl = () =>
    location.pathname === '/watch' ? new URL(location.href).searchParams.get('v') : null;

  function msToHms(ms, srt = false) {
    const t = Math.floor(ms / 1000);
    const h = String(Math.floor(t / 3600)).padStart(2, '0');
    const m = String(Math.floor((t % 3600) / 60)).padStart(2, '0');
    const s = String(t % 60).padStart(2, '0');
    return srt ? `${h}:${m}:${s},${String(ms % 1000).padStart(3, '0')}` : `${h}:${m}:${s}`;
  }
  const hmsToMs = (str) => str.trim().split(':').map(Number).reduce((a, p) => a * 60 + p, 0) * 1000;
  // ファイル名に使えない/使うと困る文字を _ に置換する(タイトル部分のみ。md 内の title はそのまま)
  //  1. Windows で禁止の文字   \ / : * ? " < > |  と制御文字
  //  2. Obsidian のリンクを壊す文字  # ^ [ ]  ([[ ]] リンクや見出し・ブロック参照と衝突する)
  //  3. 目に見えない文字(ゼロ幅スペース・方向制御文字・BOM)は削除
  //  4. 全角の \/:*?"<>| は Windows では使えるが、CONFIG.fullwidthToUnderscore=true なら _ に置換
  //  5. _ や空白の連続は1つにまとめ、先頭・末尾の空白・ドット・_ を除去(Windows/Chrome が末尾ドットを拒否するため)
  //  6. CON / NUL / COM1 などの Windows 予約名は先頭に _ を付ける
  //  7. 長さは文字(コードポイント)単位で切る(絵文字や結合文字の途中で切らない)
  // 不可視文字・制御文字の正規表現(\u エスケープを使わずに組み立てる:転記・アップロードで化けないように)
  const charRange = (a, b) => String.fromCharCode(a) + '-' + String.fromCharCode(b);
  const INVISIBLE_RE = new RegExp('[' + charRange(0x200B, 0x200F) + charRange(0x202A, 0x202E) +
    charRange(0x2060, 0x2069) + String.fromCharCode(0xFEFF) + ']', 'g');
  const CONTROL_RE = new RegExp('[' + charRange(0x00, 0x1F) + String.fromCharCode(0x7F) + ']', 'g');
  function sanitize(name) {
    let s = String(name ?? '').normalize('NFC');
    s = s.replace(INVISIBLE_RE, '');
    s = s.replace(/[\\/:*?"<>|]/g, '_').replace(CONTROL_RE, '_');
    s = s.replace(/[#^[\]]/g, '_');
    if (CONFIG.fullwidthToUnderscore) s = s.replace(/[\/:*?"<>|#[]]/g, '_');
    s = s.replace(/\s+/g, ' ')
      .replace(/\s*_[\s_]*/g, '_')           // 「 _ 」「__」→「_」
      .replace(/^[\s._]+|[\s._]+$/g, '');
    s = Array.from(s).slice(0, CONFIG.maxTitleChars).join('').replace(/[\s._]+$/g, '');
    if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i.test(s)) s = `_${s}`;
    return s || 'untitled';
  }

  function toast(msg, ok = true) {
    const el = document.createElement('div');
    el.textContent = msg;
    Object.assign(el.style, {
      position: 'fixed', right: '16px', bottom: '64px', zIndex: 2147483647,
      background: ok ? '#1f6f3f' : '#8a1f1f', color: '#fff', padding: '10px 14px',
      borderRadius: '8px', fontSize: '13px', boxShadow: '0 2px 8px rgba(0,0,0,.3)', maxWidth: '460px',
    });
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 5000);
  }

  function getMeta(videoId) {
    const player = W.document.getElementById('movie_player');
    const vd = player?.getVideoData ? player.getVideoData() : null;
    const ok = vd && vd.video_id === videoId;
    return {
      title: ((ok && vd.title) || document.querySelector('h1.ytd-watch-metadata yt-formatted-string')?.textContent ||
        document.title.replace(/ - YouTube$/, '')).trim(),
      channel: ((ok && vd.author) || document.querySelector('#owner #channel-name a')?.textContent || '').trim(),
      duration: player?.getDuration ? player.getDuration() : 0,
      playerReady: !!ok,
    };
  }

  // ---------- 動画の統計情報(公開日・再生回数・高評価数) ----------
  const SCRIPT_VERSION = '2.5.2';
  // JST の YYYY-MM-DD(sv-SE ロケールは ISO 形式の日付を返す)
  const jstDate = (d = new Date()) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Tokyo' }).format(d);
  const toNum = (v) => (v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? null : Number(v));

  function getStats(videoId) {
    const player = W.document.getElementById('movie_player');
    let pr = null;
    try { pr = player?.getPlayerResponse ? player.getPlayerResponse() : null; } catch (e) { LOG('getPlayerResponse error', e); }
    if (pr?.videoDetails?.videoId !== videoId) pr = null; // 遷移直後の古いレスポンスは使わない
    const vd = pr?.videoDetails || {};
    const mf = pr?.microformat?.playerMicroformatRenderer || {};

    let likes = toNum(mf.likeCount);
    if (likes === null) {
      // 予備:高評価ボタンの aria-label(例「他 12,345 人とともにこの動画を高く評価」)から数字を拾う
      // 「1.2万」「12K」のような丸め表記しか無いときは不正確になるので採用しない
      const label = document.querySelector('like-button-view-model button, segmented-like-dislike-button-view-model button')
        ?.getAttribute('aria-label') || '';
      if (!/[万億KMB]/.test(label)) {
        const m = label.replace(/[,,]/g, '').match(/\d+/);
        if (m) likes = Number(m[0]);
      }
    }
    const views = toNum(vd.viewCount ?? mf.viewCount);
    const publishedAt = mf.publishDate || mf.uploadDate || '';
    const pubDate = publishedAt ? new Date(publishedAt) : null;
    return {
      publishedAt,                                                            // 例 2022-09-15T11:03:16-07:00
      published: pubDate && !Number.isNaN(pubDate.getTime()) ? jstDate(pubDate) : '',
      views,
      likes,
      likeRate: views && likes !== null ? `${((likes / views) * 100).toFixed(2)}%` : '',
      lengthSec: toNum(vd.lengthSeconds),
      category: mf.category || '',
      isLive: !!vd.isLiveContent,
    };
  }

  // ---------- パネル操作 ----------
  // 開いているパネル(中身があるものを優先)を返す: { def, el }
  // 「動画の関連情報」(チャプター/文字起こしタブ)のように target-id が無いパネルもあるので、
  // 開いているパネルの中に文字起こしの行があるかどうかで判定する
  function activePanel() {
    const open = [...document.querySelectorAll('ytd-engagement-panel-section-list-renderer')]
      .filter((el) => el.getAttribute('visibility') === EXPANDED);
    for (const el of open) for (const def of PANELS) if (el.querySelector(def.seg)) return { def, el };
    for (const el of open) for (const def of PANELS) if (el.matches(def.sel)) return { def, el };
    return null;
  }
  const panelExpanded = () => !!activePanel();

  function openTranscriptPanel() {
    // 概要欄のボタンは非表示のコピーもあるので、見えているものを優先
    const cands = [...document.querySelectorAll('ytd-video-description-transcript-section-renderer button')];
    const btn = cands.find((b) => b.offsetParent !== null) || cands[0] ||
      [...document.querySelectorAll('button')].find((b) =>
        /文字起こしを表示|Show transcript/i.test(b.getAttribute('aria-label') || b.textContent || ''));
    if (!btn) return false;
    btn.click();
    return true;
  }
  const closeTranscriptPanel = () => activePanel()?.el.querySelector('#visibility-button button')?.click();

  // ---------- 取得 ----------
  function readNodes(p) {
    return [...p.el.querySelectorAll(p.def.seg)].map((n) => {
      const ts = n.querySelector(p.def.ts)?.textContent?.trim();
      const tx = [...n.querySelectorAll(p.def.tx)].map((e) => e.textContent).join(' ').replace(/\s+/g, ' ').trim();
      return ts && /^\d/.test(ts) && tx ? { startMs: hmsToMs(ts), endMs: 0, text: tx } : null;
    }).filter(Boolean);
  }
  function normalize(segs) {
    // パネルを開き直すとリストが二重に描画されることがあるので重複除去
    const map = new Map();
    for (const x of segs) map.set(`${x.startMs}|${x.text}`, x);
    const out = [...map.values()].sort((a, b) => a.startMs - b.startMs);
    out.forEach((x, i) => (x.endMs = out[i + 1]?.startMs || x.startMs + 3000));
    return out;
  }
  function readSegments() {
    const p = activePanel();
    return p ? normalize(readNodes(p)) : [];
  }
  // 新UIは表示範囲だけ描画される可能性があるので、パネル内をスクロールしながら全行を集める
  async function collectAll() {
    const p = activePanel();
    if (!p) return [];
    let acc = readNodes(p);
    const first = p.el.querySelector(p.def.seg);
    let sc = first?.parentElement;
    while (sc && sc !== p.el && !(sc.scrollHeight > sc.clientHeight + 20 && /auto|scroll/.test(getComputedStyle(sc).overflowY))) {
      sc = sc.parentElement;
    }
    // すでに動画の終わり近くまで描画されていればスクロール不要(大半はこれで即終了)
    const player = W.document.getElementById('movie_player');
    const durSec = player?.getDuration ? player.getDuration() : 0;
    const lastSec = Math.max(0, ...acc.map((x) => x.startMs)) / 1000;
    if (!durSec || lastSec >= durSec - 90) return normalize(acc);
    LOG('transcript may be virtualized, scrolling', { lastSec, durSec });
    if (p.def.name === 'modern' && sc && sc !== p.el) {
      const keep = sc.scrollTop;
      const deadline = Date.now() + 15000; // 最長15秒で打ち切り
      for (let top = 0; top < sc.scrollHeight && Date.now() < deadline; top += Math.max(200, sc.clientHeight)) {
        sc.scrollTop = top;
        await sleep(60);
        acc = acc.concat(readNodes(p));
      }
      sc.scrollTop = keep;
    }
    return normalize(acc);
  }
  const signature = (segs) => (segs.length ? `${segs.length}|${segs[0].text}|${segs[segs.length - 1].text}` : null);
  const readLang = () => activePanel()?.el.querySelector('ytd-transcript-footer-renderer')?.innerText?.trim() || '';

  // ---------- 出力 ----------
  // セグメントを段落にまとめる(日本語は詰めて、英数字どうしは空白でつなぐ)
  function toParagraphs(segs) {
    const paras = [];
    let cur = '';
    for (const { text } of segs) {
      const t = text.trim();
      if (!t) continue;
      if (cur && /[A-Za-z0-9,]$/.test(cur) && /^[A-Za-z0-9]/.test(t)) cur += ' ';
      cur += t;
      if (cur.length >= CONFIG.paragraphChars && /[。.!?!?.]$/.test(cur)) {
        paras.push(cur);
        cur = '';
      }
    }
    if (cur) paras.push(cur);
    return paras.join('\n\n');
  }

  function build(videoId, meta, segs, lang, st) {
    const url = `https://www.youtube.com/watch?v=${videoId}`;
    const fmtNum = (n) => (n === null ? '不明' : n.toLocaleString('ja-JP'));
    const statsLine = `公開日: ${st.published || '不明'} / 再生回数: ${fmtNum(st.views)} / 高評価: ${fmtNum(st.likes)}` +
      (st.likeRate ? `(${st.likeRate})` : '');
    if (CONFIG.format === 'srt') {
      return segs.map((s, i) => `${i + 1}\n${msToHms(s.startMs, true)} --> ${msToHms(s.endMs, true)}\n${s.text}\n`).join('\n');
    }
    if (CONFIG.format === 'txt') {
      if (CONFIG.textOnly) return `${meta.title}\n${meta.channel}\n${statsLine}\n\n${toParagraphs(segs)}\n`;
      return `${meta.title}\n${meta.channel}\n${url}\n${statsLine}\n\n` +
        segs.map((s) => (CONFIG.timestamps ? `[${msToHms(s.startMs)}] ` : '') + s.text).join('\n') + '\n';
    }
    // ---- md:Open Knowledge Format (OKF) v1 のプロパティ ----
    // 文字列は "..." で囲み、数値・日付はそのまま書く(Obsidian のプロパティで数値・日付型として扱われる)
    const q = (v) => `"${String(v ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
    const n = (v) => (v === null || v === undefined ? '' : String(v)); // 不明なら空欄
    const today = jstDate();
    const lenSec = st.lengthSec ?? (meta.duration ? Math.round(meta.duration) : null);
    const fm = [
      '---',
      `format: "Open Knowledge Format (YouTube Transcript) v1"`,
      `title: ${q(meta.title)}`,
      `channel: ${q(meta.channel)}`,
      `url: ${url}`,
      `video-id: ${videoId}`,
      `published: ${st.published}`,                 // 動画の公開日(JST)
      `published-at: ${q(st.publishedAt)}`,         // YouTube が返す元の日時(タイムゾーン付き)
      `duration: ${q(lenSec ? msToHms(lenSec * 1000) : '')}`,
      `views: ${n(st.views)}`,                      // 再生回数(取得日時点)
      `likes: ${n(st.likes)}`,                      // 高評価数(取得日時点。非公開なら空欄)
      `like-rate: ${q(st.likeRate)}`,               // 高評価数 ÷ 再生回数
      `stats-as-of: ${today}`,
      `category: ${q(st.category)}`,
      `language: ${q(lang)}`,
      `source-type: "YouTube 文字起こし(字幕パネル)"`,
      `source-tool: "YouTube Transcript Auto Downloader v${SCRIPT_VERSION}"`,
      `verification: "未実施(自動字幕の可能性あり)"`,
      `downloaded: ${today}`,
      `last-updated: ${today}`,
      'tags: [youtube, transcript]',
      '---', '', `# ${meta.title}`, '', `> ${meta.channel} / ${statsLine}`, '', ''].join('\n');
    if (CONFIG.textOnly) return fm + toParagraphs(segs) + '\n';
    const body = segs.map((s) => {
      if (!CONFIG.timestamps) return s.text;
      const t = msToHms(s.startMs);
      return CONFIG.mdTimeLinks ? `- [${t}](${url}&t=${Math.floor(s.startMs / 1000)}s) ${s.text}` : `- \`${t}\` ${s.text}`;
    }).join('\n');
    return fm + body + '\n';
  }

  const MIME = CONFIG.format === 'srt' ? 'application/x-subrip;charset=utf-8' : 'text/markdown;charset=utf-8';
  function toDataUrl(text) {
    const bytes = new TextEncoder().encode(text);
    let bin = '';
    for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
    // MIME を text/plain にすると Chrome が拡張子を .txt に付け替えることがあるので text/markdown にする
    return `data:${MIME};base64,` + btoa(bin);
  }

  function download(filename, content) {
    const dataUrl = toDataUrl(content);
    const fallback = (why) => {
      LOG('anchor fallback:', why);
      const a = document.createElement('a');
      a.href = URL.createObjectURL(new Blob([content], { type: MIME }));
      a.download = filename; // この経路ではサブフォルダは使えない(既定フォルダ直下)
      document.body.appendChild(a);
      a.click();
      a.remove();
      toast(`GM_download 失敗のため既定フォルダへ保存: ${why}`, false);
    };
    try {
      GM_download({
        url: dataUrl,
        name: CONFIG.subfolder ? `${CONFIG.subfolder}/${filename}` : filename,
        saveAs: false,
        conflictAction: 'uniquify',
        onerror: (e) => fallback(e?.error || JSON.stringify(e)),
        onload: () => LOG('GM_download ok', filename),
      });
    } catch (e) {
      fallback(String(e));
    }
  }

  async function save({ manual = false } = {}) {
    const videoId = getVideoIdFromUrl();
    if (!videoId) return toast('動画ページではありません', false);

    // セグメント数が安定するまで待つ(描画途中の取りこぼし防止)
    let prev = -1, segs = [];
    for (let i = 0; i < 40; i++) {
      segs = readSegments();
      if (segs.length && segs.length === prev && signature(segs) !== state.staleFirstText) break;
      prev = segs.length;
      await sleep(400);
    }
    if (!segs.length) return manual && toast('文字起こしが取得できませんでした(この動画に文字起こしが無い可能性)', false);
    if (signature(segs) === state.staleFirstText) return LOG('stale panel content, skip');
    const all = await collectAll();
    if (all.length > segs.length) segs = all;
    LOG('panel =', activePanel()?.def.name, 'segments =', segs.length);

    const meta = getMeta(videoId);
    const st = getStats(videoId);
    LOG('stats', st);
    const date = jstDate();
    // ファイル名は「取得日_タイトル.md」(動画 ID は md 内の video-id プロパティに残す)
    // 拡張子は srt 以外は常に .md(txt 形式の中身でも .md で保存)
    const ext = CONFIG.format === 'srt' ? 'srt' : 'md';
    const filename = `${date}_${sanitize(meta.title)}.${ext}`;
    download(filename, build(videoId, meta, segs, readLang(), st));
    state.savedThisView = true;
    setBtn('done');
    toast(`文字起こしを保存: ${filename}(${segs.length}行)`);

    if (state.autoOpened && CONFIG.closePanelAfterAuto) closeTranscriptPanel();
    state.autoOpened = false;
  }

  // ---------- 監視:パネルが開いて中身が入ったら保存 ----------
  let saving = false;
  let savingSince = 0;
  const SAVE_TIMEOUT_MS = 45000; // 保存処理がこれ以上かかったら固まったとみなして解除
  const isSavingStuck = () => saving && Date.now() - savingSince > SAVE_TIMEOUT_MS;
  async function checkPanel() {
    if (isSavingStuck()) { LOG('saving flag stuck, reset'); saving = false; }
    if (saving || state.savedThisView || !state.videoId) return;
    const ap = activePanel();
    if (!ap || !ap.el.querySelector(ap.def.seg)) return;
    saving = true;
    savingSince = Date.now();
    try { await save(); } catch (e) { LOG('auto save error', e); } finally { saving = false; }
  }
  new MutationObserver(() => checkPanel()).observe(document.documentElement, {
    subtree: true, childList: true, attributes: true, attributeFilter: ['visibility'],
  });

  // ---------- 遷移処理 ----------
  async function onNavigate() {
    const id = getVideoIdFromUrl();
    if (id === state.videoId) return;
    const prevId = state.videoId;
    state.videoId = id;
    state.savedThisView = false;
    state.autoOpened = false;
    // 別動画から SPA 遷移してきた場合のみ、パネルに残る旧動画の内容を「古い」とみなす
    state.staleFirstText = prevId ? signature(readSegments()) : null;
    setBtn('idle');
    if (!id) return;
    if (panelExpanded()) closeTranscriptPanel();
    if (!isAuto()) return;

    await sleep(CONFIG.autoOpenDelayMs);
    // プレイヤーが新しい動画を読み込むまで待つ
    let meta;
    for (let i = 0; i < 20; i++) {
      if (getVideoIdFromUrl() !== id) return;
      meta = getMeta(id);
      if (meta.playerReady && meta.duration > 0) break;
      await sleep(500);
    }
    if (meta.duration && meta.duration < CONFIG.minDurationSec) return LOG('short video, skip auto');
    // 概要欄内のボタンが描画されるまで待って押す
    for (let i = 0; i < 20 && getVideoIdFromUrl() === id && !state.savedThisView; i++) {
      if (panelExpanded()) break;
      if (openTranscriptPanel()) { state.autoOpened = true; LOG('auto opened transcript'); break; }
      await sleep(500);
    }
  }
  document.addEventListener('yt-navigate-finish', onNavigate);

  // ---------- 常駐ボタン ----------
  // 状態: idle=未保存(濃灰) / busy=保存中(橙) / done=保存済み(緑, 押すと再保存)
  const BTN = {
    idle: ['⬇ 文字起こし', '#3a3a3a'],
    busy: ['⏳ 保存中…', '#c77700'],
    done: ['✔ 保存済み(再保存)', '#1f6f3f'],
  };
  function setBtn(kind) {
    const b = document.getElementById('ytta-btn');
    if (!b) return;
    b.textContent = BTN[kind][0];
    b.style.background = BTN[kind][1];
  }

  function addFloatingButton() {
    if (document.getElementById('ytta-btn')) return;
    const b = document.createElement('button');
    b.id = 'ytta-btn';
    b.textContent = '⬇ 文字起こし';
    b.title = '文字起こしを保存(パネルが閉じていれば開いてから保存)';
    Object.assign(b.style, {
      position: 'fixed', right: '16px', bottom: '16px', zIndex: 2147483647, padding: '8px 12px',
      borderRadius: '18px', border: 'none', background: '#3a3a3a', color: '#fff', fontSize: '13px',
      cursor: 'pointer', opacity: '1', boxShadow: '0 2px 6px rgba(0,0,0,.3)',
    });
    b.addEventListener('click', async (ev) => {
      ev.preventDefault();
      ev.stopPropagation();
      if (!getVideoIdFromUrl()) return toast('動画ページではありません', false);
      if (isSavingStuck()) saving = false;
      if (saving) return toast('保存処理中です(少し待ってからもう一度押してください)');
      state.savedThisView = true; // 監視側との二重保存を防ぐ
      setBtn('busy');
      if (!panelExpanded()) {
        if (!openTranscriptPanel()) { setBtn('idle'); return toast('「文字起こしを表示」ボタンが見つかりません', false); }
        await sleep(500);
      }
      saving = true;
      savingSince = Date.now();
      try {
        await save({ manual: true });
      } catch (e) {
        LOG('manual save error', e);
        toast(`保存中にエラー: ${e && e.message ? e.message : e}`, false);
      } finally {
        saving = false;
        if (b.textContent === BTN.busy[0]) setBtn('idle'); // 失敗時は元に戻す
      }
    });
    document.body.appendChild(b);
  }

  // ---------- メニュー ----------
  GM_registerMenuCommand('今の動画の文字起こしを保存', () => document.getElementById('ytta-btn')?.click());
  GM_registerMenuCommand('自動で開いて保存 ON/OFF', () => {
    const v = !isAuto();
    GM_setValue('auto', v);
    toast(`自動保存: ${v ? 'ON' : 'OFF'}(OFF でも文字起こしパネルを手で開けば保存されます)`);
  });

  addFloatingButton();
  onNavigate();
  LOG('loaded v2.5.2, auto =', isAuto());
})();

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?