Fix duplicated text in Markdown from YouTube auto-captions

Rolling auto-caption cues repeat the previous line as plain text and
carry the new text in lines with inline word-timing tags. Keep only the
tagged lines in such cues (JS module and Python CLI); skip writing
Markdown files whose content is identical to one already written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-08-29 21:11:42 +02:00
parent 361c88be65
commit 03eec79b4c
3 changed files with 29 additions and 3 deletions

View File

@ -9,6 +9,13 @@ def strip_html(text):
return re.sub(r'<[^>]+>', '', text) return re.sub(r'<[^>]+>', '', text)
# YouTube auto-captions use rolling cues: the previous line is repeated as
# plain text while the new line carries inline word timings (<00:00:00.200>,
# <c>…</c>). Only the tagged lines contain new text.
def has_inline_timing_tags(line):
return re.search(r'<\d{2}:\d{2}:|<c[.\w]*>', line) is not None
def parse_vtt(content): def parse_vtt(content):
blocks = re.split(r'\n{2,}', content.strip()) blocks = re.split(r'\n{2,}', content.strip())
cues = [] cues = []
@ -23,9 +30,12 @@ def parse_vtt(content):
ts_idx = next((i for i, l in enumerate(lines) if '-->' in l), None) ts_idx = next((i for i, l in enumerate(lines) if '-->' in l), None)
if ts_idx is None: if ts_idx is None:
continue continue
text_lines = lines[ts_idx + 1:]
if any(has_inline_timing_tags(l) for l in text_lines):
text_lines = [l for l in text_lines if has_inline_timing_tags(l)]
text = ' '.join( text = ' '.join(
strip_html(l).strip() strip_html(l).strip()
for l in lines[ts_idx + 1:] for l in text_lines
if strip_html(l).strip() if strip_html(l).strip()
) )
if text: if text:

View File

@ -5,6 +5,13 @@ function stripHtml(text) {
return text.replace(/<[^>]+>/g, ''); return text.replace(/<[^>]+>/g, '');
} }
// YouTube auto-captions use rolling cues: the previous line is repeated as
// plain text while the new line carries inline word timings (<00:00:00.200>,
// <c>…</c>). Only the tagged lines contain new text.
function hasInlineTimingTags(line) {
return /<\d{2}:\d{2}:|<c[.\w]*>/.test(line);
}
function parseVtt(content) { function parseVtt(content) {
const blocks = content.split(/\n{2,}/); const blocks = content.split(/\n{2,}/);
const cues = []; const cues = [];
@ -15,8 +22,11 @@ function parseVtt(content) {
if (lines[0].startsWith('NOTE') || lines[0].startsWith('STYLE')) continue; if (lines[0].startsWith('NOTE') || lines[0].startsWith('STYLE')) continue;
const tsIdx = lines.findIndex(l => l.includes('-->')); const tsIdx = lines.findIndex(l => l.includes('-->'));
if (tsIdx === -1) continue; if (tsIdx === -1) continue;
const text = lines let textLines = lines.slice(tsIdx + 1);
.slice(tsIdx + 1) if (textLines.some(hasInlineTimingTags)) {
textLines = textLines.filter(hasInlineTimingTags);
}
const text = textLines
.map(l => stripHtml(l).trim()) .map(l => stripHtml(l).trim())
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');

View File

@ -105,12 +105,18 @@ async function runDownload(videoUrl, audioOnly, send, signal) {
} }
} }
const writtenMd = new Set();
for (const subPath of subtitlePaths) { for (const subPath of subtitlePaths) {
if (!existsSync(subPath)) continue; if (!existsSync(subPath)) continue;
send('info', `Converting: ${basename(subPath)}`); send('info', `Converting: ${basename(subPath)}`);
try { try {
const md = convertSubtitleToMarkdown(subPath); const md = convertSubtitleToMarkdown(subPath);
const mdPath = subPath.replace(/\.(vtt|srt)$/, '.md'); const mdPath = subPath.replace(/\.(vtt|srt)$/, '.md');
if (writtenMd.has(md)) {
send('info', `Skipped (identical): ${basename(mdPath)}`);
continue;
}
writtenMd.add(md);
writeFileSync(mdPath, md, 'utf-8'); writeFileSync(mdPath, md, 'utf-8');
send('info', `Saved: ${basename(mdPath)}`); send('info', `Saved: ${basename(mdPath)}`);
} catch (err) { } catch (err) {