santifer/career-ops · warning
[fetch] Playwright unavailable — falling back to plain fetch
Error message
[fetch] Playwright unavailable — falling back to plain fetch.
What it means
fetchJobPage() in openrouter-runner.mjs prefers Playwright chromium to render job pages (SPAs need JS execution), after assertSafeRemoteUrl() blocks private/loopback hosts. The dynamic import('playwright') is wrapped in try/catch: if the package is not installed in this checkout, it warns and falls back to plain fetch, which often returns an empty JS shell for client-side-rendered ATS pages -- degrading the downstream evaluation input.
Source
Thrown at openrouter-runner.mjs:404
try { u = new URL(url); } catch { throw new Error(`Invalid URL: ${url}`); }
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new Error(`Refusing non-HTTP(S) URL: ${url}`);
}
const host = u.hostname.toLowerCase();
const blocked = host === 'localhost' || host === '::1' || host.endsWith('.local') ||
/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) ||
/^169\.254\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
if (blocked) throw new Error(`Refusing private/loopback host: ${host}`);
return u;
}
async function fetchJobPage(url) {
assertSafeRemoteUrl(url);
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
console.warn('[fetch] Playwright unavailable — falling back to plain fetch.');
}
if (chromium) {
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForTimeout(2000); // wait for SPA render
const text = await page.evaluate(() => {
document.querySelectorAll('script,style,nav,footer,header').forEach(el => el.remove());
return (document.body?.innerText || document.body?.textContent || '').replace(/\s+/g, ' ').trim();
});
return text.slice(0, 16_000);
} catch (e) {
console.warn(`[fetch] Playwright error: ${e.message} — falling back to plain fetch.`);
} finally {
if (browser) await browser.close().catch(() => {});View on GitHub (pinned to 60398d6549)
Solutions
- Install the dependency: npm install playwright && npx playwright install chromium in the repo root.
- Verify the import resolves: node -e "import('playwright').then(()=>console.log('ok'),e=>console.error(e.message))".
- If a fetch fallback already produced a contentless capture, re-run the evaluation for that URL after installing.
Example fix
# before [fetch] Playwright unavailable — falling back to plain fetch. # after $ npm install playwright $ npx playwright install chromium $ node openrouter-runner.mjs eval https://example.com/jobs/1
Defensive patterns
Strategy: fallback
Validate before calling
// Detect the degraded mode up front and refuse URLs that need JS rendering
let playwrightOk = false;
try { await import('playwright'); playwrightOk = true; } catch {}
if (!playwrightOk && /lever\.co|greenhouse\.io\/job\/app/.test(url)) {
throw new Error('SPA posting needs Playwright — npm i playwright && npx playwright install chromium');
} Prevention
- Install playwright plus the chromium browser binary in every environment that scrapes job pages.
- Verify with node -e "import('playwright').then(()=>0,e=>{console.error(e.message);process.exit(1)})" before batch runs.
- Remember the fallback also skips the anti-loopback guard's richer checks — plain fetch serves raw HTML only; inspect captures from fallback runs before evaluating them.
When it happens
Trigger: npm install run without playwright in dependencies; a stripped-down/global node_modules that lacks playwright; module resolution blocked by NODE_PATH or policy; playwright present but the import itself throws before browser launch.
Common situations: CI images omitting browser packages to save size; minimal local installs; container deploys where the fallback is acceptable only for server-rendered pages.
Related errors
- Extracted text too short (likely blocked or empty)
- Could not fetch job page: ${e.message}
- [fetch] Playwright error: ${e.message} — falling back to pla
- refusing to archive restricted destination: ${preGuard.reaso
- refusing to archive restricted destination after redirect: $
AI-assisted analysis of santifer/career-ops@60398d6549 (2026-08-20).
Data as JSON: /api/errors/d99f6769cbd70a48.
Report an issue: GitHub.