From ddbd52f7f1acc0b27611343626d3abb8362de705 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:49:22 -0400 Subject: [PATCH 1/3] chore(knowledge): add course-digest skill + bundled extraction pipeline Retrofit the course-digest skill (Dometrain/Teachable course extraction and synthesis) into the knowledge plugin, replicating the youtube vendoring precedent. - Vendor @melodic/repo-analysis + @melodic/video-digestion under extraction/vendor/; rewrite deps to file:./vendor/*; setup-deps.mjs installs into ${CLAUDE_PLUGIN_DATA} and provisions Playwright Chromium into ${CLAUDE_PLUGIN_DATA}/ms-playwright. - Launcher trio (run.mjs/register-hook/resolve-hook) resolves bundled deps from the data directory; run.mjs pins PLAYWRIGHT_BROWSERS_PATH so the browser binary resolves regardless of cwd. - Re-home platform auth cookies out of the consumer repo to ${CLAUDE_PLUGIN_DATA}/auth/.auth-state.json (lib/auth-store.js); credentials via COURSE_*/TEACHABLE_* shell env vars, manual-login fallback. - Inline tsconfig (no cross-repo extends); genericize SKILL.md + context/reference paths to ${CLAUDE_PLUGIN_ROOT}; namespace slashes to /knowledge:course-digest. - Bump plugin 0.3.0 -> 0.4.0 (0.3.0 was the setup library_dir retrofit); backfill the missing 0.3.0 CHANGELOG entry; extend marketplace tags + keywords. Refs melodic-software/medley#1409 --- .claude-plugin/marketplace.json | 2 +- plugins/knowledge/.claude-plugin/plugin.json | 6 +- plugins/knowledge/CHANGELOG.md | 44 + .../knowledge/skills/course-digest/SKILL.md | 222 +++ .../context/multimodal-evaluation.md | 133 ++ .../course-digest/context/storage-schema.md | 230 +++ .../skills/course-digest/context/workflow.md | 332 ++++ .../skills/course-digest/evals/evals.json | 82 + .../extraction/adapters/adapter-contract.js | 88 + .../adapters/adapter-contract.test.js | 47 + .../extraction/adapters/auth-session.js | 10 + .../extraction/adapters/dometrain.js | 383 ++++ .../extraction/adapters/dometrain.test.js | 45 + .../extraction/adapters/teachable.js | 377 ++++ .../extraction/adapters/teachable.test.js | 106 ++ .../extraction/analyze-code-repo.js | 212 +++ .../extraction/build-course-json.js | 225 +++ .../extraction/classify-frames.js | 182 ++ .../extraction/discover-resources.js | 270 +++ .../extraction/download-resources.js | 206 ++ .../extraction/extract-course-run.js | 255 +++ .../extraction/extract-course.js | 316 ++++ .../extraction/generate-manifests.js | 204 ++ .../extraction/lib/auth-store.js | 33 + .../extraction/lib/auth/clerk.js | 34 + .../extraction/lib/auth/clerk.test.js | 29 + .../extraction/lib/auth/manual-login.js | 30 + .../extraction/lib/auth/teachable-sso.js | 32 + .../extraction/lib/auth/teachable-sso.test.js | 29 + .../extraction/lib/auth/test-helpers.js | 30 + .../course-digest/extraction/lib/browser.js | 89 + .../course-digest/extraction/lib/config.js | 51 + .../extraction/lib/config.test.js | 52 + .../extraction/lib/players/hotmart.js | 596 ++++++ .../extraction/lib/players/hotmart.test.js | 134 ++ .../extraction/lib/players/mux.js | 36 + .../extraction/lib/players/mux.test.js | 49 + .../extraction/lib/playwright-selectors.js | 40 + .../extraction/lib/validators.js | 317 ++++ .../extraction/lib/validators.test.js | 168 ++ .../extraction/package-lock.json | 1657 +++++++++++++++++ .../course-digest/extraction/package.json | 30 + .../extraction/register-hook.mjs | 8 + .../course-digest/extraction/resolve-hook.mjs | 42 + .../skills/course-digest/extraction/run.mjs | 53 + .../course-digest/extraction/setup-deps.mjs | 122 ++ .../course-digest/extraction/tsconfig.json | 15 + .../skills/course-digest/extraction/utils.js | 147 ++ .../course-digest/extraction/utils.test.js | 166 ++ .../extraction/validate-extraction.js | 125 ++ .../course-digest/extraction/vendor/README.md | 18 + .../extraction/vendor/repo-analysis/README.md | 5 + .../vendor/repo-analysis/package.json | 10 + .../vendor/repo-analysis/repo-analysis.js | 311 ++++ .../vendor/video-digestion/README.md | 5 + .../vendor/video-digestion/TUNING.md | 61 + .../video-digestion/frames/contact-sheet.js | 139 ++ .../vendor/video-digestion/frames/dedup.js | 132 ++ .../vendor/video-digestion/frames/models.js | 40 + .../video-digestion/frames/scene-detect.js | 224 +++ .../video-digestion/media/ffprobe-duration.js | 49 + .../vendor/video-digestion/package.json | 26 + .../vendor/video-digestion/shared/logger.js | 95 + .../video-digestion/shared/media-artifacts.js | 13 + .../vendor/video-digestion/shared/process.js | 124 ++ .../vendor/video-digestion/shared/progress.js | 201 ++ .../vendor/video-digestion/shared/result.js | 64 + .../vendor/video-digestion/shared/terminal.js | 28 + .../transcript/auto-caption-clean.js | 97 + .../transcript/manual-caption-clean.js | 109 ++ .../transcript/progressive-cue-merge.js | 158 ++ .../video-digestion/transcript/vtt-parser.js | 237 +++ .../course-digest/extraction/vitest.config.ts | 6 + .../reference/adapters/discovery-checklist.md | 407 ++++ .../reference/adapters/dometrain.md | 209 +++ .../reference/adapters/teachable.md | 146 ++ .../reference/analysis-template.md | 137 ++ .../reference/screenshot-strategy.md | 183 ++ .../course-digest/templates/checklist.md | 21 + 79 files changed, 11342 insertions(+), 4 deletions(-) create mode 100644 plugins/knowledge/skills/course-digest/SKILL.md create mode 100644 plugins/knowledge/skills/course-digest/context/multimodal-evaluation.md create mode 100644 plugins/knowledge/skills/course-digest/context/storage-schema.md create mode 100644 plugins/knowledge/skills/course-digest/context/workflow.md create mode 100644 plugins/knowledge/skills/course-digest/evals/evals.json create mode 100644 plugins/knowledge/skills/course-digest/extraction/adapters/adapter-contract.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/adapters/adapter-contract.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/adapters/auth-session.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/adapters/dometrain.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/adapters/dometrain.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/adapters/teachable.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/adapters/teachable.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/analyze-code-repo.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/build-course-json.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/classify-frames.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/discover-resources.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/download-resources.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/extract-course-run.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/extract-course.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/generate-manifests.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/auth-store.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/auth/clerk.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/auth/clerk.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/auth/manual-login.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/auth/teachable-sso.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/auth/teachable-sso.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/auth/test-helpers.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/browser.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/config.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/config.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/players/hotmart.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/players/hotmart.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/players/mux.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/players/mux.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/playwright-selectors.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/validators.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/lib/validators.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/package-lock.json create mode 100644 plugins/knowledge/skills/course-digest/extraction/package.json create mode 100644 plugins/knowledge/skills/course-digest/extraction/register-hook.mjs create mode 100644 plugins/knowledge/skills/course-digest/extraction/resolve-hook.mjs create mode 100644 plugins/knowledge/skills/course-digest/extraction/run.mjs create mode 100644 plugins/knowledge/skills/course-digest/extraction/setup-deps.mjs create mode 100644 plugins/knowledge/skills/course-digest/extraction/tsconfig.json create mode 100644 plugins/knowledge/skills/course-digest/extraction/utils.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/utils.test.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/validate-extraction.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/README.md create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/repo-analysis/README.md create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/repo-analysis/package.json create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/repo-analysis/repo-analysis.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/README.md create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/TUNING.md create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/frames/contact-sheet.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/frames/dedup.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/frames/models.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/frames/scene-detect.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/media/ffprobe-duration.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/package.json create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/shared/logger.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/shared/media-artifacts.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/shared/process.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/shared/progress.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/shared/result.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/shared/terminal.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/transcript/auto-caption-clean.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/transcript/manual-caption-clean.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/transcript/progressive-cue-merge.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vendor/video-digestion/transcript/vtt-parser.js create mode 100644 plugins/knowledge/skills/course-digest/extraction/vitest.config.ts create mode 100644 plugins/knowledge/skills/course-digest/reference/adapters/discovery-checklist.md create mode 100644 plugins/knowledge/skills/course-digest/reference/adapters/dometrain.md create mode 100644 plugins/knowledge/skills/course-digest/reference/adapters/teachable.md create mode 100644 plugins/knowledge/skills/course-digest/reference/analysis-template.md create mode 100644 plugins/knowledge/skills/course-digest/reference/screenshot-strategy.md create mode 100644 plugins/knowledge/skills/course-digest/templates/checklist.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f985cf144..aa5ab3679 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -95,7 +95,7 @@ "name": "knowledge", "source": "./plugins/knowledge", "category": "knowledge", - "tags": ["knowledge", "distill", "book", "pdf", "epub", "skill", "reference", "synthesis", "ingest"] + "tags": ["knowledge", "distill", "book", "pdf", "epub", "youtube", "video", "transcript", "course", "dometrain", "teachable", "skill", "reference", "synthesis", "ingest"] }, { "name": "context7", diff --git a/plugins/knowledge/.claude-plugin/plugin.json b/plugins/knowledge/.claude-plugin/plugin.json index b5a297390..892dcd959 100644 --- a/plugins/knowledge/.claude-plugin/plugin.json +++ b/plugins/knowledge/.claude-plugin/plugin.json @@ -1,14 +1,14 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "knowledge", - "version": "0.3.0", - "description": "Ingest external knowledge into durable, synthesized artifacts. Ships a book-distillation pipeline (PDF/EPUB into concept-organized, author-attributed skill reference files) and a YouTube pipeline (watch, transcript, link harvest, and repo-applicability synthesis), plus a re-runnable setup action; a configurable library directory governs where synthesized artifacts land in the consuming repo.", + "version": "0.4.0", + "description": "Ingest external knowledge into durable, synthesized artifacts. Ships a book-distillation pipeline (PDF/EPUB into concept-organized, author-attributed skill reference files), a YouTube pipeline (watch, transcript, link harvest, and repo-applicability synthesis), and a course-digest pipeline (extract and synthesize online video courses — Dometrain, Teachable — into repo-applicable recommendations), plus a re-runnable setup action; a configurable library directory governs where synthesized artifacts land in the consuming repo.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" }, "license": "MIT", - "keywords": ["knowledge", "distill", "book", "pdf", "epub", "youtube", "video", "transcript", "skill", "reference", "synthesis", "ingest"], + "keywords": ["knowledge", "distill", "book", "pdf", "epub", "youtube", "video", "transcript", "course", "dometrain", "teachable", "playwright", "skill", "reference", "synthesis", "ingest"], "userConfig": { "library_dir": { "type": "directory", diff --git a/plugins/knowledge/CHANGELOG.md b/plugins/knowledge/CHANGELOG.md index f66318294..58ddecce4 100644 --- a/plugins/knowledge/CHANGELOG.md +++ b/plugins/knowledge/CHANGELOG.md @@ -4,6 +4,50 @@ All notable changes to the `knowledge` plugin are recorded here. The `version` i `.claude-plugin/plugin.json` is the delivery vehicle — a consumer receives a change only after that version increases. +## 0.4.0 + +### Added + +- **`course-digest` skill** (`/knowledge:course-digest`) — extract and synthesize + online video courses (Dometrain, Teachable) into repo-applicable recommendations: + browser-automation transcript + frame extraction, code-companion analysis, and + multi-modal synthesis. Actions: full pipeline, `extract`, `analyze`, `status`, + `resume`, `continue`. +- Bundled `extraction/` node pipeline for the course-digest skill, with the two + shared libraries (`@melodic/repo-analysis`, `@melodic/video-digestion`) vendored + under `extraction/vendor/`. Dependencies install into `${CLAUDE_PLUGIN_DATA}` via + `skills/course-digest/extraction/setup-deps.mjs`, which also provisions Playwright's + Chromium into `${CLAUDE_PLUGIN_DATA}/ms-playwright` (idempotent; re-run after a + plugin update). ffmpeg and ImageMagick remain OS-level installs the skill's + Prerequisites section documents. + +### Changed + +- **Credential model** — course-platform login uses the user's own shell env vars + (`COURSE_*`/`TEACHABLE_*`, prefix driven by `platformConfig.authEnvPrefix`) with an + interactive manual-login fallback. Session cookies persist under + `${CLAUDE_PLUGIN_DATA}/auth/.auth-state.json` (out of the consumer repo), + keyed per platform. + +### Notes + +- The course-digest pipeline is ESM and shares the youtube skill's launcher shape + (`run.mjs` ESM resolve hook so bundled deps resolve from `${CLAUDE_PLUGIN_DATA}`); + `run.mjs` additionally pins `PLAYWRIGHT_BROWSERS_PATH` to the data directory so the + browser binary resolves regardless of cwd. +- Manual login (`node:readline` + headed browser) may not function under headless + plugin execution; env-var + cookie-reuse carry the skill regardless. +- `repo-analysis` and `video-digestion` are now vendored by both the youtube and + course-digest skills. Deduplication of the two copies is tracked separately. + +## 0.3.0 + +### Changed + +- **`setup` skill** — retrofit `library_dir` precedence resolution and portability + hardening so synthesized artifacts land at the configured library directory in the + consuming repo. + ## 0.2.0 ### Added diff --git a/plugins/knowledge/skills/course-digest/SKILL.md b/plugins/knowledge/skills/course-digest/SKILL.md new file mode 100644 index 000000000..03a1d68ec --- /dev/null +++ b/plugins/knowledge/skills/course-digest/SKILL.md @@ -0,0 +1,222 @@ +--- +name: course-digest +description: "Extract and synthesize online video courses into repo-applicable recommendations via browser automation (Playwright + claude-in-chrome), transcript extraction, frame analysis, and LLM synthesis. Use when: 'course digest', 'digest this course', 'analyze course', 'Dometrain', 'watch this course for me', 'course takeaways', 'extract from course', 'summarize course', user shares a Dometrain/Pluralsight/Udemy course URL, or wants course patterns applied to their codebase. Single public YouTube videos → use /knowledge:youtube. Actions: full pipeline (default), extract (phases 1-2 only), analyze (phases 3-5 only), status (list all digested courses), resume (continue extraction), continue (resume from saved session state)." +argument-hint: "[action] [url|slug] (e.g., /knowledge:course-digest , /knowledge:course-digest extract , /knowledge:course-digest resume , /knowledge:course-digest status)" +user-invocable: true +disable-model-invocation: false +--- + +## Pre-computed context + +course-extraction deps: !`node -e "const fs=require('fs'),path=require('path'),p=process.env.CLAUDE_PLUGIN_DATA;process.stdout.write(p&&fs.existsSync(path.join(p,'node_modules','@melodic','video-digestion'))?'installed':'MISSING - run setup-deps.mjs (see Prerequisites)')"` +Playwright Chromium: !`node -e "const fs=require('fs'),path=require('path');const b=process.env.PLAYWRIGHT_BROWSERS_PATH||(process.env.CLAUDE_PLUGIN_DATA&&path.join(process.env.CLAUDE_PLUGIN_DATA,'ms-playwright'));const ok=b&&fs.existsSync(b)&&fs.readdirSync(b).some(n=>n.startsWith('chromium'));process.stdout.write(ok?'installed':'MISSING - run setup-deps.mjs (see Prerequisites)')"` +ffmpeg: !`ffmpeg -version 2>/dev/null | head -1 || echo "MISSING — install ffmpeg (see Prerequisites)"` +ImageMagick: !`magick -version 2>/dev/null | head -1 || echo "MISSING — install ImageMagick 7 (see Prerequisites)"` + +# Course Digest + +Transform online video courses into structured knowledge and actionable repo recommendations — without watching a single video. + +## How it works + +Uses browser automation (claude-in-chrome) to navigate course platforms, extract transcripts, capture screenshots of code/slides, and collect downloadable resources. Synthesizes raw content into analysis focused on what applies to the current repository. + +Where this skill says "deeper research," use whatever external-research capability your project provides. Repo conventions override course claims — surface convention conflicts explicitly; never silently adopt a course's shortcut over team rules. + +## Emit checklist + +For any course digest run (multi-phase content acquisition + distillation + repo-applicability analysis), copy `templates/checklist.md` into `.work//course-digest-checklist.md`. Tick each phase as completed. + +## Prerequisites (verify before starting) + +1. **course-extraction deps** — `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/setup-deps.mjs"`. Installs the pipeline's node dependencies into `${CLAUDE_PLUGIN_DATA}` (persists across plugin updates) and provisions Playwright's Chromium into `${CLAUDE_PLUGIN_DATA}/ms-playwright`. Idempotent — safe to re-run, and re-run after a plugin update. +2. **Platform auth** — set `COURSE_EMAIL`/`COURSE_PASSWORD` (Dometrain → Clerk) or `TEACHABLE_EMAIL`/`TEACHABLE_PASSWORD` (Teachable) in your shell before invoking; they inherit into the pipeline's node subprocess. The env-var prefix is course-config-driven via `platformConfig.authEnvPrefix`. Session cookies persist under `${CLAUDE_PLUGIN_DATA}/auth/.auth-state.json` and are reused across runs. **Interactive manual login is the fallback** when no credentials are set — it opens a browser window for you to log in. NOTE: the manual-login prompt (`node:readline` + headed browser) may not function under headless plugin execution; env-var + cookie-reuse carry the skill regardless, and manual login is a known limitation there, not a blocker. +3. **ffmpeg** — required for video frame extraction (scene detection, interval capture). Check: `ffmpeg -version`. Install: `winget install Gyan.FFmpeg` (Windows), `brew install ffmpeg` (macOS), `sudo apt install ffmpeg` (Linux). Floor 7.1+ (newer codecs — AV1, Opus — degrade or fail below this). +4. **ImageMagick 7** — required by `classify-frames.js` for contact sheet generation (`magick montage`). Check: `magick -version`. Install: `winget install ImageMagick.ImageMagick` (Windows), `brew install imagemagick` (macOS), `sudo apt install imagemagick` (Linux). Ubuntu <26.04 ships v6 — v7 may require building from source. +5. **claude-in-chrome MCP** — needed for Phase 1 (course discovery) and frame extraction HLS URL retrieval. Run `tabs_context_mcp` as preflight. + +If any prerequisite fails, stop and inform the user. Re-run `setup-deps.mjs` for the node dependencies + Chromium; the media binaries (ffmpeg, ImageMagick) are OS-level installs via your platform's package manager per the commands above. + +## Running the pipeline scripts + +Every extraction script runs through the launcher, which resolves the vendored node dependencies from `${CLAUDE_PLUGIN_DATA}` and pins Playwright's browser path: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" [args…] +``` + +Gate on `setup-deps.mjs` first (Prerequisites above). + +### Consolidated extraction (Phases 1-2) + +The Playwright batch script handles transcripts, video frame extraction, and course metadata in a single run: + +```bash +# Full extraction: transcripts + frames + metadata +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir --extract-frames + +# Transcripts only (faster, no ffmpeg needed) +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir + +# Frames only (skip transcripts already extracted) +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir --extract-frames --skip-transcripts + +# Course metadata only +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir --metadata-only +``` + +Script uses Playwright's bundled Chromium with a fresh temp context (not Chrome itself — Chrome 136+ blocks CDP on default profiles). Auth handled via `addCookies()` after context launch: with credentials set it logs in and saves state; subsequent runs inject cached cookies automatically. Skips already-extracted lessons (crash-safe, resumable). Runs headless by default (`--show-browser` to show the browser). + +**Key technical details:** + +- Uses `--disable-blink-features=AutomationControlled` to bypass automation detection on course platform logins +- Chrome profiles are NOT used — Playwright's own Chromium with a dedicated temp context dir in the OS temp directory +- Auth state saved to `${CLAUDE_PLUGIN_DATA}/auth/.auth-state.json` (session expiry depends on the platform's auth provider, configured in `platformConfig.authWarnDays`) +- HLS video URLs captured via DOM read from the video player element (selector from `platformConfig.videoPlayerSelector`) — no claude-in-chrome needed +- Frame extraction via ffmpeg scene detection (threshold 0.1), with interval fallback for sparse results +- Content type derived from extraction results (scene detection = code, interval + high dup = talking head) — no manual tagging +- Progress tracking with per-lesson elapsed time, ETA, structured `run-report.json` output +- SIGINT handler saves progress on Ctrl+C — no work lost on interruption + +**Long-running extractions (50+ lessons):** the CC Bash tool has a hard 10-minute timeout limit. For large courses: + +```bash +# Run in background with nohup — no timeout limit +nohup node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir > extraction.log 2>&1 & +echo $! # save PID + +# Monitor progress periodically +tail -20 extraction.log + +# Check run report after completion +cat /run-report.json +``` + +## Platform detection and adapter architecture + +Extraction pipeline uses a **provider adapter pattern**. Platform-specific code lives in adapter modules (`extraction/adapters/{platform}.js`). The orchestrator (`extract-course.js`) delegates to adapters via a contract — zero platform-specific code in the orchestrator. + +**Architecture:** + +```text +extraction/ + adapters/ + adapter-contract.js # JSDoc interface + validation + factory + dometrain.js # Thin adapter: composes mux (player) + clerk (auth) + teachable.js # Thin adapter: composes hotmart (player) + teachable-sso (auth) + lib/ + players/ + hotmart.js # Hotmart HLS: intercept, iframe subtitle fetch, canvas frames + mux.js # Mux: DOM src read from mux-player element + auth/ + clerk.js # Clerk two-step login flow + teachable-sso.js # Teachable simple form login flow + auth-store.js # Resolves per-platform auth-state path under ${CLAUDE_PLUGIN_DATA} + browser.js # Shared Playwright launch, cookie injection, auth age check + validators.js # extraction validators + config.js # platformConfig validation, adapter resolution + # shared kernel + transcript: @melodic/video-digestion (vendored under extraction/vendor/) + extract-course.js # Orchestrator only — delegates to adapter + lib + discover-resources.js # Discovery tool — uses adapter.detectResources() + download-resources.js # Download lesson resources (ZIPs, PDFs) from resources.json + build-course-json.js # Teachable-specific: scaffold course.json from curriculum page + classify-frames.js # Frame classification (provider-agnostic, no adapter needed) + generate-manifests.js # Manifest generation (provider-agnostic, no adapter needed) + utils.js # Provider-agnostic utilities only +``` + +Adapters are thin composition layers — delegate to shared `lib/players/` and `lib/auth/` modules for reusable tech-layer concerns, keeping only platform-specific DOM selectors, URL patterns, and orchestration logic. + +**Every adapter method returns `Result`** (`ok`/`fail` from `@melodic/video-digestion/shared/result`) — explicit success/failure with timing, operation name, context. No silent catches, no null returns. + +| URL pattern | Platform | Adapter | Reference | +|---|---|---|---| +| `dometrain.com` | Dometrain | `adapters/dometrain.js` | [reference/adapters/dometrain.md](reference/adapters/dometrain.md) | +| `*.teachable.com`, `courses.*.tech` | Teachable (Hotmart video) | `adapters/teachable.js` | [reference/adapters/teachable.md](reference/adapters/teachable.md) | +| Single public YouTube videos | — | use /knowledge:youtube | — | +| `pluralsight.com` | Pluralsight | (future) | — | +| `udemy.com` | Udemy | (future) | — | + +**To add a new platform adapter:** follow [Provider Discovery Checklist](reference/adapters/discovery-checklist.md) to explore the platform systematically, then create `adapters/{platform}.js` implementing the 5 required methods (`extractTranscript`, `extractHlsUrl`, `detectResources`, `deriveLandingUrl`, `buildLessonUrl`). Compose from shared modules where the tech stack matches — e.g., a new platform using Hotmart video + Clerk auth would `import * as hotmart from "../lib/players/hotmart.js"` and `import * as clerk from "../lib/auth/clerk.js"`, then delegate player/auth methods while implementing only platform-specific DOM selectors and URL patterns. Add the platform to `course.json` `platform` field; the factory auto-discovers it via dynamic import. The discovery checklist also serves as regression guide when existing adapters break. + +## The pipeline + +Follow the 8-phase workflow in [context/workflow.md](context/workflow.md), each building on the previous — Discover → Extract → Process Frames → Analyze Code Repo → Validate → Synthesize → Analyze → Recommend (phases 1, 2, 2b, 2c, 2d, 3, 4, 5). Phases 1-2d are extraction (browser + CLI); 3-5 are analysis (LLM-heavy, parallelizable across modules). Storage runs continuously throughout. + +**Critical rule:** ALL context (transcripts + frames + code repo) must be gathered before Phase 3. +Module summaries note their context level: `[transcript-only]`, `[transcript+frames]`, `[full-context]`. + +### Phase 3 modalities (`[full-context]` requires all three) + +Synthesis combines three modalities — transcript (`transcript.md`), visual frames (PNGs + `manifest.json`), and companion code (`code/repo/
/`). Each module gets parallel agents (transcript + visual + code exploration), then a synthesis pass. Full modality table + multi-agent approach: [context/workflow.md](context/workflow.md) Phase 3. + +Multi-modal extraction gaps beyond these three (code OCR from frames, slide-text extraction, audio re-transcription) are evaluated with priority and effort in [context/multimodal-evaluation.md](context/multimodal-evaluation.md). + +### Dedup semantics + +Dedup phase (`classify-frames.js --phase dedup`) **reports** near-duplicates but does NOT +delete frame files. Actual frame curation happens in `generate-manifests.js` which sets +`keep: true/false` per frame. Frame PNGs remain on disk — manifests define which frames to use +during synthesis. + +### Phase tracking + +`course.json` includes a `phases` object recording which pipeline phases have completed and +when. Each phase is `null` (not started) or an object with `completedAt` timestamp and +phase-specific metrics. On resume, check which phases are non-null to skip completed work. + +### Repo freshness caveat + +Companion GitHub repos may be updated after course publication; classify transcript/code discrepancies per [context/workflow.md](context/workflow.md) "Freshness verification". **All course action items require external research verification before adoption** — course content is a starting point for research, not a final answer. + +## Invocation patterns + +Skill supports different scopes: + +| User says | Action | +|---|---| +| `/knowledge:course-digest ` | Full pipeline — discover + extract all + analyze | +| `/knowledge:course-digest extract ` | Phases 1-2 only — extract raw content, skip analysis | +| `/knowledge:course-digest analyze ` | Phases 3-5 only — analyze previously extracted content | +| `/knowledge:course-digest status` | Show all digested courses and their completion state | +| `/knowledge:course-digest resume ` | Resume extraction from where it left off (reads course.json phases) | +| `/knowledge:course-digest continue ` | Continue from a prior session — reads continuation prompt file | +| `/knowledge:course-digest` (no args) | Auto-detect: check for in-progress courses, resume the most recent | + +### Session handoff protocol + +When context is getting large (>40% used) or a session ending mid-pipeline: + +1. **Write a continuation prompt** to `/continuation-prompt.md` +2. Include: what was completed, what remains, task-by-task breakdown, known issues, quality notes +3. Update `course.json` phase markers with timestamps +4. Tell the user: *"Session state saved. Start a new session and run `/knowledge:course-digest continue ` to pick up where we left off."* + +The `continue` action reads the continuation prompt and reconstructs task context. No args defaults to checking `course.json` for the most recent in-progress course. + +## Pacing and user interaction + +Courses can have 60+ lessons. Processing all in one session may hit context limits. + +- **After discovering course structure** (Phase 1): present module/lesson list and ask user which modules to process, or confirm "all" +- **After every 5 lessons extracted**: report progress ("Extracted 5/67 lessons. Continuing...") +- **After each module completes**: save progress immediately — a crash shouldn't lose work +- **If context is getting large** (>50% used): suggest saving progress and resuming in a new session with `/knowledge:course-digest resume ` + +## Analysis output format + +Repo-applicability analysis follows the template in [reference/analysis-template.md](reference/analysis-template.md). Key deliverables: + +- **`repo-candidates.md`** — Specific patterns/practices from the course that could improve the repository, with references to where in the course they're taught +- **`action-items.md`** — Concrete next steps: rule candidates, skill suggestions, architecture patterns, testing practices, work-item candidates + +## Storage + +Generated course output lands under the invoking project's `library_dir` seam (or `${CLAUDE_PLUGIN_DATA}` when no library dir is configured), one self-contained directory per course slug. See [context/storage-schema.md](context/storage-schema.md) for the full directory structure. + +**Critical rules:** + +- No video or audio files — transcripts and screenshots capture the content +- Screenshots are PNGs — keep small (resize to 1280px wide max) +- Save progress incrementally — never buffer an entire course in memory +- Each course is self-contained under its own slug directory diff --git a/plugins/knowledge/skills/course-digest/context/multimodal-evaluation.md b/plugins/knowledge/skills/course-digest/context/multimodal-evaluation.md new file mode 100644 index 000000000..6fc64d5db --- /dev/null +++ b/plugins/knowledge/skills/course-digest/context/multimodal-evaluation.md @@ -0,0 +1,133 @@ +# Multi-Modal Gap Evaluation + +Evaluation of audio processing and multi-modal gaps in the course digest pipeline. +Produced by Task 23 (Session 7, 2026-04-01). + +## Current Pipeline Modalities + +| Modality | Source | Processing | Used in Summaries | +|----------|--------|-----------|-------------------| +| Text transcripts | Dometrain DOM panel | Timestamped markdown | Yes | +| Video frames | ffmpeg scene detection + interval | Raw PNGs, contact sheets, dedup | No (TDD has 992 frames, MCP has 0) | +| Audio | Not extracted | None | No | +| Code from video | Not extracted | None | No | +| Slide text | Not extracted | None | No | + +## Gap Analysis (Priority Order) + +### P1: Code OCR from Video Frames — HIGH VALUE, LOW EFFORT + +**Problem**: Instructor codes on screen. Transcript captures what they SAY about code but +misses actual syntax, variable names, import statements, function signatures, file structure. +For programming tutorials, this is 70%+ of visual content. + +**Evidence**: Research (MDPI 2024, Springer 2026) confirms code extraction from screenshots +captures information absent from transcripts. LLMs outperform Tesseract (~95% vs 70-80%) but +cost more. + +**Solution**: Run Tesseract OCR (with image preprocessing) on existing 992 TDD course frames. +For higher accuracy on specific frames, Claude's vision can read PNGs directly during +summarization phase. + +**Recommended approach**: Hybrid two-pass — Tesseract on all frames (free, fast), then +selectively send high-value frames (code-heavy keyframes) to Claude vision during summarization. + +**Output**: `code-snippets.md` per lesson, containing extracted code blocks. + +**Cost**: ~5 min to run OCR on 992 frames. Zero API cost for Tesseract pass. + +### P2: Slide Content Extraction — MEDIUM VALUE, LOW EFFORT + +**Problem**: Architecture diagrams, bullet point slides, visual aids captured as frames +but not processed. Text on slides contains structured information (definitions, comparisons, +workflows) not present in spoken transcript. + +**Evidence**: Panopto, FIZ Karlsruhe research confirms slide OCR adds 5-15% content over +transcripts alone. Most valuable for conceptual/architectural content. + +**Solution**: Detect slide boundaries (frame-diff threshold) and extract per-slide text via OCR +with deduplication. Not yet built. + +**Cost**: Low — runs on extracted frames. + +### P3: Code Diff Detection — MEDIUM VALUE, MEDIUM EFFORT + +**Problem**: In step-by-step coding tutorials, code evolves across lessons. Detecting what +changed between frames reveals instructor's incremental development process. + +**Solution**: Compute frame-pair diffs over OCR'd code regions and highlight code changes. + +**Cost**: Requires keyframe pairs (before/after) — needs scene analysis to identify code +transition points first. + +### P4: Audio Re-transcription (Whisper) — LOW VALUE, HIGH EFFORT + +**Problem**: Platform-provided transcripts may have auto-generated errors (names, technical +terms, acronyms). Whisper could provide higher accuracy. + +**Evidence**: BrassTranscripts benchmark shows Whisper WhisperX large-v3 achieves 88-93% +accuracy. But Dometrain transcripts from DOM panel appear to be 90%+ already (manual spot +check of MCP course transcripts shows clean, readable text with proper terminology). + +**Recommendation**: SKIP. DOM transcripts are sufficient. Only reconsider if a platform's +transcript quality degrades noticeably (add to validator checks — Task 22 already monitors +transcript quality via chars-per-minute ratios). + +### P5: Audio Analysis (Pacing, Emphasis, Speaker ID) — LOW VALUE, HIGH EFFORT + +**Problem**: Audio could reveal emphasis patterns, pacing (fast vs slow sections), +multi-speaker identification for Q&A sessions. + +**Evidence**: Limited. For course digest, we care about WHAT was said (content), not HOW it was +said (delivery). Pacing data doesn't inform repo-applicable analysis. + +**Recommendation**: SKIP entirely. Not relevant to pipeline's goal (extracting knowledge +for repo application). + +## Integration Architecture + +### Option A: Dedicated Frame-Analysis Phase (Recommended for Scale) + +``` +Phase 2: Extract → transcripts + frames +Phase 2.5: Analyze Frames (NEW) → OCR + slide extraction +Phase 3: Synthesize → module summaries (using ALL context) +``` + +Run a frame-analysis tool over extracted PNG frames: + +- Tesseract OCR (with preprocessing) on each frame for code text +- Slide-boundary detection + per-slide OCR for slide-heavy lessons +- Store results as `code-snippets.md` and `slides.md` per lesson + +**Advantage**: No changes to extraction pipeline. Runs on files already on disk. +**Challenge**: Need a maintained frame-analysis tool — either build a local script or vendor +one in. Mapping lesson frames to a stable lesson identifier is straightforward (filesystem +layout already groups frames per lesson). + +### Option B: Claude Vision During Summarization + +During Phase 3 (Synthesize), when generating module summaries, include selected keyframes +as images in Claude prompt. Claude's multimodal vision reads code from frames directly. + +**Advantage**: Zero pipeline changes. Just pass PNG paths to Claude during summarization. +**Challenge**: 992 frames is too many for a single context window. Need keyframe selection +(contact sheets or manifests already do this via classify-frames.js). + +### Recommendation + +**Start with Option B** — zero-effort and Claude already reads images. Existing +classify-frames.js + generate-manifests.js pipeline produces curated frame sets per lesson. +Include these in summarization prompt. + +**Graduate to Option A** when volume justifies automation (10+ courses) or when consistent +code extraction format matters for downstream analysis. + +## Thresholds for Re-evaluation + +| Trigger | Action | +|---------|--------| +| Platform transcript accuracy drops below 80% | Add Whisper re-transcription | +| 10+ courses digested | Automate frame analysis (Option A) — build or vendor an OCR + slide-extraction tool | +| Non-Dometrain platform without transcript panel | Build a full ingest pipeline (transcript via Whisper, scene detection via ffmpeg) | +| Slide-heavy course (>50% lessons with slides) | Add slide-boundary detection + per-slide OCR to workflow | diff --git a/plugins/knowledge/skills/course-digest/context/storage-schema.md b/plugins/knowledge/skills/course-digest/context/storage-schema.md new file mode 100644 index 000000000..71095cb98 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/context/storage-schema.md @@ -0,0 +1,230 @@ +# Storage Schema + +All course data lives under the invoking project's `library_dir` seam (or `${CLAUDE_PLUGIN_DATA}` when no library dir is configured), as `courses///`. + +## Platform naming + +Use platform's lowercase brand name: `dometrain`, `pluralsight`, `udemy`, `manning`, `oreilly`. Single public YouTube videos use `/knowledge:youtube` and its own slice layout — not this course data tree. + +## Slug naming + +Derive slug from course title: lowercase, kebab-case, max 50 chars, include instructor surname for disambiguation. + +Examples: + +- "Test-Driven Development in C#" by Guilherme Ferreira → `dometrain/tdd-csharp-ferreira` +- "From Zero to Hero: Dependency Injection in .NET" by Nick Chapsas → `dometrain/dependency-injection-dotnet-chapsas` + +## Directory structure + +``` +courses/// + course.json # Course metadata + extraction progress + modules/ + 01-course-overview/ # Zero-padded position + kebab-case title + module-summary.md # Phase 3 output: module-level synthesis + 01-welcome/ # Zero-padded position + kebab-case title + transcript.md # Timestamped transcript + notes.md # Lesson notes (if platform provides them) + code-snippets.md # Code extracted from lesson (inline code blocks) + resources.json # Download URLs, article links, PDF links (Teachable adapter) + screenshots/ # Key frames as PNGs + 001-slide-intro.png # Zero-padded, descriptive suffix + 002-code-example.png + 02-what-will-you-learn/ + transcript.md + ... + 02-the-fundamentals/ + module-summary.md + 01-what-is-tdd/ + transcript.md + ... + code/ # Code analysis: repo snapshot + per-lesson downloads + analysis.json # Phase 2c: structure, frameworks, modules (tracked in git) + repo/ # Final snapshot of companion source code + evently/ # (or section dirs for per-section repos) + src/ # Read by Phase 3 for architecture overview + cross-module patterns + test/ + downloads/ # Per-lesson code ZIPs (Initial + Final pairs) + 02.3 - Building the First Module - Initial.zip + 02.3 - Building the First Module - Final.zip + ... # Read by Phase 3 for per-lesson deltas (diff = lesson's teaching) + slides/ # Presentation slide decks (PDFs) + guides/ # Bonus course guides, supplementary PDFs (NOT slides) + resources/ # Non-code resources: SQL, Postman, OpenAPI, Keycloak configs + analysis/ # Phase 4-5 outputs + course-summary.md # Full course synthesis + repo-candidates.md # What applies to our repo + action-items.md # Concrete next steps +``` + +## File formats + +### course.json + +```json +{ + "title": "Test-Driven Development in C#", + "slug": "tdd-csharp-ferreira", + "platform": "dometrain", + "url": "https://dometrain.com/take/course/...", + "instructor": "Guilherme Ferreira", + "duration": "5h 41m", + "totalLessons": 67, + "extractedAt": "2026-03-31T...", + "status": "extracting", + "modules": [ + { + "position": 1, + "title": "Course overview", + "slug": "01-course-overview", + "lessons": [ + { + "position": 1, + "title": "Welcome", + "slug": "01-welcome", + "duration": "1m 37s", + "url": "https://...", + "status": "extracted", + "hasTranscript": true, + "hasScreenshots": false, + "hasDownload": false, + "hasVideo": true, + "providerResources": { + "lessonNotes": false, + "readThisLesson": false + } + } + ] + } + ], + "resources": { + "githubUrl": null, + "downloadAvailable": true + }, + "phases": { + "extract": { "completedAt": "2026-04-01T...", "lessonsExtracted": 44 }, + "extractFrames": { "completedAt": "2026-04-01T...", "framesExtracted": 996 }, + "processFrames": { "completedAt": "2026-04-01T...", "manifests": 44, "kept": 893 }, + "analyzeCodeRepo": { "completedAt": "2026-04-01T...", "sections": 10 }, + "validate": { "completedAt": "2026-04-01T...", "passed": 52, "warnings": 0, "failed": 0 }, + "synthesize": null, + "analyze": null + } +} +``` + +**Status values:** `pending` → `extracting` → `extracted` → `analyzed` + +**Phase tracking:** `phases` object records which pipeline phases have completed and when. +Each phase is `null` (not started) or an object with `completedAt` timestamp and phase-specific +metrics. Tools should set phase marker after successful completion. On resume, check which +phases are non-null to determine where to continue. + +### transcript.md + +```markdown +# Welcome + +**Duration:** 1m 37s +**Module:** Course overview + +## Transcript + +[0:00] Hello, and welcome to the From Zero to Hero course on test-driven +development in C#. + +[0:07] My name is Guilherme Ferreira, also known as GI, and I'm a Microsoft +MVP for developer technologies. + +[0:15] Not only that, but I'm addicted to test-driven development. +``` + +Preserve timestamps as `[M:SS]` markers at natural paragraph breaks. Clean up auto-generated transcript artifacts (repeated words, sentence fragments) but don't editorialize content. + +### module-summary.md + +```markdown +# Module: The Fundamentals + +**Lessons:** 8 | **Duration:** ~25 min + +## Key concepts + +- **Concept name** — Brief explanation. (Lesson: "Lesson Title") +- ... + +## Code patterns demonstrated + +- Pattern description with context + +## Best practices advocated + +- Practice with rationale + +## Anti-patterns warned against + +- Anti-pattern with why it's problematic + +## Tools and frameworks mentioned + +- Tool/framework with context of how it's used +``` + +### resources.json (Teachable adapter) + +Per-lesson resource metadata — download URLs, article links, PDF links extracted by adapter's `extractResources()` method. Not all platforms produce this file (Dometrain uses button-based detection instead). + +```json +{ + "downloads": [ + { "label": "02.4 - Refactoring - Initial.zip", "href": "https://uploads.teachablecdn.com/..." }, + { "label": "02.4 - Refactoring - Final.zip", "href": "https://uploads.teachablecdn.com/..." } + ], + "articleLinks": [ + { "label": "How To Use Domain Events", "href": "https://www.milanjovanovic.tech/blog/..." } + ], + "pdfLinks": [ + { "label": "Modular Monolith Architecture.pdf", "href": "https://uploads.teachablecdn.com/..." } + ], + "textContent": ["Source Code:", "Useful Articles & Resources:"] +} +``` + +### Code analysis strategy (two levels) + +**`code/repo/`** — final/latest snapshot of companion source code. Use for: + +- Architecture overview (module organization, project references, shared infrastructure) +- Cross-module patterns (how modules communicate, shared domain events) +- Complete solution understanding (what the finished app looks like) + +**`code/downloads/`** — per-lesson Initial/Final ZIP pairs. Use for: + +- Per-lesson deltas (diff between Initial and Final = what the lesson teaches) +- Understanding progression (how codebase evolves lesson by lesson) +- Catching discrepancies (what code does vs what instructor says) + +Phase 3 synthesis agents read BOTH: repo for architectural context, downloads for lesson-specific teaching. Repo alone misses journey; ZIPs alone miss big picture. + +For courses with GitHub repos instead of ZIPs, `code/repo/` is a shallow clone and per-section analysis comes from `git log` or directory-based sections. + +### Screenshots + +- Format: PNG +- Max width: 1280px +- Naming: `{NNN}-{descriptive-suffix}.png` (e.g., `001-test-list-creation.png`) +- Only capture when visual content adds information beyond transcript: + - Code on screen (IDE, terminal) + - Architecture diagrams + - Slides with visual content + - UI demonstrations +- Do NOT screenshot talking-head segments — transcript covers those + +## Size management + +- **No video/audio files** — ever +- **Screenshots**: resize to 1280px wide, compress with reasonable quality +- **Transcripts**: typically 1-3 KB per minute of video (~30 KB for a 30-min lesson) +- **course.json**: grows with lessons but stays under 50 KB for large courses +- **Expected total per course**: 1-5 MB for transcripts + metadata, 10-50 MB with screenshots diff --git a/plugins/knowledge/skills/course-digest/context/workflow.md b/plugins/knowledge/skills/course-digest/context/workflow.md new file mode 100644 index 000000000..75ac05aa9 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/context/workflow.md @@ -0,0 +1,332 @@ +# Course Digest Workflow + +Eight phases executed in order. Each phase produces artifacts consumed by subsequent phases. + +**Critical ordering rule:** ALL context must be gathered before summarization begins. Module +summaries generated from transcripts alone are incomplete — they miss code syntax, visual +diagrams, repo patterns. Full sequence: Extract → Process Frames → Analyze Code +Repo → Validate → THEN Synthesize. + +**Completeness markers:** Module summaries should note their context sources: + +- `[transcript-only]` — generated without frames or code repo (acceptable for initial pass) +- `[transcript+frames]` — includes frame analysis (better) +- `[full-context]` — transcript + frames + code repo analysis (best) + +## Phase 1: Discover + +**Goal:** Extract complete course structure from the platform. + +**Steps:** + +1. Navigate to course URL using `navigate` tool +2. Read page to identify modules and lessons (use platform adapter for selectors) +3. For each module, extract: + - Module title and position + - Lesson titles, durations, URLs, completion status +4. Check for course-level resources: + - Download button (course files) + - GitHub repository link + - Course description / prerequisites +5. Extract instructor name from **landing page** (JSON-LD `author` field or visible "Meet Your Instructor" section). Never guess — each platform hosts multiple instructors +6. Write `course.json` with full structure + +**Output:** `course.json` — metadata + complete module/lesson tree + +**Checkpoint:** Present course structure to user. Ask which modules to process (or confirm "all"). Only mandatory user interaction gate. + +## Phase 2: Extract + +**Goal:** Extract content from each lesson. + +**Per lesson, in order:** + +1. **Navigate** to lesson URL +2. **Transcript** — read from platform's transcript panel (adapter-specific). Save as `transcript.md` with timestamps preserved +3. **Screenshots** — capture frames per [screenshot strategy](../reference/screenshot-strategy.md). Only for lessons with visual content (code demos, slides, architecture diagrams). Save to `screenshots/` subdirectory +4. **Lesson notes** — check if platform provides written notes or supplementary text. Save as `notes.md` if available +5. **Code references** — extract any code shown in the lesson (from transcript context, screenshots, or linked resources). Save as `code-snippets.md` +6. **Progress** — update `course.json` with extraction status for this lesson + +**Pacing:** + +- Report progress every 5 lessons +- Save after every lesson (crash-safe) +- If transcript extraction fails for a lesson, log error and continue to next + +**Output per lesson:** `transcript.md`, optional `screenshots/`, optional `notes.md`, optional `code-snippets.md` + +## Phase 2b: Process Frames + +**Goal:** Classify extracted frames, generate contact sheets, build manifests. + +**Prerequisite:** Phase 2 must complete with `--extract-frames` for lessons with visual content. + +**Steps (sequential):** + +1. `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" classify-frames.js --course-dir --phase contact-sheets` — generate labeled thumbnail grids +2. `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" classify-frames.js --course-dir --phase dedup` — near-duplicate detection +3. `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" generate-manifests.js --course-dir ` — curate frame sets per lesson +4. `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" classify-frames.js --course-dir --phase summary` — print frame inventory + +**Output:** Contact sheets, dedup report, manifests per lesson. + +**Note:** If frames not extracted (MCP course has 0 frames), skip this phase but note summaries will be `[transcript-only]`. + +## Phase 2c-i: Download Course Resources + +**Goal:** Download all referenced external resources (source code ZIPs, PDFs, SQL scripts, +Postman collections, etc.) so they're available locally for Phase 3 analysis. + +**Prerequisite:** Phase 2 extraction must have produced `resources.json` files with download URLs. + +**Steps:** + +1. Scan all `resources.json` files for download URLs (hosted on CDN, not behind auth) +2. For each URL category: + - **Source code ZIPs** → download to `code/downloads/` — replaces Phase 2c GitHub clone when no companion repo exists + - **PDF slides** → download to `slides/` — referenced during visual analysis + - **SQL scripts, Postman collections, OpenAPI specs** → download to `resources/` — referenced during code analysis +3. Verify downloads: check file sizes, validate ZIP integrity, confirm PDF readability +4. Build download manifest (`downloads.json`) mapping lesson → downloaded files + +**Output:** `code/downloads/`, `slides/`, `resources/`, `downloads.json` + +**Provider patterns:** + +- **Dometrain**: "Download course files" button triggers ZIP download. GitHub repo link for code +- **Teachable**: Per-lesson download URLs in `resources.json` (`uploads.teachablecdn.com`). Often provides both "Initial" and "Final" ZIPs per coding lesson — delta between them shows exactly what the lesson teaches + +**When a course has BOTH GitHub repo AND downloadable ZIPs** (like Teachable courses with per-lesson ZIPs): +use ZIPs for per-lesson code state, GitHub for final/latest state. ZIPs capture +code-at-recording-time; repo may have post-publication updates. + +**Security note:** Downloaded files gitignored (`**/courses/**/code/*`, `**/courses/**/slides/*`, +`**/courses/**/resources/*`). Never commit third-party course resources to the repository. + +## Phase 2c-ii: Analyze Code Repo + +**Goal:** Clone or analyze the course's companion code repository and retain source +for Phase 3 code exploration. + +**Prerequisite:** `course.json` must have `resources.githubUrl` OR Phase 2c-i must have +downloaded source code ZIPs. If neither exists, skip. + +**Steps (GitHub repo path):** + +1. `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" analyze-code-repo.js --course-dir ` — clone to temp, detect structure, write metadata +2. Clone again to `code/repo/` for Phase 3 access: `git clone --depth 1 --single-branch code/repo/` +3. Review `code/analysis.json` for repo structure (per-section vs single-state) +4. Build section-to-module mapping table — which repo sections correspond to which course modules +5. For per-section repos: section diffs show what code changed module-to-module + +**Steps (ZIP-only path — no GitHub repo):** + +1. Extract "Final" ZIP (latest complete state) to `code/repo/` for Phase 3 access +2. If per-lesson Initial/Final ZIPs exist, extract each Final to `code/repo/{module-slug}/` +3. Analyze project structure: detect .sln, .csproj, frameworks, NuGet packages +4. Write `code/analysis.json` with structure findings +5. Build section-to-module mapping from ZIP naming conventions (e.g., "02.4 - Lesson Title") + +**Output:** `code/analysis.json`, `code/README.md`, `code/repo/` (gitignored, local only) + +**Security note:** Never commit `code/repo/` — may contain third-party copyrighted code. +Gitignore pattern `**/courses/**/code/*` blocks everything except `analysis.json` and `README.md`. +Verify clone URLs are clean public URLs — never embed PATs or tokens in `course.json`. + +**Freshness caveat:** Course companion repos may be updated after publication — authors sometimes +fix bugs, update packages, or refactor code post-recording. When Phase 3 finds discrepancies +between transcript and code, classify as: + +- **Post-publication update** (likely) — newer package versions, renamed properties, added features +- **Recording-time bug** (possible) — logic errors, missing implementations +- **Intentional simplification** (possible) — transcript describes ideal, code takes shortcuts + +Check repo's git log (`git log --oneline -20`) and last commit date against course +publication date to assess which discrepancies are updates vs original issues. Note: `--depth 1` +clones lose history — if freshness matters, clone without `--depth` for investigation phase +only, then discard. + +## Phase 2d: Validate + +**Goal:** Check extraction artifact quality before analysis begins. + +**Prerequisite:** Phases 2-2c complete. + +**Steps:** + +1. `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" validate-extraction.js --course-dir ` — run all quality checks +2. Review `validation-report.json` — fix any FAIL items before proceeding +3. On re-runs: compare against previous `validation-report.json` for regressions + +**Output:** `validation-report.json` (serves as baseline for future runs) + +**Quality gate:** Exit code 1 from validator means extraction has issues. Fix before summarizing. + +## Phase 3: Synthesize + +**Goal:** Produce per-module summaries combining all three knowledge modalities into a unified +analysis. A `[full-context]` summary is NOT just transcripts with metadata — it synthesizes what +the instructor says, what's shown on screen, what the actual code does. + +**Three modalities (all required for `[full-context]`):** + +| Modality | Source | What it captures | +|----------|--------|-----------------| +| Audio/transcript | `transcript.md` per lesson | Instructor explanations, arguments, verbal emphasis, things said but not shown | +| Visual/frames | Frame PNGs + `manifest.json` | Code on screen, architecture diagrams, slides, terminal output, UI demos | +| Code/repo | `code/repo/
/` source files | Actual implementation patterns, DI setup, project structure, what the code DOES vs what the instructor SAYS it does | + +**Multi-agent approach per module:** + +Each module gets parallel agents, then a synthesis pass: + +1. **Transcript agent** — reads all `transcript.md` files for the module. Extracts concepts, + arguments, anti-patterns, tools mentioned, lesson structure +2. **Visual agent** — views actual frame images (PNGs from `screenshots/`) and contact sheets. + Reads code shown on screen, identifies architecture diagrams, captures visual content not + described in the transcript. Use Read tool on images for multimodal analysis +3. **Code exploration agent(s)** — reads actual source files from matching `code/repo/` + section(s). Understands implementation: `Program.cs`, tool classes, DI registration, + project references, Dockerfiles. For larger sections, use multiple agents to divide and conquer +4. **Synthesis agent** — takes outputs from agents 1-3 and existing `module-summary.md` + (if any). Produces final combined summary noting where modalities agree, disagree, or + complement each other + +**Agent sizing guidance:** + +| Module type | Agents needed | Rationale | +|-------------|--------------|-----------| +| Conceptual (no code section) | 2 (transcript + visual) | No code to explore | +| Small code section (<20 files) | 3 (transcript + visual + 1 code) | Single agent covers the code | +| Large code section (20+ files) | 4+ (transcript + visual + N code) | Split code exploration by concern | +| Multi-section module | 3+ per section | Each repo section gets its own code agent | + +**Section-to-module mapping (established in Phase 2c):** + +Build this table during Phase 2c. Example from per-section repo: + +``` +| Repo Section | Module | Lessons | +|---------------------------|--------|---------| +| 01-mcp-server-stdio | M4 | L3 | +| 04-chat-agent | M5 | L4 | +| 06-mcp-server-authenticated | M6 | L3-L5 | +``` + +**Per module, identify:** + +- Core concepts taught (with lesson references) +- Code patterns demonstrated (from transcripts AND actual code AND visual frames) +- Discrepancies between what's said, shown, and coded (these are high-value findings) +- Best practices advocated +- Anti-patterns warned against +- Tools/libraries/frameworks mentioned (verify versions against current stable) +- Note context level: `[transcript-only]`, `[transcript+frames]`, or `[full-context]` + +**Output per module:** `module-summary.md` + +## Phase 4: Analyze + +**Goal:** Produce course-level synthesis. + +1. Read all module summaries +2. Identify cross-cutting themes spanning multiple modules +3. Assess instructor's overall philosophy and approach +4. Note any contradictions or tensions between recommendations +5. Write `analysis/course-summary.md` + +**Output:** `analysis/course-summary.md` + +## Phase 5: Recommend + +**Goal:** Map course learnings to THIS repository. Primary deliverable. + +1. Read `course-summary.md` + all module summaries +2. Read repository's CLAUDE.md, `.claude/rules/`, key architecture files +3. For each course concept, evaluate: + - Does our repo already implement this? (skip if yes) + - Relevant to our tech stack and architecture? + - What would adoption look like concretely? + - Effort/impact ratio? +4. Categorize recommendations using [analysis template](../reference/analysis-template.md): + - CLAUDE.md / `.claude/rules/` rule candidates + - Skill candidates (new `/skill-name` opportunities) + - Architecture pattern changes + - Testing practice improvements + - CI/CD improvements + - `/work-items` items +5. Write `analysis/repo-candidates.md` and `analysis/action-items.md` + +**Output:** `analysis/repo-candidates.md`, `analysis/action-items.md` + +## Phase 6: Store (continuous) + +Runs throughout all phases — not a separate step. See [storage-schema.md](storage-schema.md) for complete directory structure. + +**Rules:** + +- Write artifacts as they're produced — don't buffer +- Update `course.json` status after each lesson/module +- All paths relative to `data/courses//` + +## Resuming + +When invoked with `/knowledge:course-digest resume `: + +1. Read `course.json` to find extraction status +2. Skip lessons already marked as extracted +3. Continue from the first un-extracted lesson +4. If all lessons are extracted, skip to Phase 2b (Process Frames) or Phase 3 (Synthesize) + +## Execution model: sequential vs parallel + +**Bot detection constraint:** All browser-based extraction (Phase 2) MUST be sequential. Dometrain and +similar platforms rate-limit and detect automated access. Navigate no faster than ~2 seconds between +lessons. Do NOT parallelize DOM interactions, browser contexts, lesson navigation. + +**What can run in parallel:** + +| Phase | Parallelizable? | Reason | +|-------|----------------|--------| +| 1 (Discover) | No | Single browser session, sequential navigation | +| 2 (Extract) | No | Sequential lesson navigation, bot detection risk | +| 2b (Process Frames) | Partially | Contact sheets + dedup are CPU-bound, can parallelize across modules | +| 2c (Code Repo) | Yes | Git clone + analysis is independent of browser state | +| 2d (Validate) | Yes | Pure filesystem analysis, no browser | +| 3 (Synthesize) | **Yes** | Per-module summaries are independent — no DOM interaction, pure LLM | +| 4 (Analyze) | No | Depends on all module summaries | +| 5 (Recommend) | No | Depends on course summary | + +**Optimal pacing for Phase 2:** + +- Navigate between lessons at ~1.5-2s intervals (current default via `page.waitForTimeout(1500)`) +- Faster navigation risks bot detection and session invalidation +- Slower is unnecessary — the platform serves pages in <1s + +**Long-running extraction strategy:** + +- 45-lesson course: ~90 min for transcripts, ~30+ min for frames +- Use `nohup` pattern for unattended runs (see SKILL.md) +- Monitor via `tail -20 extraction.log` and `run-report.json` + +## Freshness verification (Phase 5 prerequisite) + +**Before integrating any action item from `repo-candidates.md` into the repository:** + +1. **Run `/explore`** on relevant codebase area — verify current state matches what the + action item assumes. Codebase may have changed since course was digested +2. **Run `/research`** on specific library/framework/pattern — verify recommendation is + current. Course content has a recorded-at date but no guarantee of currency: + - NuGet/npm package versions may have changed (pre-release → stable, or breaking changes) + - Framework APIs may have evolved + - Best practices may have shifted +3. **Flag stale recommendations** in `action-items.md` with `⚠ STALE` marker if `/research` + reveals recommendation is outdated + +**Example:** MCP course references `ModelContextProtocol` NuGet packages that were pre-release +at recording time. Before using recommended patterns, verify current stable version via +`/research` or `mcp__nuget__get_latest_package_version`. + +**Rule:** Course content is a starting point for research, not a final answer. Every action item +gets `/explore` + `/research` verification AT TIME OF INTEGRATION, not at digest time. diff --git a/plugins/knowledge/skills/course-digest/evals/evals.json b/plugins/knowledge/skills/course-digest/evals/evals.json new file mode 100644 index 000000000..c0219521b --- /dev/null +++ b/plugins/knowledge/skills/course-digest/evals/evals.json @@ -0,0 +1,82 @@ +{ + "skill_name": "course-digest", + "evals": [ + { + "id": 1, + "name": "status-read-only-inventory", + "prompt": "/knowledge:course-digest status", + "expected_output": "A read-only inventory of every course under the configured library directory, each with its completion state derived from the course.json phase markers. No browser session opens and no extraction, synthesis, or analysis work begins.", + "files": [], + "expectations": [ + "Reports each digested course's completion state by reading the `phases` object in its `course.json` (non-null phases = completed)", + "Does NOT launch browser automation (claude-in-chrome / Playwright) or begin any extraction", + "Does NOT run Phase 3-5 synthesis or analysis — status is a pure inventory action", + "Identifies courses by slug rather than by a hardcoded filesystem path" + ] + }, + { + "id": 2, + "name": "youtube-url-routes-to-youtube-skill", + "prompt": "Can you digest this for me? https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "expected_output": "The skill declines to run the course pipeline and routes the user to /knowledge:youtube, because a single public YouTube video is out of scope for course-digest (which targets multi-lesson course platforms like Dometrain/Teachable).", + "files": [], + "expectations": [ + "Recognizes the URL is a single public YouTube video, not a supported course-platform URL", + "Routes the user to `/knowledge:youtube` instead of running the course-digest pipeline", + "Does NOT begin Phase 1 discovery or launch extraction for the YouTube URL" + ] + }, + { + "id": 3, + "name": "full-pipeline-phase1-mandatory-gate", + "prompt": "/knowledge:course-digest https://dometrain.com/course/getting-started-with-model-context-protocol/", + "expected_output": "Prerequisites are verified first; on success Phase 1 discovers the course structure, then the run STOPS at the mandatory checkpoint to present the module/lesson list and ask which modules to process before any lesson extraction begins. Instructor is read from the landing page, and Phase 2 extraction is sequential.", + "files": [], + "expectations": [ + "Verifies prerequisites (extraction deps, Playwright browsers, ffmpeg, ImageMagick, claude-in-chrome MCP) before starting, and stops to inform the user if any prerequisite fails", + "After Phase 1 discovery, STOPS and presents the module/lesson list and asks which modules to process (or confirm 'all') before extracting — the only mandatory user-interaction gate", + "Extracts the instructor name from the course landing page (JSON-LD author or 'Meet Your Instructor'), never guessing", + "Treats Phase 2 lesson extraction as strictly sequential — does not parallelize browser navigation across lessons (bot-detection constraint)" + ] + }, + { + "id": 4, + "name": "analyze-action-skips-extraction", + "prompt": "/knowledge:course-digest analyze mcp-getting-started-charlesworth", + "expected_output": "The analyze action runs Phases 3-5 only against already-extracted content — it does not re-navigate the browser or re-extract lessons. Synthesis proceeds only after confirming all context (transcripts + frames + code repo) is gathered, and each module summary is tagged with its context-level marker.", + "files": [], + "expectations": [ + "Runs Phases 3-5 (synthesize, analyze, recommend) only — does NOT re-run Phase 1-2 browser extraction for an already-extracted course", + "Confirms all context (transcripts, frames, code repo) is gathered before beginning Phase 3 synthesis (critical ordering rule)", + "Tags each module summary with a context-level marker: `[transcript-only]`, `[transcript+frames]`, or `[full-context]`" + ] + }, + { + "id": 5, + "name": "synthesis-and-deliverable-output-shape", + "prompt": "The extraction for tdd-csharp-ferreira is done — transcripts, frames, and the companion code repo are all in place. Synthesize it and tell me what applies to our repo.", + "expected_output": "Phase 3 synthesizes each module across all three modalities (transcript, frames, code repo), surfacing and classifying discrepancies between what the instructor says, shows, and codes. Phase 5 produces both repo-candidates.md and action-items.md, categorized per the analysis template, mapped to THIS repository's .NET/modular-monolith stack.", + "files": [], + "expectations": [ + "Phase 3 synthesizes across all three modalities (transcript, visual frames, code repo) rather than summarizing transcripts alone", + "Surfaces discrepancies between what the instructor says, what frames show, and what the code does, classifying each as post-publication update / recording-time bug / intentional simplification", + "Produces BOTH `repo-candidates.md` AND `action-items.md` as the Phase 5 deliverables", + "Categorizes recommendations per the analysis template (e.g. already-implemented vs high/medium priority candidates; CLAUDE.md/rule, skill, architecture, testing, CI/CD, and /work-items buckets)", + "Frames recommendations against THIS repository's stack and conventions, not generically" + ] + }, + { + "id": 6, + "name": "adoption-requires-freshness-verification", + "prompt": "The action-items.md for the MCP course looks great — go ahead and apply the top CLAUDE.md rule candidate and the package-version recommendation to our repo.", + "expected_output": "Before integrating any action item, the skill runs /explore on the relevant codebase area and /research (or nuget version lookup) on the referenced library/pattern, since course content is a starting point for research, not a final answer. Recommendations found to be outdated are flagged STALE rather than silently adopted.", + "files": [], + "expectations": [ + "Runs `/explore` on the relevant codebase area to verify current state before adopting the action item", + "Runs `/research` (or a live version lookup such as nuget latest-version) on the referenced library/pattern to confirm currency before adoption", + "Flags any recommendation that research reveals is outdated with a ⚠ STALE marker instead of adopting it silently", + "Does NOT integrate the course recommendation into repo files without first completing verification" + ] + } + ] +} diff --git a/plugins/knowledge/skills/course-digest/extraction/adapters/adapter-contract.js b/plugins/knowledge/skills/course-digest/extraction/adapters/adapter-contract.js new file mode 100644 index 000000000..df1f7ad87 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/adapters/adapter-contract.js @@ -0,0 +1,88 @@ +/** + * Adapter contract definition and factory for the course-extraction pipeline. + * + * Each platform adapter is a plain object implementing these methods. + * No classes, no inheritance - composition via method properties. + * + * The contract prescribes WHAT adapters produce, not HOW they produce it. + * Dometrain reads DOM directly. Teachable intercepts network traffic and + * fetches from cross-origin iframe contexts. Both return the same result types. + */ + +/** @typedef {import('@melodic/video-digestion/shared/result').Result} PipelineResult */ + +/** + * @typedef {Object} CourseExtractAdapter + * @property {(page: import('playwright').Page, platformCfg: object) => Promise} extractTranscript + * @property {(page: import('playwright').Page, platformCfg: object) => Promise} extractHlsUrl + * @property {(page: import('playwright').Page, platformCfg: object) => Promise} detectResources + * @property {(courseUrl: string, platformCfg: object) => string} deriveLandingUrl + * @property {(course: object, lesson: object, platformCfg: object) => string} buildLessonUrl + * @property {(page: import('playwright').Page, platformCfg: object) => Promise} [setupSession] + * @property {(page: import('playwright').Page, platformCfg: object, lesson: object) => Promise} [prepareLessonPage] + * @property {(page: import('playwright').Page, platformCfg: object) => Promise} [extractResources] + * @property {(page: import('playwright').Page, courseUrl: string, platformCfg: object) => Promise} [extractMetadata] + * @property {(input: import('./auth-session.js').AuthSessionInput) => Promise} [authenticate] + * @property {(page: import('playwright').Page, platformCfg: object) => Promise} [preflight] + * @property {(platformCfg: object) => void} [validateConfig] + * @property {object} [defaults] + */ + +import { fail, ok } from "@melodic/video-digestion/shared/result"; + +import { resolveAdapter, validatePlatformConfig } from "../lib/config.js"; + +const REQUIRED_METHODS = [ + "extractTranscript", + "extractHlsUrl", + "detectResources", + "deriveLandingUrl", + "buildLessonUrl", +]; + +/** + * Validate that an adapter object implements all required methods. + * @param {object} adapter + * @param {string} platform + * @returns {PipelineResult} + */ +export function validateAdapter(adapter, platform) { + const missing = REQUIRED_METHODS.filter((m) => typeof adapter[m] !== "function"); + if (missing.length > 0) { + return fail( + `Adapter "${platform}" missing required methods: ${missing.join(", ")}`, + "validate-adapter", + null, + 0, + ); + } + return ok(adapter, "validate-adapter", null, 0); +} + +/** + * Resolve, validate, and return an adapter for the given platform. + * Validates both the platformConfig and the adapter contract. + * + * @param {string} platform + * @param {object} platformCfg + * @returns {Promise} + */ +export async function createAdapter(platform, platformCfg) { + const configResult = validatePlatformConfig(platformCfg, platform); + if (!configResult.success) return configResult; + + const adapterModule = await resolveAdapter(platform); + if (!adapterModule) { + return fail( + `No adapter found for platform "${platform}". Expected: adapters/${platform}.js`, + "create-adapter", + null, + 0, + ); + } + + const adapterResult = validateAdapter(adapterModule, platform); + if (!adapterResult.success) return adapterResult; + + return ok(adapterModule, "create-adapter", null, 0); +} diff --git a/plugins/knowledge/skills/course-digest/extraction/adapters/adapter-contract.test.js b/plugins/knowledge/skills/course-digest/extraction/adapters/adapter-contract.test.js new file mode 100644 index 000000000..0d1261ebb --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/adapters/adapter-contract.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { createAdapter, validateAdapter } from "./adapter-contract.js"; + +describe("validateAdapter", () => { + const validAdapter = { + extractTranscript: async () => {}, + extractHlsUrl: async () => {}, + detectResources: async () => {}, + deriveLandingUrl: () => "", + buildLessonUrl: () => "", + }; + + it("should accept adapter with all required methods", () => { + const result = validateAdapter(validAdapter, "test"); + expect(result.success).toBe(true); + }); + + it("should reject adapter missing extractTranscript", () => { + const { extractTranscript, ...partial } = validAdapter; + const result = validateAdapter(partial, "test"); + expect(result.success).toBe(false); + expect(result.error).toContain("extractTranscript"); + }); + + it("should reject adapter missing deriveLandingUrl", () => { + const { deriveLandingUrl, ...partial } = validAdapter; + const result = validateAdapter(partial, "test"); + expect(result.success).toBe(false); + expect(result.error).toContain("deriveLandingUrl"); + }); + + it("should reject adapter with non-function method", () => { + const bad = { ...validAdapter, extractTranscript: "not a function" }; + const result = validateAdapter(bad, "test"); + expect(result.success).toBe(false); + expect(result.error).toContain("extractTranscript"); + }); +}); + +describe("createAdapter", () => { + it("should return fail for unknown platform", async () => { + const result = await createAdapter("nonexistent", {}); + expect(result.success).toBe(false); + expect(result.error).toContain("nonexistent"); + }); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/adapters/auth-session.js b/plugins/knowledge/skills/course-digest/extraction/adapters/auth-session.js new file mode 100644 index 000000000..e37b6e4cc --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/adapters/auth-session.js @@ -0,0 +1,10 @@ +/** + * @typedef {Object} AuthSessionInput + * @property {import('playwright').BrowserContext} context + * @property {import('playwright').Page} page + * @property {object} course + * @property {string} storageStatePath + * @property {object} platformCfg + */ + +export {}; diff --git a/plugins/knowledge/skills/course-digest/extraction/adapters/dometrain.js b/plugins/knowledge/skills/course-digest/extraction/adapters/dometrain.js new file mode 100644 index 000000000..cb4a920ab --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/adapters/dometrain.js @@ -0,0 +1,383 @@ +/** + * Dometrain platform adapter for course-extraction. + * + * This adapter composes: + * - lib/players/mux.js — HLS URL from Mux player DOM element + * - lib/auth/clerk.js — two-step Clerk login flow + * + * All Dometrain-specific DOM interaction, URL patterns, and orchestration + * live here. The orchestrator delegates to these methods via the adapter + * contract — zero platform-specific code in extract-course.js. + */ + +import { fail, ok, timed } from "@melodic/video-digestion/shared/result"; +import { writeStdout } from "@melodic/video-digestion/shared/terminal"; + +import { login as clerkLogin } from "../lib/auth/clerk.js"; +import { promptManualLogin } from "../lib/auth/manual-login.js"; +import { getHlsUrl } from "../lib/players/mux.js"; +import { + DESCRIPTION_META_SELECTOR, + TRANSCRIPT_PANEL_SELECTOR, +} from "../lib/playwright-selectors.js"; +import { courseBaseUrl } from "../utils.js"; + +const LOADING_TRANSCRIPT_PREFIX = /^Loading transcript\s*/m; +const NO_TRANSCRIPT_SUFFIX = /No transcript available for this lesson\.\s*$/m; +const TRANSCRIPT_TIMESTAMP_LINE = /^\d+:\d{2}$/; +const TRAILING_LESSON_SLUG = /\/[^/]+\/$/; +const TRAILING_COURSE_ID_SUFFIX = /-\d+\/$/; +const DATE_LAST_UPDATED = /last\s+updated[:\s]+(\w+\s+\d{1,2},?\s+\d{4})/i; +const DATE_UPDATED = /updated[:\s]+(\w+\s+\d{1,2},?\s+\d{4})/i; +const DATE_PUBLISHED = /published[:\s]+(\w+\s+\d{1,2},?\s+\d{4})/i; +const DATE_RELEASED = /released[:\s]+(\w+\s+\d{1,2},?\s+\d{4})/i; +const VISIBLE_DATE_PATTERNS = [DATE_LAST_UPDATED, DATE_UPDATED, DATE_PUBLISHED, DATE_RELEASED]; + +// --------------------------------------------------------------------------- +// Adapter defaults (Dometrain-specific config) +// --------------------------------------------------------------------------- + +export const defaults = { + videoPlayerSelector: "mux-player", + authWarnDays: 6, + authProvider: "clerk", + resourceButtons: { + download: "Download course files", + lessonNotes: "Show lesson notes", + readThisLesson: "Read this lesson", + transcript: "Transcript", + }, + frameExtraction: { + sceneThreshold: 0.1, + intervalFps: "1/15", + minFramesForScene: 5, + }, +}; + +// --------------------------------------------------------------------------- +// Required adapter methods +// --------------------------------------------------------------------------- + +/** + * Extract transcript from the Dometrain transcript panel. + * Clicks "Transcript" button, reads the panel, formats as [M:SS] segments. + */ +export async function extractTranscript(page, _platformCfg) { + return timed("extract-transcript", null, async () => { + const btn = page.locator("button", { hasText: "Transcript" }).first(); + if (await btn.isVisible().catch(() => false)) { + await btn.click(); + await page.waitForTimeout(2000); + } + + const transcriptEl = page.locator(TRANSCRIPT_PANEL_SELECTOR).first(); + if (!(await transcriptEl.isVisible({ timeout: 5000 }).catch(() => false))) { + throw new Error("Transcript panel not visible"); + } + + const raw = await transcriptEl.innerText(); + const cleaned = raw + .replace(LOADING_TRANSCRIPT_PREFIX, "") + .replace(NO_TRANSCRIPT_SUFFIX, "") + .trim(); + + if (!cleaned || cleaned === "Loading transcript") { + throw new Error("No transcript content available"); + } + + const lines = cleaned.split("\n"); + const segments = []; + let current = ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (TRANSCRIPT_TIMESTAMP_LINE.test(trimmed)) { + if (current) segments.push(current.trim()); + current = `[${trimmed}] `; + } else if (trimmed) { + current += `${trimmed} `; + } + } + if (current) segments.push(current.trim()); + + return segments.join("\n\n"); + }); +} + +/** + * Extract HLS URL from the Mux player element. + * Delegates to mux.getHlsUrl(). + */ +export async function extractHlsUrl(page, platformCfg) { + return timed("extract-hls-url", null, async () => { + await page.waitForTimeout(2000); + const selector = platformCfg.videoPlayerSelector ?? defaults.videoPlayerSelector; + return getHlsUrl(page, selector); + }); +} + +/** + * Detect which resource buttons are visible on the current lesson page. + */ +export async function detectResources(page, platformCfg) { + return timed("detect-resources", null, async () => { + const labels = { + ...defaults.resourceButtons, + ...platformCfg.resourceButtons, + }; + const videoSelector = platformCfg.videoPlayerSelector ?? defaults.videoPlayerSelector; + + return page.evaluate( + ({ labels: l, videoSel }) => { + const buttons = Array.from(document.querySelectorAll("button")); + const check = (label) => { + const btn = buttons.find((b) => b.textContent.trim().startsWith(label)); + if (!btn) return false; + const style = window.getComputedStyle(btn); + return style.display !== "none" && style.visibility !== "hidden"; + }; + + return { + hasDownload: check(l.download), + hasLessonNotes: check(l.lessonNotes), + hasReadThisLesson: check(l.readThisLesson), + hasTranscript: check(l.transcript), + hasVideo: !!document.querySelector(`${videoSel}, video`), + }; + }, + { labels, videoSel: videoSelector }, + ); + }); +} + +/** + * Pre-flight check: verify critical DOM selectors exist on the current page. + * Call on the first lesson page (already loaded during auth) before iterating + * all lessons. Catches platform UI changes early — saves 45+ wasted navigations. + */ +export async function preflight(page, platformCfg) { + const videoSel = platformCfg.videoPlayerSelector ?? defaults.videoPlayerSelector; + + const checks = await page.evaluate( + ({ videoSel: vs, panelSelector }) => { + const videoPlayer = !!document.querySelector(`${vs}, video`); + const transcriptButton = Array.from(document.querySelectorAll("button")).some( + (b) => b.textContent.trim() === "Transcript", + ); + const transcriptPanel = !!document.querySelector(panelSelector); + return { videoPlayer, transcriptButton, transcriptPanel }; + }, + { videoSel, panelSelector: TRANSCRIPT_PANEL_SELECTOR }, + ); + + const failures = Object.entries(checks) + .filter(([, passed]) => !passed) + .map(([name]) => name); + + if (failures.length > 0) { + return fail( + `Preflight failed — missing: ${failures.join(", ")}. Platform may have changed their DOM structure.`, + "preflight", + checks, + 0, + ); + } + + return ok(checks, "preflight", null, 0); +} + +/** + * Derive the public landing page URL from the lesson player URL. + * Strips "/take/" prefix, trailing lesson slug, and numeric courseId suffix. + */ +export function deriveLandingUrl(courseUrl, platformCfg) { + const landingPattern = platformCfg.landingUrlPattern ?? ""; + if (!landingPattern.includes(" -> ")) return courseUrl; + + const [from, to] = landingPattern.split(" -> "); + let url = courseUrl.replace(from, to); + url = url.replace(TRAILING_LESSON_SLUG, "/"); + url = url.replace(TRAILING_COURSE_ID_SUFFIX, "/"); + return url; +} + +/** + * Build a full lesson URL for Dometrain. + * Pattern: {baseUrl}{lesson.slug}/ + */ +export function buildLessonUrl(course, lesson, _platformCfg) { + const baseUrl = courseBaseUrl(course.url); + return `${baseUrl}${lesson.slug}/`; +} + +// --------------------------------------------------------------------------- +// Optional adapter methods +// --------------------------------------------------------------------------- + +function applyJsonLdMetadata(metadata, jsonLd) { + if (!jsonLd) return; + metadata.structuredData = jsonLd; + if (jsonLd.name) metadata.title = jsonLd.name; + if (jsonLd.description) metadata.description = jsonLd.description; + if (jsonLd.dateCreated) metadata.dateCreated = jsonLd.dateCreated; + if (jsonLd.dateModified) metadata.dateModified = jsonLd.dateModified; + if (jsonLd.datePublished) metadata.datePublished = jsonLd.datePublished; + if (jsonLd.aggregateRating) { + metadata.rating = { + value: jsonLd.aggregateRating.ratingValue, + count: jsonLd.aggregateRating.ratingCount, + best: jsonLd.aggregateRating.bestRating, + }; + } + if (jsonLd.author) { + const authors = Array.isArray(jsonLd.author) ? jsonLd.author : [jsonLd.author]; + metadata.authors = authors.map((a) => a.name ?? a).filter(Boolean); + } +} + +function applyOgMetadata(metadata, ogTags) { + if (Object.keys(ogTags).length === 0) return; + metadata.ogTags = ogTags; + if (ogTags["og:image"]) metadata.thumbnailUrl = ogTags["og:image"]; + if (!metadata.description && ogTags["og:description"]) { + metadata.description = ogTags["og:description"]; + } + if (ogTags["article:modified_time"]) metadata.dateModified = ogTags["article:modified_time"]; + if (ogTags["article:published_time"]) metadata.datePublished = ogTags["article:published_time"]; +} + +function findVisibleDate(pageText) { + for (const pattern of VISIBLE_DATE_PATTERNS) { + const match = pageText.match(pattern); + if (match) return match[0]; + } + return null; +} + +async function fetchJsonLd(page) { + return page + .evaluate(() => { + const isCourse = (node) => node["@type"] === "Course" || node["@type"]?.includes?.("Course"); + const scripts = document.querySelectorAll('script[type="application/ld+json"]'); + for (const script of scripts) { + try { + const data = JSON.parse(script.textContent); + if (isCourse(data)) return data; + if (Array.isArray(data["@graph"])) { + const course = data["@graph"].find(isCourse); + if (course) return course; + } + } catch { + /* ignore parse errors */ + } + } + return null; + }) + .catch(() => null); +} + +async function fetchOgTags(page) { + return page + .evaluate(() => { + const tags = {}; + for (const meta of document.querySelectorAll("meta")) { + const prop = meta.getAttribute("property") || meta.getAttribute("name"); + if ( + prop?.startsWith("og:") || + prop?.startsWith("twitter:") || + prop?.includes("date") || + prop?.includes("time") || + prop?.includes("modified") || + prop?.includes("published") + ) { + tags[prop] = meta.getAttribute("content"); + } + } + return tags; + }) + .catch(() => ({})); +} + +async function fetchDescriptionMeta(page) { + return page + .evaluate((selector) => { + const meta = document.querySelector(selector); + return meta?.getAttribute("content") || null; + }, DESCRIPTION_META_SELECTOR) + .catch(() => null); +} + +/** + * Extract course metadata from the landing page (JSON-LD, OG tags, visible text). + */ +export async function extractMetadata(page, courseUrl, platformCfg) { + return timed("extract-metadata", null, async () => { + const metadata = {}; + const landingUrl = deriveLandingUrl(courseUrl, platformCfg); + + await page.goto(landingUrl, { + waitUntil: "domcontentloaded", + timeout: 15000, + }); + await page.waitForTimeout(2000); + + applyJsonLdMetadata(metadata, await fetchJsonLd(page)); + applyOgMetadata(metadata, await fetchOgTags(page)); + + const pageText = await page.innerText("body").catch(() => ""); + const visibleDate = findVisibleDate(pageText); + if (visibleDate) metadata.visibleDate = visibleDate; + + if (!metadata.description) { + const desc = await fetchDescriptionMeta(page); + if (desc) metadata.description = desc; + } + + metadata.landingUrl = landingUrl; + return metadata; + }); +} + +/** + * Authenticate with Dometrain via Clerk login flow. + */ +/** @param {import('./auth-session.js').AuthSessionInput} input */ +export async function authenticate({ context, page, course, storageStatePath, platformCfg }) { + const baseUrl = courseBaseUrl(course.url); + const firstLesson = course.modules[0].lessons[0]; + const videoSelector = platformCfg.videoPlayerSelector ?? defaults.videoPlayerSelector; + const envPrefix = platformCfg.authEnvPrefix ?? "COURSE"; + const loginUrl = platformCfg.loginUrl; + + await page + .goto(`${baseUrl}${firstLesson.slug}/`, { + waitUntil: "domcontentloaded", + timeout: 15000, + }) + .catch(() => {}); + await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); + await page.waitForTimeout(2000); + + const hasPlayer = await page + .evaluate((sel) => !!document.querySelector(sel), videoSelector) + .catch(() => false); + + if (hasPlayer) { + writeStdout(" Already authenticated.\n"); + return { baseUrl }; + } + + const email = process.env[`${envPrefix}_EMAIL`]; + const password = process.env[`${envPrefix}_PASSWORD`]; + + if (email && password && loginUrl) { + writeStdout(" Logging in automatically..."); + await clerkLogin(page, email, password, loginUrl); + await context.storageState({ path: storageStatePath }); + writeStdout(" Logged in and saved auth state.\n"); + } else { + await promptManualLogin(context, storageStatePath, envPrefix); + } + + return { baseUrl }; +} diff --git a/plugins/knowledge/skills/course-digest/extraction/adapters/dometrain.test.js b/plugins/knowledge/skills/course-digest/extraction/adapters/dometrain.test.js new file mode 100644 index 000000000..5aaeff60b --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/adapters/dometrain.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { defaults, deriveLandingUrl } from "./dometrain.js"; + +describe("deriveLandingUrl", () => { + const platformCfg = { + landingUrlPattern: "/take/course/ -> /course/", + }; + + it("should strip /take/ prefix, lesson slug, and numeric courseId", () => { + const url = + "https://dometrain.com/take/course/from-zero-to-hero-tdd-csharp-2732006/welcome-54128298/"; + const result = deriveLandingUrl(url, platformCfg); + expect(result).toBe("https://dometrain.com/course/from-zero-to-hero-tdd-csharp/"); + }); + + it("should handle URL without numeric courseId suffix", () => { + const url = "https://dometrain.com/take/course/some-course-slug/lesson-slug/"; + const result = deriveLandingUrl(url, platformCfg); + expect(result).toBe("https://dometrain.com/course/some-course-slug/"); + }); + + it("should return original URL when no landing pattern", () => { + const url = "https://dometrain.com/take/course/foo/bar/"; + const result = deriveLandingUrl(url, {}); + expect(result).toBe(url); + }); +}); + +describe("defaults", () => { + it("should have mux-player as video selector", () => { + expect(defaults.videoPlayerSelector).toBe("mux-player"); + }); + + it("should have resource button labels", () => { + expect(defaults.resourceButtons.download).toBe("Download course files"); + expect(defaults.resourceButtons.transcript).toBe("Transcript"); + }); + + it("should have frame extraction defaults", () => { + expect(defaults.frameExtraction.sceneThreshold).toBe(0.1); + expect(defaults.frameExtraction.intervalFps).toBe("1/15"); + expect(defaults.frameExtraction.minFramesForScene).toBe(5); + }); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/adapters/teachable.js b/plugins/knowledge/skills/course-digest/extraction/adapters/teachable.js new file mode 100644 index 000000000..127a4a652 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/adapters/teachable.js @@ -0,0 +1,377 @@ +/** + * Teachable platform adapter for course-extraction. + * + * Teachable uses Hotmart as its video player (cross-origin iframe at + * player.hotmart.com). Videos stream via HLS with WebVTT subtitle segments. + * + * This adapter composes: + * - lib/players/hotmart.js — HLS interception, iframe subtitle fetch, canvas frames + * - lib/auth/teachable-sso.js — simple form login flow + * + * Key technical differences from Dometrain: + * - Transcript: HLS subtitle manifest intercept → fetch WebVTT from iframe context + * - HLS URL: intercepted via page.on("response"), not read from DOM + * - Resources: 6 attachment types via .lecture-attachment-type-* CSS classes + * - Auth: cookie-based (Teachable accounts), not Clerk + * - URL construction: /courses/{slug}/lectures/{id}, not baseUrl+lessonSlug + */ + +import { fail, ok, timed } from "@melodic/video-digestion/shared/result"; +import { writeStdout } from "@melodic/video-digestion/shared/terminal"; + +import { promptManualLogin } from "../lib/auth/manual-login.js"; +import { login as teachableLogin } from "../lib/auth/teachable-sso.js"; +import { + extractFrames as extractHotmartFrames, + getHlsUrl, + getTranscript, + installInterceptors, + preparePage, +} from "../lib/players/hotmart.js"; +import { INSTRUCTOR_HEADING_SELECTOR } from "../lib/playwright-selectors.js"; + +const LECTURE_ATTACHMENT_TYPE_SOURCE = "lecture-attachment-type-(\\w+)"; +const CODE_LANGUAGE_SOURCE = "language-(\\w+)"; +const COURSE_SLUG_PATH = /\/courses\/([^/]+)/; + +// --------------------------------------------------------------------------- +// Adapter defaults (Teachable/Hotmart-specific config) +// --------------------------------------------------------------------------- + +export const defaults = { + videoPlayerSelector: ".hotmart_video_player", + authWarnDays: 14, + authProvider: "teachable", + subtitleLanguage: "eng", + resourceSelectors: { + video: ".lecture-attachment-type-video", + text: ".lecture-attachment-type-text", + file: ".lecture-attachment-type-file", + codeDisplay: ".lecture-attachment-type-code_display", + pdfEmbed: ".lecture-attachment-type-pdf_embed", + codeEmbed: ".lecture-attachment-type-code_embed", + }, + frameExtraction: { + sceneThreshold: 0.1, + intervalFps: "1/15", + minFramesForScene: 5, + }, + playbackWaitMs: 5000, + manifestTimeoutMs: 15000, +}; + +// --------------------------------------------------------------------------- +// Required adapter methods +// --------------------------------------------------------------------------- + +/** + * Extract transcript from Hotmart HLS subtitle stream. + * Delegates to hotmart.getTranscript(). + */ +export async function extractTranscript(page, _platformCfg) { + return timed("extract-transcript", null, () => getTranscript(page)); +} + +/** + * Extract HLS master URL from captured network interception data. + * Delegates to hotmart.getHlsUrl(). + */ +export async function extractHlsUrl(page, _platformCfg) { + return timed("extract-hls-url", null, () => getHlsUrl(page)); +} + +/** + * Detect available resources on the current Teachable lesson page. + * Reads .lecture-attachment-type-* CSS classes from the DOM. + */ +export async function detectResources(page, platformCfg) { + return timed("detect-resources", null, async () => { + const selectors = { + ...defaults.resourceSelectors, + ...platformCfg.resourceSelectors, + }; + + return page.evaluate( + ({ sel, attachmentTypeSource }) => { + const has = (s) => !!document.querySelector(s); + const attachmentTypePattern = new RegExp(attachmentTypeSource); + + return { + hasVideo: has(".hotmart_video_player"), + hasTranscript: false, + hasDownload: has(sel.file), + hasLessonNotes: false, + hasReadThisLesson: false, + hasCodeSnippets: has(sel.codeDisplay), + hasArticleLinks: document.querySelectorAll(`${sel.text} a[href]`).length > 0, + hasPdfEmbed: has(sel.pdfEmbed), + hasCodeEmbed: has(sel.codeEmbed), + hasTextContent: has(sel.text), + attachmentTypes: Array.from(document.querySelectorAll(".lecture-attachment")).map( + (a) => a.className.match(attachmentTypePattern)?.[1] ?? "unknown", + ), + }; + }, + { sel: selectors, attachmentTypeSource: LECTURE_ATTACHMENT_TYPE_SOURCE }, + ); + }); +} + +/** + * Derive the public landing page URL from the enrolled course URL. + */ +export function deriveLandingUrl(courseUrl, platformCfg) { + if (platformCfg.landingUrl) return platformCfg.landingUrl; + return courseUrl.replace("/enrolled/", "/"); +} + +// --------------------------------------------------------------------------- +// Optional lifecycle hooks +// --------------------------------------------------------------------------- + +/** + * Install page.on("response") interceptors for HLS and subtitle data. + * Delegates to hotmart.installInterceptors(). + */ +export async function setupSession(page, platformCfg) { + const subtitleLang = platformCfg.subtitleLanguage ?? defaults.subtitleLanguage; + installInterceptors(page, subtitleLang); +} + +/** + * Prepare a lesson page for extraction. + * Delegates to hotmart.preparePage(). + */ +export async function prepareLessonPage(page, platformCfg, lesson) { + return timed("prepare-lesson-page", { lesson: lesson?.title }, async () => { + const subtitleLang = platformCfg.subtitleLanguage ?? defaults.subtitleLanguage; + const manifestTimeout = platformCfg.manifestTimeoutMs ?? defaults.manifestTimeoutMs; + + return preparePage(page, subtitleLang, manifestTimeout); + }); +} + +/** + * Extract per-lesson resources from the Teachable DOM. + */ +async function scrapeCodeSnippets(page, codeDisplaySelector) { + return page.evaluate( + ({ selector, languageSource }) => { + const languagePattern = new RegExp(languageSource); + const snippets = []; + for (const el of document.querySelectorAll(selector)) { + const codeEl = el.querySelector("pre, code"); + const text = codeEl?.textContent?.trim(); + if (!text) continue; + snippets.push({ + code: text, + language: codeEl.className?.match(languagePattern)?.[1] ?? null, + }); + } + return snippets; + }, + { selector: codeDisplaySelector, languageSource: CODE_LANGUAGE_SOURCE }, + ); +} + +async function scrapeDownloadLinks(page, fileSelector) { + return page.evaluate((selector) => { + const downloads = []; + for (const el of document.querySelectorAll(selector)) { + for (const link of el.querySelectorAll("a[href]")) { + downloads.push({ label: link.textContent?.trim(), href: link.href }); + } + } + return downloads; + }, fileSelector); +} + +async function scrapeTextAttachments(page, textSelector) { + return page.evaluate((selector) => { + const articleLinks = []; + const textContent = []; + for (const el of document.querySelectorAll(selector)) { + for (const link of el.querySelectorAll("a[href]")) { + const label = link.textContent?.trim(); + const href = link.href; + if (label && href && !href.includes("teachablecdn")) { + articleLinks.push({ label, href }); + } + } + const text = el.textContent?.trim(); + if (text && text.length < 1000) textContent.push(text); + } + return { articleLinks, textContent }; + }, textSelector); +} + +async function scrapePdfLinks(page, pdfSelector) { + return page.evaluate((selector) => { + const pdfLinks = []; + for (const el of document.querySelectorAll(selector)) { + for (const link of el.querySelectorAll("a[href]")) { + pdfLinks.push({ label: link.textContent?.trim(), href: link.href }); + } + } + return pdfLinks; + }, pdfSelector); +} + +export async function extractResources(page, platformCfg) { + return timed("extract-resources", null, async () => { + const selectors = { + ...defaults.resourceSelectors, + ...platformCfg.resourceSelectors, + }; + + const [codeSnippets, downloads, textData, pdfLinks] = await Promise.all([ + scrapeCodeSnippets(page, selectors.codeDisplay), + scrapeDownloadLinks(page, selectors.file), + scrapeTextAttachments(page, selectors.text), + scrapePdfLinks(page, selectors.pdfEmbed), + ]); + + return { + codeSnippets, + downloads, + articleLinks: textData.articleLinks, + textContent: textData.textContent, + pdfLinks, + }; + }); +} + +/** + * Extract video frames via canvas drawImage within the Hotmart iframe. + * Delegates to hotmart.extractFrames(). + */ +export async function extractFramesCanvas({ page, duration, outputDir, options = {} }) { + return timed("extract-frames-canvas", null, () => + extractHotmartFrames(page, duration, outputDir, options), + ); +} + +/** + * Pre-flight check: verify Hotmart iframe loads and Teachable API responds. + */ +export async function preflight(page, _platformCfg) { + const checks = await page.evaluate(() => { + const hotmartEl = !!document.querySelector(".hotmart_video_player"); + const lectureContent = !!document.querySelector(".lecture-content"); + const attachments = document.querySelectorAll(".lecture-attachment").length; + return { hotmart: hotmartEl, lectureContent, attachments }; + }); + + const failures = Object.entries(checks) + .filter(([key, val]) => key !== "attachments" && !val) + .map(([name]) => name); + + if (failures.length > 0) { + return fail( + `Preflight failed — missing: ${failures.join(", ")}. Platform may have changed.`, + "preflight", + checks, + 0, + ); + } + + return ok(checks, "preflight", null, 0); +} + +/** + * Authenticate with Teachable. + * Navigation and auth detection stay here; login flow delegates to teachableSSO. + */ +/** @param {import('./auth-session.js').AuthSessionInput} input */ +export async function authenticate({ context, page, course, storageStatePath, platformCfg }) { + const videoSelector = platformCfg.videoPlayerSelector ?? defaults.videoPlayerSelector; + const envPrefix = platformCfg.authEnvPrefix ?? "TEACHABLE"; + + const firstVideoLesson = course.modules.flatMap((m) => m.lessons).find((l) => l.duration); + + if (!firstVideoLesson) { + throw new Error("No video lessons found in course."); + } + + const lessonUrl = buildLessonUrl(course, firstVideoLesson, platformCfg); + await page.goto(lessonUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {}); + await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); + await page.waitForTimeout(2000); + + const hasPlayer = await page + .evaluate((sel) => !!document.querySelector(sel), videoSelector) + .catch(() => false); + + if (hasPlayer) { + writeStdout(" Already authenticated.\n"); + return; + } + + const email = process.env[`${envPrefix}_EMAIL`]; + const password = process.env[`${envPrefix}_PASSWORD`]; + const loginUrl = platformCfg.loginUrl; + + if (email && password && loginUrl) { + writeStdout(" Logging in automatically..."); + await teachableLogin(page, email, password, loginUrl); + await context.storageState({ path: storageStatePath }); + writeStdout(" Logged in and saved auth state.\n"); + } else { + await promptManualLogin(context, storageStatePath, envPrefix); + } +} + +/** + * Extract course metadata from the landing/enrolled page. + */ +export async function extractMetadata(page, _courseUrl, _platformCfg) { + return timed("extract-metadata", null, async () => { + const metadata = {}; + + const ogTags = await page + .evaluate(() => { + const tags = {}; + for (const meta of document.querySelectorAll("meta")) { + const prop = meta.getAttribute("property") || meta.getAttribute("name"); + if (prop?.startsWith("og:") || prop?.startsWith("twitter:")) { + tags[prop] = meta.getAttribute("content"); + } + } + return tags; + }) + .catch(() => ({})); + + if (Object.keys(ogTags).length > 0) { + metadata.ogTags = ogTags; + if (ogTags["og:title"]) metadata.title = ogTags["og:title"]; + if (ogTags["og:description"]) metadata.description = ogTags["og:description"]; + if (ogTags["og:image"]) metadata.thumbnailUrl = ogTags["og:image"]; + } + + const instructor = await page + .evaluate((selector) => { + const el = document.querySelector(selector); + return el?.textContent?.trim() ?? null; + }, INSTRUCTOR_HEADING_SELECTOR) + .catch(() => null); + + if (instructor) metadata.instructor = instructor; + + return metadata; + }); +} + +// --------------------------------------------------------------------------- +// URL construction +// --------------------------------------------------------------------------- + +export function buildLessonUrl(course, lesson, platformCfg) { + const baseUrl = platformCfg.baseUrl ?? course.url?.split("/courses/")[0]; + const courseSlug = platformCfg.courseSlug ?? extractCourseSlug(course.url); + return `${baseUrl}/courses/${courseSlug}/lectures/${lesson.lectureId ?? lesson.slug}`; +} + +function extractCourseSlug(url) { + const match = url?.match(COURSE_SLUG_PATH); + return match?.[1] ?? ""; +} diff --git a/plugins/knowledge/skills/course-digest/extraction/adapters/teachable.test.js b/plugins/knowledge/skills/course-digest/extraction/adapters/teachable.test.js new file mode 100644 index 000000000..582613b81 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/adapters/teachable.test.js @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { buildLessonUrl, defaults, deriveLandingUrl } from "./teachable.js"; + +describe("defaults", () => { + it("should have hotmart video player selector", () => { + expect(defaults.videoPlayerSelector).toBe(".hotmart_video_player"); + }); + + it("should have teachable auth provider", () => { + expect(defaults.authProvider).toBe("teachable"); + }); + + it("should have subtitle language default", () => { + expect(defaults.subtitleLanguage).toBe("eng"); + }); + + it("should have resource selectors for all Teachable attachment types", () => { + expect(defaults.resourceSelectors.video).toBe(".lecture-attachment-type-video"); + expect(defaults.resourceSelectors.text).toBe(".lecture-attachment-type-text"); + expect(defaults.resourceSelectors.file).toBe(".lecture-attachment-type-file"); + expect(defaults.resourceSelectors.codeDisplay).toBe(".lecture-attachment-type-code_display"); + expect(defaults.resourceSelectors.pdfEmbed).toBe(".lecture-attachment-type-pdf_embed"); + expect(defaults.resourceSelectors.codeEmbed).toBe(".lecture-attachment-type-code_embed"); + }); + + it("should have frame extraction defaults", () => { + expect(defaults.frameExtraction.sceneThreshold).toBe(0.1); + expect(defaults.frameExtraction.intervalFps).toBe("1/15"); + expect(defaults.frameExtraction.minFramesForScene).toBe(5); + }); + + it("should have playback and manifest timeouts", () => { + expect(defaults.playbackWaitMs).toBe(5000); + expect(defaults.manifestTimeoutMs).toBe(15000); + }); +}); + +describe("deriveLandingUrl", () => { + it("should return configured landingUrl when provided", () => { + const platformCfg = { landingUrl: "https://example.com/landing" }; + expect(deriveLandingUrl("https://example.com/courses/enrolled/123", platformCfg)).toBe( + "https://example.com/landing", + ); + }); + + it("should strip /enrolled/ segment when no landingUrl configured", () => { + const url = "https://courses.example.com/courses/enrolled/my-course"; + expect(deriveLandingUrl(url, {})).toBe("https://courses.example.com/courses/my-course"); + }); + + it("should return original URL when no /enrolled/ and no landingUrl", () => { + const url = "https://courses.example.com/courses/my-course"; + expect(deriveLandingUrl(url, {})).toBe(url); + }); +}); + +describe("buildLessonUrl", () => { + const course = { + url: "https://www.courses.example.com/courses/enrolled/12345", + }; + + it("should build URL from platformCfg baseUrl and courseSlug", () => { + const lesson = { lectureId: "99999", slug: "99999" }; + const platformCfg = { + baseUrl: "https://www.courses.example.com", + courseSlug: "my-awesome-course", + }; + + expect(buildLessonUrl(course, lesson, platformCfg)).toBe( + "https://www.courses.example.com/courses/my-awesome-course/lectures/99999", + ); + }); + + it("should use lectureId over slug when available", () => { + const lesson = { lectureId: "11111", slug: "some-slug" }; + const platformCfg = { + baseUrl: "https://www.courses.example.com", + courseSlug: "the-course", + }; + + expect(buildLessonUrl(course, lesson, platformCfg)).toContain("/lectures/11111"); + }); + + it("should fall back to slug when lectureId is missing", () => { + const lesson = { slug: "lesson-slug" }; + const platformCfg = { + baseUrl: "https://www.courses.example.com", + courseSlug: "the-course", + }; + + expect(buildLessonUrl(course, lesson, platformCfg)).toContain("/lectures/lesson-slug"); + }); + + it("should extract courseSlug from course URL when not in platformCfg", () => { + const courseWithSlug = { + url: "https://www.courses.example.com/courses/modular-monolith/lectures/123", + }; + const lesson = { lectureId: "456" }; + const platformCfg = { baseUrl: "https://www.courses.example.com" }; + + expect(buildLessonUrl(courseWithSlug, lesson, platformCfg)).toBe( + "https://www.courses.example.com/courses/modular-monolith/lectures/456", + ); + }); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/analyze-code-repo.js b/plugins/knowledge/skills/course-digest/extraction/analyze-code-repo.js new file mode 100644 index 000000000..027afe667 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/analyze-code-repo.js @@ -0,0 +1,212 @@ +/** + * Course code repository analysis tool. + * + * Clones the course's companion GitHub repo (shallow), analyzes its structure, + * detects frameworks, and generates analysis metadata for the course digest. + * + * Usage: + * node analyze-code-repo.js --course-dir [--verbose] [--quiet] [--skip-clone] + * + * Options: + * --course-dir Path to directory containing course.json (required) + * --skip-clone Skip cloning, analyze existing code/ directory + * --verbose Show debug output + * --quiet Show only warnings and errors + */ + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + countFiles, + detectFrameworks, + detectRepoStructure, + diffSections, + parseGitHubUrl, +} from "@melodic/repo-analysis"; +import { createLogger } from "@melodic/video-digestion/shared/logger"; + +import { loadCourseDir, parseCliArgs, resolveLogLevel } from "./utils.js"; + +const args = parseCliArgs({ + "skip-clone": { type: "boolean", default: false }, +}); +const log = createLogger(resolveLogLevel(args)); + +const MAX_SIZE_KB = 500000; +const WARN_SIZE_KB = 100000; + +function checkRepoSize(parsed) { + const sizeResult = spawnSync( + "gh", + ["api", `repos/${parsed.owner}/${parsed.repo}`, "--jq", ".size"], + { + encoding: "utf-8", + timeout: 15000, + }, + ); + + if (sizeResult.status !== 0 || !sizeResult.stdout.trim()) { + log.warn(" Could not check repo size (gh api failed). Proceeding with clone."); + log.debug(` gh stderr: ${sizeResult.stderr?.trim()}`); + return; + } + + const sizeKB = Number.parseInt(sizeResult.stdout.trim(), 10); + const sizeMB = Math.round(sizeKB / 1024); + log.info(` Repo size: ~${sizeMB} MB`); + + if (sizeKB > MAX_SIZE_KB) { + log.error(` Repo too large (${sizeMB} MB > ${MAX_SIZE_KB / 1024} MB limit). Aborting.`); + process.exit(1); + } + if (sizeKB > WARN_SIZE_KB) { + log.warn(` Large repo (${sizeMB} MB). Clone may take a while.`); + } +} + +function cloneRepoToTemp(githubUrl, parsed) { + const cloneDir = join(tmpdir(), `course-repo-${parsed.repo}-${Date.now()}`); + log.info(` Cloning to temp: ${cloneDir}`); + + const cloneResult = spawnSync( + "git", + ["clone", "--depth", "1", "--single-branch", githubUrl, cloneDir], + { encoding: "utf-8", timeout: 120000 }, + ); + + if (cloneResult.status !== 0) { + log.error(` Clone failed: ${cloneResult.stderr?.trim()}`); + process.exit(1); + } + log.info(" Clone complete."); + return cloneDir; +} + +function resolveCloneDir(githubUrl, parsed, courseDir) { + if (args["skip-clone"]) { + const codeOutputDir = join(courseDir, "code"); + if (!existsSync(codeOutputDir)) { + log.error(" --skip-clone specified but code/ directory does not exist."); + process.exit(1); + } + log.info(" Using existing code/ directory (--skip-clone)."); + return { cloneDir: codeOutputDir, isTemp: false }; + } + + checkRepoSize(parsed); + return { cloneDir: cloneRepoToTemp(githubUrl, parsed), isTemp: true }; +} + +function buildReadme(githubUrl, structure, frameworks, fileCounts) { + return [ + "# Course Code Repository", + "", + `**Source:** ${githubUrl}`, + `**Cloned:** ${new Date().toISOString()}`, + "**Method:** `git clone --depth 1 --single-branch`", + "", + "## Structure", + "", + `Type: **${structure.type}**`, + structure.sections ? `Sections: ${structure.sections.map((s) => s.name).join(", ")}` : "", + "", + "## Frameworks", + "", + ...frameworks.map((fw) => `- **${fw.framework}**: \`${fw.file}\``), + "", + "## Files", + "", + `Total: ${fileCounts.total}`, + "", + "See `analysis.json` for full details.", + "", + ] + .filter(Boolean) + .join("\n"); +} + +function main() { + const { courseDir, course } = loadCourseDir(args, { logger: log }); + const codeOutputDir = join(courseDir, "code"); + const githubUrl = course.resources?.githubUrl; + + log.info(`\n ${course.title} — Code Repo Analysis`); + log.debug(` Node: ${process.version} | OS: ${process.platform}`); + + if (!githubUrl) { + log.warn(" No githubUrl in course.json resources — skipping code repo analysis."); + return; + } + + log.info(` GitHub: ${githubUrl}`); + + const parsed = parseGitHubUrl(githubUrl); + if (!parsed) { + log.error(` Could not parse GitHub URL: ${githubUrl}`); + process.exit(1); + } + + const { cloneDir, isTemp } = resolveCloneDir(githubUrl, parsed, courseDir); + + log.info(" Analyzing structure..."); + const structure = detectRepoStructure(cloneDir); + log.info( + ` Structure: ${structure.type}${structure.sections ? ` (${structure.sections.length} sections)` : ""}`, + ); + + log.info(" Detecting frameworks..."); + const frameworks = detectFrameworks(cloneDir); + for (const fw of frameworks) { + log.info(` ${fw.framework}: ${fw.file}`); + } + + log.info(" Counting files..."); + const fileCounts = countFiles(cloneDir); + log.info(` Total files: ${fileCounts.total}`); + log.debug(` By extension: ${JSON.stringify(fileCounts.byExtension)}`); + + let sectionDiffs = []; + if (structure.type === "per-section" && structure.sections.length >= 2) { + log.info(" Computing section diffs..."); + sectionDiffs = diffSections(cloneDir, structure.sections); + for (const d of sectionDiffs) { + log.info(` ${d.from} → ${d.to}: +${d.added} ~${d.modified} -${d.removed}`); + } + } + + mkdirSync(codeOutputDir, { recursive: true }); + + const analysis = { + analyzedAt: new Date().toISOString(), + githubUrl, + owner: parsed.owner, + repo: parsed.repo, + structure: structure.type, + sections: structure.sections ?? null, + frameworks, + fileCounts, + sectionDiffs: sectionDiffs.length > 0 ? sectionDiffs : null, + }; + + const analysisPath = join(codeOutputDir, "analysis.json"); + writeFileSync(analysisPath, JSON.stringify(analysis, null, 2), "utf-8"); + writeFileSync( + join(codeOutputDir, "README.md"), + buildReadme(githubUrl, structure, frameworks, fileCounts), + "utf-8", + ); + + if (isTemp && cloneDir.startsWith(tmpdir())) { + log.debug(` Cleaning up temp clone: ${cloneDir}`); + rmSync(cloneDir, { recursive: true, force: true }); + } + + log.info("\n ────────────────────────────────────────────"); + log.info(` Analysis: ${analysisPath}`); + log.info(` README: ${join(codeOutputDir, "README.md")}\n`); +} + +main(); diff --git a/plugins/knowledge/skills/course-digest/extraction/build-course-json.js b/plugins/knowledge/skills/course-digest/extraction/build-course-json.js new file mode 100644 index 000000000..1084ff722 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/build-course-json.js @@ -0,0 +1,225 @@ +/** + * Build course.json for a Teachable course by scraping the curriculum page. + * + * Usage: node build-course-json.js --course-url --output-dir + * + * Example: + * node build-course-json.js \ + * --course-url "https://www.courses.example.tech/courses/enrolled/2518872" \ + * --output-dir "/courses//" + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; + +import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; + +import { resolveAuthStatePath } from "./lib/auth-store.js"; +import { closeBrowser, launchBrowser } from "./lib/browser.js"; +import { parseDuration } from "./utils.js"; + +const COURSE_ID_SUFFIX = /\/(\d+)$/; +const SCRAPE_REGEX_SOURCES = { + slugPrefix: "^\\d+\\s*-\\s*", + nonAlpha: "[^a-z0-9\\s-]", + whitespace: "\\s+", + lectureId: "lectures\\/(\\d+)", + duration: "\\((\\d+:\\d+)\\)", +}; +const INSTRUCTOR_CLASS_FRAGMENT = "instructor"; + +const { values: args } = parseArgs({ + options: { + "course-url": { type: "string" }, + "output-dir": { type: "string" }, + }, + strict: false, +}); + +function scrapeCurriculumInBrowser({ sources, instructorFragment }) { + function isSkippedModuleHeading(text, el) { + if (text.includes("COMPLETE") || text.includes("Community")) return true; + if (text === "Modular Monolith Architecture + Community Access") return true; + const instructorSelector = `[class*="${instructorFragment}"]`; + return !!( + el.parentElement?.querySelector(instructorSelector) || el.closest(instructorSelector) + ); + } + + const slugPrefix = new RegExp(sources.slugPrefix); + const nonAlpha = new RegExp(sources.nonAlpha, "g"); + const whitespace = new RegExp(sources.whitespace, "g"); + const lectureIdPattern = new RegExp(sources.lectureId); + const durationPattern = new RegExp(sources.duration); + + const allElements = Array.from(document.querySelectorAll('h2, h3, a[href*="/lectures/"]')); + const modules = []; + let currentModule = null; + let modulePosition = 0; + const headingEl = document.querySelector("h2"); + const courseTitle = headingEl?.textContent?.trim() ?? ""; + + for (const el of allElements) { + if (el.tagName === "H2") { + const text = el.textContent.trim(); + if (isSkippedModuleHeading(text, el)) continue; + + modulePosition++; + const slugNum = String(modulePosition).padStart(2, "0"); + const slugText = text + .replace(slugPrefix, "") + .toLowerCase() + .replace(nonAlpha, "") + .replace(whitespace, "-") + .substring(0, 50); + + currentModule = { + position: modulePosition, + title: text, + slug: `${slugNum}-${slugText}`, + lessons: [], + }; + modules.push(currentModule); + } else if (el.tagName === "A" && currentModule) { + const lectureId = el.href?.match(lectureIdPattern)?.[1]; + const h3 = el.querySelector("h3"); + if (lectureId && h3 && !currentModule.lessons.some((l) => l.lectureId === lectureId)) { + const fullText = el.textContent.trim(); + const durationMatch = fullText.match(durationPattern); + const rawDuration = durationMatch ? durationMatch[1] : ""; + const title = h3.textContent.trim(); + + let duration = ""; + if (rawDuration) { + const parts = rawDuration.split(":"); + duration = `${parts[0]}m ${parts[1]}s`; + } + + const pos = currentModule.lessons.length + 1; + currentModule.lessons.push({ + position: pos, + title, + duration, + lectureId, + slug: lectureId, + status: "pending", + hasTranscript: false, + hasScreenshots: false, + hasDownload: false, + hasVideo: !!rawDuration, + providerResources: {}, + }); + } + } + } + + return { title: courseTitle, modules }; +} + +function logCurriculumSummary(courseData) { + writeStdout(`Course: ${courseData.title}`); + writeStdout(`Modules: ${courseData.modules.length}`); + const totalLessons = courseData.modules.reduce((sum, m) => sum + m.lessons.length, 0); + writeStdout(`Lessons: ${totalLessons}`); + + for (const mod of courseData.modules) { + writeStdout(` ${mod.slug}: ${mod.title} (${mod.lessons.length} lessons)`); + } + + return totalLessons; +} + +async function main() { + if (!args["course-url"] || !args["output-dir"]) { + writeStderr("Usage: node build-course-json.js --course-url --output-dir "); + process.exit(1); + } + + const courseUrl = args["course-url"]; + const outputDir = resolve(args["output-dir"]); + const authStatePath = resolveAuthStatePath("teachable"); + + mkdirSync(outputDir, { recursive: true }); + + const { browser, context, page, authDir, cookieCount } = await launchBrowser({ + headless: false, + storageStatePath: authStatePath, + profilePrefix: "build-course-json", + }); + if (cookieCount > 0) { + writeStdout(`Injected ${cookieCount} cookies.`); + } else { + writeStdout("No auth state found. You may need to log in manually."); + } + writeStdout(`\nNavigating to: ${courseUrl}`); + await page.goto(courseUrl, { waitUntil: "domcontentloaded", timeout: 20000 }); + await page.waitForTimeout(3000); + + writeStdout("Scraping curriculum...\n"); + const courseData = await page.evaluate(scrapeCurriculumInBrowser, { + sources: SCRAPE_REGEX_SOURCES, + instructorFragment: INSTRUCTOR_CLASS_FRAGMENT, + }); + + const totalLessons = logCurriculumSummary(courseData); + + const totalSeconds = courseData.modules + .flatMap((m) => m.lessons) + .reduce((sum, l) => sum + parseDuration(l.duration), 0); + const hours = Math.floor(totalSeconds / 3600); + const mins = Math.floor((totalSeconds % 3600) / 60); + + const courseId = courseUrl.match(COURSE_ID_SUFFIX)?.[1] ?? ""; + + const courseJson = { + title: courseData.title, + slug: "modular-monolith-jovanovic", + platform: "teachable", + platformConfig: { + loginUrl: "https://sso.teachable.com/secure/146684/identity/login", + videoPlayerSelector: ".hotmart_video_player", + referer: "https://player.hotmart.com/", + authProvider: "teachable", + authWarnDays: 14, + authEnvPrefix: "TEACHABLE", + subtitleLanguage: "eng", + courseSlug: "modular-monolith-architecture-community", + baseUrl: "https://www.courses.milanjovanovic.tech", + curriculumUrl: courseUrl, + }, + url: courseUrl, + courseId, + instructor: "Milan Jovanović", + duration: `${hours}h ${mins}m`, + totalLessons, + extractedAt: null, + status: "pending", + modules: courseData.modules, + resources: { + githubUrl: null, + downloadAvailable: true, + }, + phases: { + extract: null, + extractFrames: null, + processFrames: null, + analyzeCodeRepo: null, + validate: null, + synthesize: null, + analyze: null, + }, + }; + + const outputPath = join(outputDir, "course.json"); + writeFileSync(outputPath, JSON.stringify(courseJson, null, 2), "utf-8"); + writeStdout(`\n✓ course.json written to: ${outputPath}`); + writeStdout(` ${totalLessons} lessons, ${hours}h ${mins}m total`); + + await closeBrowser(context, authDir, browser); +} + +main().catch((e) => { + writeStderr("Fatal:", e); + process.exit(1); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/classify-frames.js b/plugins/knowledge/skills/course-digest/extraction/classify-frames.js new file mode 100644 index 000000000..0f96bce67 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/classify-frames.js @@ -0,0 +1,182 @@ +/** + * Frame classification pipeline for course-digest. + * + * Phases: + * contact-sheets — Generate labeled thumbnail grids per lesson (shared contact-sheet stage) + * dedup — Perceptual-hash near-duplicate detection for interval frames + * summary — Print frame inventory table + * + * Usage: + * node classify-frames.js --course-dir --phase contact-sheets + * node classify-frames.js --course-dir --phase dedup + * node classify-frames.js --course-dir --phase summary + * + * Requires: ImageMagick 7 on PATH for contact-sheet generation + */ + +import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { createContactSheet } from "@melodic/video-digestion/frames/contact-sheet"; +import { deduplicateFrames } from "@melodic/video-digestion/frames/dedup"; +import { createLogger } from "@melodic/video-digestion/shared/logger"; + +import { + detectFrameMethod, + lessonDirName, + loadCourseDir, + parseCliArgs, + resolveLogLevel, + walkPngs, +} from "./utils.js"; + +const args = parseCliArgs({ + phase: { type: "string", default: "contact-sheets" }, +}); + +const log = createLogger(resolveLogLevel(args)); + +function forEachLesson(courseDir, course, fn) { + const modulesDir = join(courseDir, "modules"); + for (const module of course.modules) { + for (const lesson of module.lessons) { + if (!lesson.hasScreenshots) continue; + const lDir = join( + modulesDir, + module.slug, + lessonDirName(lesson.position, lesson.title), + "screenshots", + ); + const pngs = walkPngs(lDir); + if (pngs.length === 0) continue; + fn(module, lesson, pngs); + } + } +} + +async function generateContactSheets(courseDir, course) { + const outDir = join(courseDir, "contact-sheets"); + mkdirSync(outDir, { recursive: true }); + + let generated = 0; + + for (const entry of collectLessons(courseDir, course)) { + const { module, lesson, pngs } = entry; + const label = `M${module.position}L${lesson.position}`; + const outFile = join(outDir, `${label}-${lessonDirName(lesson.position, lesson.title)}.jpg`); + + if (existsSync(outFile)) { + log.debug(` SKIP ${label} ${lesson.title.substring(0, 40)} (exists)`); + continue; + } + + // biome-ignore lint/performance/noAwaitInLoops: contact sheets are built sequentially to cap ImageMagick memory + const sheet = await createContactSheet(pngs, outFile, {}, { log }); + if (sheet) { + const sizeKB = Math.round(statSync(outFile).size / 1024); + log.info( + ` OK ${label} ${lesson.title.substring(0, 40).padEnd(42)} ${String(pngs.length).padStart(3)} frames → ${sizeKB}KB`, + ); + generated++; + } + } + + log.info(`\n Generated: ${generated} contact sheets → ${outDir}`); +} + +function collectLessons(courseDir, course) { + /** @type {{ module: object, lesson: object, pngs: string[] }[]} */ + const lessons = []; + forEachLesson(courseDir, course, (module, lesson, pngs) => { + lessons.push({ module, lesson, pngs }); + }); + return lessons; +} + +async function computeDedup(courseDir, course) { + let totalFrames = 0; + let totalDups = 0; + const results = {}; + + for (const { module, lesson, pngs } of collectLessons(courseDir, course)) { + const key = `M${module.position}L${lesson.position}`; + // biome-ignore lint/performance/noAwaitInLoops: lesson dedup runs sequentially to cap memory on large courses + const frameSet = await deduplicateFrames(pngs, {}, { log }); + + totalFrames += frameSet.total; + totalDups += frameSet.duplicates; + + results[key] = { + title: lesson.title, + duration: lesson.duration, + total: frameSet.total, + duplicates: frameSet.duplicates, + unique: frameSet.unique.length, + method: detectFrameMethod(frameSet.frames), + frames: frameSet.frames.map((frame) => ({ + file: frame.file, + isInterval: frame.isInterval, + likelyDuplicate: frame.likelyDuplicate, + phash: frame.phash, + })), + }; + + if (frameSet.duplicates > 0) { + const pct = Math.round((frameSet.duplicates / frameSet.total) * 100); + log.info( + ` ${key} ${lesson.title.substring(0, 42).padEnd(44)} ${String(frameSet.total).padStart(3)} frames, ${String(frameSet.duplicates).padStart(2)} dups (${pct}%)`, + ); + } + } + + const pct = totalFrames > 0 ? Math.round((totalDups / totalFrames) * 100) : 0; + log.info(`\n Total: ${totalFrames} frames, ${totalDups} likely duplicates (${pct}%)`); + log.info(` Estimated unique: ~${totalFrames - totalDups} frames`); + + const reportPath = join(courseDir, "dedup-report.json"); + writeFileSync(reportPath, JSON.stringify(results, null, 2), "utf-8"); + log.info(` Report: ${reportPath}`); +} + +function printSummary(courseDir, course) { + log.info(" Mod Lesson Frames Method AvgKB"); + log.info(` ${"-".repeat(78)}`); + + let grandTotal = 0; + + forEachLesson(courseDir, course, (module, lesson, pngs) => { + const sizes = pngs.map((p) => statSync(p).size); + const avgKB = Math.round(sizes.reduce((a, b) => a + b, 0) / sizes.length / 1024); + const method = detectFrameMethod(pngs.map((p) => basename(p))); + + log.info( + ` M${module.position}L${String(lesson.position).padStart(2)} ${lesson.title.substring(0, 44).padEnd(46)} ${String(pngs.length).padStart(4)} ${method.padEnd(10)} ${String(avgKB).padStart(4)}`, + ); + grandTotal += pngs.length; + }); + + log.info(` ${"-".repeat(78)}`); + log.info(` Grand total: ${grandTotal} frames`); +} + +const { courseDir, course } = loadCourseDir(args, { logger: log }); + +log.info(`\n ${course.title} — Phase: ${args.phase}\n`); + +switch (args.phase) { + // biome-ignore lint/suspicious/noUnnecessaryConditions: args.phase is runtime CLI argv (parseArgs strict:false); reachable via --phase. Biome narrows it to the literal default "contact-sheets". + case "contact-sheets": + await generateContactSheets(courseDir, course); + break; + // biome-ignore lint/suspicious/noUnnecessaryConditions: args.phase is runtime CLI argv; reachable via --phase dedup. Biome's literal-narrowing to the default is wrong across the argv boundary. + case "dedup": + await computeDedup(courseDir, course); + break; + // biome-ignore lint/suspicious/noUnnecessaryConditions: args.phase is runtime CLI argv; reachable via --phase summary. Biome's literal-narrowing to the default is wrong across the argv boundary. + case "summary": + printSummary(courseDir, course); + break; + default: + log.error(` Unknown phase: ${args.phase}`); + process.exit(1); +} diff --git a/plugins/knowledge/skills/course-digest/extraction/discover-resources.js b/plugins/knowledge/skills/course-digest/extraction/discover-resources.js new file mode 100644 index 000000000..ef6d4006f --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/discover-resources.js @@ -0,0 +1,270 @@ +/** + * Resource discovery tool for course-digest. + * + * Navigates lesson pages and reports available resource types per lesson. + * Does NOT extract content — discovery and reporting only. + * Uses the adapter pattern for platform-specific resource detection. + * + * Usage: + * node discover-resources.js --course-dir [options] + * + * Options: + * --course-dir Path to directory containing course.json (required) + * --sample Check a representative sample per module (default: all lessons) + * --no-headless Show the browser window (default: headless) + */ + +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { createLogger } from "@melodic/video-digestion/shared/logger"; + +import { createAdapter } from "./adapters/adapter-contract.js"; +import { resolveAuthStatePath } from "./lib/auth-store.js"; +import { checkAuthAge, closeBrowser, launchBrowser } from "./lib/browser.js"; +import { findFirstVideoLesson, loadCourseDir, parseCliArgs, resolveLogLevel } from "./utils.js"; + +const args = parseCliArgs({ + sample: { type: "boolean", default: false }, + headless: { type: "boolean", default: true }, +}); +const log = createLogger(resolveLogLevel(args)); + +/** + * Select a representative sample of lessons from each module. + * Picks: first lesson, last lesson, and one from the middle. + */ +function selectSample(modules) { + const sampled = []; + for (const mod of modules) { + const lessons = mod.lessons; + if (lessons.length === 0) continue; + + const indices = new Set([0, lessons.length - 1]); + if (lessons.length > 2) indices.add(Math.floor(lessons.length / 2)); + + for (const i of indices) { + sampled.push({ module: mod, lesson: lessons[i] }); + } + } + return sampled; +} + +function formatLessonLabel(mod, lesson) { + return `M${mod.position}L${lesson.position}`; +} + +function recordNonVideoLesson(mod, lesson, report) { + report.push({ + module: mod.position, + lesson: lesson.position, + title: lesson.title, + type: "non-video", + hasVideo: false, + hasTranscript: false, + hasDownload: false, + hasLessonNotes: false, + hasReadThisLesson: false, + }); +} + +async function navigateToLesson(page, url) { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 }); + await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(1500); +} + +async function inspectLesson({ module: mod, lesson }, ctx) { + const label = formatLessonLabel(mod, lesson); + + if (!lesson.duration || lesson.title === "Rate this course") { + recordNonVideoLesson(mod, lesson, ctx.report); + log.info( + ` ${label.padEnd(7)}${lesson.title.substring(0, 46).padEnd(48)}(non-video — skipped)`, + ); + return; + } + + const url = ctx.adapter.buildLessonUrl(ctx.course, lesson, ctx.platformCfg); + try { + await navigateToLesson(ctx.page, url); + } catch { + log.warn(` ${label.padEnd(7)}${lesson.title.substring(0, 46).padEnd(48)}FAILED (nav error)`); + return; + } + + const resourceResult = await ctx.adapter.detectResources(ctx.page, ctx.platformCfg); + if (!resourceResult.success) { + log.warn( + ` ${label.padEnd(7)}${lesson.title.substring(0, 46).padEnd(48)}FAILED (detect error)`, + ); + return; + } + + ctx.checked++; + const resources = resourceResult.data; + ctx.report.push({ + module: mod.position, + lesson: lesson.position, + title: lesson.title, + slug: lesson.slug, + ...resources, + }); + + const flag = (val) => (val ? "✓" : "·"); + log.info( + " " + + label.padEnd(7) + + lesson.title.substring(0, 46).padEnd(48) + + flag(resources.hasVideo).padEnd(7) + + flag(resources.hasTranscript).padEnd(6) + + flag(resources.hasDownload).padEnd(5) + + flag(resources.hasLessonNotes).padEnd(7) + + flag(resources.hasReadThisLesson), + ); +} + +async function inspectAllLessons(lessonList, ctx) { + await lessonList.reduce(async (chain, entry) => { + await chain; + await inspectLesson(entry, ctx); + }, Promise.resolve()); +} + +async function main() { + const { courseDir, course } = loadCourseDir(args, { logger: log }); + const platformCfg = course.platformConfig ?? {}; + + log.info(`\n ${course.title} — Resource Discovery`); + log.info(` Platform: ${course.platform ?? "unknown"}`); + log.debug(` Node: ${process.version} | OS: ${process.platform}`); + + if (!course.platform) { + log.error( + " course.json missing required 'platform' field. Set to 'dometrain', 'teachable', etc.", + ); + process.exit(1); + } + const storageStatePath = resolveAuthStatePath(course.platform); + const adapterResult = await createAdapter(course.platform, platformCfg); + if (!adapterResult.success) { + log.error(` ${adapterResult.error}`); + process.exit(1); + } + const adapter = adapterResult.data; + + checkAuthAge(storageStatePath, platformCfg); + + const lessonList = args.sample + ? selectSample(course.modules) + : course.modules.flatMap((mod) => mod.lessons.map((lesson) => ({ module: mod, lesson }))); + + const modeLabel = args.sample + ? `sample (${lessonList.length} lessons from ${course.modules.length} modules)` + : `full (${lessonList.length} lessons)`; + log.info(` Mode: ${modeLabel}`); + + log.info(" Launching Playwright Chromium...\n"); + const { browser, context, page, authDir, cookieCount } = await launchBrowser({ + headless: args.headless, + storageStatePath, + profilePrefix: "discover-resources", + }); + if (cookieCount > 0) log.info(` Injected ${cookieCount} saved cookies.`); + + const videoSelector = platformCfg.videoPlayerSelector ?? "video"; + + // Verify auth against the first video lesson to avoid false "not authenticated" errors + // when course.modules[0].lessons[0] is a non-video intro or announcement with no player. + const firstVideoLesson = findFirstVideoLesson(course.modules); + if (!firstVideoLesson) { + log.error(" ✗ No video lessons found in course.json. Cannot verify authentication."); + await closeBrowser(context, authDir, browser); + process.exit(1); + } + const authCheckUrl = adapter.buildLessonUrl(course, firstVideoLesson, platformCfg); + await page.goto(authCheckUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {}); + await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); + await page.waitForTimeout(2000); + + const hasPlayer = await page + .evaluate((sel) => !!document.querySelector(sel), videoSelector) + .catch(() => false); + + if (!hasPlayer) { + log.error( + " ✗ Not authenticated. Run extract-course.js first to establish the auth session.", + ); + await closeBrowser(context, authDir, browser); + process.exit(1); + } + log.info(" ✓ Authenticated\n"); + + log.info( + " " + + "Mod".padEnd(7) + + "Lesson".padEnd(48) + + "Video".padEnd(7) + + "Txpt".padEnd(6) + + "DL".padEnd(5) + + "Notes".padEnd(7) + + "Read", + ); + log.info(` ${"-".repeat(80)}`); + + const report = []; + const discoveryCtx = { + adapter, + course, + page, + platformCfg, + report, + checked: 0, + }; + await inspectAllLessons(lessonList, discoveryCtx); + const checked = discoveryCtx.checked; + + await closeBrowser(context, authDir, browser); + + const withDownload = report.filter((r) => r.hasDownload).length; + const withNotes = report.filter((r) => r.hasLessonNotes).length; + const withRead = report.filter((r) => r.hasReadThisLesson).length; + const withVideo = report.filter((r) => r.hasVideo).length; + + log.info(`\n ----------------------------------------`); + log.info(` Checked: ${checked} lessons`); + log.info( + ` Video: ${withVideo} | Download: ${withDownload} | Notes: ${withNotes} | Read: ${withRead}`, + ); + + const reportPath = join(courseDir, "discovery-report.json"); + writeFileSync( + reportPath, + JSON.stringify( + { + course: course.title, + platform: course.platform, + discoveredAt: new Date().toISOString(), + mode: args.sample ? "sample" : "full", + summary: { + totalLessons: lessonList.length, + checked, + withVideo, + withDownload, + withNotes, + withReadThisLesson: withRead, + }, + lessons: report, + }, + null, + 2, + ), + "utf-8", + ); + log.info(` Report: ${reportPath}\n`); +} + +main().catch((e) => { + log.error("Fatal error:", e); + process.exit(1); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/download-resources.js b/plugins/knowledge/skills/course-digest/extraction/download-resources.js new file mode 100644 index 000000000..4714ddad4 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/download-resources.js @@ -0,0 +1,206 @@ +/** + * Download all referenced resources from resources.json files. + * + * Scans all modules/lessons for resources.json, extracts download URLs + * and PDF links, fetches each file, and saves to the course data directory. + * + * Usage: node download-resources.js --course-dir [--dry-run] + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import { basename, dirname, extname, join } from "node:path"; + +import { createLogger } from "@melodic/video-digestion/shared/logger"; + +import { loadCourseDir, parseCliArgs, resolveLogLevel } from "./utils.js"; + +const args = parseCliArgs({ + "dry-run": { type: "boolean", default: false }, +}); +const log = createLogger(resolveLogLevel(args)); + +function download(url, destPath, timeout = 30000) { + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : http; + const req = client.get(url, { timeout }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + download(res.headers.location, destPath, timeout).then(resolve).catch(reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} for ${url.substring(0, 80)}`)); + return; + } + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const buffer = Buffer.concat(chunks); + writeFileSync(destPath, buffer); + resolve(buffer.length); + }); + res.on("error", reject); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("Timeout")); + }); + }); +} + +function collectResourcesFromFile(full, buckets) { + const data = JSON.parse(readFileSync(full, "utf-8")); + const lessonDir = basename(dirname(full)); + const moduleDir = basename(dirname(dirname(full))); + const context = { lessonDir, moduleDir, source: full }; + + for (const d of data.downloads ?? []) { + buckets.allDownloads.push({ ...d, ...context }); + } + for (const p of data.pdfLinks ?? []) { + buckets.allPdfs.push({ ...p, ...context }); + } + for (const a of data.articleLinks ?? []) { + if (!a.href?.includes("affiliate")) { + buckets.allArticles.push({ ...a, ...context }); + } + } +} + +function walkModules(dir, buckets) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + walkModules(full, buckets); + } else if (entry === "resources.json") { + collectResourcesFromFile(full, buckets); + } + } +} + +function buildDownloadItems({ + uniqueDownloads, + uniquePdfs, + downloadsDir, + slidesDir, + resourcesDir, +}) { + return [ + ...uniqueDownloads.map((d) => { + const filename = d.label || basename(new URL(d.href).pathname); + const ext = extname(filename).toLowerCase(); + const targetDir = ext === ".sql" || ext === ".json" ? resourcesDir : downloadsDir; + return { href: d.href, filename, destPath: join(targetDir, filename) }; + }), + ...uniquePdfs.map((p) => { + const filename = p.label || basename(new URL(p.href).pathname); + return { href: p.href, filename, destPath: join(slidesDir, filename) }; + }), + ]; +} + +async function downloadOneItem(item, stats) { + if (existsSync(item.destPath)) { + stats.skipped++; + return; + } + + try { + const bytes = await download(item.href, item.destPath); + stats.totalBytes += bytes; + stats.success++; + if (stats.success % 10 === 0) { + log.info(` ${stats.success} downloaded, ${stats.failed} failed, ${stats.skipped} skipped`); + } + } catch (e) { + stats.failed++; + log.warn(` FAIL: ${item.filename} — ${e.message.substring(0, 60)}`); + } +} + +async function downloadAllItems(downloadItems) { + const stats = { success: 0, failed: 0, skipped: 0, totalBytes: 0 }; + await downloadItems.reduce(async (chain, item) => { + await chain; + await downloadOneItem(item, stats); + }, Promise.resolve()); + return stats; +} + +async function main() { + const { courseDir } = loadCourseDir(args, { logger: log }); + const modulesDir = join(courseDir, "modules"); + const downloadsDir = join(courseDir, "code", "downloads"); + const slidesDir = join(courseDir, "slides"); + const resourcesDir = join(courseDir, "resources"); + + if (!existsSync(modulesDir)) { + log.error(`Modules directory not found: ${modulesDir}`); + process.exit(1); + } + + const buckets = { allDownloads: [], allPdfs: [], allArticles: [] }; + walkModules(modulesDir, buckets); + const { allDownloads, allPdfs, allArticles } = buckets; + + const uniqueDownloads = [...new Map(allDownloads.map((d) => [d.href, d])).values()]; + const uniquePdfs = [...new Map(allPdfs.map((p) => [p.href, p])).values()]; + const uniqueArticles = [...new Map(allArticles.map((a) => [a.href, a])).values()]; + + log.info(`\nResources found:`); + log.info(` Downloads: ${uniqueDownloads.length} unique files`); + log.info(` PDFs: ${uniquePdfs.length} unique files`); + log.info(` Articles: ${uniqueArticles.length} unique links (saved as references)`); + + if (args["dry-run"]) { + log.info("\n[DRY RUN] Would download:"); + for (const d of uniqueDownloads) { + log.info(` DL: ${d.label}`); + } + for (const p of uniquePdfs) { + log.info(` PDF: ${p.label}`); + } + log.info("\nArticle references:"); + for (const a of uniqueArticles) { + log.info(` ${a.label}`); + } + return; + } + + mkdirSync(downloadsDir, { recursive: true }); + mkdirSync(slidesDir, { recursive: true }); + mkdirSync(resourcesDir, { recursive: true }); + + const downloadItems = buildDownloadItems({ + uniqueDownloads, + uniquePdfs, + downloadsDir, + slidesDir, + resourcesDir, + }); + + log.info("\nDownloading files...\n"); + const { success, failed, skipped, totalBytes } = await downloadAllItems(downloadItems); + + const articlesPath = join(courseDir, "article-links.json"); + writeFileSync(articlesPath, JSON.stringify(uniqueArticles, null, 2), "utf-8"); + + const mb = (totalBytes / 1024 / 1024).toFixed(1); + log.info(`\n========================================`); + log.info(`Downloaded: ${success} files (${mb} MB)`); + log.info(`Skipped (exist): ${skipped}`); + log.info(`Failed: ${failed}`); + log.info(`Articles saved: ${uniqueArticles.length} references`); + log.info(`\nOutput:`); + log.info(` ${downloadsDir}`); + log.info(` ${slidesDir}`); + log.info(` ${resourcesDir}`); + log.info(` ${articlesPath}`); +} + +main().catch((e) => { + log.error("Fatal:", e); + process.exit(1); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/extract-course-run.js b/plugins/knowledge/skills/course-digest/extraction/extract-course-run.js new file mode 100644 index 000000000..77ccc1816 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/extract-course-run.js @@ -0,0 +1,255 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { formatTranscriptMd, lessonDirName, parseDuration } from "./utils.js"; + +function saveLessonResources(lessonDir, res) { + if (res.codeSnippets?.length > 0) { + const snippetsMd = res.codeSnippets + .map( + (s, i) => + `### Snippet ${i + 1}${s.language ? ` (${s.language})` : ""}\n\n\`\`\`${s.language ?? ""}\n${s.code}\n\`\`\``, + ) + .join("\n\n"); + writeFileSync(join(lessonDir, "code-snippets.md"), `${snippetsMd}\n`, "utf-8"); + } + + const resourceData = {}; + if (res.downloads?.length > 0) resourceData.downloads = res.downloads; + if (res.articleLinks?.length > 0) resourceData.articleLinks = res.articleLinks; + if (res.pdfLinks?.length > 0) resourceData.pdfLinks = res.pdfLinks; + if (res.textContent?.length > 0) resourceData.textContent = res.textContent; + + if (Object.keys(resourceData).length > 0) { + writeFileSync( + join(lessonDir, "resources.json"), + JSON.stringify(resourceData, null, 2), + "utf-8", + ); + } +} + +async function extractLessonFrames({ ctx, lesson, lessonDir, url, durationSec }) { + const { adapter, page, platformCfg, frameConfig, log, ffmpegReferer, extractFramesFn, stats } = + ctx; + const screenshotsDir = join(lessonDir, "screenshots"); + + log.info(` FRAMES ${lesson.position}. ${lesson.title} — extracting...`); + + if (adapter.extractFramesCanvas) { + const canvasResult = await adapter.extractFramesCanvas({ + page, + duration: durationSec, + outputDir: screenshotsDir, + options: frameConfig, + }); + log.logResult(canvasResult); + + if (canvasResult.success && canvasResult.data.count > 0) { + lesson.hasScreenshots = true; + stats.framesExtracted += canvasResult.data.count; + log.info(` ${canvasResult.data.count} frames (${canvasResult.data.method})`); + } else { + log.warn(" No frames extracted via canvas."); + } + return; + } + + if (!adapter.setupSession) { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {}); + await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); + } + + const hlsResult = await adapter.extractHlsUrl(page, platformCfg); + log.logResult(hlsResult); + + if (!hlsResult.success) { + log.warn(` SKIP-FRAMES ${lesson.position}. ${lesson.title} (${hlsResult.error})`); + return; + } + + const sceneDir = join(screenshotsDir, "scene-01"); + const result = await extractFramesFn(hlsResult.data, sceneDir, ffmpegReferer, frameConfig); + if (result.count > 0) { + lesson.hasScreenshots = true; + stats.framesExtracted += result.count; + log.info(` ${result.count} frames (${result.method})`); + } else { + log.warn(" No frames extracted."); + } +} + +async function detectLessonResources(lesson, ctx) { + const { adapter, page, platformCfg, log } = ctx; + if (lesson.hasDownload !== undefined) return; + + const resourceResult = await adapter.detectResources(page, platformCfg); + log.logResult(resourceResult); + if (!resourceResult.success) return; + + const resources = resourceResult.data; + lesson.hasDownload = resources.hasDownload; + lesson.hasVideo = resources.hasVideo; + lesson.providerResources = { + lessonNotes: resources.hasLessonNotes ?? false, + readThisLesson: resources.hasReadThisLesson ?? false, + codeSnippets: resources.hasCodeSnippets ?? false, + articleLinks: resources.hasArticleLinks ?? false, + pdfEmbed: resources.hasPdfEmbed ?? false, + }; +} + +async function extractLessonTranscript({ + module, + lesson, + lessonDir, + transcriptPath, + ctx, + lessonStart, +}) { + const { adapter, page, platformCfg, log, stats, tracker } = ctx; + const transcriptResult = await adapter.extractTranscript(page, platformCfg); + log.logResult(transcriptResult); + + if (!transcriptResult.success) { + stats.failed++; + tracker.item(stats.lessonIndex, lesson.title, { + success: false, + error: transcriptResult.error, + durationMs: performance.now() - lessonStart, + }); + return false; + } + + mkdirSync(lessonDir, { recursive: true }); + writeFileSync(transcriptPath, formatTranscriptMd(lesson, module, transcriptResult.data), "utf-8"); + + lesson.status = "extracted"; + lesson.hasTranscript = true; + stats.extracted++; + tracker.item(stats.lessonIndex, lesson.title, { + success: true, + chars: transcriptResult.data.length, + durationMs: performance.now() - lessonStart, + }); + return true; +} + +async function processLesson(module, lesson, ctx) { + const { + args, + adapter, + course, + courseJson, + log, + modulesDir, + navigateWithFallback, + page, + platformCfg, + skipTitles, + stats, + tracker, + } = ctx; + + const lessonDir = join(modulesDir, module.slug, lessonDirName(lesson.position, lesson.title)); + const transcriptPath = join(lessonDir, "transcript.md"); + + if (skipTitles.has(lesson.title)) { + stats.skipped++; + return; + } + + const skipTranscript = + args.skipTranscripts || lesson.status === "extracted" || existsSync(transcriptPath); + const needsTranscript = !skipTranscript; + const durationSec = parseDuration(lesson.duration); + const shouldExtractFrames = args.extractFrames && durationSec > 0 && !lesson.hasScreenshots; + const isNonVideoLesson = !lesson.duration; + const needsWork = + needsTranscript || shouldExtractFrames || (isNonVideoLesson && adapter.extractResources); + + if (!needsWork) { + stats.skipped++; + return; + } + + stats.lessonIndex++; + const lessonStart = performance.now(); + const url = adapter.buildLessonUrl(course, lesson, platformCfg); + + if (!(await navigateWithFallback(page, url))) { + stats.failed++; + tracker.item(stats.lessonIndex, lesson.title, { + success: false, + error: "nav error", + durationMs: performance.now() - lessonStart, + }); + return; + } + await page.waitForTimeout(1500); + + if (adapter.prepareLessonPage) { + const prepResult = await adapter.prepareLessonPage(page, platformCfg, lesson); + log.logResult(prepResult); + if (!prepResult.success) { + log.warn(` PREP-WARN ${lesson.position}. ${lesson.title}: ${prepResult.error}`); + } else if (prepResult.data?.warning) { + log.warn(` ${prepResult.data.warning}`); + } + } + + await detectLessonResources(lesson, ctx); + + if (needsTranscript && !isNonVideoLesson) { + const ok = await extractLessonTranscript({ + module, + lesson, + lessonDir, + transcriptPath, + ctx, + lessonStart, + }); + if (!ok) return; + } + + if (shouldExtractFrames && !isNonVideoLesson) { + await extractLessonFrames({ ctx, lesson, lessonDir, url, durationSec }); + } + + if (adapter.extractResources) { + const resResult = await adapter.extractResources(page, platformCfg); + log.logResult(resResult); + if (resResult.success && resResult.data) { + mkdirSync(lessonDir, { recursive: true }); + saveLessonResources(lessonDir, resResult.data); + } + } + + writeFileSync(courseJson, JSON.stringify(course, null, 2), "utf-8"); +} + +export async function runLessonExtraction(ctx) { + const { course, log, modulesDir } = ctx; + + await course.modules.reduce(async (moduleChain, module) => { + await moduleChain; + log.info(` Module ${module.position}: ${module.title}`); + const lessons = module.lessons.map((lesson) => ({ module, lesson })); + await lessons.reduce(async (lessonChain, entry) => { + await lessonChain; + await processLesson(entry.module, entry.lesson, ctx); + }, Promise.resolve()); + }, Promise.resolve()); + + return { modulesDir }; +} + +export function createRunStats() { + return { + extracted: 0, + skipped: 0, + failed: 0, + framesExtracted: 0, + lessonIndex: 0, + }; +} diff --git a/plugins/knowledge/skills/course-digest/extraction/extract-course.js b/plugins/knowledge/skills/course-digest/extraction/extract-course.js new file mode 100644 index 000000000..1c82ac655 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/extract-course.js @@ -0,0 +1,316 @@ +/** + * Course extraction orchestrator. + * + * Delegates all platform-specific work to the adapter pattern: + * adapters/{platform}.js — transcript, HLS, resources, metadata, auth + * lib/browser.js — shared Playwright infrastructure + * lib/config.js — platformConfig validation, adapter resolution + * @melodic/video-digestion/shared/result — Result type, structured logging + * + * Frame extraction (ffmpeg) stays here — it's provider-agnostic. + * + * Usage: + * node extract-course.js --course-dir [options] + * + * Options: + * --course-dir Path to directory containing course.json (required) + * --extract-frames Also capture HLS URLs and extract video frames via ffmpeg + * --metadata-only Only extract course metadata from the landing page, then exit + * --skip-transcripts Skip transcript extraction (useful when re-running for frames only) + * --no-headless Show the browser window (default: headless) + */ + +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { extractSceneFrames } from "@melodic/video-digestion/frames/scene-detect"; +import { createLogger } from "@melodic/video-digestion/shared/logger"; +import { createTracker } from "@melodic/video-digestion/shared/progress"; + +import { createAdapter } from "./adapters/adapter-contract.js"; +import { createRunStats, runLessonExtraction } from "./extract-course-run.js"; +import { resolveAuthStatePath } from "./lib/auth-store.js"; +import { checkAuthAge, closeBrowser, launchBrowser } from "./lib/browser.js"; +import { courseBaseUrl, loadCourseDir, parseCliArgs, resolveLogLevel } from "./utils.js"; + +const args = parseCliArgs({ + "extract-frames": { type: "boolean", default: false }, + "metadata-only": { type: "boolean", default: false }, + "skip-transcripts": { type: "boolean", default: false }, + headless: { type: "boolean", default: true }, + "show-browser": { type: "boolean", default: false }, +}); +const log = createLogger(resolveLogLevel(args)); + +async function navigateWithFallback(page, url) { + try { + await page.goto(url, { waitUntil: "networkidle", timeout: 15000 }); + return true; + } catch { + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 }); + return true; + } catch { + return false; + } + } +} + +async function extractFrames(hlsUrl, outputDir, referer, frameConfig = {}) { + const result = await extractSceneFrames( + hlsUrl, + outputDir, + { + sceneThreshold: frameConfig.sceneThreshold, + intervalFps: frameConfig.intervalFps, + minFramesForScene: frameConfig.minFramesForScene, + referer, + }, + { log }, + ); + + return { + method: result.method, + sceneCount: result.sceneCount, + intervalCount: result.intervalCount, + count: result.count, + }; +} + +function ensureFfmpegAvailable() { + if (!args["extract-frames"]) return; + const ffCheck = spawnSync("ffmpeg", ["-version"], { stdio: "pipe" }); + if (ffCheck.status !== 0) { + log.error(" ffmpeg not found on PATH. Required for --extract-frames."); + process.exit(1); + } + log.info(" ffmpeg: available"); +} + +async function runPreflight({ adapter, page, platformCfg, context, authDir, browser }) { + if (!adapter.preflight) return 0; + + const pfStart = performance.now(); + const preflightResult = await adapter.preflight(page, platformCfg); + const preflightDurationMs = Math.round(performance.now() - pfStart); + log.logResult(preflightResult); + + if (!preflightResult.success) { + log.error(`\n ✗ ${preflightResult.error}`); + log.error(" Aborting — fix the adapter selectors before extracting.\n"); + await closeBrowser(context, authDir, browser); + process.exit(1); + } + + const checks = preflightResult.data; + log.info( + ` Preflight: video=${checks.videoPlayer ? "✓" : "✗"} transcript-btn=${checks.transcriptButton ? "✓" : "✗"} transcript-panel=${checks.transcriptPanel ? "✓" : "✗"}`, + ); + return preflightDurationMs; +} + +async function runMetadataPhase({ + adapter, + page, + course, + courseJson, + platformCfg, + context, + authDir, + browser, +}) { + if (!args["metadata-only"] && course.metadata) return 0; + + log.info("\n Extracting course metadata..."); + const metaStart = performance.now(); + const metaResult = await adapter.extractMetadata(page, course.url, platformCfg); + const metadataDurationMs = Math.round(performance.now() - metaStart); + log.logResult(metaResult); + + if (metaResult.success && Object.keys(metaResult.data).length > 0) { + course.metadata = metaResult.data; + writeFileSync(courseJson, JSON.stringify(course, null, 2), "utf-8"); + log.info(` Metadata found: ${Object.keys(metaResult.data).join(", ")}`); + } else { + log.info(" No metadata found on course landing page."); + } + + if (args["metadata-only"]) { + await closeBrowser(context, authDir, browser); + log.info("\n Done (metadata only)."); + process.exit(0); + } + + return metadataDurationMs; +} + +async function main() { + const { courseDir, courseJsonPath: courseJson, course } = loadCourseDir(args, { logger: log }); + const modulesDir = join(courseDir, "modules"); + const platformCfg = course.platformConfig ?? {}; + + if (!course.platform) { + log.error( + " course.json missing required 'platform' field. Set to 'dometrain', 'teachable', etc.", + ); + process.exit(1); + } + const platformName = course.platform; + const storageStatePath = resolveAuthStatePath(platformName); + + log.info(`\n ${course.title} — ${course.totalLessons} lessons`); + log.info(` Platform: ${platformName}`); + log.debug(` Node: ${process.version} | OS: ${process.platform}`); + log.debug( + ` Options: extractFrames=${args["extract-frames"]} skipTranscripts=${args["skip-transcripts"]} metadataOnly=${args["metadata-only"]} headless=${args.headless}`, + ); + + const adapterResult = await createAdapter(platformName, platformCfg); + if (!adapterResult.success) { + log.error(` ${adapterResult.error}`); + process.exit(1); + } + const adapter = adapterResult.data; + log.debug(` Adapter: ${platformName} (resolved)`); + + const ffmpegReferer = platformCfg.referer ?? ""; + const frameConfig = { + ...adapter.defaults?.frameExtraction, + ...platformCfg.frameExtraction, + }; + + checkAuthAge(storageStatePath, platformCfg); + ensureFfmpegAvailable(); + + const headless = !args["show-browser"] && args.headless; + log.info(" Launching Playwright Chromium..."); + log.info(` Browser headless: ${headless} (show-browser=${args["show-browser"]})`); + const { browser, context, page, authDir, cookieCount } = await launchBrowser({ + headless, + storageStatePath, + }); + if (cookieCount > 0) log.info(` Injected ${cookieCount} saved cookies.`); + + const authStart = performance.now(); + const authResult = await adapter.authenticate({ + context, + page, + course, + storageStatePath, + platformCfg, + }); + const authDurationMs = Math.round(performance.now() - authStart); + const baseUrl = authResult?.baseUrl ?? courseBaseUrl(course.url); + log.info(` Base: ${baseUrl}`); + log.debug(` Auth: ${authDurationMs}ms`); + + const preflightDurationMs = await runPreflight({ + adapter, + page, + platformCfg, + context, + authDir, + browser, + }); + const metadataDurationMs = await runMetadataPhase({ + adapter, + page, + course, + courseJson, + platformCfg, + context, + authDir, + browser, + }); + + if (adapter.setupSession) { + await adapter.setupSession(page, platformCfg); + log.debug(" Adapter session setup complete."); + } + + log.info("\n ----------------------------------------\n"); + + const skipTitles = new Set(platformCfg.skipLessonTitles ?? ["Rate this course"]); + const processable = course.modules + .flatMap((m) => m.lessons) + .filter((l) => l.duration && !skipTitles.has(l.title)); + const tracker = createTracker(processable.length, { logger: log }); + tracker.start(); + + const stats = createRunStats(); + const extractionStart = performance.now(); + const saveProgress = () => { + writeFileSync(courseJson, JSON.stringify(course, null, 2), "utf-8"); + const report = tracker.finish(); + writeFileSync(join(courseDir, "run-report.json"), JSON.stringify(report, null, 2), "utf-8"); + log.info("\n Progress saved (interrupted)."); + }; + process.on("SIGINT", () => { + saveProgress(); + process.exit(0); + }); + + await runLessonExtraction({ + args: { + skipTranscripts: args["skip-transcripts"], + extractFrames: args["extract-frames"], + }, + adapter, + course, + courseJson, + extractFramesFn: extractFrames, + ffmpegReferer, + frameConfig, + log, + modulesDir, + navigateWithFallback, + page, + platformCfg, + skipTitles, + stats, + tracker, + }); + + await closeBrowser(context, authDir, browser); + const extractionDurationMs = Math.round(performance.now() - extractionStart); + + const report = tracker.finish({ + environment: { + nodeVersion: process.version, + platform: process.platform, + adapter: platformName, + options: { + extractFrames: args["extract-frames"], + skipTranscripts: args["skip-transcripts"], + metadataOnly: args["metadata-only"], + verbose: args.verbose, + quiet: args.quiet, + }, + }, + phases: { + authMs: authDurationMs, + preflightMs: preflightDurationMs, + metadataMs: metadataDurationMs, + extractionMs: extractionDurationMs, + }, + }); + writeFileSync(join(courseDir, "run-report.json"), JSON.stringify(report, null, 2), "utf-8"); + + log.info("\n ----------------------------------------"); + log.info( + ` Transcripts — Extracted: ${stats.extracted} | Skipped: ${stats.skipped} | Failed: ${stats.failed}`, + ); + if (args["extract-frames"]) { + log.info(` Frames — Total extracted: ${stats.framesExtracted}`); + } + log.info( + ` Total lessons processed: ${stats.extracted + stats.skipped + stats.failed}/${course.totalLessons}`, + ); + log.info(` Run report: ${join(courseDir, "run-report.json")}`); +} + +main().catch((e) => { + log.error("Fatal error:", e); + process.exit(1); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/generate-manifests.js b/plugins/knowledge/skills/course-digest/extraction/generate-manifests.js new file mode 100644 index 000000000..164923ae9 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/generate-manifests.js @@ -0,0 +1,204 @@ +/** + * Generate manifest.json per lesson with frame classification and transcript pairing. + * + * Reads dedup-report.json and course.json, classifies each frame, pairs with + * nearest transcript segment by timestamp, and writes manifest.json per lesson. + * + * Usage: + * node generate-manifests.js --course-dir + * + * Manifest schema (course-agnostic): + * - file: frame filename + * - timestamp: estimated video timestamp in seconds + * - timestampEstimated: true if linearly interpolated (scene frames), + * false if derived from extraction interval (interval frames at 1/15 fps) + * - type: classification (code/slide/talking-head) + * - description: one-line description (null until visual analysis) + * - transcriptContext: nearest transcript segment text + * - keep: boolean — true for unique valuable content + * + * Classification rules: + * - Scene-detected frames → "code" (all keep) + * - Interval frames flagged as duplicate → keep only LAST in each consecutive run + * (progressive bullet build-up = later frames have more content) + * - Interval frames with high dup rate and all interval → "talking-head" (discard) + * - Remaining interval frames → "slide" + * + * Output: screenshots/manifest.json per lesson directory + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { createLogger } from "@melodic/video-digestion/shared/logger"; + +import { + lessonDirName, + loadCourseDir, + parseCliArgs, + parseDuration, + resolveLogLevel, +} from "./utils.js"; + +const args = parseCliArgs({ + "dry-run": { type: "boolean", default: false }, +}); +const log = createLogger(resolveLogLevel(args)); + +function parseTranscript(transcriptPath) { + if (!existsSync(transcriptPath)) return []; + const text = readFileSync(transcriptPath, "utf-8"); + const segments = []; + const pattern = /\[(\d+):(\d{2})\]\s*(.*?)(?=\n\n\[|\n\n$|$)/gs; + for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) { + const minutes = Number.parseInt(match[1], 10); + const seconds = Number.parseInt(match[2], 10); + segments.push({ + timestamp: minutes * 60 + seconds, + text: match[3].trim(), + }); + } + return segments; +} + +function nearestSegment(segments, targetTimestamp) { + if (segments.length === 0) return null; + let closest = segments[0]; + let minDist = Math.abs(segments[0].timestamp - targetTimestamp); + for (const seg of segments) { + const dist = Math.abs(seg.timestamp - targetTimestamp); + if (dist < minDist) { + minDist = dist; + closest = seg; + } + } + return closest; +} + +function classifyLesson(dedupEntry, durationSec) { + const { frames } = dedupEntry; + const intervalFrames = frames.filter((f) => f.isInterval); + const sceneFrames = frames.filter((f) => !f.isInterval); + + // Check if this is a talking-head lesson: all interval, high avg size, mostly duplicates + const isTalkingHead = + sceneFrames.length === 0 && + intervalFrames.length > 3 && + dedupEntry.duplicates / dedupEntry.total > 0.8; + + const classified = []; + + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + const isScene = !frame.isInterval; + const isDup = !!frame.likelyDuplicate; + + // Estimate timestamp from frame position within the video duration + const position = i / Math.max(frames.length - 1, 1); + const estimatedTimestamp = Math.round(position * durationSec); + + let type; + let keep; + + if (isScene) { + // Scene-detected frames are always code/IDE content + type = "code"; + keep = true; + } else if (isTalkingHead) { + type = "talking-head"; + keep = false; + } else if (isDup) { + // For consecutive duplicate runs, keep only the LAST frame in each run + // (progressive bullet build-up = later frames have more content) + const nextFrame = frames[i + 1]; + const isLastInRun = !nextFrame?.likelyDuplicate; + type = "slide"; + keep = isLastInRun; + } else { + type = "slide"; + keep = true; + } + + classified.push({ + file: frame.file, + timestamp: estimatedTimestamp, + timestampEstimated: !frame.isInterval, + type, + description: null, + keep, + }); + } + + return classified; +} + +const { courseDir, course } = loadCourseDir(args, { logger: log }); +const dedupPath = join(courseDir, "dedup-report.json"); + +if (!existsSync(dedupPath)) { + log.error(" dedup-report.json not found. Run classify-frames.js --phase dedup first."); + process.exit(1); +} + +const dedup = JSON.parse(readFileSync(dedupPath, "utf-8")); + +log.info(`\n ${course.title} — Generating manifests\n`); + +let totalManifests = 0; +let totalKept = 0; +let totalDiscarded = 0; + +for (const module of course.modules) { + for (const lesson of module.lessons) { + if (!lesson.hasScreenshots) continue; + + const key = `M${module.position}L${lesson.position}`; + const dedupEntry = dedup[key]; + if (!dedupEntry) continue; + + const durationSec = parseDuration(lesson.duration); + const lessonDir = join( + courseDir, + "modules", + module.slug, + lessonDirName(lesson.position, lesson.title), + ); + const screenshotsDir = join(lessonDir, "screenshots"); + const transcriptPath = join(lessonDir, "transcript.md"); + const manifestPath = join(screenshotsDir, "manifest.json"); + + const classified = classifyLesson(dedupEntry, durationSec); + + // Pair each classified frame with its nearest transcript segment. + const segments = parseTranscript(transcriptPath); + for (const frame of classified) { + const seg = nearestSegment(segments, frame.timestamp); + if (seg) { + frame.transcriptContext = seg.text.substring(0, 200); + } + } + + const kept = classified.filter((f) => f.keep).length; + const discarded = classified.length - kept; + totalKept += kept; + totalDiscarded += discarded; + + if (!args["dry-run"]) { + writeFileSync(manifestPath, JSON.stringify(classified, null, 2), "utf-8"); + } + + totalManifests++; + const pct = Math.round((kept / classified.length) * 100); + log.info( + ` ${key} ${lesson.title.substring(0, 42).padEnd(44)} ${String(classified.length).padStart(3)} frames, ${String(kept).padStart(3)} kept (${pct}%)`, + ); + } +} + +log.info(`\n ────────────────────────────`); +log.info(` Manifests: ${totalManifests}`); +log.info(` Kept: ${totalKept} frames`); +log.info(` Discarded: ${totalDiscarded} frames`); +log.info( + ` ${args["dry-run"] ? "(dry run — no files written)" : `Written to: */screenshots/manifest.json`}`, +); diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/auth-store.js b/plugins/knowledge/skills/course-digest/extraction/lib/auth-store.js new file mode 100644 index 000000000..bfe42e8b8 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/auth-store.js @@ -0,0 +1,33 @@ +/** + * Resolve where a platform's Playwright session cookies persist. + * + * Auth state is a platform-level login session (reused across every course on + * that platform), re-homed out of the consumer repo into the plugin's persistent + * data directory so credentials survive plugin updates and never land in a + * tracked course directory. Keyed by platform. + * + * `${CLAUDE_PLUGIN_DATA}/auth/.auth-state.json` under Claude Code; for + * direct dev / test runs where the harness has not set CLAUDE_PLUGIN_DATA, a + * machine-local `${home}/.claude/course-digest/auth/…` fallback keeps the same + * out-of-repo, persistent shape. The directory is created on resolve so Playwright's + * `storageState({ path })` — which does not create parent directories — can write. + */ +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export function resolveAuthDir() { + const base = process.env.CLAUDE_PLUGIN_DATA || join(homedir(), ".claude", "course-digest"); + const dir = join(base, "auth"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * @param {string} platform — e.g. "dometrain", "teachable" + * @returns {string} absolute path to the platform's auth-state file + */ +export function resolveAuthStatePath(platform) { + const safe = String(platform).replace(/[^a-z0-9_-]/gi, "-"); + return join(resolveAuthDir(), `${safe}.auth-state.json`); +} diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/auth/clerk.js b/plugins/knowledge/skills/course-digest/extraction/lib/auth/clerk.js new file mode 100644 index 000000000..f9b82bbd8 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/auth/clerk.js @@ -0,0 +1,34 @@ +/** + * Clerk authentication flow. + * + * Two-step Clerk login: identifier input → Continue → password input → Continue. + * Used by Dometrain and any future platform using Clerk for auth. + */ + +import { + CONTINUE_OR_SUBMIT_BUTTON, + IDENTIFIER_INPUT_SELECTOR, + PASSWORD_INPUT_SELECTOR, +} from "../playwright-selectors.js"; + +/** + * Perform Clerk two-step login. + * + * @param {import('playwright').Page} page + * @param {string} email + * @param {string} password + * @param {string} loginUrl + */ +export async function login(page, email, password, loginUrl) { + await page.goto(loginUrl, { waitUntil: "networkidle", timeout: 15000 }); + await page.waitForTimeout(2000); + + await page.locator(IDENTIFIER_INPUT_SELECTOR).fill(email); + await page.locator(CONTINUE_OR_SUBMIT_BUTTON).first().click(); + await page.waitForTimeout(2000); + + await page.locator(PASSWORD_INPUT_SELECTOR).fill(password); + await page.locator(CONTINUE_OR_SUBMIT_BUTTON).first().click(); + await page.waitForLoadState("networkidle", { timeout: 15000 }); + await page.waitForTimeout(3000); +} diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/auth/clerk.test.js b/plugins/knowledge/skills/course-digest/extraction/lib/auth/clerk.test.js new file mode 100644 index 000000000..6c7c2986b --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/auth/clerk.test.js @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { login } from "./clerk.js"; +import { createMockPage } from "./test-helpers.js"; + +describe("clerk auth module", () => { + let page; + + beforeEach(async () => { + page = createMockPage(); + await login(page, "user@test.com", "pass123", "https://auth.example.com/login"); + }); + + it("should navigate to login URL", () => { + expect(page.actions[0]).toEqual({ type: "goto", url: "https://auth.example.com/login" }); + }); + + it("should fill email then password in two steps", () => { + const fills = page.actions.filter((a) => a.type === "fill"); + expect(fills).toHaveLength(2); + expect(fills[0].value).toBe("user@test.com"); + expect(fills[1].value).toBe("pass123"); + }); + + it("should click Continue/submit twice", () => { + const clicks = page.actions.filter((a) => a.type === "click"); + expect(clicks).toHaveLength(2); + }); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/auth/manual-login.js b/plugins/knowledge/skills/course-digest/extraction/lib/auth/manual-login.js new file mode 100644 index 000000000..19c9d6fb1 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/auth/manual-login.js @@ -0,0 +1,30 @@ +/** + * Shared manual login fallback for adapters. + * + * When automated credentials are unavailable, prompts the user to + * log in manually in the browser window and press Enter to continue. + */ + +import { createInterface } from "node:readline/promises"; + +import { writeStdout } from "@melodic/video-digestion/shared/terminal"; + +/** + * Prompt the user to log in manually and save auth state when done. + * + * @param {import('playwright').BrowserContext} context + * @param {string} storageStatePath + * @param {string} envPrefix — e.g. "COURSE" or "TEACHABLE" + */ +export async function promptManualLogin(context, storageStatePath, envPrefix) { + writeStdout(` Not authenticated. Set ${envPrefix}_EMAIL and ${envPrefix}_PASSWORD,`); + writeStdout(" or log in manually in the browser window and press Enter.\n"); + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + await rl.question(" Press Enter when logged in: "); + rl.close(); + await context.storageState({ path: storageStatePath }); + writeStdout(" Saved auth state for future runs.\n"); +} diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/auth/teachable-sso.js b/plugins/knowledge/skills/course-digest/extraction/lib/auth/teachable-sso.js new file mode 100644 index 000000000..db0c41659 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/auth/teachable-sso.js @@ -0,0 +1,32 @@ +/** + * Teachable SSO authentication flow. + * + * Simple form: email input + password input + submit button. + * Used by Teachable-hosted course platforms. + */ + +import { + EMAIL_INPUT_SELECTOR, + PASSWORD_INPUT_SELECTOR, + SUBMIT_BUTTON_SELECTOR, +} from "../playwright-selectors.js"; + +/** + * Perform Teachable SSO login. + * + * @param {import('playwright').Page} page + * @param {string} email + * @param {string} password + * @param {string} loginUrl + */ +export async function login(page, email, password, loginUrl) { + await page.goto(loginUrl, { waitUntil: "networkidle", timeout: 15000 }); + await page.waitForTimeout(2000); + + await page.locator(EMAIL_INPUT_SELECTOR).fill(email); + await page.locator(PASSWORD_INPUT_SELECTOR).fill(password); + + await page.locator(SUBMIT_BUTTON_SELECTOR).first().click(); + await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); + await page.waitForTimeout(3000); +} diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/auth/teachable-sso.test.js b/plugins/knowledge/skills/course-digest/extraction/lib/auth/teachable-sso.test.js new file mode 100644 index 000000000..ff36cd128 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/auth/teachable-sso.test.js @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { login } from "./teachable-sso.js"; +import { createMockPage } from "./test-helpers.js"; + +describe("teachable-sso auth module", () => { + let page; + + beforeEach(async () => { + page = createMockPage(); + await login(page, "user@test.com", "pass123", "https://school.teachable.com/sign_in"); + }); + + it("should navigate to login URL", () => { + expect(page.actions[0]).toEqual({ type: "goto", url: "https://school.teachable.com/sign_in" }); + }); + + it("should fill email and password", () => { + const fills = page.actions.filter((a) => a.type === "fill"); + expect(fills).toHaveLength(2); + expect(fills[0].value).toBe("user@test.com"); + expect(fills[1].value).toBe("pass123"); + }); + + it("should click submit once", () => { + const clicks = page.actions.filter((a) => a.type === "click"); + expect(clicks).toHaveLength(1); + }); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/auth/test-helpers.js b/plugins/knowledge/skills/course-digest/extraction/lib/auth/test-helpers.js new file mode 100644 index 000000000..623565a6d --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/auth/test-helpers.js @@ -0,0 +1,30 @@ +export function createMockPage() { + const actions = []; + const mockLocator = (selector) => ({ + fill: async (value) => { + actions.push({ type: "fill", selector, value }); + }, + click: async () => { + actions.push({ type: "click", selector }); + }, + first: () => ({ + click: async () => { + actions.push({ type: "click", selector }); + }, + }), + }); + + return { + actions, + goto: async (url) => { + actions.push({ type: "goto", url }); + }, + waitForTimeout: async (ms) => { + actions.push({ type: "waitForTimeout", ms }); + }, + waitForLoadState: async () => { + actions.push({ type: "waitForLoadState" }); + }, + locator: mockLocator, + }; +} diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/browser.js b/plugins/knowledge/skills/course-digest/extraction/lib/browser.js new file mode 100644 index 000000000..30e61afb6 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/browser.js @@ -0,0 +1,89 @@ +/** + * Shared browser infrastructure for course-extraction scripts. + * + * Consolidates duplicated browser launch, cookie injection, and auth age + * checking from extract-course.js and discover-resources.js. + */ + +import { existsSync, mkdirSync, statSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { chromium } from "playwright"; + +import { injectSavedCookies } from "../utils.js"; + +const DEFAULT_AUTH_WARN_DAYS = 6; + +/** + * Check auth state freshness and warn if stale. + * @param {string} storageStatePath + * @param {object} platformCfg + */ +export function checkAuthAge(storageStatePath, platformCfg) { + if (!existsSync(storageStatePath)) return; + const stat = statSync(storageStatePath); + const ageDays = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24); + const warnDays = platformCfg.authWarnDays ?? DEFAULT_AUTH_WARN_DAYS; + if (ageDays > warnDays) { + const provider = platformCfg.authProvider ?? "platform"; + writeStdout( + ` ⚠ Auth state is ${Math.round(ageDays)} days old (${provider} sessions may have expired).`, + ); + writeStdout(" Re-authentication may be needed.\n"); + } +} + +/** + * Launch a Playwright browser with a fresh temp profile. + * Returns the context, page, auth dir path, and injected cookie count. + * + * @param {object} options + * @param {boolean} [options.headless=true] + * @param {string} [options.storageStatePath] — path to .auth-state.json + * @param {string} [options.profilePrefix="course-extraction"] — temp dir prefix + * @returns {Promise<{browser: import('playwright').Browser, context: import('playwright').BrowserContext, page: import('playwright').Page, authDir: string, cookieCount: number}>} + */ +export async function launchBrowser({ + headless = true, + storageStatePath, + profilePrefix = "course-extraction", +} = {}) { + const authDir = join(tmpdir(), `${profilePrefix}-${Date.now()}`); + mkdirSync(authDir, { recursive: true }); + + // Use browser.launch + newContext instead of launchPersistentContext. + // Persistent contexts handle cross-origin iframe events differently — + // page.on("request"/"response") may not fire for iframe sub-resources. + const browser = await chromium.launch({ + headless, + timeout: 60000, + args: [ + "--disable-blink-features=AutomationControlled", + "--autoplay-policy=no-user-gesture-required", + ], + }); + const context = await browser.newContext(); + const page = await context.newPage(); + + let cookieCount = 0; + if (storageStatePath) { + cookieCount = await injectSavedCookies(context, storageStatePath); + } + + return { browser, context, page, authDir, cookieCount }; +} + +/** + * Close the browser and clean up the temp profile directory. + * @param {import('playwright').BrowserContext} context + * @param {string} authDir + * @param {import('playwright').Browser} [browser] + */ +export async function closeBrowser(context, authDir, browser) { + await context.close(); + if (browser) await browser.close().catch(() => {}); + rm(authDir, { recursive: true, force: true }).catch(() => {}); +} diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/config.js b/plugins/knowledge/skills/course-digest/extraction/lib/config.js new file mode 100644 index 000000000..4acec4655 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/config.js @@ -0,0 +1,51 @@ +/** + * Platform config validation and adapter resolution. + * + * Validates platformConfig at startup (fail-fast) and dynamically imports + * the correct adapter module for the course platform. + */ + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { fail, ok } from "@melodic/video-digestion/shared/result"; + +const REQUIRED_FIELDS = ["videoPlayerSelector", "loginUrl", "authEnvPrefix"]; + +/** + * Validate that platformConfig contains all required fields. + * Returns a Result — fail-fast at startup, not mid-extraction. + * + * @param {object} platformCfg + * @param {string} platform + * @returns {import('@melodic/video-digestion/shared/result').Result} + */ +export function validatePlatformConfig(platformCfg, platform) { + const missing = REQUIRED_FIELDS.filter((f) => !platformCfg[f]); + if (missing.length > 0) { + return fail( + `Platform "${platform}" missing required platformConfig fields: ${missing.join(", ")}`, + "validate-config", + null, + 0, + ); + } + return ok(platformCfg, "validate-config", null, 0); +} + +/** + * Dynamically import an adapter module by platform name. + * Returns the adapter module or null if not found. + * + * @param {string} platform — e.g., "dometrain" + * @returns {Promise} + */ +export async function resolveAdapter(platform) { + const thisDir = dirname(fileURLToPath(import.meta.url)); + const adapterPath = join(thisDir, "..", "adapters", `${platform}.js`); + if (!existsSync(adapterPath)) { + return null; + } + return import(`../adapters/${platform}.js`); +} diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/config.test.js b/plugins/knowledge/skills/course-digest/extraction/lib/config.test.js new file mode 100644 index 000000000..156f2566a --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/config.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAdapter, validatePlatformConfig } from "./config.js"; + +describe("validatePlatformConfig", () => { + const validConfig = { + videoPlayerSelector: "mux-player", + loginUrl: "https://dometrain.com/sign-in", + authEnvPrefix: "DOMETRAIN", + }; + + it("should accept valid config with all required fields", () => { + const result = validatePlatformConfig(validConfig, "dometrain"); + expect(result.success).toBe(true); + }); + + it("should reject missing videoPlayerSelector", () => { + const { videoPlayerSelector, ...cfg } = validConfig; + const result = validatePlatformConfig(cfg, "dometrain"); + expect(result.success).toBe(false); + expect(result.error).toContain("videoPlayerSelector"); + }); + + it("should reject missing loginUrl", () => { + const { loginUrl, ...cfg } = validConfig; + const result = validatePlatformConfig(cfg, "dometrain"); + expect(result.success).toBe(false); + expect(result.error).toContain("loginUrl"); + }); + + it("should reject missing authEnvPrefix", () => { + const { authEnvPrefix, ...cfg } = validConfig; + const result = validatePlatformConfig(cfg, "dometrain"); + expect(result.success).toBe(false); + expect(result.error).toContain("authEnvPrefix"); + }); + + it("should report all missing fields at once", () => { + const result = validatePlatformConfig({}, "dometrain"); + expect(result.success).toBe(false); + expect(result.error).toContain("videoPlayerSelector"); + expect(result.error).toContain("loginUrl"); + expect(result.error).toContain("authEnvPrefix"); + }); +}); + +describe("resolveAdapter", () => { + it("should return null for unknown adapter", async () => { + const result = await resolveAdapter("nonexistent-platform"); + expect(result).toBeNull(); + }); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/players/hotmart.js b/plugins/knowledge/skills/course-digest/extraction/lib/players/hotmart.js new file mode 100644 index 000000000..0007edc3d --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/players/hotmart.js @@ -0,0 +1,596 @@ +/** + * Hotmart HLS video player module. + * + * Extracts all Hotmart-specific player logic: network interception for HLS + * master URLs and subtitle manifests, Video.js/hls.js player introspection, + * subtitle segment fetching from cross-origin iframe context, and canvas-based + * frame extraction. + * + * CRITICAL: All fetch() calls for subtitle segments MUST execute inside + * hotmartFrame.evaluate() — Hotmart's CDN uses CORS restrictions that only + * allow requests from the player.hotmart.com origin. + */ + +import { writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { + parseSubtitleManifest, + processSubtitleSegments, +} from "@melodic/video-digestion/transcript/vtt-parser"; + +const VIDEO_ID_PREFIX = /^(\w+)-\d+-/; +const PNG_DATA_URL_PREFIX = /^data:image\/png;base64,/; + +// --------------------------------------------------------------------------- +// Module-level state (per-session singleton) +// --------------------------------------------------------------------------- + +/** @type {Map} */ +const capturedData = new Map(); + +/** @type {boolean} */ +let interceptorsInstalled = false; + +function getOrCreateEntry(pageUrl) { + const existing = capturedData.get(pageUrl); + if (existing) return existing; + const entry = { hlsMasterUrl: null, subtitleManifestBody: null }; + capturedData.set(pageUrl, entry); + return entry; +} + +function findHotmartFrame(page) { + return page.frames().find((f) => f.url().includes("player.hotmart.com")); +} + +const SUBTITLE_BATCH_SIZE = 15; + +async function fetchSubtitleBatch(hotmartFrame, batch) { + return hotmartFrame.evaluate(async (urls) => { + const responses = await Promise.allSettled( + urls.map(async (url) => { + const resp = await fetch(url); + if (!resp.ok) return { ok: false, status: resp.status }; + const text = await resp.text(); + return { ok: true, text, length: text.length }; + }), + ); + return responses.map((r) => + r.status === "fulfilled" ? r.value : { ok: false, error: "rejected" }, + ); + }, batch); +} + +function collectSegmentBatchResults(results, segmentBodies, fetchFailedRef) { + for (const result of results) { + if (result.ok && result.length > 0) { + segmentBodies.push(result.text); + } else { + fetchFailedRef.count++; + } + } +} + +async function fetchSubtitleSegmentBatches(hotmartFrame, absoluteUrls) { + const segmentBodies = []; + const fetchFailedRef = { count: 0 }; + const batchStarts = []; + for (let i = 0; i < absoluteUrls.length; i += SUBTITLE_BATCH_SIZE) { + batchStarts.push(i); + } + + await batchStarts.reduce(async (chain, start) => { + await chain; + const batch = absoluteUrls.slice(start, start + SUBTITLE_BATCH_SIZE); + const results = await fetchSubtitleBatch(hotmartFrame, batch); + collectSegmentBatchResults(results, segmentBodies, fetchFailedRef); + }, Promise.resolve()); + + return { segmentBodies, fetchFailed: fetchFailedRef.count }; +} + +async function captureCanvasFrame(hotmartFrame, seekTime, maxWidth) { + return hotmartFrame + .evaluate( + async ({ seekTime: time, mw }) => { + const v = document.querySelector("video"); + if (!v) return null; + + v.currentTime = time; + await new Promise((resolve) => { + const handler = () => { + v.removeEventListener("seeked", handler); + resolve(); + }; + v.addEventListener("seeked", handler); + setTimeout(resolve, 3000); + }); + await new Promise((r) => setTimeout(r, 200)); + + const canvas = document.createElement("canvas"); + canvas.width = Math.min(v.videoWidth, mw); + canvas.height = Math.round((canvas.width / v.videoWidth) * v.videoHeight); + const ctx2d = canvas.getContext("2d"); + ctx2d.drawImage(v, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL("image/png"); + }, + { seekTime, mw: maxWidth }, + ) + .catch(() => null); +} + +async function captureCanvasFrames({ hotmartFrame, timestamps, outputDir, maxWidth, fs }) { + let success = 0; + + await timestamps.reduce(async (chain, seekTime) => { + await chain; + const frameData = await captureCanvasFrame(hotmartFrame, seekTime, maxWidth); + if (frameData?.startsWith("data:image/png")) { + const base64 = frameData.replace(PNG_DATA_URL_PREFIX, ""); + const buffer = Buffer.from(base64, "base64"); + const outFile = fs.join(outputDir, `interval_${String(success + 1).padStart(4, "0")}.png`); + fs.writeFileSync(outFile, buffer); + success++; + } + }, Promise.resolve()); + + return success; +} + +function mergeHlsFields(target, source) { + for (const key of [ + "masterUrl", + "masterUrlFull", + "subtitlePlaylistUrl", + "subtitlePlaylistUrlFull", + "hasVjs", + "hasHlsJs", + ]) { + if (source[key]) target[key] = source[key]; + } +} + +async function readVideoSrcHls(hotmartFrame) { + return hotmartFrame.evaluate(() => { + const v = document.querySelector("video"); + if (!v) return { error: "no video" }; + const src = v.src || v.currentSrc; + if (!src?.includes(".m3u8")) { + return { src: src?.substring(0, 120), hasSrcM3u8: false }; + } + return { + src: src.substring(0, 120), + hasSrcM3u8: true, + masterUrlFull: src, + masterUrl: src.split("?")[0], + }; + }); +} + +async function readVjsHls(hotmartFrame, subtitleLang) { + return hotmartFrame.evaluate((lang) => { + const v = document.querySelector("video"); + const vjsEl = v?.closest(".video-js"); + const vjsPlayer = vjsEl?.__vjs_player__ ?? vjsEl?.player; + if (!vjsPlayer) return {}; + + const result = { hasVjs: true }; + try { + const tech = vjsPlayer.tech({ IWillNotUseThisInPlugins: true }); + const vhs = tech?.vhs || tech?.hls; + if (vhs?.playlists?.master?.uri) { + result.masterUrlFull = vhs.playlists.master.uri; + result.masterUrl = vhs.playlists.master.uri.split("?")[0]; + } + const master = + vhs?.playlists?.master || vhs?.masterPlaylistController_?.mainPlaylistLoader_?.master; + if (master?.mediaGroups?.SUBTITLES) { + for (const group of Object.values(master.mediaGroups.SUBTITLES)) { + for (const [key, track] of Object.entries(group)) { + if (key.toLowerCase().includes(lang) || track.language?.includes(lang)) { + result.subtitlePlaylistUrlFull = track.resolvedUri || track.uri; + result.subtitlePlaylistUrl = result.subtitlePlaylistUrlFull?.split("?")[0]; + return result; + } + } + } + } + } catch { + /* ignore tech access errors */ + } + return result; + }, subtitleLang); +} + +async function readHlsJsData(hotmartFrame, subtitleLang) { + return hotmartFrame.evaluate((lang) => { + const v = document.querySelector("video"); + const hlsInstance = v?._hls || window.hls || window.Hls?.instances?.[0]; + if (!hlsInstance) return {}; + + const result = { hasHlsJs: true }; + try { + if (hlsInstance.url?.includes(".m3u8")) { + result.masterUrlFull = hlsInstance.url; + result.masterUrl = hlsInstance.url.split("?")[0]; + } + for (const track of hlsInstance.subtitleTracks || []) { + if (track.lang?.includes(lang) || track.name?.toLowerCase()?.includes(lang)) { + result.subtitlePlaylistUrlFull = track.url; + result.subtitlePlaylistUrl = track.url?.split("?")[0]; + break; + } + } + } catch { + /* ignore */ + } + return result; + }, subtitleLang); +} + +async function scanWindowHls(hotmartFrame, subtitleLang) { + return hotmartFrame.evaluate((lang) => { + for (const key of Object.keys(window)) { + try { + const obj = window[key]; + if (!obj?.url?.includes?.(".m3u8") || typeof obj.destroy !== "function") continue; + const result = { + hasHlsJs: true, + masterUrlFull: obj.url, + masterUrl: obj.url.split("?")[0], + }; + for (const t of obj.subtitleTracks || []) { + if (t.lang?.includes(lang)) { + result.subtitlePlaylistUrlFull = t.url; + result.subtitlePlaylistUrl = t.url?.split("?")[0]; + break; + } + } + return result; + } catch { + /* ignore property access errors */ + } + } + return {}; + }, subtitleLang); +} + +async function readHlsDataFromFrame(hotmartFrame, subtitleLang) { + try { + const base = await readVideoSrcHls(hotmartFrame); + if (base.error) return base; + + const result = { + src: base.src, + hasSrcM3u8: base.hasSrcM3u8, + hasVjs: false, + hasHlsJs: false, + masterUrl: base.masterUrl ?? null, + masterUrlFull: base.masterUrlFull ?? null, + subtitlePlaylistUrl: null, + subtitlePlaylistUrlFull: null, + }; + + mergeHlsFields(result, await readVjsHls(hotmartFrame, subtitleLang)); + mergeHlsFields(result, await readHlsJsData(hotmartFrame, subtitleLang)); + if (!result.masterUrlFull) { + mergeHlsFields(result, await scanWindowHls(hotmartFrame, subtitleLang)); + } + + return result; + } catch (e) { + return { error: `evaluate failed: ${e.message}` }; + } +} + +async function storeSubtitleManifest(hotmartFrame, hlsData, currentUrl) { + if (!hlsData.subtitlePlaylistUrlFull) return null; + + const manifestBody = await hotmartFrame.evaluate(async (url) => { + const resp = await fetch(url); + if (!resp.ok) return null; + return resp.text(); + }, hlsData.subtitlePlaylistUrlFull); + + if (!manifestBody) return null; + + const segCount = parseSubtitleManifest(manifestBody).length; + writeStdout(` ✓ Subtitle manifest fetched: ${segCount} segments`); + + const entry = getOrCreateEntry(currentUrl); + entry.hlsMasterUrl = hlsData.masterUrlFull; + entry.subtitleManifestBody = manifestBody; + + return segCount; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Install page.on("request") and page.on("response") interceptors for + * HLS master URL and subtitle manifest capture. + * + * Safe to call multiple times — installs only once per module lifetime. + * + * @param {import('playwright').Page} page + * @param {string} subtitleLang — e.g. "eng" + */ +export function installInterceptors(page, subtitleLang) { + if (interceptorsInstalled) return; + + page.on("request", (request) => { + const url = request.url(); + if (url.includes("master-pkg-t-") && url.includes(".m3u8")) { + getOrCreateEntry(page.url()).hlsMasterUrl = url; + } + }); + + page.on("response", async (response) => { + const url = response.url(); + if (response.status() !== 200) return; + + try { + if (url.includes("master-pkg-t-") && url.includes(".m3u8")) { + getOrCreateEntry(page.url()).hlsMasterUrl = url; + } + + if (url.includes(`textstream_${subtitleLang}`) && url.includes(".m3u8")) { + const body = await response.text(); + getOrCreateEntry(page.url()).subtitleManifestBody = body; + } + } catch { + // Response body may not be available for all intercepted responses + } + }); + + interceptorsInstalled = true; +} + +/** + * Prepare a lesson page for extraction: find iframe, read HLS data from + * Video.js/hls.js internals, fetch subtitle manifest. + * + * @param {import('playwright').Page} page + * @param {string} subtitleLang — e.g. "eng" + * @param {number} manifestTimeoutMs + * @returns {Promise<{ hasVideo: boolean, hotmartFrame?: object, hlsMasterUrl?: string, subtitleSegments?: number, warning?: string }>} + */ +export async function preparePage(page, subtitleLang, _manifestTimeoutMs) { + const currentUrl = page.url(); + + capturedData.delete(currentUrl); + + const hasHotmart = await hasHotmartPlayer(page); + + writeStdout(` hasHotmart: ${hasHotmart}`); + + if (!hasHotmart) { + return { hasVideo: false, hotmartFrame: null }; + } + + const allFrames = page.frames(); + writeStdout( + ` Frames: ${allFrames.length} — ${allFrames.map((f) => f.url().split("?")[0].substring(0, 50)).join(", ")}`, + ); + const hotmartFrame = findHotmartFrame(page); + + if (!hotmartFrame) { + throw new Error("Hotmart video player detected in DOM but iframe not accessible."); + } + writeStdout(" ✓ Hotmart iframe found."); + + await hotmartFrame.waitForTimeout(2000); + + const videoState = await hotmartFrame + .evaluate(() => { + const v = document.querySelector("video"); + return v ? { paused: v.paused, currentTime: v.currentTime, readyState: v.readyState } : null; + }) + .catch(() => null); + + writeStdout(` Video state: ${JSON.stringify(videoState)}`); + + if (!videoState) { + throw new Error("No