From dc1e3ce05d3bafb9a16da97f2d471335a3305e19 Mon Sep 17 00:00:00 2001 From: Miguel Cordero Collar Date: Sun, 15 Mar 2026 16:21:12 +0100 Subject: [PATCH] fix: add configurable YouTube chapters output Add an includeChapters setting (default enabled) so users can choose whether chapter headers and the Chapters summary are included in transcript extraction. Made-with: Cursor --- CHANGELOG.md | 2 + extension/content/youtube.js | 60 ++++++++++-- extension/options.html | 6 ++ extension/options/settings.js | 1 + extension/popup/settings.js | 1 + extension/shared/defaults.js | 2 + tests/unit/content/youtube.test.js | 144 ++++++++++++++++++++++++++++- tests/unit/popup/settings.test.js | 2 + tests/unit/shared/defaults.test.js | 1 + tests/unit/utils.test.js | 1 + 10 files changed, 213 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c8fe6..791dee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Contribution Templates**: Added GitHub issue forms (bug report, feature request, question/support), issue template configuration, pull request template, and a new `CONTRIBUTING.md` guide to standardize community contributions. - **Frosted Glass Floating Button**: New "Glass" button style option that renders the floating button as a colorless frosted glass element using `backdrop-filter` blur and saturation, with subtle white borders and inset highlights. Glass is now the default style; users can switch back to solid (accent-colored) in settings. - **Floating Button Detection Hint**: Added a configurable floating-button hint badge that shows extraction mode (`Article` or `Page`) before click so users can tell what will be copied at a glance. +- **YouTube Chapters Toggle**: Added a new `includeChapters` setting (default: enabled) to include or skip chapter headers and the `## Chapters` section in transcript extraction. ### Fixed - **YouTube Transcript (New UI)**: Restored transcript extraction for YouTube's updated transcript panel DOM while preserving compatibility with the legacy transcript layout. +- **YouTube Chapters Extraction**: Added chapter extraction support for YouTube's new transcript UI (`timeline-chapter-view-model`), rendering chapters both inline as section headers and as a dedicated `## Chapters` summary section with timestamps. - **X Video Media Extraction**: Improved X video output by extracting direct video links when available and falling back to a permalink + poster-thumbnail capture for blob-backed video players. - **Article Detection Notification Defaults**: Switched article-detected info notification to default off in favor of the new floating-button mode hint (still configurable in settings). diff --git a/extension/content/youtube.js b/extension/content/youtube.js index bdbec3f..854106f 100644 --- a/extension/content/youtube.js +++ b/extension/content/youtube.js @@ -64,7 +64,8 @@ async function waitForTranscriptAndCopy(settings = {}) { ); throw new Error('Transcript failed to load within timeout period.'); } - let transcriptText = extractTranscriptText(settings.includeTimestamps !== false); + const includeChapters = settings.includeChapters !== false; + let transcriptText = extractTranscriptText(settings.includeTimestamps !== false, includeChapters); let metaMd = ''; if ( settings.addTitleToTranscript || @@ -90,6 +91,15 @@ async function waitForTranscriptAndCopy(settings = {}) { if (settings.addUrlToTranscript && videoUrl) metaMd += `**Video URL:** ${videoUrl}\n`; if (metaMd) metaMd += '\n'; } + + if (includeChapters) { + const chapters = extractChapters(); + const chaptersMd = formatChaptersSection(chapters); + if (chaptersMd) { + metaMd += chaptersMd + '\n\n'; + } + } + transcriptText = metaMd + transcriptText; const userSettings = await getSettings(); chrome.storage.sync.get( @@ -136,24 +146,61 @@ async function waitForTranscriptAndCopy(settings = {}) { } } -export function extractTranscriptText(includeTimestamps = true) { +export function extractChapters() { + const chapters = []; + + // Old UI: chapter headers inside transcript section headers + document.querySelectorAll('ytd-transcript-section-header-renderer').forEach((el) => { + const title = el.querySelector('.shelf-header-layout-wiz__title')?.textContent?.trim(); + if (title) chapters.push({ title }); + }); + + // New UI: chapter view models + document.querySelectorAll('timeline-chapter-view-model').forEach((el) => { + const title = el.querySelector('.ytwTimelineChapterViewModelTitle')?.textContent?.trim(); + const timestamp = el + .querySelector('.ytwTimelineChapterViewModelTimestamp') + ?.textContent?.trim(); + if (title) chapters.push({ title, timestamp }); + }); + + return chapters; +} + +export function formatChaptersSection(chapters) { + if (!chapters.length) return ''; + const lines = chapters.map((ch) => { + return ch.timestamp ? `- ${ch.title} (${ch.timestamp})` : `- ${ch.title}`; + }); + return `## Chapters\n${lines.join('\n')}`; +} + +export function extractTranscriptText(includeTimestamps = true, includeChapters = true) { let transcript = ''; const allElements = Array.from( document.querySelectorAll( - 'ytd-transcript-segment-renderer, ytd-transcript-section-header-renderer, transcript-segment-view-model' + 'ytd-transcript-segment-renderer, ytd-transcript-section-header-renderer, transcript-segment-view-model, timeline-chapter-view-model' ) ); allElements.forEach((element) => { - if (element.tagName === 'YTD-TRANSCRIPT-SECTION-HEADER-RENDERER') { + const tag = element.tagName; + if (includeChapters && tag === 'YTD-TRANSCRIPT-SECTION-HEADER-RENDERER') { const headerText = element .querySelector('.shelf-header-layout-wiz__title') ?.textContent?.trim(); if (headerText) { transcript += `\n\n## ${headerText}\n`; } + } else if (includeChapters && tag === 'TIMELINE-CHAPTER-VIEW-MODEL') { + const headerText = element + .querySelector('.ytwTimelineChapterViewModelTitle') + ?.textContent?.trim(); + if (headerText) { + transcript += `\n\n## ${headerText}\n`; + } } else if ( - element.tagName === 'YTD-TRANSCRIPT-SEGMENT-RENDERER' || - element.tagName === 'TRANSCRIPT-SEGMENT-VIEW-MODEL' + tag === 'YTD-TRANSCRIPT-SEGMENT-RENDERER' || + tag === 'TRANSCRIPT-SEGMENT-VIEW-MODEL' ) { const timestamp = element .querySelector('.segment-timestamp, .ytwTranscriptSegmentViewModelTimestamp') @@ -182,6 +229,7 @@ export async function copyYouTubeTranscript(settings = null) { chrome.storage.sync.get( { includeTimestamps: true, + includeChapters: true, addTitleToTranscript: true, addChannelToTranscript: true, addUrlToTranscript: true, diff --git a/extension/options.html b/extension/options.html index 147d220..41a0939 100644 --- a/extension/options.html +++ b/extension/options.html @@ -301,6 +301,12 @@

YouTube Settings

description="Add time markers to transcript lines" checked > + ({ @@ -73,6 +77,15 @@ describe('YouTube content script logic', () => { expect(result).toContain('## Chapter 1'); }); + it('excludes chapter headers when includeChapters is false', () => { + const result = extractTranscriptText(true, false); + + expect(result).toContain('[0:00]'); + expect(result).toContain('[0:05]'); + expect(result).toContain('[1:30]'); + expect(result).not.toContain('## Chapter 1'); + }); + it('extracts transcript from the new YouTube transcript DOM', () => { document.body.innerHTML = ` @@ -122,5 +135,134 @@ describe('YouTube content script logic', () => { expect(result).toBe('Text without timestamp'); expect(result).not.toContain('['); }); + + it('extracts inline chapter headers from the new YouTube UI', () => { + document.body.innerHTML = ` + +

Chapter 1: Introduction

+
+ +
0:00
+ Welcome to the video +
+ +

Chapter 2: Deep Dive

+
+ +
5:30
+ Let us dive deeper +
+ `; + + const result = extractTranscriptText(true); + expect(result).toContain('## Chapter 1: Introduction'); + expect(result).toContain('[0:00] Welcome to the video'); + expect(result).toContain('## Chapter 2: Deep Dive'); + expect(result).toContain('[5:30] Let us dive deeper'); + }); + + it('skips new UI inline chapter headers when includeChapters is false', () => { + document.body.innerHTML = ` + +

Chapter 1: Introduction

+
+ +
0:00
+ Welcome to the video +
+ `; + + const result = extractTranscriptText(true, false); + expect(result).not.toContain('## Chapter 1: Introduction'); + expect(result).toContain('[0:00] Welcome to the video'); + }); + }); + + describe('extractChapters', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('extracts chapters from old UI (ytd-transcript-section-header-renderer)', () => { + document.body.innerHTML = ` + +
Introduction
+
+ +
Main Topic
+
+ `; + + const chapters = extractChapters(); + expect(chapters).toHaveLength(2); + expect(chapters[0].title).toBe('Introduction'); + expect(chapters[1].title).toBe('Main Topic'); + }); + + it('extracts chapters from new UI (timeline-chapter-view-model)', () => { + document.body.innerHTML = ` + +

Chapter 1: Intro

+
+ +

Chapter 2: The Problem

+
+ `; + + const chapters = extractChapters(); + expect(chapters).toHaveLength(2); + expect(chapters[0].title).toBe('Chapter 1: Intro'); + expect(chapters[1].title).toBe('Chapter 2: The Problem'); + }); + + it('extracts chapters with timestamps from new UI', () => { + document.body.innerHTML = ` + +
0:00
+

Chapter 1: Start

+
+ +
12:34
+

Chapter 2: Middle

+
+ `; + + const chapters = extractChapters(); + expect(chapters).toHaveLength(2); + expect(chapters[0]).toEqual({ title: 'Chapter 1: Start', timestamp: '0:00' }); + expect(chapters[1]).toEqual({ title: 'Chapter 2: Middle', timestamp: '12:34' }); + }); + + it('returns empty array when no chapters exist', () => { + document.body.innerHTML = ` + + Just a segment + + `; + + const chapters = extractChapters(); + expect(chapters).toHaveLength(0); + }); + }); + + describe('formatChaptersSection', () => { + it('formats chapters without timestamps', () => { + const chapters = [{ title: 'Intro' }, { title: 'Main Topic' }]; + const result = formatChaptersSection(chapters); + expect(result).toBe('## Chapters\n- Intro\n- Main Topic'); + }); + + it('formats chapters with timestamps', () => { + const chapters = [ + { title: 'Intro', timestamp: '0:00' }, + { title: 'Deep Dive', timestamp: '5:30' }, + ]; + const result = formatChaptersSection(chapters); + expect(result).toBe('## Chapters\n- Intro (0:00)\n- Deep Dive (5:30)'); + }); + + it('returns empty string when no chapters', () => { + expect(formatChaptersSection([])).toBe(''); + }); }); }); diff --git a/tests/unit/popup/settings.test.js b/tests/unit/popup/settings.test.js index 3c30c1c..6ba0fd8 100644 --- a/tests/unit/popup/settings.test.js +++ b/tests/unit/popup/settings.test.js @@ -7,6 +7,7 @@ describe('popup.js - DEFAULTS', () => { it('has all required settings', () => { expect(DEFAULTS).toHaveProperty('globalEnabled'); expect(DEFAULTS).toHaveProperty('includeTimestamps'); + expect(DEFAULTS).toHaveProperty('includeChapters'); expect(DEFAULTS).toHaveProperty('jumpToDomain'); expect(DEFAULTS).toHaveProperty('enableYouTubeIntegration'); expect(DEFAULTS).toHaveProperty('enableHackerNewsIntegration'); @@ -17,6 +18,7 @@ describe('popup.js - DEFAULTS', () => { it('has proper default values', () => { expect(DEFAULTS.globalEnabled).toBe(true); expect(DEFAULTS.includeTimestamps).toBe(true); + expect(DEFAULTS.includeChapters).toBe(true); expect(DEFAULTS.jumpToDomain).toBe(false); expect(DEFAULTS.downloadInsteadOfCopy).toBe(false); expect(DEFAULTS.enableUsageKpi).toBe(true); diff --git a/tests/unit/shared/defaults.test.js b/tests/unit/shared/defaults.test.js index 95f0724..a641e79 100644 --- a/tests/unit/shared/defaults.test.js +++ b/tests/unit/shared/defaults.test.js @@ -8,6 +8,7 @@ describe('shared/defaults', () => { // YouTube settings expect(DEFAULTS.includeTimestamps).toBe(true); + expect(DEFAULTS.includeChapters).toBe(true); expect(DEFAULTS.addTitleToTranscript).toBe(true); expect(DEFAULTS.addChannelToTranscript).toBe(true); expect(DEFAULTS.addUrlToTranscript).toBe(true); diff --git a/tests/unit/utils.test.js b/tests/unit/utils.test.js index 3758ea5..ede016a 100644 --- a/tests/unit/utils.test.js +++ b/tests/unit/utils.test.js @@ -23,6 +23,7 @@ describe('utils.js', () => { it('returns default settings when storage is empty', async () => { const settings = await getSettings(); expect(settings.includeTimestamps).toBe(true); + expect(settings.includeChapters).toBe(true); expect(settings.jumpToDomain).toBe(false); });