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