/** * Savr — YouTube transcript extraction. * * This file is the single source of truth for the extraction. The extension's * background serializes `scrapeYouTubeTranscript` into the watch tab with * `scripting.executeScript`, and the app's build (the `minify-bookmarklet` Vite * plugin) inlines the same function into the Preferences bookmarklet. * * Keep it self-contained — no imports, no closure over module state — or both * consumers break. * * The returned content is HTML rather than plain text so the video thumbnail * can travel with it: the app's image pipeline downloads that image like any * article image, which also gives the saved article its list thumbnail. The * transcript itself stays in a
, matching how the raw text ingest renders
* plain text.
*/
export async function scrapeYouTubeTranscript() {
const STRIP_TIMESTAMPS = true; // false → keep leading "[00:34] " markers
// YouTube's transcript DOM changed: modern pages use
// with ytw* class names; older pages used
// with .segment-* class names. Match both.
const SEGMENT_SEL = "transcript-segment-view-model, ytd-transcript-segment-renderer";
const TIMESTAMP_SEL = ".ytwTranscriptSegmentViewModelTimestamp, .segment-timestamp";
const TEXT_SEL = ".ytAttributedStringHost, .segment-text";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForSegments(maxTries, interval) {
for (let i = 0; i < maxTries; i++) {
const found = document.querySelectorAll(SEGMENT_SEL);
if (found.length) return found;
await sleep(interval);
}
return document.querySelectorAll(SEGMENT_SEL);
}
const escapeHtml = (value) =>
value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
// --- Open the transcript panel (if not already open) ---------------------
let segments = await waitForSegments(2, 300);
if (!segments.length) {
const candidate =
[...document.querySelectorAll("[aria-label]")].find((el) =>
/^show transcript$/i.test((el.getAttribute("aria-label") || "").trim())
) ||
[...document.querySelectorAll("[aria-label]")].find((el) =>
/transcript/i.test(el.getAttribute("aria-label") || "")
);
if (candidate) {
candidate.scrollIntoView({ block: "center" });
await sleep(200);
candidate.click();
candidate
.closest("button, yt-button-shape, tp-yt-paper-button, ytd-menu-service-item-renderer")
?.click();
segments = await waitForSegments(10, 400);
} else {
// No transcript button on this page — give a slow render one last chance.
segments = await waitForSegments(2, 300);
}
}
if (!segments.length) {
return {
ok: false,
reason:
"Could not open the transcript panel automatically. Try opening it manually once, then run this again.",
};
}
// --- Scrape transcript + metadata ----------------------------------------
const lines = [...segments].map((segment) => {
const time = segment.querySelector(TIMESTAMP_SEL)?.textContent.trim() || "";
// Don't fall back to segment.textContent — the new DOM nests the timestamp
// and a hidden a11y label ("1 second") inside the segment, which would
// pollute the line.
const text = segment.querySelector(TEXT_SEL)?.textContent.trim() || "";
if (STRIP_TIMESTAMPS) return text;
return time ? `[${time}] ${text}` : text;
});
const title =
document
.querySelector("h1.ytd-watch-metadata, #title h1, h1.title yt-formatted-string")
?.textContent?.trim() ||
(document.title || "YouTube transcript").replace(/\s*-\s*YouTube\s*$/, "").trim();
const author =
document
.querySelector("ytd-channel-name #text a, #owner #channel-name a, ytd-channel-name a")
?.textContent?.trim() || null;
// og:image is the video's large thumbnail; some pages lack it, so fall back
// to the well-known i.ytimg.com pattern for the video id.
const videoId = new URLSearchParams(window.location.search).get("v");
const thumbnail =
document.querySelector('meta[property="og:image"]')?.getAttribute("content") ||
(videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : null);
const parts = [];
if (thumbnail) {
parts.push(`})
`);
}
parts.push(
`${escapeHtml(
lines.join("\n")
)}`
);
return {
ok: true,
contentType: "text/html",
content: parts.join(""),
title,
author,
url: window.location.href,
};
}