spb/social-runtime-crawler
Public
TypeScript 91.8%
HTML 3.2%
JavaScript 3%
SQL 1.4%
CSS 0.7%
1import fs from "node:fs";2import path from "node:path";3import type { Page } from "playwright";4import { createLogger, shortHash, sleep, type MediaLevel, type ObservedEntity, type ObservedMedia, type Platform, type Provenance } from "@src/shared";5import type { ClassifiedResponse, DomSnapshot } from "@src/observers";67const log = createLogger("media");89/**10 * Media Intelligence Layer (§17–§19, §47). Video detection fuses three surfaces:11 * - DOM <video> elements (playback state, dimensions, duration)12 * - network media manifests / segments (delivery kind + CDN hostnames)13 * - entities of type video (platform id, title, author, thumbnail)14 * Frame sampling (LEVEL 2) captures a few element screenshots at fixed positions — no download of the artifact.15 */16export function detectVideos(opts: { platform: Platform; snapshot: DomSnapshot; entities: ObservedEntity[]; responses: ClassifiedResponse[]; pageUrl: string }): ObservedMedia[] {17 const out = new Map<string, ObservedMedia>();18 const mediaResponses = opts.responses.filter((r) => r.kind === "media_manifest" || r.kind === "media_segment");19 const hostnames = [...new Set(mediaResponses.map((r) => r.fingerprint.hostname))];20 const manifest = mediaResponses.find((r) => r.kind === "media_manifest");21 const deliveryKind: "progressive" | "hls" | "dash" | "unknown" = manifest22 ? /m3u8|mpegurl/i.test(manifest.response.url + manifest.response.content_type)23 ? "hls"24 : /mpd|dash/i.test(manifest.response.url + manifest.response.content_type)25 ? "dash"26 : "unknown"27 : mediaResponses.length28 ? "progressive"29 : "unknown";3031 // 1) Current video (the one playing / present in DOM) — bind it to the current-page video entity when there is one.32 const current = opts.entities.find((e) => e.type === "video" && (e.context === "current video" || (e.url && samePage(e.url, opts.pageUrl))));33 for (const v of opts.snapshot.video_elements) {34 if (!(v.width > 0 || v.duration || v.current_src)) continue;35 const id = current?.platform_id ?? shortHash(v.current_src ?? v.src ?? opts.pageUrl, 12);36 const fp = `${opts.platform}:video:${id}`;37 const prov: Provenance[] = [{ surface: "dom", confidence: 0.9, detail: "video element" }];38 if (mediaResponses.length) prov.push({ surface: "network", confidence: 0.85, detail: `${mediaResponses.length} media responses` });39 if (current) prov.push(...current.provenance);40 out.set(fp, {41 media_type: "video",42 platform: opts.platform,43 platform_media_id: current?.platform_id ?? undefined,44 url: v.current_src ?? v.src,45 page_url: opts.pageUrl,46 title: current?.name ?? opts.snapshot.h1,47 author: current?.author,48 duration_s: v.duration ?? current?.media?.duration_s,49 width: v.width || undefined,50 height: v.height || undefined,51 thumbnail_url: v.poster ?? current?.media?.thumbnail_url,52 delivery: { kind: deliveryKind, manifest_url: manifest?.response.url, hostnames },53 fingerprint: fp,54 provenance: prov,55 });56 }57 // 2) Video entities visible on the page (feed cards, search results): metadata-level media records.58 for (const e of opts.entities) {59 if (e.type !== "video" || out.has(e.fingerprint)) continue;60 out.set(e.fingerprint, {61 media_type: "video",62 platform: opts.platform,63 platform_media_id: e.platform_id,64 page_url: e.url,65 title: e.name,66 author: e.author,67 duration_s: e.media?.duration_s,68 thumbnail_url: e.media?.thumbnail_url,69 fingerprint: e.fingerprint,70 provenance: e.provenance,71 });72 }73 return [...out.values()];74}7576function samePage(a: string, b: string): boolean {77 try {78 const ua = new URL(a);79 const ub = new URL(b);80 return ua.pathname === ub.pathname && ua.searchParams.get("v") === ub.searchParams.get("v");81 } catch {82 return false;83 }84}8586/**87 * VideoFrameSampler (§18): LEVEL ≥ 2 — screenshot the <video> element at 0 / 25 / 50 / 75 / 100 %.88 * Seeking a video the account is legitimately watching is normal viewer behaviour.89 * Returns file paths; perceptual hashing is left as a follow-up (MediaDeduplicator seam).90 */91export async function sampleVideoFrames(page: Page, media: ObservedMedia, mediaDir: string, level: MediaLevel, maxFrames = 5): Promise<string[]> {92 if (level < 2) return [];93 const video = page.locator("video").first();94 if (!(await video.isVisible().catch(() => false))) return [];95 const dir = path.join(mediaDir, media.platform, media.platform_media_id ?? shortHash(media.fingerprint, 10));96 fs.mkdirSync(dir, { recursive: true });97 const duration = media.duration_s ?? (await video.evaluate((v: HTMLVideoElement) => (Number.isFinite(v.duration) ? v.duration : 0)).catch(() => 0));98 const positions = [0, 0.25, 0.5, 0.75, 0.98].slice(0, maxFrames);99 const files: string[] = [];100 const deadline = Date.now() + 20_000; // frame sampling is best-effort and bounded101 for (const p of positions) {102 if (Date.now() > deadline) break;103 try {104 if (duration > 2) {105 await video.evaluate((v: HTMLVideoElement, t: number) => {106 v.currentTime = t;107 }, Math.min(duration - 0.5, duration * p));108 await sleep(600);109 }110 const file = path.join(dir, `frame_${Math.round(p * 100)}.jpg`);111 await video.screenshot({ path: file, type: "jpeg", quality: 70, timeout: 5000 });112 files.push(file);113 if (duration <= 2) break;114 } catch (err) {115 log.debug("frame capture failed", { err: (err as Error).message });116 }117 }118 return files;119}120