Files
santifer--career-ops/plugins/apify/_apify.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

203 lines
7.9 KiB
JavaScript

// Apify transport helper — runs pre-built actors and returns dataset items.
// Used by the Apify provider plugin (plugins/apify/index.mjs).
//
// Ported from the `providers/_apify.mjs` contributed by @ageem23 in #693 (with
// thanks). The only change for the plugin layer: runActor() takes the token as
// an argument (supplied from the plugin's scoped ctx.env) instead of reading
// process.env directly. Files prefixed with _ are never discovered as plugins.
//
// Uses the async pattern (start → poll → fetch dataset) rather than the
// long-polling /run-sync-get-dataset-items endpoint, which holds one HTTP
// connection open for the full actor run and gets cut by some networks /
// Windows SChannel before Apify can flush the response.
// Egress note: this helper keeps its own fetch (its retry / shared-deadline /
// abort logic is the whole point and doesn't map cleanly onto ctx.fetch). It
// self-constrains harder than allowedHosts would — every request is built from
// this single hardcoded base + a strictly-validated actorId (normalizeActorId),
// so it can only ever reach api.apify.com. The manifest's allowedHosts mirrors
// that for doctor/review visibility.
const APIFY_API_BASE = 'https://api.apify.com/v2';
const DEFAULT_RUN_TIMEOUT_MS = 180_000;
const POLL_INTERVAL_MS = 3_000;
const PER_REQUEST_TIMEOUT_MS = 15_000;
const CONNECT_RETRY_ATTEMPTS = 3;
const TERMINAL_STATUSES = new Set(['SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT']);
export function hasToken(token = process.env.APIFY_TOKEN) {
return Boolean(token);
}
// Apify accepts both "user/actor" and "user~actor" in URLs; normalize to `~`.
// Validate strictly so a malformed config can't escape the intended
// /acts/<actor>/runs path with extra `/`, `..`, `?`, or `#` characters and
// send our bearer token to an unintended endpoint on api.apify.com.
const ACTOR_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*[~/][A-Za-z0-9][A-Za-z0-9_.-]*$/;
export function normalizeActorId(actorId) {
if (typeof actorId !== 'string' || !ACTOR_ID_RE.test(actorId)) {
throw new Error(
`apify: invalid actorId ${JSON.stringify(actorId)}. ` +
`Expected "owner/actor" or "owner~actor" with letters, digits, "_", ".", or "-" only.`
);
}
const [owner, name] = actorId.split(/[~/]/, 2);
return `${encodeURIComponent(owner)}~${encodeURIComponent(name)}`;
}
// Apify supports auth via ?token= or Authorization: Bearer. The query-string
// form leaks the token into HTTP access logs and any error/log line that
// includes the URL, so always use the header.
function authHeaders(token) {
return { authorization: `Bearer ${token}` };
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function fetchJsonOnce(url, init = {}, timeoutMs = PER_REQUEST_TIMEOUT_MS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
if (!res.ok) {
const text = await res.text().catch(() => '');
const err = new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
err.status = res.status;
throw err;
}
return await res.json();
} finally {
clearTimeout(timer);
}
}
// Cold connects to api.apify.com occasionally exceed Undici's 10s internal
// connect timeout on this host — retry with backoff before giving up. When
// `deadline` is provided, each attempt's per-request timeout and the
// inter-attempt backoff are both capped against the remaining budget.
async function fetchJson(
url,
init = {},
timeoutMs = PER_REQUEST_TIMEOUT_MS,
attempts = CONNECT_RETRY_ATTEMPTS,
deadline = null,
) {
let lastErr;
for (let i = 0; i < attempts; i++) {
let thisTimeout = timeoutMs;
if (deadline != null) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) throw lastErr || new Error('Apify request budget exhausted');
thisTimeout = Math.min(timeoutMs, remainingMs);
}
try {
return await fetchJsonOnce(url, init, thisTimeout);
} catch (err) {
lastErr = err;
if (err.status >= 400 && err.status < 500) throw err;
if (i < attempts - 1) {
const backoff = 500 * (i + 1);
const sleepMs = deadline != null ? Math.min(backoff, deadline - Date.now()) : backoff;
if (sleepMs > 0) await sleep(sleepMs);
else if (deadline != null) throw lastErr;
}
}
}
throw lastErr;
}
async function startRun(actorId, input, token, deadline = null) {
const url = `${APIFY_API_BASE}/acts/${normalizeActorId(actorId)}/runs`;
const body = await fetchJson(
url,
{
method: 'POST',
headers: { 'content-type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(input || {}),
},
PER_REQUEST_TIMEOUT_MS,
CONNECT_RETRY_ATTEMPTS,
deadline,
);
const runId = body?.data?.id;
if (!runId) {
throw new Error(`Apify did not return a run id: ${JSON.stringify(body).slice(0, 200)}`);
}
return runId;
}
// Best-effort — if we give up on a run, stop the actor so credits aren't wasted.
async function abortRun(runId, token) {
const url = `${APIFY_API_BASE}/actor-runs/${runId}/abort`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
try {
await fetch(url, { method: 'POST', headers: authHeaders(token), signal: controller.signal });
} catch {} finally {
clearTimeout(timer);
}
}
async function waitForRun(runId, token, deadline, timeoutMs) {
const url = `${APIFY_API_BASE}/actor-runs/${runId}`;
let lastError;
while (Date.now() < deadline) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) break;
try {
const body = await fetchJsonOnce(
url,
{ headers: authHeaders(token) },
Math.min(PER_REQUEST_TIMEOUT_MS, remainingMs),
);
const run = body?.data;
if (run && TERMINAL_STATUSES.has(run.status)) return run;
lastError = undefined;
} catch (err) {
// 4xx (401/403 auth revoked, 404 run not found) won't succeed on retry.
if (err?.status >= 400 && err.status < 500) throw err;
lastError = err;
}
const sleepMs = Math.min(POLL_INTERVAL_MS, deadline - Date.now());
if (sleepMs > 0) await sleep(sleepMs);
}
// Fire-and-forget cleanup; don't add abortRun's 5s to our wall-clock budget.
void abortRun(runId, token).catch(() => {});
const suffix = lastError ? ` (last error: ${lastError.message})` : '';
throw new Error(`Apify run ${runId} did not finish within ${Math.round(timeoutMs / 1000)}s${suffix}`);
}
async function fetchDatasetItems(runId, token, deadline = null) {
const url = `${APIFY_API_BASE}/actor-runs/${runId}/dataset/items`;
const items = await fetchJson(
url,
{ headers: authHeaders(token) },
PER_REQUEST_TIMEOUT_MS * 2,
CONNECT_RETRY_ATTEMPTS,
deadline,
);
if (!Array.isArray(items)) {
throw new Error(`Apify run ${runId} returned non-array dataset payload`);
}
return items;
}
export async function runActor(actorId, input, { timeoutMs = DEFAULT_RUN_TIMEOUT_MS, token = process.env.APIFY_TOKEN } = {}) {
if (!token) throw new Error('APIFY_TOKEN not set');
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error(`apify: invalid timeoutMs ${JSON.stringify(timeoutMs)} (must be a positive finite number of milliseconds)`);
}
// Single deadline shared across startRun → waitForRun → fetchDatasetItems so
// the caller's timeoutMs is the end-to-end ceiling, not just the wait loop.
const deadline = Date.now() + timeoutMs;
const runId = await startRun(actorId, input, token, deadline);
const run = await waitForRun(runId, token, deadline, timeoutMs);
if (run.status !== 'SUCCEEDED') {
const reason = run.statusMessage ? `: ${run.statusMessage}` : '';
throw new Error(`Apify actor ${actorId} finished with status ${run.status}${reason}`);
}
return await fetchDatasetItems(runId, token, deadline);
}