import { parse } from 'node-html-parser'; const UA = 'assemblinker/0.0.1 (https://tangled.org/bmann.ca/assemblinker)'; export interface PageData { url: string; title?: string; description?: string; links: string[]; } export async function fetchPage(url: string): Promise<{ html: string; finalUrl: string }> { const response = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(15000), redirect: 'follow', }); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } const finalUrl = response.url || url; const html = await response.text(); return { html, finalUrl }; } export function parsePage(sourceUrl: string, html: string, finalUrl?: string): PageData { const root = parse(html, { comment: false }); const baseUrl = finalUrl ?? sourceUrl; const baseHref = root.querySelector('base[href]')?.getAttribute('href'); const resolveBase = baseHref ? new URL(baseHref, baseUrl).href : baseUrl; const title = root.querySelector('meta[property="og:title"]')?.getAttribute('content') || root.querySelector('title')?.text || undefined; const description = root.querySelector('meta[property="og:description"]')?.getAttribute('content') || root.querySelector('meta[name="description"]')?.getAttribute('content') || bodyExcerpt(root) || undefined; const sourceHostname = new URL(sourceUrl).hostname; const seen = new Set(); const links: string[] = []; for (const anchor of root.querySelectorAll('a[href]')) { const href = anchor.getAttribute('href'); if (!href) continue; try { const resolved = new URL(href, resolveBase); if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') continue; resolved.hash = ''; const normalized = resolved.href; if (seen.has(normalized)) continue; if (resolved.hostname === sourceHostname) continue; seen.add(normalized); links.push(normalized); } catch { // ignore malformed URLs } } return { url: baseUrl, title: title?.trim() || undefined, description: description?.trim() || undefined, links, }; } function bodyExcerpt(root: ReturnType): string | undefined { const body = root.querySelector('body'); if (!body) return; for (const tag of body.querySelectorAll('script, style, noscript, iframe, nav, footer, header')) { tag.remove(); } const text = body.text .replace(/\s+/g, ' ') .trim() .slice(0, 300) .trim(); return text || undefined; } export async function getPageData(url: string): Promise { const { html, finalUrl } = await fetchPage(url); return parsePage(url, html, finalUrl); } export async function getLinkMetadata(url: string): Promise> { try { const { title, description } = await getPageData(url); return { title, description }; } catch { return {}; } }