@ariangibson/firecrawl-lite-mcp-server 1.1.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ // Browser session management shared by scraping and screenshots.
2
+ //
3
+ // Owns everything that happens between "we have a URL" and "we have a
4
+ // navigated, stealth-configured page": launch flags, proxy and user-agent
5
+ // setup, anti-detection shims, pre-navigation delay, and guaranteed browser
6
+ // cleanup. Callers supply only the work to do on the page.
7
+ //
8
+ // The browser itself is injected (`BrowserDeps.launchBrowser`) so the whole
9
+ // flow can be exercised in tests with a stub; the default launches stealth
10
+ // Puppeteer.
11
+ // --- Default (real) dependencies -------------------------------------------
12
+ // Puppeteer will be loaded dynamically to handle mixed module issues
13
+ let puppeteer;
14
+ // Initialize puppeteer modules and ensure Chrome is available
15
+ export async function initializePuppeteer() {
16
+ if (puppeteer)
17
+ return;
18
+ const puppeteerExtra = await import('puppeteer-extra');
19
+ const stealthPlugin = await import('puppeteer-extra-plugin-stealth');
20
+ puppeteer = puppeteerExtra.default;
21
+ // Configure puppeteer-extra with stealth plugin
22
+ puppeteer.use(stealthPlugin.default());
23
+ // Check if Chrome is available, install if needed
24
+ try {
25
+ await puppeteer.executablePath();
26
+ }
27
+ catch {
28
+ console.error('Chrome not found, attempting to install...');
29
+ const { execSync } = await import('child_process');
30
+ try {
31
+ execSync('npx puppeteer browsers install chrome', { stdio: 'inherit' });
32
+ console.error('Chrome installation completed successfully');
33
+ }
34
+ catch {
35
+ console.error('Failed to install Chrome automatically. Please run: npx puppeteer browsers install chrome');
36
+ throw new Error('Chrome browser not found and automatic installation failed');
37
+ }
38
+ }
39
+ }
40
+ export async function launchPuppeteer(options) {
41
+ await initializePuppeteer();
42
+ return puppeteer.launch(options);
43
+ }
44
+ export function sleep(ms) {
45
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
+ }
47
+ export const defaultBrowserDeps = { launchBrowser: launchPuppeteer, sleep };
48
+ // Puppeteer launch flags with anti-detection tweaks.
49
+ // SECURITY: --disable-web-security is deliberately absent; it is a major risk.
50
+ export function buildLaunchArgs(userAgent, proxyUrl) {
51
+ const args = [
52
+ '--no-sandbox',
53
+ '--disable-setuid-sandbox',
54
+ '--disable-dev-shm-usage',
55
+ '--disable-accelerated-2d-canvas',
56
+ '--no-first-run',
57
+ '--no-zygote',
58
+ '--disable-gpu',
59
+ '--disable-features=VizDisplayCompositor',
60
+ '--disable-blink-features=AutomationControlled',
61
+ '--disable-extensions-except',
62
+ '--disable-plugins-discovery',
63
+ '--no-default-browser-check',
64
+ '--no-experiments',
65
+ '--disable-default-apps',
66
+ '--disable-sync',
67
+ '--disable-translate',
68
+ '--hide-scrollbars',
69
+ '--mute-audio',
70
+ '--no-pings',
71
+ '--no-session-id',
72
+ '--disable-background-timer-throttling',
73
+ '--disable-backgrounding-occluded-windows',
74
+ '--disable-renderer-backgrounding',
75
+ `--user-agent=${userAgent}`,
76
+ ];
77
+ if (proxyUrl)
78
+ args.push(`--proxy-server=${proxyUrl}`);
79
+ return args;
80
+ }
81
+ export function randomBetween(minMs, maxMs) {
82
+ return Math.floor(Math.random() * (maxMs - minMs)) + minMs;
83
+ }
84
+ // Launch a stealth browser, navigate to `url`, run `work` against the page,
85
+ // and always close the browser afterwards. Errors from navigation or `work`
86
+ // propagate to the caller.
87
+ export async function withBrowserPage(url, options, work, deps = defaultBrowserDeps) {
88
+ const { userAgent, viewport, proxy, delayMin, delayMax } = options;
89
+ const logSuffix = options.logLabel ? ` ${options.logLabel}` : '';
90
+ const hasProxyAuth = !!(proxy.url && proxy.username && proxy.password);
91
+ if (proxy.url) {
92
+ // SECURITY: Never log proxy URLs that might contain credentials
93
+ console.error(hasProxyAuth ? `Using authenticated proxy${logSuffix}: [REDACTED]` : `Using proxy${logSuffix}: ${proxy.url}`);
94
+ }
95
+ const browser = await deps.launchBrowser({
96
+ headless: 'new', // Use new headless mode for better stealth
97
+ args: buildLaunchArgs(userAgent, proxy.url),
98
+ });
99
+ try {
100
+ const page = await browser.newPage();
101
+ await page.setUserAgent(userAgent);
102
+ await page.setViewport(viewport);
103
+ // Add common browser properties to avoid detection
104
+ await page.evaluateOnNewDocument(() => {
105
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
106
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
107
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
108
+ window.chrome = { runtime: {} };
109
+ });
110
+ if (hasProxyAuth) {
111
+ await page.authenticate({ username: proxy.username, password: proxy.password });
112
+ }
113
+ await deps.sleep(randomBetween(delayMin, delayMax));
114
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
115
+ // Simulate human-like behavior: pause 2-5s after load
116
+ await deps.sleep(randomBetween(2000, 5000));
117
+ return await work(page);
118
+ }
119
+ finally {
120
+ await browser.close();
121
+ }
122
+ }
123
+ // Run `attempt` up to `maxAttempts` times until it returns `success: true`.
124
+ // Each attempt picks up the next proxy/user agent via the caller. A thrown
125
+ // error or an unsuccessful result both count as a failed attempt. When every
126
+ // attempt fails, `onFailure` builds the caller's failure result.
127
+ export async function withRetries(attempt, options, onFailure) {
128
+ const { maxAttempts, label, url } = options;
129
+ let lastError = null;
130
+ for (let n = 1; n <= maxAttempts; n++) {
131
+ try {
132
+ const result = await attempt();
133
+ if (result.success)
134
+ return result;
135
+ lastError = result.error || `Unknown ${label.toLowerCase()} error`;
136
+ }
137
+ catch (error) {
138
+ lastError = error instanceof Error ? error.message : String(error);
139
+ }
140
+ if (n < maxAttempts) {
141
+ console.error(`${label} attempt ${n} failed for ${url}, trying next proxy...`);
142
+ }
143
+ }
144
+ return onFailure(`Failed after ${maxAttempts} attempts. Last error: ${lastError}`);
145
+ }
package/dist/config.js ADDED
@@ -0,0 +1,67 @@
1
+ // Server configuration, resolved once from the environment.
2
+ //
3
+ // This is the single answer to "what does the server think its settings are".
4
+ // Every env var the server reads is parsed here, with its default, so the
5
+ // rest of the code never touches process.env. Pure: pass any env record in.
6
+ import { DEFAULT_USER_AGENT, parseProxyUrls, parseUserAgents, parseLlmConfig, } from './utils.js';
7
+ export const DEFAULT_VIEWPORT_WIDTH = 1920;
8
+ export const DEFAULT_VIEWPORT_HEIGHT = 1080;
9
+ export const DEFAULT_SCRAPE_DELAY_MIN = 1000;
10
+ export const DEFAULT_SCRAPE_DELAY_MAX = 3000;
11
+ export const DEFAULT_BATCH_DELAY_MIN = 2000;
12
+ export const DEFAULT_BATCH_DELAY_MAX = 5000;
13
+ // Max time to wait for the DOM to stop changing after load. Early-exits when
14
+ // stable, so this only caps pages that keep mutating. Bump it (e.g. 12000) for
15
+ // sites that inject content via long setTimeouts.
16
+ export const DEFAULT_SETTLE_MAX_MS = 3000;
17
+ export const DEFAULT_RETRY_ATTEMPTS = 3;
18
+ export const DEFAULT_PORT = 3000;
19
+ function positiveNumber(raw, fallback) {
20
+ const n = Number(raw);
21
+ return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 ? n : fallback;
22
+ }
23
+ function flag(raw) {
24
+ return raw === 'true';
25
+ }
26
+ export function loadConfig(env = process.env) {
27
+ return {
28
+ scraping: {
29
+ userAgents: parseUserAgents(env.SCRAPE_USER_AGENT || '', DEFAULT_USER_AGENT),
30
+ viewportWidth: positiveNumber(env.SCRAPE_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_WIDTH),
31
+ viewportHeight: positiveNumber(env.SCRAPE_VIEWPORT_HEIGHT, DEFAULT_VIEWPORT_HEIGHT),
32
+ delayMin: positiveNumber(env.SCRAPE_DELAY_MIN, DEFAULT_SCRAPE_DELAY_MIN),
33
+ delayMax: positiveNumber(env.SCRAPE_DELAY_MAX, DEFAULT_SCRAPE_DELAY_MAX),
34
+ batchDelayMin: positiveNumber(env.SCRAPE_BATCH_DELAY_MIN, DEFAULT_BATCH_DELAY_MIN),
35
+ batchDelayMax: positiveNumber(env.SCRAPE_BATCH_DELAY_MAX, DEFAULT_BATCH_DELAY_MAX),
36
+ settleMaxMs: positiveNumber(env.SCRAPE_SETTLE_MAX_MS, DEFAULT_SETTLE_MAX_MS),
37
+ },
38
+ retry: {
39
+ maxAttempts: positiveNumber(env.FIRECRAWL_RETRY_MAX_ATTEMPTS, DEFAULT_RETRY_ATTEMPTS),
40
+ },
41
+ llm: parseLlmConfig(env),
42
+ proxy: {
43
+ urls: parseProxyUrls(env.PROXY_SERVER_URL || ''),
44
+ username: env.PROXY_SERVER_USERNAME,
45
+ password: env.PROXY_SERVER_PASSWORD,
46
+ proxyLlmApi: flag(env.PROXY_LLM_API),
47
+ },
48
+ endpoints: {
49
+ enableHttpStreamableEndpoint: flag(env.ENABLE_HTTP_STREAMABLE_ENDPOINT),
50
+ enableSseEndpoint: flag(env.ENABLE_SSE_ENDPOINT),
51
+ enableFirecrawlApi: flag(env.ENABLE_FIRECRAWL_API),
52
+ firecrawlApiKey: env.FIRECRAWL_API_KEY?.trim() || undefined,
53
+ port: positiveNumber(env.PORT, DEFAULT_PORT),
54
+ },
55
+ };
56
+ }
57
+ // Round-robin over a list. Returns `undefined` forever for an empty list.
58
+ export function createRotator(items) {
59
+ let index = 0;
60
+ return () => {
61
+ if (items.length === 0)
62
+ return undefined;
63
+ const item = items[index];
64
+ index = (index + 1) % items.length;
65
+ return item;
66
+ };
67
+ }
@@ -0,0 +1,86 @@
1
+ // Firecrawl-compatible REST API helpers.
2
+ //
3
+ // Agent frameworks such as Hermes Agent talk to a "self-hosted Firecrawl"
4
+ // through the official firecrawl SDK, which only needs `POST /v2/scrape`
5
+ // to return `{ success: true, data: Document }`. These pure helpers parse
6
+ // that request and shape our scrape result into a Firecrawl Document, so
7
+ // the server can pose as a Firecrawl instance without any Firecrawl
8
+ // infrastructure. Kept free of side effects so they can be unit tested.
9
+ import { isValidUrl } from './utils.js';
10
+ const KNOWN_FORMATS = ['markdown', 'html', 'rawHtml', 'links', 'screenshot'];
11
+ // Normalise the `formats` array. Firecrawl accepts both plain strings
12
+ // (`"markdown"`) and object entries (`{ type: "markdown" }`); unknown formats
13
+ // are ignored rather than rejected so newer SDKs keep working.
14
+ function parseFormats(raw) {
15
+ if (!Array.isArray(raw))
16
+ return ['markdown'];
17
+ const formats = [];
18
+ for (const entry of raw) {
19
+ const type = typeof entry === 'string'
20
+ ? entry
21
+ : entry && typeof entry === 'object' && typeof entry.type === 'string'
22
+ ? entry.type
23
+ : undefined;
24
+ if (type && KNOWN_FORMATS.includes(type) && !formats.includes(type)) {
25
+ formats.push(type);
26
+ }
27
+ }
28
+ return formats.length > 0 ? formats : ['markdown'];
29
+ }
30
+ export function parseFirecrawlScrapeRequest(body) {
31
+ if (!body || typeof body !== 'object') {
32
+ return { ok: false, error: 'Request body must be a JSON object' };
33
+ }
34
+ const { url, formats, onlyMainContent } = body;
35
+ if (typeof url !== 'string' || url.trim().length === 0) {
36
+ return { ok: false, error: 'Missing required field: url' };
37
+ }
38
+ const trimmed = url.trim();
39
+ if (!isValidUrl(trimmed)) {
40
+ return { ok: false, error: 'Invalid URL. Only http and https URLs are supported.' };
41
+ }
42
+ return {
43
+ ok: true,
44
+ request: {
45
+ url: trimmed,
46
+ formats: parseFormats(formats),
47
+ onlyMainContent: onlyMainContent !== false,
48
+ },
49
+ };
50
+ }
51
+ // Shape a scrape result into a Firecrawl v2 Document. Only the requested
52
+ // formats are populated (like Firecrawl) so large HTML isn't shipped to
53
+ // clients that asked for markdown only.
54
+ export function buildFirecrawlDocument(result, formats, extra = {}) {
55
+ const doc = {};
56
+ if (formats.includes('markdown'))
57
+ doc.markdown = result.markdown;
58
+ if (formats.includes('html'))
59
+ doc.html = result.html;
60
+ if (formats.includes('rawHtml'))
61
+ doc.rawHtml = result.html;
62
+ if (formats.includes('links'))
63
+ doc.links = extra.links ?? [];
64
+ const metadata = {
65
+ title: result.title,
66
+ sourceURL: result.url,
67
+ url: result.url,
68
+ statusCode: 200,
69
+ };
70
+ if (extra.description)
71
+ metadata.description = extra.description;
72
+ if (extra.language)
73
+ metadata.language = extra.language;
74
+ doc.metadata = metadata;
75
+ return doc;
76
+ }
77
+ // Optional bearer-token auth for the REST API. When no key is configured the
78
+ // endpoint is open (matching self-hosted Firecrawl with USE_DB_AUTHENTICATION=false).
79
+ export function isFirecrawlRequestAuthorized(authorizationHeader, expectedKey) {
80
+ if (!expectedKey)
81
+ return true;
82
+ if (!authorizationHeader)
83
+ return false;
84
+ const match = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
85
+ return !!match && match[1].trim() === expectedKey;
86
+ }
@@ -0,0 +1,263 @@
1
+ // HTML cleaning and Markdown conversion.
2
+ //
3
+ // Clean-room implementation using only permissively-licensed libraries
4
+ // (cheerio: MIT, turndown: MIT, turndown-plugin-gfm: MIT). Deliberately does
5
+ // NOT copy code from the AGPL-licensed Firecrawl project — only well-known,
6
+ // standard techniques.
7
+ // Use cheerio's slim build: it excludes the undici-based `fromURL` helper
8
+ // (which we don't use). The full build pulls undici, which references the
9
+ // `File` global and crashes on Node < 20 at import time.
10
+ import { load } from 'cheerio/slim';
11
+ import TurndownService from 'turndown';
12
+ import { gfm } from 'turndown-plugin-gfm';
13
+ // Semantic containers that usually wrap the primary article/content.
14
+ const MAIN_CONTENT_SELECTORS = [
15
+ 'main',
16
+ 'article',
17
+ '[role="main"]',
18
+ '#main',
19
+ '#content',
20
+ '.main-content',
21
+ '.post-content',
22
+ '.entry-content',
23
+ '.article-content',
24
+ '.article-body',
25
+ ];
26
+ // Elements that never carry readable content — always removed.
27
+ const STRIP_SELECTORS = [
28
+ 'script',
29
+ 'style',
30
+ 'noscript',
31
+ 'template',
32
+ 'svg',
33
+ 'iframe',
34
+ 'head',
35
+ 'meta',
36
+ 'link',
37
+ 'base',
38
+ ];
39
+ // Structural / chrome elements removed when onlyMainContent is requested.
40
+ // These are common, generic selectors (not a copied list).
41
+ const NON_MAIN_SELECTORS = [
42
+ 'header',
43
+ 'footer',
44
+ 'nav',
45
+ 'aside',
46
+ 'form',
47
+ '.header',
48
+ '.footer',
49
+ '.nav',
50
+ '.navbar',
51
+ '.navigation',
52
+ '.menu',
53
+ '.sidebar',
54
+ '.aside',
55
+ '.ad',
56
+ '.ads',
57
+ '.advert',
58
+ '.advertisement',
59
+ '.social',
60
+ '.share',
61
+ '.sharing',
62
+ '.newsletter',
63
+ '.subscribe',
64
+ '.cookie',
65
+ '.cookie-banner',
66
+ '.consent',
67
+ '.popup',
68
+ '.modal',
69
+ '.overlay',
70
+ '.breadcrumb',
71
+ '.breadcrumbs',
72
+ '.pagination',
73
+ '.related',
74
+ '.comments',
75
+ '#header',
76
+ '#footer',
77
+ '#nav',
78
+ '#sidebar',
79
+ '#comments',
80
+ '[role="banner"]',
81
+ '[role="navigation"]',
82
+ '[role="complementary"]',
83
+ '[role="contentinfo"]',
84
+ '[aria-hidden="true"]',
85
+ ];
86
+ // Find the semantic container that holds the bulk of the page's text. Returns
87
+ // the element only when it clearly dominates the body (avoids scoping to a tiny
88
+ // or empty <main>), otherwise undefined so the caller falls back to chrome
89
+ // stripping.
90
+ function pickMainContainer($) {
91
+ const bodyLen = $('body').text().replace(/\s+/g, ' ').trim().length;
92
+ if (bodyLen === 0)
93
+ return undefined;
94
+ let best;
95
+ let bestLen = 0;
96
+ for (const selector of MAIN_CONTENT_SELECTORS) {
97
+ $(selector).each((_, el) => {
98
+ const len = $(el).text().replace(/\s+/g, ' ').trim().length;
99
+ if (len > bestLen) {
100
+ bestLen = len;
101
+ best = el;
102
+ }
103
+ });
104
+ }
105
+ // Require the container to hold a meaningful share of the page text.
106
+ if (best && bestLen >= 200 && bestLen / bodyLen >= 0.4) {
107
+ return best;
108
+ }
109
+ return undefined;
110
+ }
111
+ // Load HTML into cheerio and strip noise, optionally reduce to main content,
112
+ // and resolve relative links/images to absolute URLs.
113
+ function clean(html, options) {
114
+ const $ = load(html);
115
+ $(STRIP_SELECTORS.join(',')).remove();
116
+ // Recover lazy-loaded images: many sites put a placeholder in src and the
117
+ // real URL in a data-* attribute. Promote it before the base64 cull below.
118
+ $('img').each((_, el) => {
119
+ const $img = $(el);
120
+ const src = $img.attr('src');
121
+ const lazy = $img.attr('data-src') ||
122
+ $img.attr('data-original') ||
123
+ $img.attr('data-lazy-src') ||
124
+ $img.attr('data-lazy') ||
125
+ $img.attr('data-srcset')?.split(',')[0]?.trim().split(' ')[0];
126
+ if ((!src || src.startsWith('data:')) && lazy) {
127
+ $img.attr('src', lazy);
128
+ }
129
+ });
130
+ // Drop inline base64 / data-URI images — they bloat the output enormously
131
+ // and carry no value for an LLM reader.
132
+ $('img[src^="data:"]').remove();
133
+ $('img[srcset^="data:"]').removeAttr('srcset');
134
+ // Drop images with no usable source (icons/placeholders) so they don't leave
135
+ // stray "!" markers in the output.
136
+ $('img:not([src]), img[src=""]').remove();
137
+ // Flatten layout tables (no <th> header row, or role="presentation"). The GFM
138
+ // plugin only converts true data tables and leaves the rest as raw HTML, so
139
+ // we collapse non-data tables to plain divs to keep that HTML out of the
140
+ // Markdown. Genuine data tables (with <th>) are left for GFM to convert.
141
+ $('table').each((_, el) => {
142
+ const $t = $(el);
143
+ const isPresentation = ($t.attr('role') || '').toLowerCase() === 'presentation';
144
+ const hasHeader = $t.find('tr').first().find('th').length > 0;
145
+ if (isPresentation || !hasHeader) {
146
+ const rows = $t
147
+ .find('tr')
148
+ .map((_, r) => `<div>${$(r)
149
+ .find('td,th')
150
+ .map((_, c) => $(c).html() ?? '')
151
+ .get()
152
+ .join(' ')}</div>`)
153
+ .get()
154
+ .join('');
155
+ $t.replaceWith(rows || '');
156
+ }
157
+ });
158
+ if (options.onlyMainContent) {
159
+ // Prefer a clear semantic content container when one holds the bulk of the
160
+ // page text; otherwise fall back to stripping generic chrome from the body.
161
+ const main = pickMainContainer($);
162
+ if (main) {
163
+ const mainHtml = $.html(main);
164
+ $('body').empty().append(mainHtml);
165
+ }
166
+ $(NON_MAIN_SELECTORS.join(',')).remove();
167
+ }
168
+ if (options.baseUrl) {
169
+ $('a[href]').each((_, el) => {
170
+ const href = $(el).attr('href');
171
+ if (href) {
172
+ try {
173
+ $(el).attr('href', new URL(href, options.baseUrl).href);
174
+ }
175
+ catch {
176
+ /* leave as-is if it can't be resolved */
177
+ }
178
+ }
179
+ });
180
+ $('img[src]').each((_, el) => {
181
+ const src = $(el).attr('src');
182
+ if (src) {
183
+ try {
184
+ $(el).attr('src', new URL(src, options.baseUrl).href);
185
+ }
186
+ catch {
187
+ /* leave as-is */
188
+ }
189
+ }
190
+ });
191
+ }
192
+ return $;
193
+ }
194
+ function makeTurndown() {
195
+ const td = new TurndownService({
196
+ headingStyle: 'atx',
197
+ hr: '---',
198
+ bulletListMarker: '-',
199
+ codeBlockStyle: 'fenced',
200
+ emDelimiter: '_',
201
+ });
202
+ // GFM support: tables, strikethrough, task lists.
203
+ td.use(gfm);
204
+ // Drop anything that survived but carries no readable value.
205
+ td.remove(['script', 'style', 'noscript', 'iframe', 'form']);
206
+ return td;
207
+ }
208
+ // Tidy up common turndown artifacts: empty links/images, stray escapes, and
209
+ // runs of blank lines.
210
+ function tidyMarkdown(md) {
211
+ return md
212
+ .replace(/!\[[^\]]*\]\(\s*\)/g, '') // images with empty src
213
+ .replace(/\[\s*\]\([^)]*\)/g, '') // links with empty text
214
+ .replace(/\[([^\]]+)\]\(\s*\)/g, '$1') // links with empty href -> text
215
+ .replace(/^[ \t]*!+[ \t]*$/gm, '') // stray image markers left on their own line
216
+ .replace(/^#{1,6}[ \t]*$/gm, '') // empty headings
217
+ .replace(/[ \t]+$/gm, '') // trailing whitespace
218
+ .replace(/\n{3,}/g, '\n\n') // collapse blank lines
219
+ .trim();
220
+ }
221
+ // Convert HTML to Markdown. Returns trimmed Markdown text.
222
+ export function htmlToMarkdown(html, options = {}) {
223
+ const $ = clean(html, options);
224
+ const body = $('body').html() ?? $.html() ?? '';
225
+ if (!body.trim())
226
+ return '';
227
+ const markdown = makeTurndown().turndown(body);
228
+ return tidyMarkdown(markdown);
229
+ }
230
+ // Extract readable plain text (used for the `content` field).
231
+ export function htmlToText(html, options = {}) {
232
+ const $ = clean(html, options);
233
+ const text = ($('body').text() || $.root().text() || '')
234
+ .replace(/[ \t]+/g, ' ')
235
+ .replace(/ *\n */g, '\n')
236
+ .replace(/\n{3,}/g, '\n\n')
237
+ .trim();
238
+ return text;
239
+ }
240
+ // Pull lightweight page metadata (description, language, absolute links) from
241
+ // rendered HTML. Used to enrich Firecrawl-compatible API responses.
242
+ export function extractPageMetadata(html, baseUrl) {
243
+ const $ = load(html);
244
+ const description = $('meta[name="description"]').attr('content')?.trim() ||
245
+ $('meta[property="og:description"]').attr('content')?.trim() ||
246
+ undefined;
247
+ const language = $('html').attr('lang')?.trim() || undefined;
248
+ const links = new Set();
249
+ $('a[href]').each((_, el) => {
250
+ const href = $(el).attr('href');
251
+ if (!href)
252
+ return;
253
+ try {
254
+ const abs = new URL(href, baseUrl).toString();
255
+ if (abs.startsWith('http://') || abs.startsWith('https://'))
256
+ links.add(abs);
257
+ }
258
+ catch {
259
+ // ignore unparseable hrefs
260
+ }
261
+ });
262
+ return { description, language, links: Array.from(links) };
263
+ }