A lightweight Bun workspace monorepo for downloading YouTube transcripts.
YouTube strictly rate-limits or blocks non-residential IP addresses (like those of Cloudflare Workers, AWS, or other cloud edge hosting). To reliably download transcripts, this project uses a client-side architecture:
- Userscript: Runs in the browser and injects a "Transcript" button on YouTube videos.
- Local HTTP Server: A background process listening on
localhost:3456. When you click the userscript button, it sends the video ID to this server, which scrapes the transcript using your residential IP address and returns it.
packages/scraper: Core library that contacts YouTube's Innertube API, fetches the caption track, and parses it (zero build step in local development).packages/server: Background HTTP API server that loads the scraper directly in-memory (no subprocess spawning).packages/userscript: Browser userscript built using Vite andvite-plugin-monkey.packages/cli: Command-line interfaces, tests, and debugging scripts.
Run from the root directory:
bun installThis step is optional -- the server runs on port 3456 without authentication if no .env exists. To change either, copy the example and edit it:
cp .env.example .envImportant:
.env.exampleshipsSERVER_API_KEY=your_secure_api_key_hereas a placeholder. Copying it unedited does not leave auth disabled -- it makes the server require that literal string, so every userscript request comes back401. Either set a real key or delete the line entirely.
(If you do define a SERVER_API_KEY, you should also create packages/userscript/.env containing VITE_TRANSCRIPT_API_KEY=your_key)
The server has to be running for the userscript to work. To have it start automatically at login instead of launching it by hand every boot:
./install.shThis installs a systemd user service (yt-transcript-server.service). Being a user service, it starts with your graphical session rather than at boot proper -- which is what you want, since the server is only useful once your browser is up. Run loginctl enable-linger $USER if you additionally need it reachable before login.
Re-run ./install.sh to apply any change to the unit template, and ./install.sh --uninstall to remove it.
journalctl --user -u yt-transcript-server -f # follow logs
systemctl --user stop yt-transcript-server # stop (frees the port for dev)Note: while the service is running it holds the port, so
bun run dev:serverwill fail to bind. Stop the service before hot-reload work.
Execute these commands from the root directory:
- Start local server (with hot reload):
(Stop the systemd service first if you installed it -- see Autostart -- otherwise this fails to bind the port)
bun run dev:server
- Build userscript:
(Produces output inside
bun run build:userscript
packages/userscript/dist/youtube-copy-transcript.user.jsfor installation in Tampermonkey/Violentmonkey)
- Run CLI to print transcript:
bun run cli <videoId-or-URL>
- Inspect raw Innertube player response (for debugging):
bun run inspect <videoId>
- Run unit tests:
bun run test - Run workspace typechecks:
bun run typecheck
- Format & Lint code (handled automatically on commit via Lefthook):
bun run format bun run lint
This is the standard workflow when browsing YouTube. The transcript is fetched via the local server using your home IP and copied to your browser's clipboard automatically.
sequenceDiagram
autonumber
actor User as Browser User
participant Script as Userscript (packages/userscript)
participant Server as Bun Server (packages/server)
participant Scraper as Scraper Library (packages/scraper)
participant YouTube as YouTube API
User->>Script: Click "Transcript" Button
Script->>Script: Extract video ID from DOM/URL
Script->>Server: HTTP GET /transcript?videoId=<id> (x-api-key header)
Server->>Server: Validate x-api-key
Server->>Scraper: Call fetchTranscript(videoId) in-memory
Scraper->>YouTube: Fetch watch page HTML (extract Innertube API Key)
Scraper->>YouTube: POST /youtubei/v1/player (get captionTracks list)
Scraper->>YouTube: GET caption track URL (fetch raw XML transcript)
Scraper->>Scraper: Parse XML, convert to Plaintext, format with YAML metadata
Scraper-->>Server: Return formatted transcript string
Server-->>Script: HTTP 200 JSON { videoId, transcript }
Script->>Script: Write transcript text to Clipboard (GM_setClipboard)
Script->>Script: Persist video ID to storage (GM_setValue)
Script-->>User: Show "Copied!" button state & green dot indicators
- Who fetches the transcript? The
@youtube-transcript/scraperlibrary (embedded in@youtube-transcript/server) coordinates the requests to YouTube. - Who copies to the clipboard? The browser-level userscript processes the returned text and writes it to your system clipboard using Tampermonkey's
GM_setClipboard. - Who persists the state? The userscript saves the copied video ID locally via Tampermonkey storage so that the button can reflect a "previously copied" indicator (a green dot) if you revisit the page.
This is the developer workflow. You run the scraping pipeline directly in your terminal, and can optionally output transcripts, save debug files, or copy to the clipboard.
sequenceDiagram
autonumber
actor Dev as Developer / Terminal
participant CLI as CLI wrapper (packages/cli)
participant Scraper as Scraper Library (packages/scraper)
participant YouTube as YouTube API
participant System as System Clipboard
Dev->>CLI: bun run cli <videoId-or-URL> --copy --debug
CLI->>Scraper: Call fetchTranscript(videoId, config) in-memory
Scraper->>YouTube: Fetch watch page, caption track list, and XML transcript
Scraper->>Scraper: Parse and format output
Scraper-->>CLI: Return transcript and metadata
CLI->>Dev: Print formatted transcript to stdout
Note over CLI,System: If --copy / -c flag is passed
CLI->>System: Copy transcript text using clipboardy library
Note over Scraper: If --debug flag is passed
Scraper->>CLI: Write raw logs, HTML, and JSON to ./debug/
- Who fetches the transcript? The
@youtube-transcript/scraperlibrary (imported by@youtube-transcript/cli). - Who copies to the clipboard? The
@youtube-transcript/clipackage, utilizing theclipboardylibrary to interface with your OS clipboard. - Who writes debug files? The
@youtube-transcript/scraperdebug handler writes intermediate HTTP responses to a local./debugdirectory if the--debugflag is passed.