Files
STCourses/packages/feature-import/src/packt.ts
T
2026-10-30 03:15:09 +01:00

75 lines
4.1 KiB
TypeScript

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 };
}