const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; const MAX_RETRY_DELAY_MS = 1_000; function sourceContractError(message) { return Object.assign(new Error(`SOURCE_CONTRACT_VIOLATION:${message}`), { code: 'SOURCE_CONTRACT_VIOLATION', publicReason: message, nonRetryable: true, }); } function validateSourceUrl(value, policy) { let url; try { url = new URL(value); } catch { throw sourceContractError('INVALID_URL'); } if ( url.protocol !== 'https:' || url.origin !== policy.origin || url.username || url.password || !policy.path(url.pathname) ) { throw sourceContractError('UNAPPROVED_URL'); } return url; } export function requestBudget(maxRequests) { let count = 0; return { consume() { if (count >= maxRequests) throw sourceContractError('REQUEST_BUDGET_EXCEEDED'); count += 1; }, get count() { return count; }, }; } function retryDelayMs(response) { const raw = response?.headers?.get('Retry-After'); if (!raw) return 100; const seconds = Number(raw); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000); const retryAt = Date.parse(raw); return Number.isFinite(retryAt) ? Math.max(0, retryAt - Date.now()) : 100; } async function waitForRetry(response) { const delayMs = retryDelayMs(response); if (delayMs > MAX_RETRY_DELAY_MS) return false; if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)); return true; } export async function fetchText(fetchFn, value, { policy, budget, onRedirect = () => {}, assertTargetAllowed = () => {}, }) { let target = validateSourceUrl(value, policy); assertTargetAllowed(target); let redirected = false; let redirects = 0; let transientRetries = 0; for (;;) { budget.consume(); let response; try { response = await fetchFn(target.toString(), { headers: { Accept: 'text/html,text/plain;q=0.9,*/*;q=0.1', 'Accept-Language': 'en,zh-CN;q=0.8', 'User-Agent': 'WorldMonitor/2.10 (+https://worldmonitor.app)', }, redirect: 'manual', signal: AbortSignal.timeout(12_000), }); } catch (error) { const permanentTls = error?.code === 'SELF_SIGNED_CERT_IN_CHAIN' || error?.cause?.code === 'SELF_SIGNED_CERT_IN_CHAIN' || /self signed certificate|certificate chain/i.test( `${String(error?.message)} ${String(error?.cause?.message)}`, ); if (transientRetries === 0 && !permanentTls && error?.code !== 'SOURCE_CONTRACT_VIOLATION') { transientRetries += 1; await waitForRetry(); continue; } throw error; } if (response.status >= 300 && response.status < 400) { onRedirect('encountered'); if (redirects >= 1) { onRedirect('rejected'); throw sourceContractError('REDIRECT_LIMIT_EXCEEDED'); } const location = response.headers.get('Location'); if (!location) { onRedirect('rejected'); throw sourceContractError('REDIRECT_WITHOUT_LOCATION'); } try { target = validateSourceUrl(new URL(location, target).toString(), policy); assertTargetAllowed(target); } catch (error) { onRedirect('rejected'); if (error?.publicReason === 'ROBOTS_DISALLOW') throw error; if (error?.code === 'SOURCE_CONTRACT_VIOLATION') { throw sourceContractError(`REDIRECT_REJECTED_${error.publicReason}`); } throw error; } redirected = true; redirects += 1; transientRetries = 0; onRedirect('followed'); continue; } if (response.redirected) { onRedirect('rejected'); throw sourceContractError('IMPLICIT_REDIRECT'); } if (!response.ok) { const error = Object.assign(new Error(`HTTP_${response.status}`), { status: response.status }); if ( transientRetries === 0 && (response.status === 408 || response.status === 429 || response.status >= 500) && await waitForRetry(response) ) { transientRetries += 1; continue; } throw error; } const declaredLength = Number(response.headers.get('Content-Length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { throw sourceContractError('RESPONSE_TOO_LARGE'); } let text; if (response.body?.getReader) { const reader = response.body.getReader(); const decoder = new TextDecoder(); const chunks = []; let received = 0; try { for (;;) { const { done, value: chunk } = await reader.read(); if (done) break; received += chunk.byteLength; if (received > MAX_RESPONSE_BYTES) { await reader.cancel('response exceeds China macro source limit'); throw sourceContractError('RESPONSE_TOO_LARGE'); } chunks.push(decoder.decode(chunk, { stream: true })); } chunks.push(decoder.decode()); text = chunks.join(''); } finally { reader.releaseLock(); } } else { text = await response.text(); if (new TextEncoder().encode(text).byteLength > MAX_RESPONSE_BYTES) { throw sourceContractError('RESPONSE_TOO_LARGE'); } } return { text, redirected, url: target.toString(), }; } } export function reasonFor(error) { if (error?.code === 'SOURCE_CONTRACT_VIOLATION') return error.publicReason; if (Number.isInteger(error?.status)) return `HTTP_${error.status}`; if ( error?.code === 'SELF_SIGNED_CERT_IN_CHAIN' || error?.cause?.code === 'SELF_SIGNED_CERT_IN_CHAIN' || /self signed certificate|certificate chain/i.test( `${String(error?.message)} ${String(error?.cause?.message)}`, ) ) return 'TLS_CERTIFICATE_ERROR'; if (error?.name === 'TimeoutError' || /timeout/i.test(String(error?.message))) return 'TIMEOUT'; if (/MALFORMED_RELEASE/.test(String(error?.message))) return 'SCHEMA_DRIFT'; return 'FETCH_FAILED'; } export function findReleaseUrl(html, baseUrl, titlePattern, label, policy) { const anchorPattern = /]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi; for (const match of html.matchAll(anchorPattern)) { const title = String(match[2]).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); if (titlePattern.test(title)) { return validateSourceUrl(new URL(match[1], baseUrl).toString(), policy).toString(); } } throw new Error(`MALFORMED_RELEASE:${label}_LINK`); } function robotsGroups(text) { const groups = []; let current = { agents: [], rules: [] }; let groupHasDirectives = false; const finishGroup = () => { if (current.agents.length > 0) groups.push(current); current = { agents: [], rules: [] }; groupHasDirectives = false; }; for (const rawLine of String(text).split(/\r?\n/)) { const line = rawLine.replace(/#.*$/, '').trim(); if (!line) continue; const separator = line.indexOf(':'); if (separator < 0) continue; const field = line.slice(0, separator).trim().toLowerCase(); const value = line.slice(separator + 1).trim(); if (field === 'user-agent') { if (groupHasDirectives) finishGroup(); current.agents.push(value.toLowerCase()); continue; } if (current.agents.length === 0) continue; groupHasDirectives = true; if ((field === 'allow' || field === 'disallow') && value) { current.rules.push({ kind: field, path: value }); } } finishGroup(); return groups; } function robotsDisallowPaths(text, candidatePaths) { const groups = robotsGroups(text); const crawler = 'worldmonitor'; const specific = groups.filter((group) => ( group.agents.some((agent) => agent !== '*' && crawler.startsWith(agent)) )); const applicable = specific.length > 0 ? specific : groups.filter((group) => group.agents.includes('*')); const rules = applicable.flatMap((group) => group.rules); return candidatePaths.some((candidatePath) => { const matches = rules .filter((rule) => candidatePath.startsWith(rule.path)) .sort((left, right) => ( right.path.length - left.path.length || (left.kind === 'allow' ? -1 : 1) )); return matches[0]?.kind === 'disallow'; }); } export function robotsDisallowAll(text) { return robotsDisallowPaths(text, ['/']); } export function assertRobotsAllowed(text, candidatePaths) { if (robotsDisallowPaths(text, candidatePaths)) { throw sourceContractError('ROBOTS_DISALLOW'); } } export async function checkRobots(fetchFn, url, options) { try { const result = await fetchText(fetchFn, url, options); assertRobotsAllowed(result.text, options.candidatePaths ?? ['/']); return { status: 'allows_candidate_paths', text: result.text }; } catch (error) { if (error?.status === 404) return { status: 'no_rules_published', text: '' }; throw error; } }