-
-
Notifications
You must be signed in to change notification settings - Fork 12.6k
Expand file tree
/
Copy pathbrowser-extract.mjs
More file actions
251 lines (229 loc) · 10 KB
/
Copy pathbrowser-extract.mjs
File metadata and controls
251 lines (229 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
#!/usr/bin/env node
/**
* browser-extract.mjs — headless Playwright reader for the scan / JD-extraction
* path (the opt-in alternative to the browser MCP; see #1449).
*
* The token cost of the MCP path is `browser_snapshot` streaming a page's whole
* accessibility tree back to the model on every navigate. This helper renders
* the same page headlessly and returns COMPACT JSON — just the fields the agent
* needs — so the model processes a small result instead of a full snapshot.
*
* STRICTLY READ-ONLY: it navigates and reads the DOM. No clicks, typing, or form
* fills — that boundary is exactly what keeps this separate from `apply`.
*
* Usage:
* node browser-extract.mjs <url> [--mode jd|listing] [--max N] [--max-chars N] [--timeout MS]
*
* `--max-chars` overrides the jd-mode text cap (default 12000) — raise it when a
* long JD would otherwise be truncated at the tail, at the cost of more tokens.
*
* Modes:
* jd (default) — one posting page → { url, title, text }. `text` is the main
* visible text, whitespace-collapsed and length-capped. For the
* pipeline / oferta / auto-pipeline JD-extraction step.
* listing — a careers/board page → { url, jobs: [{ title, url }] }. Visible
* anchors that look like individual postings, deduped. For scan
* Level 1 (reading a company's open roles).
*
* Output: compact JSON to stdout. Exit 0 on success; exit 1 on a hard error,
* printing `{ "error": "...", "code": "..." }` (so a caller/mode can fall back
* to the MCP path silently). Reuses liveness-browser.mjs's SSRF host guard and
* realistic-UA context so it isn't instantly bot-walled.
*/
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import * as yaml from 'js-yaml';
import { LIVENESS_CONTEXT_OPTIONS, rejectPrivateOrInvalid } from './liveness-browser.mjs';
const CAREER_OPS = dirname(fileURLToPath(import.meta.url));
const DEFAULT_TIMEOUT_MS = 15_000;
const HYDRATION_WAIT_MS = 2_000;
const JD_TEXT_CAP = 12_000; // plenty for a JD; a fraction of a full snapshot
const DEFAULT_LISTING_MAX = 200;
// Anchor labels that are navigation chrome, not job postings. Kept small and
// lowercase; matched against the trimmed label.
const NAV_LABEL_STOPWORDS = new Set([
'home', 'about', 'about us', 'contact', 'contact us', 'login', 'log in', 'sign in',
'sign up', 'register', 'privacy', 'privacy policy', 'terms', 'cookies', 'cookie policy',
'careers', 'jobs', 'search', 'menu', 'back', 'next', 'previous', 'apply', 'apply now',
'learn more', 'read more', 'faq', 'blog', 'news', 'help', 'support', 'english',
]);
/**
* Resolve the configured scan extractor: `cli` (this helper) or `mcp` (default).
* Reads `scan.extractor` from config/profile.yml; anything unrecognized — or a
* missing/unreadable file — yields `mcp` so behavior never breaks. Exported so
* doctor.mjs reports the same value.
* @param {string} [profilePath]
* @returns {'cli'|'mcp'}
*/
export function resolveExtractorMode(profilePath = join(CAREER_OPS, 'config/profile.yml')) {
try {
if (!existsSync(profilePath)) return 'mcp';
const raw = yaml.load(readFileSync(profilePath, 'utf-8')) || {};
const v = raw?.scan?.extractor;
return v === 'cli' ? 'cli' : 'mcp';
} catch {
return 'mcp';
}
}
// Collapse runs of whitespace and cap length so the JD text stays compact.
export function compactText(s, cap = JD_TEXT_CAP) {
const text = String(s ?? '').replace(/[ \t ]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
return text.length > cap ? `${text.slice(0, cap)}…` : text;
}
/**
* Shape a JD-mode result from the raw DOM read. Pure — exported for tests.
* @param {{ title?: string, text?: string }} raw
* @param {string} finalUrl
*/
export function normalizeJd(raw, finalUrl, textCap = JD_TEXT_CAP) {
return {
url: finalUrl,
title: compactText(raw?.title || '', 300),
text: compactText(raw?.text || '', textCap),
};
}
/**
* Shape a listing-mode result: keep visible anchors that look like individual
* job postings, deduped by resolved URL, capped at `max`. Pure — exported for
* tests. Anchors are dropped when the label is empty/too short or a nav
* stopword, or the href isn't a resolvable http(s) URL.
* @param {Array<{ href?: string, label?: string }>} anchors
* @param {string} finalUrl - the page URL, used as the base to resolve relatives
* @param {number} [max]
*/
export function normalizeListing(anchors, finalUrl, max = DEFAULT_LISTING_MAX) {
const jobs = [];
const seen = new Set();
for (const a of Array.isArray(anchors) ? anchors : []) {
const label = String(a?.label ?? '').replace(/\s+/g, ' ').trim();
if (label.length < 3 || NAV_LABEL_STOPWORDS.has(label.toLowerCase())) continue;
let url;
try {
url = new URL(String(a?.href ?? ''), finalUrl).href;
} catch {
continue;
}
if (!/^https?:$/.test(new URL(url).protocol)) continue;
if (seen.has(url)) continue;
seen.add(url);
jobs.push({ title: label, url });
if (jobs.length >= max) break;
}
return { url: finalUrl, jobs };
}
/**
* Parse CLI args into { url, mode, max, timeout }. Index-based so a flag value
* (e.g. the `listing` in `--mode listing`) is never mistaken for the URL, and an
* explicit `0` is honored instead of being silently replaced by the default.
* Exported for tests.
* @param {string[]} argv - process.argv.slice(2)
*/
export function parseArgs(argv) {
const FLAGS = new Set(['--mode', '--max', '--max-chars', '--timeout']);
let url;
let mode = 'jd';
let max = DEFAULT_LISTING_MAX;
let maxChars = JD_TEXT_CAP;
let timeout = DEFAULT_TIMEOUT_MS;
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (FLAGS.has(tok)) {
const val = argv[++i]; // consume the next token as this flag's value
const n = Number(val);
if (tok === '--mode' && val != null) mode = val;
else if (tok === '--max' && Number.isInteger(n) && n >= 0) max = n;
else if (tok === '--max-chars' && Number.isInteger(n) && n > 0) maxChars = n;
else if (tok === '--timeout' && Number.isInteger(n) && n > 0) timeout = n;
} else if (!tok.startsWith('--') && url === undefined) {
url = tok;
}
}
return { url, mode, max, maxChars, timeout };
}
// Read the raw DOM inside the page: title, main visible text, and visible
// anchors. Runs in the browser context; returns plain data only.
async function readDom(page) {
return page.evaluate(() => {
const title = (document.querySelector('h1')?.innerText || document.title || '').trim();
// Main text: prefer <main>/[role=main]/<article>, else body; strip nav chrome.
const root =
document.querySelector('main, [role="main"], article') || document.body;
let text = '';
if (root) {
const clone = root.cloneNode(true);
clone.querySelectorAll('script, style, nav, header, footer, noscript').forEach((el) => el.remove());
text = clone.innerText || '';
}
const anchors = Array.from(document.querySelectorAll('a[href]'))
.filter((el) => {
if (el.closest('nav, header, footer')) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
return el.getClientRects().length > 0;
})
.map((el) => ({ href: el.getAttribute('href') || '', label: (el.innerText || '').trim() }));
return { title, text, anchors };
});
}
async function main() {
const { url, mode, max, maxChars, timeout } = parseArgs(process.argv.slice(2));
if (!url) {
console.error(JSON.stringify({ error: 'usage: browser-extract.mjs <url> [--mode jd|listing] [--max N] [--max-chars N]', code: 'no_url' }));
process.exit(1);
}
if (mode !== 'jd' && mode !== 'listing') {
console.error(JSON.stringify({ error: `unknown mode "${mode}" (expected jd|listing)`, code: 'bad_mode' }));
process.exit(1);
}
const guard = rejectPrivateOrInvalid(url);
if (guard) {
console.error(JSON.stringify({ error: guard.reason, code: guard.code }));
process.exit(1);
}
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
console.error(JSON.stringify({ error: 'playwright not installed', code: 'no_playwright' }));
process.exit(1);
}
let browser;
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext(LIVENESS_CONTEXT_OPTIONS);
// Block every request (main navigation, redirect hop, or subresource) to a
// private/loopback/link-local or non-http(s) host. Guarding only the initial
// URL isn't enough once we return page CONTENT: a server-side redirect could
// otherwise steer the browser at internal infrastructure (SSRF).
await context.route('**/*', (route) => {
if (rejectPrivateOrInvalid(route.request().url())) return route.abort('blockedbyclient');
return route.continue();
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
await page.waitForTimeout(HYDRATION_WAIT_MS); // let SPAs hydrate
// Belt-and-suspenders: never emit content read from a private final URL.
const finalUrl = page.url();
const finalGuard = rejectPrivateOrInvalid(finalUrl);
if (finalGuard) {
console.error(JSON.stringify({ error: `blocked final URL: ${finalGuard.reason}`, code: finalGuard.code }));
process.exitCode = 1;
return;
}
const raw = await readDom(page);
const result = mode === 'listing'
? normalizeListing(raw.anchors, finalUrl, max)
: normalizeJd(raw, finalUrl, maxChars);
process.stdout.write(JSON.stringify(result));
} catch (err) {
console.error(JSON.stringify({ error: `navigation error: ${String(err.message).split('\n')[0]}`, code: 'navigation_error' }));
process.exitCode = 1;
} finally {
if (browser) await browser.close().catch(() => {});
}
}
// Only run main() when invoked directly, not when imported by tests.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}