import { parseHTML } from 'linkedom'; export interface ExtractionResult { matches: string[]; count: number; method: string; } export type TableRow = Record; export interface PageMetadata { title: string | null; description: string | null; keywords: string | null; ogTitle: string | null; ogDescription: string | null; ogImage: string | null; ogUrl: string | null; ogType: string | null; ogSiteName: string | null; ogLocale: string | null; twitterCard: string | null; twitterTitle: string | null; twitterDescription: string | null; twitterImage: string | null; twitterSite: string | null; canonical: string | null; robots: string | null; hreflang: Array<{ lang: string; href: string }>; themeColor: string | null; favicon: string | null; jsonLd: object[]; schemaTypes: string[]; lang: string | null; } export function extractByCssSelector(html: string, selector: string, attr?: string): ExtractionResult { try { const { document } = parseHTML(html); const nodes = Array.from(document.querySelectorAll(selector)); const matches = nodes.map((n: any) => { if (attr) return (n as any).getAttribute(attr) ?? ''; return (n as any).textContent?.trim() ?? ''; }).filter(Boolean); return { matches, count: matches.length, method: 'css-selector' }; } catch (e) { return { matches: [], count: 0, method: 'css-selector-error' }; } } export function extractByXPath(html: string, xpath: string): ExtractionResult { try { const { document, XPathResult } = parseHTML(html) as any; const result = document.evaluate(xpath, document, null, XPathResult?.ANY_TYPE ?? 0, null); const matches: string[] = []; let node = result.iterateNext?.(); while (node) { const text = (node as any).textContent?.trim() ?? (node as any).nodeValue?.trim() ?? ''; if (text) matches.push(text); node = result.iterateNext?.(); } return { matches, count: matches.length, method: 'xpath' }; } catch (e) { return { matches: [], count: 0, method: 'xpath-error' }; } } export function extractByRegex(text: string, pattern: string, flags?: string): ExtractionResult { try { const re = new RegExp(pattern, (flags ?? 'g').includes('g') ? flags : (flags ?? '') + 'g'); const matches: string[] = []; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { matches.push(m[1] ?? m[0]); if (!re.global) break; } return { matches, count: matches.length, method: 'regex' }; } catch { return { matches: [], count: 0, method: 'regex-error' }; } } export function extractTables(html: string): TableRow[][] { try { const { document } = parseHTML(html); const tables = Array.from(document.querySelectorAll('table')); return tables.map((table: any) => { const headers: string[] = []; const rows: TableRow[] = []; const ths = [...table.querySelectorAll('thead th, thead td, tr:first-child th')]; if (ths.length > 0) { ths.forEach((th: any, i: number) => headers.push(th.textContent?.trim() || `col${i + 1}`)); } const trs = [...table.querySelectorAll('tbody tr, tr')]; for (const tr of trs) { const cells = [...(tr as any).querySelectorAll('td, th')]; if (cells.length === 0) continue; if (headers.length === 0) { cells.forEach((_: any, i: number) => headers.push(`col${i + 1}`)); } const row: TableRow = {}; cells.forEach((cell: any, i: number) => { const key = headers[i] || `col${i + 1}`; row[key] = cell.textContent?.trim() ?? ''; }); if (Object.values(row).some(v => v)) rows.push(row); } return rows; }).filter(t => t.length > 0); } catch { return []; } } export function extractMetadata(html: string, url: string): PageMetadata { const meta: PageMetadata = { title: null, description: null, keywords: null, ogTitle: null, ogDescription: null, ogImage: null, ogUrl: null, ogType: null, ogSiteName: null, ogLocale: null, twitterCard: null, twitterTitle: null, twitterDescription: null, twitterImage: null, twitterSite: null, canonical: null, robots: null, hreflang: [], themeColor: null, favicon: null, jsonLd: [], schemaTypes: [], lang: null, }; try { const { document } = parseHTML(html); meta.title = (document as any).title ?? (document as any).querySelector('title')?.textContent?.trim() ?? null; meta.lang = (document as any).documentElement?.getAttribute('lang') ?? null; const metas = [...(document as any).querySelectorAll('meta')]; for (const m of metas) { const name = (m as any).getAttribute('name')?.toLowerCase() ?? ''; const prop = (m as any).getAttribute('property')?.toLowerCase() ?? ''; const content = (m as any).getAttribute('content') ?? ''; if (name === 'description') meta.description = content; if (name === 'keywords') meta.keywords = content; if (name === 'robots') meta.robots = content; if (name === 'twitter:card') meta.twitterCard = content; if (name === 'twitter:title') meta.twitterTitle = content; if (name === 'twitter:description') meta.twitterDescription = content; if (name === 'twitter:image') meta.twitterImage = content; if (name === 'twitter:site') meta.twitterSite = content; if (name === 'theme-color') meta.themeColor = content; if (prop === 'og:title') meta.ogTitle = content; if (prop === 'og:description') meta.ogDescription = content; if (prop === 'og:image') meta.ogImage = content; if (prop === 'og:url') meta.ogUrl = content; if (prop === 'og:type') meta.ogType = content; if (prop === 'og:site_name') meta.ogSiteName = content; if (prop === 'og:locale') meta.ogLocale = content; } const canonical = (document as any).querySelector('link[rel="canonical"]'); if (canonical) meta.canonical = canonical.getAttribute('href') ?? null; const faviconEl = (document as any).querySelector('link[rel="icon"], link[rel="shortcut icon"]'); if (faviconEl) meta.favicon = faviconEl.getAttribute('href') ?? null; const hreflangEls = [...(document as any).querySelectorAll('link[rel="alternate"][hreflang]')]; for (const el of hreflangEls) { const lang = (el as any).getAttribute('hreflang') ?? ''; const href = (el as any).getAttribute('href') ?? ''; if (lang && href) meta.hreflang.push({ lang, href }); } const jsonLdScripts = [...(document as any).querySelectorAll('script[type="application/ld+json"]')]; for (const s of jsonLdScripts) { try { const parsed = JSON.parse((s as any).textContent ?? '{}'); meta.jsonLd.push(parsed); const types = Array.isArray(parsed) ? parsed.map((p: any) => p['@type']).flat() : [parsed['@type']]; meta.schemaTypes.push(...types.filter(Boolean)); } catch { /* skip invalid JSON-LD */ } } } catch { /* non-fatal */ } return meta; } export interface ProductData { name: string | null; description: string | null; brand: string | null; sku: string | null; price: number | null; priceCurrency: string | null; availability: string | null; rating: number | null; reviewCount: number | null; image: string | null; url: string | null; } export function extractStructuredProduct(jsonLd: object[]): ProductData | null { for (const item of jsonLd) { try { const type = (item as any)['@type']; const isProduct = Array.isArray(type) ? type.some((t: any) => typeof t === 'string' && t.toLowerCase() === 'product') : typeof type === 'string' && type.toLowerCase() === 'product'; if (!isProduct) continue; const i = item as any; const offers = Array.isArray(i.offers) ? i.offers[0] : i.offers; const rawPrice = offers?.price; const price = rawPrice != null ? parseFloat(rawPrice) : NaN; const rawRating = i.aggregateRating?.ratingValue; const rating = rawRating != null ? parseFloat(rawRating) : NaN; const rawReviewCount = i.aggregateRating?.reviewCount; const reviewCount = rawReviewCount != null ? parseInt(rawReviewCount, 10) : NaN; const rawAvailability: string | undefined = offers?.availability; const availability = rawAvailability ? rawAvailability.replace(/^https?:\/\/schema\.org\//, '') : null; const rawImage = i.image; const image = Array.isArray(rawImage) ? (rawImage[0] ?? null) : (rawImage ?? null); const brand = i.brand?.name ?? i.brand ?? null; return { name: i.name ?? null, description: i.description ?? null, brand: typeof brand === 'string' ? brand : null, sku: i.sku ?? null, price: isNaN(price) ? null : price, priceCurrency: offers?.priceCurrency ?? null, availability, rating: isNaN(rating) ? null : rating, reviewCount: isNaN(reviewCount) ? null : reviewCount, image, url: i.url ?? null, }; } catch { /* skip malformed item */ } } return null; } export function extractImages(html: string, baseUrl: string): Array<{ src: string; alt: string; width?: number; height?: number }> { try { const { document } = parseHTML(html); const base = new URL(baseUrl); return Array.from((document as any).querySelectorAll('img')) .map((img: any) => { const src = img.getAttribute('src') ?? img.getAttribute('data-src') ?? ''; if (!src) return null; try { return { src: new URL(src, base).href, alt: (img.getAttribute('alt') ?? '') as string, width: img.getAttribute('width') ? parseInt(img.getAttribute('width')) : undefined, height: img.getAttribute('height') ? parseInt(img.getAttribute('height')) : undefined, } as { src: string; alt: string; width?: number; height?: number }; } catch { return null; } }) .filter((x): x is { src: string; alt: string; width?: number; height?: number } => x !== null); } catch { return []; } } export function extractMediaFiles(html: string, baseUrl: string): Array<{ url: string; type: 'image' | 'video' | 'audio' | 'document'; tag: string }> { const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|ico|avif)(\?.*)?$/i; const VIDEO_EXT = /\.(mp4|webm|ogv|mov|avi)(\?.*)?$/i; const AUDIO_EXT = /\.(mp3|ogg|wav|flac|aac)(\?.*)?$/i; const DOC_EXT = /\.(pdf|docx?|xlsx?|pptx?|csv|zip)(\?.*)?$/i; function classify(url: string, tag: string): 'image' | 'video' | 'audio' | 'document' { if (tag === 'img' || IMAGE_EXT.test(url)) return 'image'; if (tag === 'video' || VIDEO_EXT.test(url)) return 'video'; if (tag === 'audio' || AUDIO_EXT.test(url)) return 'audio'; if (DOC_EXT.test(url)) return 'document'; return 'document'; } try { const { document } = parseHTML(html); const base = new URL(baseUrl); const results: Array<{ url: string; type: 'image' | 'video' | 'audio' | 'document'; tag: string }> = []; const seen = new Set(); const selectors: [string, string] = ['img[src],video[src],audio[src],source[src]', 'src']; const attrPairs: Array<[string, string]> = [ ['img', 'src'], ['video', 'src'], ['audio', 'src'], ['source', 'src'], ['a', 'href'], ]; for (const [sel, attr] of attrPairs) { for (const el of [...(document as any).querySelectorAll(sel)]) { const raw = (el as any).getAttribute(attr) ?? ''; if (!raw) continue; try { const resolved = new URL(raw, base).href; if (seen.has(resolved)) continue; if (sel === 'a' && !DOC_EXT.test(resolved) && !IMAGE_EXT.test(resolved) && !VIDEO_EXT.test(resolved) && !AUDIO_EXT.test(resolved)) continue; seen.add(resolved); results.push({ url: resolved, type: classify(resolved, sel), tag: sel }); } catch { /* skip */ } } } return results; } catch { return []; } }