feat(import): Packt parser and packt-video job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34f27d05c9
commit
07e2223c8a
8 files changed
+473
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import { decodeEntities, stripTags } from "./util/text";
|
||||
|
||||
export interface PacktClip { number: number; title: string; video: string; }
|
||||
export interface PacktSection { number: number; title: string; clips: PacktClip[] }
|
||||
export interface PacktIndex { title: string; sections: PacktSection[]; warnings: string[] }
|
||||
|
||||
/**
|
||||
* Parses the Packt player's index.html structurally:
|
||||
* - the section overview: `<h1>Section N: …</h1>` followed by a `<ul>` of clip titles, in order, usually WITHOUT "Video N.M:" prefixes;
|
||||
* - the per-section chapter blocks (`class="chapter" id="content-N"`) with one `<li>` per clip: `<h1>N.M title</h1>` and `data-video="…"`.
|
||||
* Chapter blocks are authoritative for the video file; the overview list fills in any title or whole section that is missing.
|
||||
*/
|
||||
export function parsePacktIndex(html: string): PacktIndex {
|
||||
const warnings: string[] = [];
|
||||
const intro = /<div[^>]*class="intro"[\s\S]*?<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html);
|
||||
const titleTag = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
||||
const title = stripTags(intro?.[1] ?? titleTag?.[1] ?? "") || "Imported course";
|
||||
|
||||
const chapterStart = html.search(/<div[^>]*class="chapter"/i);
|
||||
const overview = chapterStart >= 0 ? html.slice(0, chapterStart) : html;
|
||||
const chaptersHtml = chapterStart >= 0 ? html.slice(chapterStart) : "";
|
||||
|
||||
// overview: sections in document order
|
||||
const secRe = /<h1[^>]*>\s*Section\s+(\d+)\s*[:.-]\s*([\s\S]*?)<\/h1>/gi;
|
||||
const heads = [...overview.matchAll(secRe)];
|
||||
if (heads.length === 0) throw new Error("not a Packt video package: no \"Section N: …\" headings found in index.html");
|
||||
const overviewTitles = new Map<number, string[]>();
|
||||
const sectionTitles = new Map<number, string>();
|
||||
heads.forEach((h, i) => {
|
||||
const n = Number(h[1]);
|
||||
sectionTitles.set(n, stripTags(h[2]!));
|
||||
const block = overview.slice(h.index! + h[0].length, heads[i + 1]?.index ?? overview.length);
|
||||
const ul = /<ul[^>]*>([\s\S]*?)<\/ul>/i.exec(block);
|
||||
const items = ul ? [...ul[1]!.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)].map((m) => stripTags(m[1]!)).filter(Boolean) : [];
|
||||
const firstP = /<p[^>]*>\s*Video\s+\d+\.\d+\s*:\s*([\s\S]*?)<\/p>/i.exec(block);
|
||||
if (items.length === 0 && firstP) items.push(stripTags(firstP[1]!));
|
||||
overviewTitles.set(n, items);
|
||||
});
|
||||
|
||||
// chapters: clips with their files
|
||||
const chapterClips = new Map<number, PacktClip[]>();
|
||||
const chapRe = /<div[^>]*class="chapter"[^>]*id="content-(\d+)"[^>]*>/gi;
|
||||
const chaps = [...chaptersHtml.matchAll(chapRe)];
|
||||
chaps.forEach((c, i) => {
|
||||
const sec = Number(c[1]);
|
||||
const block = chaptersHtml.slice(c.index! + c[0].length, chaps[i + 1]?.index ?? chaptersHtml.length);
|
||||
const clipRe = /<h1[^>]*>\s*(?:Video\s+)?(\d+)\.(\d+)\s*[:.]?\s*([\s\S]*?)<\/h1>/gi;
|
||||
const hs = [...block.matchAll(clipRe)];
|
||||
const clips: PacktClip[] = [];
|
||||
hs.forEach((h, j) => {
|
||||
const part = block.slice(h.index!, hs[j + 1]?.index ?? block.length);
|
||||
const vid = /data-video="([^"]+)"/i.exec(part)?.[1] ?? /href="([^"]+\.(?:mp4|webm|m4v|mov))"/i.exec(part)?.[1];
|
||||
const num = Number(h[2]);
|
||||
if (!vid) { warnings.push(`clip ${sec}.${num} has no video file in index.html`); return; }
|
||||
clips.push({ number: num, title: stripTags(h[3]!), video: decodeEntities(vid) });
|
||||
});
|
||||
chapterClips.set(sec, clips);
|
||||
});
|
||||
|
||||
const sections: PacktSection[] = [];
|
||||
for (const n of [...sectionTitles.keys()].sort((a, b) => a - b)) {
|
||||
let clips = chapterClips.get(n);
|
||||
const titles = overviewTitles.get(n) ?? [];
|
||||
if (!clips || clips.length === 0) {
|
||||
warnings.push(`section ${n} has no chapter block; clips were taken from the overview list and mapped to videos/video${n}_M.mp4 in order`);
|
||||
clips = titles.map((t, i) => ({ number: i + 1, title: t, video: `videos/video${n}_${i + 1}.mp4` }));
|
||||
} else {
|
||||
clips = clips.map((c, i) => ({ ...c, title: c.title || titles[i] || `Video ${n}.${c.number}` }));
|
||||
if (titles.length && titles.length !== clips.length) warnings.push(`section ${n}: overview lists ${titles.length} clips but the chapter has ${clips.length}`);
|
||||
}
|
||||
sections.push({ number: n, title: sectionTitles.get(n)!, clips });
|
||||
}
|
||||
return { title, sections, warnings };
|
||||
}
|
||||
Reference in new issue
Block a user