Skill Tracer
Skills › gstack › browse › media-extract.ts

media-extract.ts

177 lines · browse/src/media-extract.ts at 3f79d81

1/**
2 * Media extraction helper — shared between `media` (read) and `scrape` (write) commands.
3 *
4 * Runs page.evaluate() to discover all media elements on the page:
5 * - <img> with src, srcset, currentSrc, alt, dimensions, loading, data-src
6 * - <video> with currentSrc, poster, duration, <source> children, HLS/DASH detection
7 * - <audio> with src, duration, type
8 * - CSS background-image (capped at 500 elements)
9 */
11import type { Page, Frame } from 'playwright';
13export interface ImageInfo {
14 index: number;
15 src: string;
16 srcset: string;
17 currentSrc: string;
18 alt: string;
19 width: number;
20 height: number;
21 naturalWidth: number;
22 naturalHeight: number;
23 loading: string;
24 dataSrc: string;
25 visible: boolean;
28export interface VideoSource {
29 src: string;
30 type: string;
33export interface VideoInfo {
34 index: number;
35 src: string;
36 currentSrc: string;
37 poster: string;
38 width: number;
39 height: number;
40 duration: number;
41 type: string;
42 sources: VideoSource[];
43 isHLS: boolean;
44 isDASH: boolean;
47export interface AudioInfo {
48 index: number;
49 src: string;
50 currentSrc: string;
51 duration: number;
52 type: string;
55export interface BackgroundImageInfo {
56 index: number;
57 url: string;
58 selector: string;
59 element: string;
62export interface MediaResult {
63 images: ImageInfo[];
64 videos: VideoInfo[];
65 audio: AudioInfo[];
66 backgroundImages: BackgroundImageInfo[];
67 total: number;
70/** Extract all media elements from the page or a scoped subtree. */
71export async function extractMedia(
72 target: Page | Frame,
73 options?: { selector?: string; filter?: 'images' | 'videos' | 'audio' },
74): Promise<MediaResult> {
75 const result = await target.evaluate(({ scopeSelector, filter }) => {
76 const root = scopeSelector
77 ? document.querySelector(scopeSelector) || document
78 : document;
80 const images: any[] = [];
81 const videos: any[] = [];
82 const audio: any[] = [];
83 const backgroundImages: any[] = [];
85 // Images
86 if (!filter || filter === 'images') {
87 const imgs = root.querySelectorAll('img');
88 imgs.forEach((img, i) => {
89 const rect = img.getBoundingClientRect();
90 images.push({
91 index: i,
92 src: img.src || '',
93 srcset: img.srcset || '',
94 currentSrc: img.currentSrc || '',
95 alt: img.alt || '',
96 width: img.width,
97 height: img.height,
98 naturalWidth: img.naturalWidth,
99 naturalHeight: img.naturalHeight,
100 loading: img.loading || '',
101 dataSrc: img.getAttribute('data-src') || img.getAttribute('data-lazy-src') || img.getAttribute('data-original') || '',
102 visible: rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0,
103 });
104 });
105 }
107 // Videos
108 if (!filter || filter === 'videos') {
109 const vids = root.querySelectorAll('video');
110 vids.forEach((vid, i) => {
111 const sources = Array.from(vid.querySelectorAll('source')).map(s => ({
112 src: s.src || '',
113 type: s.type || '',
114 }));
115 const isHLS = sources.some(s => s.type.includes('mpegURL') || s.src.includes('.m3u8'));
116 const isDASH = sources.some(s => s.type.includes('dash') || s.src.includes('.mpd'));
117 videos.push({
118 index: i,
119 src: vid.src || '',
120 currentSrc: vid.currentSrc || '',
121 poster: vid.poster || '',
122 width: vid.videoWidth || vid.width,
123 height: vid.videoHeight || vid.height,
124 duration: isFinite(vid.duration) ? vid.duration : 0,
125 type: sources[0]?.type || '',
126 sources,
127 isHLS,
128 isDASH,
129 });
130 });
131 }
133 // Audio
134 if (!filter || filter === 'audio') {
135 const auds = root.querySelectorAll('audio');
136 auds.forEach((aud, i) => {
137 const source = aud.querySelector('source');
138 audio.push({
139 index: i,
140 src: aud.src || source?.src || '',
141 currentSrc: aud.currentSrc || '',
142 duration: isFinite(aud.duration) ? aud.duration : 0,
143 type: source?.type || '',
144 });
145 });
146 }
148 // Background images (capped at 500 elements for performance)
149 if (!filter || filter === 'images') {
150 const allElements = root.querySelectorAll('*');
151 let bgCount = 0;
152 for (let i = 0; i < allElements.length && bgCount < 500; i++) {
153 const el = allElements[i];
154 const bg = getComputedStyle(el).backgroundImage;
155 if (bg && bg !== 'none') {
156 const urlMatch = bg.match(/url\(["']?([^"')]+)["']?\)/);
157 if (urlMatch && urlMatch[1] && !urlMatch[1].startsWith('data:')) {
158 backgroundImages.push({
159 index: bgCount,
160 url: urlMatch[1],
161 selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).join('.') : ''),
162 element: el.tagName.toLowerCase(),
163 });
164 bgCount++;
165 }
166 }
167 }
168 }
170 return { images, videos, audio, backgroundImages };
171 }, { scopeSelector: options?.selector || null, filter: options?.filter || null });
173 return {
174 ...result,
175 total: result.images.length + result.videos.length + result.audio.length + result.backgroundImages.length,
176 };