printhive/lib/flaresolverr.js
dlawler489 ef2093b73f Say when MakerWorld is waiting for you to prove you're not a robot
The challenge was detected but treated as an ordinary download failure, so
the message didn't say that only you can clear it. Now it stops the run,
is remembered centrally, and watches skip downloads while it stands —
metadata still imports, and models stay partial for "Get files". The
Watched page shows a banner with a link to MakerWorld and an "I've solved
it" button; a successful download clears it too. Detection also covers a
challenge page returned where JSON was expected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:44:54 +10:00

60 lines
2.7 KiB
JavaScript

// Minimal FlareSolverr client. FlareSolverr runs a headless browser that solves
// Cloudflare's anti-bot challenge and returns the resolved page HTML.
// https://github.com/FlareSolverr/FlareSolverr
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// opts: { maxTimeout, cookies: [{ name, value, domain }], retries }
async function solve(flaresolverrUrl, targetUrl, opts = {}) {
const endpoint = flaresolverrUrl.replace(/\/+$/, '') + '/v1';
const body = { cmd: 'request.get', url: targetUrl, maxTimeout: opts.maxTimeout || 60000 };
if (opts.cookies) body.cookies = opts.cookies;
// FlareSolverr serializes browser sessions and returns 500 on back-to-back
// requests, so retry transient failures with a growing backoff.
const retries = opts.retries ?? 4;
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
if (attempt > 0) await sleep(2000 * attempt);
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
// FlareSolverr is told to give up at maxTimeout; if it doesn't answer
// even then, stop waiting rather than hanging the request for ever.
signal: AbortSignal.timeout((opts.maxTimeout || 60000) + 15000),
});
if (res.status >= 500) { lastErr = new Error(`FlareSolverr HTTP ${res.status}`); continue; }
if (!res.ok) throw new Error(`FlareSolverr HTTP ${res.status}`);
const data = await res.json();
if (data.status !== 'ok' || !data.solution) throw new Error(`FlareSolverr: ${data.message || 'no solution'}`);
return data.solution.response;
} catch (err) {
lastErr = err;
if (!/HTTP 5\d\d|fetch failed|ECONNRESET|network|timeout|aborted/i.test(err.message)) throw err;
}
}
throw lastErr;
}
// Like solve(), but for endpoints that return JSON. A real browser wraps JSON
// in a <pre> tag, so strip tags before parsing.
async function solveJson(flaresolverrUrl, targetUrl, opts = {}) {
const html = await solve(flaresolverrUrl, targetUrl, opts);
// The browser wraps JSON in <pre> and HTML-encodes entities (notably & -> &amp;
// inside signed URLs), so strip tags and decode before parsing.
const text = html.replace(/<[^>]*>/g, '')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&amp;/g, '&')
.trim();
try {
return JSON.parse(text);
} catch {
// Keep a little of what came back: a site answering with a page instead of
// JSON is usually asking the visitor to prove they're human.
throw Object.assign(new Error('FlareSolverr did not return parseable JSON.'), { raw: text.slice(0, 300) });
}
}
module.exports = { solve, solveJson };