Examples & benchmarks / #86 · 2026-11-07 · Example · Medium · for web scraping, llm data
Crawl a whole site as a job: map, crawl, stop, resume, results, zip and a signed webhook
Map books.toscrape.com, crawl 100 pages of it as a background job, stop it part-way and resume it, then read the results as JSON lines and as a zip archive, and check the signed `job.done` and `job.archive` webhooks. Pages per minute and thread-seconds per page from the job's own bill, in five languages.
Run on production on 2026-11-10: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
One page is one call. A whole site is hundreds or thousands of pages, minutes or hours of work, and you don't want to hold a connection open for it, re-fetch what you already have after a stop, or poll forever to learn it's done. A crawl job runs in the background on your threads: you start it with one request, follow it, stop it and resume it where it left off, and get told by a signed webhook when it's finished. This case does the whole round trip on a site made for scraping practice, with a bounded crawl, and reads what it cost from the job itself.
What we used, and why
| What | Why |
|---|---|
POST /v1/functions/map | The instant map: the URLs a site lists in its sitemaps plus the links on its start page, in one call — a quick look at how big the site is before crawling it. |
POST /v1/functions/crawl | max_depth: 2, max_pages: 100, concurrency: 4, delay_ms: 250, formats: ["markdown"] and an extract of each page's h1 and price. Returns 202 {job_id} at once; the job runs in pooled Camoufox tabs. |
deliver: {webhook: {url, events: ["done", "archive"]}, format: "archive"} | A zip of every result built at the end, a job.done POST to your URL when the job ends, and a job.archive POST with the zip's bytes and download url when it is ready (only when events includes "archive"). The receiver in the programs is cdpfleet.com's own test inbox, which keeps deliveries an hour and reads them back with GET; use your own public https endpoint. |
GET /v1/jobs/{id} | Polled every 3 s: status, progress {done, failed, queued, total}, runs, concurrency, billed_seconds, results, archive. |
DELETE /v1/jobs/{id} → POST /v1/jobs/{id}/resume | Stop once 30 pages are done (stopping, then stopped, results kept), then continue as a second run from the job's checkpoint. |
GET /v1/jobs/{id}/results?cursor= and /results.jsonl | The stored results at the stop, part by part; at the end, everything as one JSON-lines download. |
GET /v1/jobs/{id}/archive | {status, bytes, url}: the url downloads the zip straight from storage, without your key. Read with the standard zip library in each language (Node: a short central-directory reader and zlib). |
GET /v1/me/webhook-secret | The account's whsec_…; each delivery's x-cdpfleet-signature: t=…,v1=… must equal HMAC-SHA256(secret, "<t>.<raw body>"). |
How it works
- Map the site with one instant call and count the URLs it found and where they came from.
- Start the crawl job with a webhook and
format: "archive"; poll it until 30 pages are done, then stop it and time how long it takes to reachstopped. Read the results stored so far and what the webhook inbox has received. - Resume it and poll until the second run has ended.
- Download
results.jsonl: count the lines, the distinct URLs, the pages with Markdown, the book pages with a price, the depths, and the URLs the second run added. - Wait for the archive, download the zip and list it: the Markdown and
extract.jsonfiles per page,index.jsonl,errors.jsonl,job.json. - Read the deliveries from the webhook inbox, check every signature with the account's secret, and download the zip again from the link in
job.archive. - Work out pages per minute and thread-seconds per page from
billed_secondsandconcurrency.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)
// Node.js 18+, no packages. env: CDPFLEET_API_KEY (a paid plan: jobs aren't on Community)
import crypto from 'node:crypto';
import zlib from 'node:zlib';
const KEY = process.env.CDPFLEET_API_KEY;
const API = 'https://cdpfleet.com';
const SITE = 'https://books.toscrape.com/'; // a sandbox made for scraping practice
const CRAWL = { max_depth: 2, max_pages: 100, concurrency: 4, delay_ms: 250, formats: ['markdown'] };
const EXTRACT = { title: 'h1', price: '.product_main .price_color' };
const STOP_AT = 30; // stop the job once this many pages are done, then resume it
const ENDED = ['done', 'failed', 'stopped', 'interrupted'];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const secs = (ms) => Math.round(ms / 100) / 10;
async function api(method, path, body) {
const res = await fetch(`${API}${path}`, {
method,
headers: { 'x-api-key': KEY, ...(body ? { 'content-type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch { /* not JSON */ }
return { status: res.status, json, text, headers: res.headers };
}
// Poll the job every 3 s until `until(job)` holds.
async function waitFor(id, until, ms) {
let job;
for (const end = Date.now() + ms; Date.now() < end; await sleep(3000)) {
job = (await api('GET', `/v1/jobs/${id}`)).json;
if (job && until(job)) return job;
}
throw new Error(`job ${id}: still ${job?.status} after ${ms / 1000} s`);
}
async function stored(id) { // results stored so far, part by part
const rows = [];
for (let cursor = 0; cursor !== null;) {
const r = (await api('GET', `/v1/jobs/${id}/results?cursor=${cursor}`)).json;
rows.push(...r.results);
if (r.next_cursor === cursor) break; // nothing new yet
cursor = r.next_cursor;
}
return rows;
}
// A zip's file names, and one file's bytes (Node has no unzip of its own; zip64-aware).
function unzip(buf, want) {
const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
let count = buf.readUInt16LE(eocd + 10);
let at = buf.readUInt32LE(eocd + 16);
if (count === 0xffff || at === 0xffffffff) {
const z = Number(buf.readBigUInt64LE(eocd - 20 + 8)); // zip64 end record
count = Number(buf.readBigUInt64LE(z + 32));
at = Number(buf.readBigUInt64LE(z + 48));
}
const names = [];
let data = null;
for (let i = 0; i < count; i++) {
let comp = buf.readUInt32LE(at + 20);
let local = buf.readUInt32LE(at + 42);
const method = buf.readUInt16LE(at + 10);
const nameLen = buf.readUInt16LE(at + 28);
const extraLen = buf.readUInt16LE(at + 30);
const name = buf.toString('utf8', at + 46, at + 46 + nameLen);
for (let x = at + 46 + nameLen; x < at + 46 + nameLen + extraLen; x += 4 + buf.readUInt16LE(x + 2)) {
if (buf.readUInt16LE(x) !== 1) continue; // zip64 sizes and offset
let p = x + 4;
if (buf.readUInt32LE(at + 24) === 0xffffffff) p += 8;
if (comp === 0xffffffff) { comp = Number(buf.readBigUInt64LE(p)); p += 8; }
if (local === 0xffffffff) local = Number(buf.readBigUInt64LE(p));
}
names.push(name);
if (name === want) {
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
const raw = buf.subarray(start, start + comp);
data = method === 8 ? zlib.inflateRawSync(raw) : raw;
}
at += 46 + nameLen + extraLen + buf.readUInt16LE(at + 32);
}
return { names, data };
}
// 1. Map: the site's URLs in one instant call (sitemaps + the start page's links).
let t = Date.now();
const map = await api('POST', '/v1/functions/map', { url: SITE, max_urls: 5000 });
const mapOut = {
status: map.status, seconds: secs(Date.now() - t), urls: map.json?.urls?.length ?? null,
sources: map.json?.sources ?? null, partial: map.headers.get('x-cdpfleet-partial'),
categories: (map.json?.urls || []).filter((u) => u.includes('/catalogue/category/')).length,
};
// 2. Start the crawl as a job: a zip archive at the end, and webhooks when it's done and when the zip is ready.
// The receiver here is cdpfleet.com's own test inbox (keeps deliveries an hour, read back with GET);
// use your own public https endpoint.
const hook = `${API}/e2e/hook/${crypto.randomBytes(18).toString('base64url')}`;
const tStart = Date.now();
const start = await api('POST', '/v1/functions/crawl', {
url: SITE, ...CRAWL, extract: EXTRACT,
deliver: { webhook: { url: hook, events: ['done', 'archive'] }, format: 'archive' },
});
if (start.status !== 202) throw new Error(`crawl ${start.status} ${start.text}`);
const id = start.json.job_id;
// 3. Watch it, and stop it part-way.
const before = await waitFor(id, (j) => (j.progress?.done || 0) >= STOP_AT || ENDED.includes(j.status), 600000);
const stopReply = await api('DELETE', `/v1/jobs/${id}`);
const tStop = Date.now();
const stopped = await waitFor(id, (j) => ENDED.includes(j.status), 300000);
const tStopped = Date.now();
const atStop = await stored(id);
const hooksAtStop = (await (await fetch(hook)).json()).items;
const archiveAtStop = (await api('GET', `/v1/jobs/${id}/archive`)).json;
// 4. Resume: a new run of the same job, from its checkpoint.
const tResume = Date.now();
const resume = await api('POST', `/v1/jobs/${id}/resume`);
// 5. Wait for the second run to end.
const done = await waitFor(id, (j) => j.runs >= 2 && ENDED.includes(j.status), 900000);
const tDone = Date.now();
// 6a. Every result as one JSON-lines file.
const jsonl = await (await fetch(`${API}/v1/jobs/${id}/results.jsonl`, { headers: { 'x-api-key': KEY } })).text();
const rows = jsonl.trim().split('\n').map((l) => JSON.parse(l));
const urls = new Set(rows.map((r) => r.url));
const firstRun = new Set(atStop.map((r) => r.url));
// 6b. The same results as a zip (built when the job ended).
const archive = await waitFor(id, (j) => ['ready', 'failed'].includes(j.archive?.status), 300000)
.then(() => api('GET', `/v1/jobs/${id}/archive`)).then((r) => r.json);
const zip = Buffer.from(await (await fetch(archive.url)).arrayBuffer()); // a storage link, no key
const { names, data: index } = unzip(zip, 'index.jsonl');
// 7. The webhooks: every delivery is signed with the account's secret, as HMAC-SHA256(secret, "<t>.<raw body>").
const secret = (await api('GET', '/v1/me/webhook-secret')).json.secret;
const check = (d) => {
const { t: ts, v1 } = Object.fromEntries((d.signature || '').split(',').map((kv) => kv.split('=')));
const body = JSON.parse(d.body);
return {
event: d.event, status: body.data?.status ?? null, job_id_matches: body.job_id === id,
delivery_id_is_uuid: /^[0-9a-f-]{36}$/.test(d.delivery || ''),
signature_valid: Boolean(v1) && v1 === crypto.createHmac('sha256', secret).update(`${ts}.${d.body}`).digest('hex'),
signed_at: Number(ts), data: body.data,
};
};
let deliveries = [];
for (const end = Date.now() + 90000; Date.now() < end; await sleep(3000)) {
deliveries = (await (await fetch(hook)).json()).items.map(check);
if (deliveries.some((d) => d.event === 'job.done' && d.status === 'done') && deliveries.some((d) => d.event === 'job.archive')) break;
}
const final = deliveries.find((d) => d.event === 'job.done' && d.status === 'done');
// job.archive carries the zip's size and a download link, like GET /v1/jobs/{id}/archive.
const archiveHook = deliveries.find((d) => d.event === 'job.archive');
const hookZip = typeof archiveHook?.data?.url === 'string' ? await fetch(archiveHook.data.url).then((r) => r.arrayBuffer()) : null;
// 8. What it cost: a run holds `concurrency` threads, billed_seconds is the total in thread-seconds.
const runSeconds = done.billed_seconds / done.concurrency;
console.log(JSON.stringify({
language: 'node',
site: SITE,
map: mapOut,
crawl: { ...CRAWL, extract: Object.keys(EXTRACT), start_status: start.status },
stop: {
requested_at_done: before.progress?.done ?? 0, reply: stopReply.json?.status ?? stopReply.status,
seconds_to_stopped: secs(tStopped - tStop), status: stopped.status, progress: stopped.progress,
stored_results: atStop.length, webhooks_by_then: hooksAtStop.map((d) => `${d.event} ${JSON.parse(d.body).data?.status}`), archive: archiveAtStop?.status ?? null,
},
resume: { status: resume.status, reply: resume.json?.status ?? null },
job: {
status: done.status, runs: done.runs, progress: done.progress, concurrency: done.concurrency,
billed_seconds: done.billed_seconds, results: { parts: done.results?.parts, bytes: done.results?.bytes },
started_at: done.started_at, ended_at: done.ended_at, expires_at: done.expires_at,
},
results_jsonl: {
lines: rows.length, distinct_urls: urls.size, bytes: Buffer.byteLength(jsonl),
status_200: rows.filter((r) => r.status === 200).length,
statuses: Object.fromEntries([...new Set(rows.map((r) => String(r.status)))].sort().map((s) => [s, rows.filter((r) => String(r.status) === s).length])),
errors: rows.filter((r) => r.error).map((r) => ({ url: r.url, status: r.status ?? null, error: String(r.error).slice(0, 120) })),
with_markdown: rows.filter((r) => typeof r.markdown === 'string' && r.markdown.length > 0).length,
books_with_price: rows.filter((r) => typeof r.extract?.price === 'string').length,
category_pages: rows.filter((r) => r.url.includes('/catalogue/category/')).length,
depths: Object.fromEntries([...new Set(rows.map((r) => r.depth))].sort().map((d) => [d, rows.filter((r) => r.depth === d).length])),
first_run: firstRun.size, second_run_new: [...urls].filter((u) => !firstRun.has(u)).length,
sample: rows.filter((r) => r.extract?.price).slice(0, 2).map((r) => ({ url: r.url, title: r.extract.title, price: r.extract.price })),
},
archive: {
status: archive.status, bytes: archive.bytes, downloaded_bytes: zip.length, files: names.length,
markdown_files: names.filter((n) => n.endsWith('/index.md')).length,
extract_files: names.filter((n) => n.endsWith('/extract.json')).length,
index_lines: index ? index.toString('utf8').trim().split('\n').length : 0,
has: ['index.jsonl', 'errors.jsonl', 'job.json'].filter((n) => names.includes(n)),
},
webhook: {
deliveries: deliveries.map(({ data, signed_at, ...d }) => d),
final_done: final ? { data: final.data, signed_seconds_after_end: final.signed_at - Math.floor(Date.parse(done.ended_at) / 1000) } : null,
archive: archiveHook ? {
status: archiveHook.data?.status ?? null, bytes: archiveHook.data?.bytes ?? null, has_url: typeof archiveHook.data?.url === 'string',
url_downloads_bytes: hookZip ? hookZip.byteLength : null, signed_seconds_after_end: archiveHook.signed_at - Math.floor(Date.parse(done.ended_at) / 1000),
} : null,
},
cost: {
billed_thread_seconds: done.billed_seconds, pages: rows.length,
run_seconds: Math.round(runSeconds * 10) / 10,
pages_per_minute: Math.round(rows.length / (runSeconds / 60) * 10) / 10,
thread_seconds_per_page: Math.round(done.billed_seconds / rows.length * 10) / 10,
client_running_seconds: secs(tStopped - tStart + tDone - tResume),
},
measured_at: new Date().toISOString().slice(0, 10),
}, null, 2));
# pip install requests
# env: CDPFLEET_API_KEY (a paid plan: jobs aren't on Community)
import hashlib
import hmac
import io
import json
import os
import re
import secrets
import time
import zipfile
from datetime import datetime, timezone
import requests
KEY = os.environ["CDPFLEET_API_KEY"]
API = "https://cdpfleet.com"
SITE = "https://books.toscrape.com/" # a sandbox made for scraping practice
CRAWL = {"max_depth": 2, "max_pages": 100, "concurrency": 4, "delay_ms": 250, "formats": ["markdown"]}
EXTRACT = {"title": "h1", "price": ".product_main .price_color"}
STOP_AT = 30 # stop the job once this many pages are done, then resume it
ENDED = ["done", "failed", "stopped", "interrupted"]
HEADERS = {"x-api-key": KEY}
def secs(s):
return round(s, 1)
def api(method, path, body=None):
return requests.request(method, API + path, headers=HEADERS, json=body, timeout=90)
def wait_for(job_id, until, seconds):
end = time.time() + seconds
job = None
while time.time() < end:
job = api("GET", f"/v1/jobs/{job_id}").json()
if until(job):
return job
time.sleep(3)
raise RuntimeError(f"job {job_id}: still {job and job.get('status')} after {seconds} s")
def stored(job_id): # results stored so far, part by part
rows, cursor = [], 0
while cursor is not None:
r = api("GET", f"/v1/jobs/{job_id}/results?cursor={cursor}").json()
rows += r["results"]
if r["next_cursor"] == cursor: # nothing new yet
break
cursor = r["next_cursor"]
return rows
def hook_items(hook):
return requests.get(hook, timeout=30).json()["items"]
# 1. Map: the site's URLs in one instant call (sitemaps + the start page's links).
t = time.time()
m = api("POST", "/v1/functions/map", {"url": SITE, "max_urls": 5000})
mj = m.json() if m.ok else {}
map_out = {
"status": m.status_code, "seconds": secs(time.time() - t), "urls": len(mj.get("urls", [])) if m.ok else None,
"sources": mj.get("sources"), "partial": m.headers.get("x-cdpfleet-partial"),
"categories": sum("/catalogue/category/" in u for u in mj.get("urls", [])),
}
# 2. Start the crawl as a job: a zip archive at the end, and webhooks when it's done and when the zip is ready.
# The receiver here is cdpfleet.com's own test inbox (keeps deliveries an hour, read back with GET);
# use your own public https endpoint.
hook = f"{API}/e2e/hook/{secrets.token_urlsafe(18)}"
t_start = time.time()
start = api("POST", "/v1/functions/crawl", {
"url": SITE, **CRAWL, "extract": EXTRACT,
"deliver": {"webhook": {"url": hook, "events": ["done", "archive"]}, "format": "archive"},
})
if start.status_code != 202:
raise RuntimeError(f"crawl {start.status_code} {start.text}")
job_id = start.json()["job_id"]
# 3. Watch it, and stop it part-way.
before = wait_for(job_id, lambda j: (j.get("progress") or {}).get("done", 0) >= STOP_AT or j["status"] in ENDED, 600)
stop_reply = api("DELETE", f"/v1/jobs/{job_id}")
t_stop = time.time()
stopped = wait_for(job_id, lambda j: j["status"] in ENDED, 300)
t_stopped = time.time()
at_stop = stored(job_id)
hooks_at_stop = hook_items(hook)
archive_at_stop = api("GET", f"/v1/jobs/{job_id}/archive").json()
# 4. Resume: a new run of the same job, from its checkpoint.
t_resume = time.time()
resume = api("POST", f"/v1/jobs/{job_id}/resume")
# 5. Wait for the second run to end.
done = wait_for(job_id, lambda j: j.get("runs", 0) >= 2 and j["status"] in ENDED, 900)
t_done = time.time()
# 6a. Every result as one JSON-lines file.
jsonl = api("GET", f"/v1/jobs/{job_id}/results.jsonl").content.decode("utf-8")
rows = [json.loads(line) for line in jsonl.strip().split("\n")]
urls = {r["url"] for r in rows}
first_run = {r["url"] for r in at_stop}
# 6b. The same results as a zip (built when the job ended).
wait_for(job_id, lambda j: (j.get("archive") or {}).get("status") in ("ready", "failed"), 300)
archive = api("GET", f"/v1/jobs/{job_id}/archive").json()
zip_bytes = requests.get(archive["url"], timeout=120).content # a storage link, no key
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as z:
names = z.namelist()
index = z.read("index.jsonl").decode("utf-8") if "index.jsonl" in names else ""
# 7. The webhooks: every delivery is signed with the account's secret, as HMAC-SHA256(secret, "<t>.<raw body>").
secret = api("GET", "/v1/me/webhook-secret").json()["secret"]
def check(d):
parts = dict(kv.split("=", 1) for kv in (d.get("signature") or "").split(",") if "=" in kv)
body = json.loads(d["body"])
want = hmac.new(secret.encode(), f"{parts.get('t')}.{d['body']}".encode(), hashlib.sha256).hexdigest()
return {
"event": d["event"], "status": (body.get("data") or {}).get("status"), "job_id_matches": body.get("job_id") == job_id,
"delivery_id_is_uuid": bool(re.fullmatch(r"[0-9a-f-]{36}", d.get("delivery") or "")),
"signature_valid": bool(parts.get("v1")) and hmac.compare_digest(parts["v1"], want),
"signed_at": int(parts.get("t") or 0), "data": body.get("data"),
}
deliveries = []
end = time.time() + 90
while time.time() < end:
deliveries = [check(d) for d in hook_items(hook)]
if any(d["event"] == "job.done" and d["status"] == "done" for d in deliveries) and any(d["event"] == "job.archive" for d in deliveries):
break
time.sleep(3)
final = next((d for d in deliveries if d["event"] == "job.done" and d["status"] == "done"), None)
# job.archive carries the zip's size and a download link, like GET /v1/jobs/{id}/archive.
archive_hook = next((d for d in deliveries if d["event"] == "job.archive"), None)
archive_hook_data = (archive_hook or {}).get("data") or {}
hook_zip = requests.get(archive_hook_data["url"], timeout=120).content if isinstance(archive_hook_data.get("url"), str) else None
# 8. What it cost: a run holds `concurrency` threads, billed_seconds is the total in thread-seconds.
run_seconds = done["billed_seconds"] / done["concurrency"]
ended_at = datetime.fromisoformat(done["ended_at"].replace("Z", "+00:00")).timestamp()
depths = sorted({r.get("depth") for r in rows})
print(json.dumps({
"language": "python",
"site": SITE,
"map": map_out,
"crawl": {**CRAWL, "extract": list(EXTRACT), "start_status": start.status_code},
"stop": {
"requested_at_done": (before.get("progress") or {}).get("done", 0),
"reply": (stop_reply.json() or {}).get("status", stop_reply.status_code),
"seconds_to_stopped": secs(t_stopped - t_stop), "status": stopped["status"], "progress": stopped.get("progress"),
"stored_results": len(at_stop),
"webhooks_by_then": [f"{d['event']} {(json.loads(d['body']).get('data') or {}).get('status')}" for d in hooks_at_stop],
"archive": archive_at_stop.get("status"),
},
"resume": {"status": resume.status_code, "reply": (resume.json() or {}).get("status")},
"job": {
"status": done["status"], "runs": done["runs"], "progress": done.get("progress"), "concurrency": done["concurrency"],
"billed_seconds": done["billed_seconds"],
"results": {"parts": done["results"]["parts"], "bytes": done["results"]["bytes"]},
"started_at": done["started_at"], "ended_at": done["ended_at"], "expires_at": done["expires_at"],
},
"results_jsonl": {
"lines": len(rows), "distinct_urls": len(urls), "bytes": len(jsonl.encode("utf-8")),
"status_200": sum(r.get("status") == 200 for r in rows),
"statuses": {s: sum(json.dumps(r.get("status")) == s for r in rows) for s in sorted({json.dumps(r.get("status")) for r in rows})},
"errors": [{"url": r["url"], "status": r.get("status"), "error": str(r["error"])[:120]} for r in rows if r.get("error")],
"with_markdown": sum(isinstance(r.get("markdown"), str) and len(r["markdown"]) > 0 for r in rows),
"books_with_price": sum(isinstance((r.get("extract") or {}).get("price"), str) for r in rows),
"category_pages": sum("/catalogue/category/" in r["url"] for r in rows),
"depths": {str(d): sum(r.get("depth") == d for r in rows) for d in depths},
"first_run": len(first_run), "second_run_new": len(urls - first_run),
"sample": [{"url": r["url"], "title": r["extract"]["title"], "price": r["extract"]["price"]}
for r in rows if (r.get("extract") or {}).get("price")][:2],
},
"archive": {
"status": archive["status"], "bytes": archive["bytes"], "downloaded_bytes": len(zip_bytes), "files": len(names),
"markdown_files": sum(n.endswith("/index.md") for n in names),
"extract_files": sum(n.endswith("/extract.json") for n in names),
"index_lines": len(index.strip().split("\n")) if index.strip() else 0,
"has": [n for n in ["index.jsonl", "errors.jsonl", "job.json"] if n in names],
},
"webhook": {
"deliveries": [{k: v for k, v in d.items() if k not in ("data", "signed_at")} for d in deliveries],
"final_done": {"data": final["data"], "signed_seconds_after_end": final["signed_at"] - int(ended_at)} if final else None,
"archive": {
"status": archive_hook_data.get("status"), "bytes": archive_hook_data.get("bytes"),
"has_url": isinstance(archive_hook_data.get("url"), str),
"url_downloads_bytes": len(hook_zip) if hook_zip is not None else None,
"signed_seconds_after_end": archive_hook["signed_at"] - int(ended_at),
} if archive_hook else None,
},
"cost": {
"billed_thread_seconds": done["billed_seconds"], "pages": len(rows),
"run_seconds": round(run_seconds, 1),
"pages_per_minute": round(len(rows) / (run_seconds / 60), 1),
"thread_seconds_per_page": round(done["billed_seconds"] / len(rows), 1),
"client_running_seconds": secs(t_stopped - t_start + t_done - t_resume),
},
"measured_at": datetime.now(timezone.utc).date().isoformat(),
}, indent=2, ensure_ascii=False))
// Java 21. Maven: com.google.code.gson:gson:2.11.0
// env: CDPFLEET_API_KEY (a paid plan: jobs aren't on Community)
import com.google.gson.*;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.SecureRandom;
import java.time.*;
import java.util.*;
import java.util.function.Predicate;
import java.util.zip.ZipFile;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String API = "https://cdpfleet.com";
static final String SITE = "https://books.toscrape.com/"; // a sandbox made for scraping practice
static final int MAX_DEPTH = 2, MAX_PAGES = 100, CONCURRENCY = 4, DELAY_MS = 250;
static final int STOP_AT = 30; // stop the job once this many pages are done, then resume it
static final Set<String> ENDED = Set.of("done", "failed", "stopped", "interrupted");
static final HttpClient HTTP = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();
static HttpResponse<byte[]> send(String method, String url, JsonObject body, boolean withKey) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(120));
if (withKey) b.header("x-api-key", KEY);
if (body != null) b.header("content-type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(body.toString()));
else b.method(method, HttpRequest.BodyPublishers.noBody());
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofByteArray());
}
static JsonObject json(HttpResponse<byte[]> r) {
try { return JsonParser.parseString(new String(r.body(), StandardCharsets.UTF_8)).getAsJsonObject(); } catch (Exception e) { return new JsonObject(); }
}
static JsonObject api(String method, String path, JsonObject body) throws Exception { return json(send(method, API + path, body, true)); }
static double num(JsonObject o, String k) { return o != null && o.has(k) && !o.get(k).isJsonNull() ? o.get(k).getAsDouble() : 0; }
static String str(JsonObject o, String k) { return o != null && o.has(k) && !o.get(k).isJsonNull() ? o.get(k).getAsString() : null; }
static JsonObject obj(JsonObject o, String k) { return o != null && o.has(k) && o.get(k).isJsonObject() ? o.getAsJsonObject(k) : new JsonObject(); }
static double round1(double v) { return Math.round(v * 10) / 10.0; }
// Poll the job every 3 s until `until` holds.
static JsonObject waitFor(String id, Predicate<JsonObject> until, int seconds) throws Exception {
JsonObject job = null;
for (long end = System.currentTimeMillis() + seconds * 1000L; System.currentTimeMillis() < end; Thread.sleep(3000)) {
job = api("GET", "/v1/jobs/" + id, null);
if (until.test(job)) return job;
}
throw new RuntimeException("job " + id + ": still " + str(job, "status") + " after " + seconds + " s");
}
static List<JsonObject> stored(String id) throws Exception { // results stored so far, part by part
List<JsonObject> rows = new ArrayList<>();
JsonElement cursor = new JsonPrimitive(0);
while (!cursor.isJsonNull()) {
JsonObject r = api("GET", "/v1/jobs/" + id + "/results?cursor=" + cursor.getAsLong(), null);
for (JsonElement e : r.getAsJsonArray("results")) rows.add(e.getAsJsonObject());
if (r.get("next_cursor").equals(cursor)) break; // nothing new yet
cursor = r.get("next_cursor");
}
return rows;
}
static JsonArray hookItems(String hook) throws Exception { return json(send("GET", hook, null, false)).getAsJsonArray("items"); }
static String hmac(String secret, String data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}
public static void main(String[] args) throws Exception {
// 1. Map: the site's URLs in one instant call (sitemaps + the start page's links).
long t = System.currentTimeMillis();
JsonObject mapBody = new JsonObject();
mapBody.addProperty("url", SITE);
mapBody.addProperty("max_urls", 5000);
HttpResponse<byte[]> mapRes = send("POST", API + "/v1/functions/map", mapBody, true);
JsonObject m = json(mapRes);
JsonObject mapOut = new JsonObject();
mapOut.addProperty("status", mapRes.statusCode());
mapOut.addProperty("seconds", round1((System.currentTimeMillis() - t) / 1000.0));
int categories = 0;
if (m.has("urls")) for (JsonElement u : m.getAsJsonArray("urls")) if (u.getAsString().contains("/catalogue/category/")) categories++;
mapOut.addProperty("urls", m.has("urls") ? m.getAsJsonArray("urls").size() : 0);
mapOut.add("sources", m.get("sources"));
mapOut.addProperty("partial", mapRes.headers().firstValue("x-cdpfleet-partial").orElse(null));
mapOut.addProperty("categories", categories);
// 2. Start the crawl as a job: a zip archive at the end, and webhooks when it's done and when the zip is ready.
// The receiver here is cdpfleet.com's own test inbox (keeps deliveries an hour, read back with GET);
// use your own public https endpoint.
byte[] token = new byte[18];
new SecureRandom().nextBytes(token);
String hook = API + "/e2e/hook/" + Base64.getUrlEncoder().withoutPadding().encodeToString(token);
JsonObject crawl = new JsonObject();
crawl.addProperty("max_depth", MAX_DEPTH);
crawl.addProperty("max_pages", MAX_PAGES);
crawl.addProperty("concurrency", CONCURRENCY);
crawl.addProperty("delay_ms", DELAY_MS);
crawl.add("formats", GSON.toJsonTree(List.of("markdown")));
JsonObject body = crawl.deepCopy();
body.addProperty("url", SITE);
body.add("extract", GSON.toJsonTree(Map.of("title", "h1", "price", ".product_main .price_color")));
body.add("deliver", JsonParser.parseString("{\"webhook\":{\"url\":\"" + hook + "\",\"events\":[\"done\",\"archive\"]},\"format\":\"archive\"}"));
long tStart = System.currentTimeMillis();
HttpResponse<byte[]> startRes = send("POST", API + "/v1/functions/crawl", body, true);
if (startRes.statusCode() != 202) throw new RuntimeException("crawl " + startRes.statusCode() + " " + new String(startRes.body()));
String id = str(json(startRes), "job_id");
// 3. Watch it, and stop it part-way.
JsonObject before = waitFor(id, j -> num(obj(j, "progress"), "done") >= STOP_AT || ENDED.contains(str(j, "status")), 600);
JsonObject stopReply = api("DELETE", "/v1/jobs/" + id, null);
long tStop = System.currentTimeMillis();
JsonObject stopped = waitFor(id, j -> ENDED.contains(str(j, "status")), 300);
long tStopped = System.currentTimeMillis();
List<JsonObject> atStop = stored(id);
JsonArray hooksAtStop = new JsonArray();
for (JsonElement d : hookItems(hook)) {
JsonObject b = JsonParser.parseString(str(d.getAsJsonObject(), "body")).getAsJsonObject();
hooksAtStop.add(str(d.getAsJsonObject(), "event") + " " + str(obj(b, "data"), "status"));
}
JsonObject archiveAtStop = api("GET", "/v1/jobs/" + id + "/archive", null);
// 4. Resume: a new run of the same job, from its checkpoint.
long tResume = System.currentTimeMillis();
HttpResponse<byte[]> resumeRes = send("POST", API + "/v1/jobs/" + id + "/resume", null, true);
// 5. Wait for the second run to end.
JsonObject done = waitFor(id, j -> num(j, "runs") >= 2 && ENDED.contains(str(j, "status")), 900);
long tDone = System.currentTimeMillis();
// 6a. Every result as one JSON-lines file.
byte[] jsonl = send("GET", API + "/v1/jobs/" + id + "/results.jsonl", null, true).body();
List<JsonObject> rows = new ArrayList<>();
for (String line : new String(jsonl, StandardCharsets.UTF_8).strip().split("\n")) rows.add(JsonParser.parseString(line).getAsJsonObject());
Set<String> urls = new LinkedHashSet<>(), firstRun = new HashSet<>();
for (JsonObject r : rows) urls.add(str(r, "url"));
for (JsonObject r : atStop) firstRun.add(str(r, "url"));
int status200 = 0, withMarkdown = 0, booksWithPrice = 0, categoryPages = 0;
JsonArray errors = new JsonArray(), sample = new JsonArray();
TreeMap<String, Integer> depthCount = new TreeMap<>(), statuses = new TreeMap<>();
for (JsonObject r : rows) {
if (num(r, "status") == 200) status200++;
statuses.merge(r.has("status") && !r.get("status").isJsonNull() ? String.valueOf(r.get("status").getAsInt()) : "null", 1, Integer::sum);
if (r.has("error") && !r.get("error").isJsonNull()) {
JsonObject e = new JsonObject();
e.addProperty("url", str(r, "url"));
e.add("status", r.has("status") ? r.get("status") : JsonNull.INSTANCE);
String msg = r.get("error").isJsonPrimitive() ? r.get("error").getAsString() : r.get("error").toString();
e.addProperty("error", msg.substring(0, Math.min(120, msg.length())));
errors.add(e);
}
String md = str(r, "markdown");
if (md != null && !md.isEmpty()) withMarkdown++;
String price = str(obj(r, "extract"), "price");
if (price != null) {
booksWithPrice++;
if (sample.size() < 2) {
JsonObject s = new JsonObject();
s.addProperty("url", str(r, "url"));
s.addProperty("title", str(obj(r, "extract"), "title"));
s.addProperty("price", price);
sample.add(s);
}
}
if (str(r, "url").contains("/catalogue/category/")) categoryPages++;
depthCount.merge(r.has("depth") ? String.valueOf(r.get("depth").getAsInt()) : "null", 1, Integer::sum);
}
long secondNew = urls.stream().filter(u -> !firstRun.contains(u)).count();
// 6b. The same results as a zip (built when the job ended).
waitFor(id, j -> Set.of("ready", "failed").contains(String.valueOf(str(obj(j, "archive"), "status"))), 300);
JsonObject archive = api("GET", "/v1/jobs/" + id + "/archive", null);
byte[] zipBytes = send("GET", str(archive, "url"), null, false).body(); // a storage link, no key
Path tmp = Files.createTempFile("crawl", ".zip");
Files.write(tmp, zipBytes);
List<String> names = new ArrayList<>();
int indexLines = 0;
try (ZipFile zf = new ZipFile(tmp.toFile())) {
zf.stream().forEach(e -> names.add(e.getName()));
var entry = zf.getEntry("index.jsonl");
if (entry != null) {
String text = new String(zf.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8).strip();
indexLines = text.isEmpty() ? 0 : text.split("\n").length;
}
}
Files.delete(tmp);
// 7. The webhooks: every delivery is signed with the account's secret, as HMAC-SHA256(secret, "<t>.<raw body>").
String secret = str(api("GET", "/v1/me/webhook-secret", null), "secret");
JsonArray deliveries = new JsonArray();
JsonObject finalData = null, archiveData = null;
long finalSignedAt = 0, archiveSignedAt = 0;
for (long end = System.currentTimeMillis() + 90000; System.currentTimeMillis() < end && (finalData == null || archiveData == null);) {
deliveries = new JsonArray();
finalData = null;
archiveData = null;
for (JsonElement el : hookItems(hook)) {
JsonObject d = el.getAsJsonObject();
Map<String, String> parts = new HashMap<>();
for (String kv : String.valueOf(str(d, "signature")).split(",")) { String[] p = kv.split("=", 2); if (p.length == 2) parts.put(p[0], p[1]); }
String raw = str(d, "body");
JsonObject b = JsonParser.parseString(raw).getAsJsonObject();
String want = hmac(secret, parts.get("t") + "." + raw);
JsonObject o = new JsonObject();
o.addProperty("event", str(d, "event"));
o.addProperty("status", str(obj(b, "data"), "status"));
o.addProperty("job_id_matches", id.equals(str(b, "job_id")));
o.addProperty("delivery_id_is_uuid", String.valueOf(str(d, "delivery")).matches("[0-9a-f-]{36}"));
o.addProperty("signature_valid", parts.containsKey("v1") && java.security.MessageDigest.isEqual(parts.get("v1").getBytes(), want.getBytes()));
deliveries.add(o);
if (finalData == null && "job.done".equals(str(d, "event")) && "done".equals(str(obj(b, "data"), "status"))) {
finalData = obj(b, "data");
finalSignedAt = Long.parseLong(parts.get("t"));
}
if (archiveData == null && "job.archive".equals(str(d, "event"))) {
archiveData = obj(b, "data");
archiveSignedAt = Long.parseLong(parts.get("t"));
}
}
if (finalData == null || archiveData == null) Thread.sleep(3000);
}
JsonElement finalOut = JsonNull.INSTANCE;
if (finalData != null) {
JsonObject f = new JsonObject();
f.add("data", finalData);
f.addProperty("signed_seconds_after_end", finalSignedAt - Instant.parse(str(done, "ended_at")).getEpochSecond());
finalOut = f;
}
// job.archive carries the zip's size and a download link, like GET /v1/jobs/{id}/archive.
JsonElement archiveHookOut = JsonNull.INSTANCE;
if (archiveData != null) {
JsonObject a = new JsonObject();
String hookZipUrl = str(archiveData, "url");
a.addProperty("status", str(archiveData, "status"));
a.add("bytes", archiveData.has("bytes") ? archiveData.get("bytes") : JsonNull.INSTANCE);
a.addProperty("has_url", hookZipUrl != null);
a.addProperty("url_downloads_bytes", hookZipUrl != null ? send("GET", hookZipUrl, null, false).body().length : null);
a.addProperty("signed_seconds_after_end", archiveSignedAt - Instant.parse(str(done, "ended_at")).getEpochSecond());
archiveHookOut = a;
}
// 8. What it cost: a run holds `concurrency` threads, billed_seconds is the total in thread-seconds.
double billed = num(done, "billed_seconds");
double runSeconds = billed / num(done, "concurrency");
JsonObject out = new JsonObject();
out.addProperty("language", "java");
out.addProperty("site", SITE);
out.add("map", mapOut);
JsonObject crawlOut = crawl.deepCopy();
crawlOut.add("extract", GSON.toJsonTree(List.of("title", "price")));
crawlOut.addProperty("start_status", startRes.statusCode());
out.add("crawl", crawlOut);
JsonObject stop = new JsonObject();
stop.addProperty("requested_at_done", (int) num(obj(before, "progress"), "done"));
stop.addProperty("reply", str(stopReply, "status"));
stop.addProperty("seconds_to_stopped", round1((tStopped - tStop) / 1000.0));
stop.addProperty("status", str(stopped, "status"));
stop.add("progress", stopped.get("progress"));
stop.addProperty("stored_results", atStop.size());
stop.add("webhooks_by_then", hooksAtStop);
stop.addProperty("archive", str(archiveAtStop, "status"));
out.add("stop", stop);
JsonObject resume = new JsonObject();
resume.addProperty("status", resumeRes.statusCode());
resume.addProperty("reply", str(json(resumeRes), "status"));
out.add("resume", resume);
JsonObject job = new JsonObject();
for (String k : List.of("status", "runs", "progress", "concurrency", "billed_seconds")) job.add(k, done.get(k));
JsonObject res = new JsonObject();
res.add("parts", obj(done, "results").get("parts"));
res.add("bytes", obj(done, "results").get("bytes"));
job.add("results", res);
for (String k : List.of("started_at", "ended_at", "expires_at")) job.add(k, done.get(k));
out.add("job", job);
JsonObject rj = new JsonObject();
rj.addProperty("lines", rows.size());
rj.addProperty("distinct_urls", urls.size());
rj.addProperty("bytes", jsonl.length);
rj.addProperty("status_200", status200);
rj.add("statuses", GSON.toJsonTree(statuses));
rj.add("errors", errors);
rj.addProperty("with_markdown", withMarkdown);
rj.addProperty("books_with_price", booksWithPrice);
rj.addProperty("category_pages", categoryPages);
rj.add("depths", GSON.toJsonTree(depthCount));
rj.addProperty("first_run", firstRun.size());
rj.addProperty("second_run_new", secondNew);
rj.add("sample", sample);
out.add("results_jsonl", rj);
JsonObject ar = new JsonObject();
ar.addProperty("status", str(archive, "status"));
ar.add("bytes", archive.get("bytes"));
ar.addProperty("downloaded_bytes", zipBytes.length);
ar.addProperty("files", names.size());
ar.addProperty("markdown_files", names.stream().filter(x -> x.endsWith("/index.md")).count());
ar.addProperty("extract_files", names.stream().filter(x -> x.endsWith("/extract.json")).count());
ar.addProperty("index_lines", indexLines);
ar.add("has", GSON.toJsonTree(List.of("index.jsonl", "errors.jsonl", "job.json").stream().filter(names::contains).toList()));
out.add("archive", ar);
JsonObject wh = new JsonObject();
wh.add("deliveries", deliveries);
wh.add("final_done", finalOut);
wh.add("archive", archiveHookOut);
out.add("webhook", wh);
JsonObject cost = new JsonObject();
cost.addProperty("billed_thread_seconds", billed);
cost.addProperty("pages", rows.size());
cost.addProperty("run_seconds", round1(runSeconds));
cost.addProperty("pages_per_minute", round1(rows.size() / (runSeconds / 60)));
cost.addProperty("thread_seconds_per_page", round1(billed / rows.size()));
cost.addProperty("client_running_seconds", round1((tStopped - tStart + tDone - tResume) / 1000.0));
out.add("cost", cost);
out.addProperty("measured_at", LocalDate.now(ZoneOffset.UTC).toString());
System.out.println(GSON.toJson(out));
}
}
// .NET 9, no packages. env: CDPFLEET_API_KEY (a paid plan: jobs aren't on Community)
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const string Api = "https://cdpfleet.com";
const string Site = "https://books.toscrape.com/"; // a sandbox made for scraping practice
const int StopAt = 30; // stop the job once this many pages are done, then resume it
var crawl = new JsonObject { ["max_depth"] = 2, ["max_pages"] = 100, ["concurrency"] = 4, ["delay_ms"] = 250, ["formats"] = new JsonArray("markdown") };
var ended = new HashSet<string> { "done", "failed", "stopped", "interrupted" };
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
async Task<HttpResponseMessage> Send(HttpMethod method, string url, JsonNode? body = null, bool withKey = true)
{
var req = new HttpRequestMessage(method, url);
if (withKey) req.Headers.Add("x-api-key", key);
if (body != null) req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
return await http.SendAsync(req);
}
async Task<JsonObject> Json(HttpResponseMessage r)
{
try { return JsonNode.Parse(await r.Content.ReadAsStringAsync())!.AsObject(); } catch { return new JsonObject(); }
}
async Task<JsonObject> CallApi(HttpMethod method, string path, JsonNode? body = null) => await Json(await Send(method, Api + path, body));
double Num(JsonNode? n) => n is JsonValue v && v.TryGetValue<double>(out var d) ? d : 0;
string? Str(JsonNode? n) => n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
double Round1(double v) => Math.Round(v, 1, MidpointRounding.AwayFromZero);
JsonNode? Copy(JsonNode? n) => n?.DeepClone();
// Poll the job every 3 s until `until` holds.
async Task<JsonObject> WaitFor(string id, Func<JsonObject, bool> until, int seconds)
{
JsonObject? job = null;
for (var end = DateTime.UtcNow.AddSeconds(seconds); DateTime.UtcNow < end; await Task.Delay(3000))
{
job = await CallApi(HttpMethod.Get, $"/v1/jobs/{id}");
if (until(job)) return job;
}
throw new Exception($"job {id}: still {Str(job?["status"])} after {seconds} s");
}
async Task<List<JsonObject>> Stored(string id) // results stored so far, part by part
{
var rows = new List<JsonObject>();
JsonNode? cursor = 0;
while (cursor != null)
{
var r = await CallApi(HttpMethod.Get, $"/v1/jobs/{id}/results?cursor={cursor.ToJsonString()}");
rows.AddRange(r["results"]!.AsArray().Select(x => x!.AsObject()));
if (r["next_cursor"]?.ToJsonString() == cursor.ToJsonString()) break; // nothing new yet
cursor = Copy(r["next_cursor"]);
}
return rows;
}
async Task<JsonArray> HookItems(string hook) => (await Json(await Send(HttpMethod.Get, hook, withKey: false)))["items"]!.AsArray();
// 1. Map: the site's URLs in one instant call (sitemaps + the start page's links).
var t = DateTime.UtcNow;
var mapRes = await Send(HttpMethod.Post, $"{Api}/v1/functions/map", new JsonObject { ["url"] = Site, ["max_urls"] = 5000 });
var m = await Json(mapRes);
var mapUrls = m["urls"]?.AsArray().Select(u => (string)u!).ToList() ?? new List<string>();
var mapOut = new JsonObject
{
["status"] = (int)mapRes.StatusCode, ["seconds"] = Round1((DateTime.UtcNow - t).TotalSeconds), ["urls"] = mapUrls.Count,
["sources"] = Copy(m["sources"]), ["partial"] = mapRes.Headers.TryGetValues("x-cdpfleet-partial", out var p) ? p.First() : null,
["categories"] = mapUrls.Count(u => u.Contains("/catalogue/category/")),
};
// 2. Start the crawl as a job: a zip archive at the end, and webhooks when it's done and when the zip is ready.
// The receiver here is cdpfleet.com's own test inbox (keeps deliveries an hour, read back with GET);
// use your own public https endpoint.
var hook = $"{Api}/e2e/hook/{Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)).Replace('+', '-').Replace('/', '_').TrimEnd('=')}";
var body = crawl.DeepClone().AsObject();
body["url"] = Site;
body["extract"] = new JsonObject { ["title"] = "h1", ["price"] = ".product_main .price_color" };
body["deliver"] = new JsonObject { ["webhook"] = new JsonObject { ["url"] = hook, ["events"] = new JsonArray("done", "archive") }, ["format"] = "archive" };
var tStart = DateTime.UtcNow;
var startRes = await Send(HttpMethod.Post, $"{Api}/v1/functions/crawl", body);
if ((int)startRes.StatusCode != 202) throw new Exception($"crawl {(int)startRes.StatusCode} {await startRes.Content.ReadAsStringAsync()}");
var id = (string)(await Json(startRes))["job_id"]!;
// 3. Watch it, and stop it part-way.
var before = await WaitFor(id, j => Num(j["progress"]?["done"]) >= StopAt || ended.Contains(Str(j["status"]) ?? ""), 600);
var stopReply = await CallApi(HttpMethod.Delete, $"/v1/jobs/{id}");
var tStop = DateTime.UtcNow;
var stopped = await WaitFor(id, j => ended.Contains(Str(j["status"]) ?? ""), 300);
var tStopped = DateTime.UtcNow;
var atStop = await Stored(id);
var hooksAtStop = new JsonArray((await HookItems(hook)).Select(d => (JsonNode?)$"{Str(d!["event"])} {Str(JsonNode.Parse(Str(d["body"])!)!["data"]?["status"])}").ToArray());
var archiveAtStop = await CallApi(HttpMethod.Get, $"/v1/jobs/{id}/archive");
// 4. Resume: a new run of the same job, from its checkpoint.
var tResume = DateTime.UtcNow;
var resumeRes = await Send(HttpMethod.Post, $"{Api}/v1/jobs/{id}/resume");
var resume = await Json(resumeRes);
// 5. Wait for the second run to end.
var done = await WaitFor(id, j => Num(j["runs"]) >= 2 && ended.Contains(Str(j["status"]) ?? ""), 900);
var tDone = DateTime.UtcNow;
// 6a. Every result as one JSON-lines file.
var jsonl = await (await Send(HttpMethod.Get, $"{Api}/v1/jobs/{id}/results.jsonl")).Content.ReadAsByteArrayAsync();
var rows = Encoding.UTF8.GetString(jsonl).Trim().Split('\n').Select(l => JsonNode.Parse(l)!.AsObject()).ToList();
var urls = rows.Select(r => Str(r["url"])!).ToHashSet();
var firstRun = atStop.Select(r => Str(r["url"])!).ToHashSet();
var depths = new JsonObject();
foreach (var g in rows.GroupBy(r => r["depth"]?.ToJsonString() ?? "null").OrderBy(g => g.Key)) depths[g.Key] = g.Count();
// 6b. The same results as a zip (built when the job ended).
await WaitFor(id, j => Str(j["archive"]?["status"]) is "ready" or "failed", 300);
var archive = await CallApi(HttpMethod.Get, $"/v1/jobs/{id}/archive");
var zipBytes = await (await Send(HttpMethod.Get, Str(archive["url"])!, withKey: false)).Content.ReadAsByteArrayAsync(); // a storage link, no key
List<string> names;
var indexLines = 0;
using (var zip = new ZipArchive(new MemoryStream(zipBytes), ZipArchiveMode.Read))
{
names = zip.Entries.Select(e => e.FullName).ToList();
var entry = zip.GetEntry("index.jsonl");
if (entry != null)
{
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
var text = (await reader.ReadToEndAsync()).Trim();
indexLines = text.Length == 0 ? 0 : text.Split('\n').Length;
}
}
// 7. The webhooks: every delivery is signed with the account's secret, as HMAC-SHA256(secret, "<t>.<raw body>").
var secret = Str((await CallApi(HttpMethod.Get, "/v1/me/webhook-secret"))["secret"])!;
var deliveries = new JsonArray();
JsonNode? finalData = null, archiveData = null;
long finalSignedAt = 0, archiveSignedAt = 0;
for (var end = DateTime.UtcNow.AddSeconds(90); DateTime.UtcNow < end && (finalData == null || archiveData == null);)
{
deliveries = new JsonArray();
finalData = null;
archiveData = null;
foreach (var d in await HookItems(hook))
{
var parts = (Str(d!["signature"]) ?? "").Split(',').Select(kv => kv.Split('=', 2)).Where(a => a.Length == 2).ToDictionary(a => a[0], a => a[1]);
var raw = Str(d["body"])!;
var b = JsonNode.Parse(raw)!;
var want = Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{parts.GetValueOrDefault("t")}.{raw}")));
deliveries.Add(new JsonObject
{
["event"] = Str(d["event"]), ["status"] = Str(b["data"]?["status"]), ["job_id_matches"] = Str(b["job_id"]) == id,
["delivery_id_is_uuid"] = Regex.IsMatch(Str(d["delivery"]) ?? "", "^[0-9a-f-]{36}$"),
["signature_valid"] = parts.TryGetValue("v1", out var v1) && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(v1), Encoding.ASCII.GetBytes(want)),
});
if (finalData == null && Str(d["event"]) == "job.done" && Str(b["data"]?["status"]) == "done")
{
finalData = Copy(b["data"]);
finalSignedAt = long.Parse(parts["t"]);
}
if (archiveData == null && Str(d["event"]) == "job.archive")
{
archiveData = Copy(b["data"]);
archiveSignedAt = long.Parse(parts["t"]);
}
}
if (finalData == null || archiveData == null) await Task.Delay(3000);
}
// job.archive carries the zip's size and a download link, like GET /v1/jobs/{id}/archive.
var hookZipUrl = Str(archiveData?["url"]);
int? hookZipBytes = hookZipUrl == null ? null : (await (await Send(HttpMethod.Get, hookZipUrl, withKey: false)).Content.ReadAsByteArrayAsync()).Length;
// 8. What it cost: a run holds `concurrency` threads, billed_seconds is the total in thread-seconds.
var billed = Num(done["billed_seconds"]);
var runSeconds = billed / Num(done["concurrency"]);
var crawlOut = crawl.DeepClone().AsObject();
crawlOut["extract"] = new JsonArray("title", "price");
crawlOut["start_status"] = (int)startRes.StatusCode;
Console.WriteLine(new JsonObject
{
["language"] = "csharp",
["site"] = Site,
["map"] = mapOut,
["crawl"] = crawlOut,
["stop"] = new JsonObject
{
["requested_at_done"] = Num(before["progress"]?["done"]), ["reply"] = Str(stopReply["status"]),
["seconds_to_stopped"] = Round1((tStopped - tStop).TotalSeconds), ["status"] = Str(stopped["status"]), ["progress"] = Copy(stopped["progress"]),
["stored_results"] = atStop.Count, ["webhooks_by_then"] = hooksAtStop, ["archive"] = Str(archiveAtStop["status"]),
},
["resume"] = new JsonObject { ["status"] = (int)resumeRes.StatusCode, ["reply"] = Str(resume["status"]) },
["job"] = new JsonObject
{
["status"] = Copy(done["status"]), ["runs"] = Copy(done["runs"]), ["progress"] = Copy(done["progress"]), ["concurrency"] = Copy(done["concurrency"]),
["billed_seconds"] = Copy(done["billed_seconds"]), ["results"] = new JsonObject { ["parts"] = Copy(done["results"]?["parts"]), ["bytes"] = Copy(done["results"]?["bytes"]) },
["started_at"] = Copy(done["started_at"]), ["ended_at"] = Copy(done["ended_at"]), ["expires_at"] = Copy(done["expires_at"]),
},
["results_jsonl"] = new JsonObject
{
["lines"] = rows.Count, ["distinct_urls"] = urls.Count, ["bytes"] = jsonl.Length,
["status_200"] = rows.Count(r => Num(r["status"]) == 200),
["statuses"] = new JsonObject(rows.GroupBy(r => r["status"]?.ToJsonString() ?? "null").OrderBy(g => g.Key, StringComparer.Ordinal).Select(g => KeyValuePair.Create(g.Key, (JsonNode?)g.Count()))),
["errors"] = new JsonArray(rows.Where(r => r["error"] != null).Select(r =>
{
var msg = r["error"] is JsonValue ev && ev.TryGetValue<string>(out var es) ? es : r["error"]!.ToJsonString();
return (JsonNode?)new JsonObject { ["url"] = Str(r["url"]), ["status"] = Copy(r["status"]), ["error"] = msg[..Math.Min(120, msg.Length)] };
}).ToArray()),
["with_markdown"] = rows.Count(r => !string.IsNullOrEmpty(Str(r["markdown"]))),
["books_with_price"] = rows.Count(r => Str(r["extract"]?["price"]) != null),
["category_pages"] = rows.Count(r => Str(r["url"])!.Contains("/catalogue/category/")),
["depths"] = depths,
["first_run"] = firstRun.Count, ["second_run_new"] = urls.Count(u => !firstRun.Contains(u)),
["sample"] = new JsonArray(rows.Where(r => Str(r["extract"]?["price"]) != null).Take(2)
.Select(r => (JsonNode?)new JsonObject { ["url"] = Str(r["url"]), ["title"] = Str(r["extract"]!["title"]), ["price"] = Str(r["extract"]!["price"]) }).ToArray()),
},
["archive"] = new JsonObject
{
["status"] = Str(archive["status"]), ["bytes"] = Copy(archive["bytes"]), ["downloaded_bytes"] = zipBytes.Length, ["files"] = names.Count,
["markdown_files"] = names.Count(x => x.EndsWith("/index.md")), ["extract_files"] = names.Count(x => x.EndsWith("/extract.json")),
["index_lines"] = indexLines,
["has"] = new JsonArray(new[] { "index.jsonl", "errors.jsonl", "job.json" }.Where(names.Contains).Select(x => (JsonNode?)x).ToArray()),
},
["webhook"] = new JsonObject
{
["deliveries"] = deliveries,
["final_done"] = finalData == null ? null : new JsonObject
{
["data"] = finalData, ["signed_seconds_after_end"] = finalSignedAt - DateTimeOffset.Parse(Str(done["ended_at"])!).ToUnixTimeSeconds(),
},
["archive"] = archiveData == null ? null : new JsonObject
{
["status"] = Str(archiveData["status"]), ["bytes"] = Copy(archiveData["bytes"]), ["has_url"] = hookZipUrl != null,
["url_downloads_bytes"] = hookZipBytes,
["signed_seconds_after_end"] = archiveSignedAt - DateTimeOffset.Parse(Str(done["ended_at"])!).ToUnixTimeSeconds(),
},
},
["cost"] = new JsonObject
{
["billed_thread_seconds"] = billed, ["pages"] = rows.Count, ["run_seconds"] = Round1(runSeconds),
["pages_per_minute"] = Round1(rows.Count / (runSeconds / 60)),
["thread_seconds_per_page"] = Round1(billed / rows.Count),
["client_running_seconds"] = Round1((tStopped - tStart + (tDone - tResume)).TotalSeconds),
},
["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// Go 1.22, standard library only. env: CDPFLEET_API_KEY (a paid plan: jobs aren't on Community)
package main
import (
"archive/zip"
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const api = "https://cdpfleet.com"
const site = "https://books.toscrape.com/" // a sandbox made for scraping practice
const stopAt = 30 // stop the job once this many pages are done, then resume it
var key = os.Getenv("CDPFLEET_API_KEY")
var crawl = map[string]any{"max_depth": 2, "max_pages": 100, "concurrency": 4, "delay_ms": 250, "formats": []string{"markdown"}}
var extract = map[string]string{"title": "h1", "price": ".product_main .price_color"}
var ended = map[string]bool{"done": true, "failed": true, "stopped": true, "interrupted": true}
var client = &http.Client{Timeout: 120 * time.Second}
type M = map[string]any
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func round1(f float64) float64 { return math.Round(f*10) / 10 }
// call sends a request with the API key (when withKey) and returns the status, headers and body.
func call(method, url string, body any, withKey bool) (int, http.Header, []byte) {
var rd io.Reader
if body != nil {
rd = bytes.NewReader(must(json.Marshal(body)))
}
req := must(http.NewRequest(method, url, rd))
if withKey {
req.Header.Set("x-api-key", key)
}
if body != nil {
req.Header.Set("content-type", "application/json")
}
res := must(client.Do(req))
defer res.Body.Close()
return res.StatusCode, res.Header, must(io.ReadAll(res.Body))
}
func apiJSON(method, path string, body any) (int, http.Header, M) {
status, h, b := call(method, api+path, body, true)
var m M
_ = json.Unmarshal(b, &m)
return status, h, m
}
func num(v any) float64 {
f, _ := v.(float64)
return f
}
func str(v any) string {
s, _ := v.(string)
return s
}
func obj(v any) M {
m, _ := v.(M)
return m
}
// waitFor polls the job every 3 s until until(job) holds.
func waitFor(id string, until func(M) bool, d time.Duration) M {
var job M
for end := time.Now().Add(d); time.Now().Before(end); time.Sleep(3 * time.Second) {
_, _, job = apiJSON("GET", "/v1/jobs/"+id, nil)
if job != nil && until(job) {
return job
}
}
log.Fatalf("job %s: still %v after %v", id, job["status"], d)
return nil
}
// stored returns the results stored so far, part by part.
func stored(id string) []M {
var rows []M
cursor := any(0.0)
for cursor != nil {
_, _, r := apiJSON("GET", fmt.Sprintf("/v1/jobs/%s/results?cursor=%v", id, cursor), nil)
for _, x := range r["results"].([]any) {
rows = append(rows, x.(M))
}
if r["next_cursor"] == cursor { // nothing new yet
break
}
cursor = r["next_cursor"]
}
return rows
}
func hookItems(hook string) []M {
_, _, b := call("GET", hook, nil, false)
var r struct{ Items []M }
must(0, json.Unmarshal(b, &r))
return r.Items
}
func main() {
// 1. Map: the site's URLs in one instant call (sitemaps + the start page's links).
t := time.Now()
mapStatus, mapHeaders, m := apiJSON("POST", "/v1/functions/map", M{"url": site, "max_urls": 5000})
urlsFound, categories := 0, 0
if list, ok := m["urls"].([]any); ok {
urlsFound = len(list)
for _, u := range list {
if strings.Contains(str(u), "/catalogue/category/") {
categories++
}
}
}
var partial any
if p := mapHeaders.Get("x-cdpfleet-partial"); p != "" {
partial = p
}
mapOut := M{"status": mapStatus, "seconds": round1(time.Since(t).Seconds()), "urls": urlsFound, "sources": m["sources"], "partial": partial, "categories": categories}
// 2. Start the crawl as a job: a zip archive at the end, and webhooks when it's done and when the zip is ready.
// The receiver here is cdpfleet.com's own test inbox (keeps deliveries an hour, read back with GET);
// use your own public https endpoint.
token := make([]byte, 18)
must(rand.Read(token))
hook := api + "/e2e/hook/" + base64.RawURLEncoding.EncodeToString(token)
body := M{"url": site, "extract": extract, "deliver": M{"webhook": M{"url": hook, "events": []string{"done", "archive"}}, "format": "archive"}}
for k, v := range crawl {
body[k] = v
}
tStart := time.Now()
startStatus, _, start := apiJSON("POST", "/v1/functions/crawl", body)
if startStatus != 202 {
log.Fatalf("crawl %d %v", startStatus, start)
}
id := str(start["job_id"])
// 3. Watch it, and stop it part-way.
before := waitFor(id, func(j M) bool { return num(obj(j["progress"])["done"]) >= stopAt || ended[str(j["status"])] }, 10*time.Minute)
_, _, stopReply := apiJSON("DELETE", "/v1/jobs/"+id, nil)
tStop := time.Now()
stopped := waitFor(id, func(j M) bool { return ended[str(j["status"])] }, 5*time.Minute)
tStopped := time.Now()
atStop := stored(id)
hooksAtStop := []string{}
for _, d := range hookItems(hook) {
var b M
_ = json.Unmarshal([]byte(str(d["body"])), &b)
hooksAtStop = append(hooksAtStop, fmt.Sprintf("%s %v", str(d["event"]), obj(b["data"])["status"]))
}
_, _, archiveAtStop := apiJSON("GET", "/v1/jobs/"+id+"/archive", nil)
// 4. Resume: a new run of the same job, from its checkpoint.
tResume := time.Now()
resumeStatus, _, resume := apiJSON("POST", "/v1/jobs/"+id+"/resume", nil)
// 5. Wait for the second run to end.
done := waitFor(id, func(j M) bool { return num(j["runs"]) >= 2 && ended[str(j["status"])] }, 15*time.Minute)
tDone := time.Now()
// 6a. Every result as one JSON-lines file.
_, _, jsonl := call("GET", api+"/v1/jobs/"+id+"/results.jsonl", nil, true)
var rows []M
for _, line := range strings.Split(strings.TrimSpace(string(jsonl)), "\n") {
var r M
must(0, json.Unmarshal([]byte(line), &r))
rows = append(rows, r)
}
urls, firstRun := map[string]bool{}, map[string]bool{}
for _, r := range rows {
urls[str(r["url"])] = true
}
for _, r := range atStop {
firstRun[str(r["url"])] = true
}
secondNew := 0
for u := range urls {
if !firstRun[u] {
secondNew++
}
}
status200, withMarkdown, booksWithPrice, categoryPages := 0, 0, 0, 0
errors, sample := []M{}, []M{}
depthCount, statuses := map[string]int{}, map[string]int{}
for _, r := range rows {
if num(r["status"]) == 200 {
status200++
}
if r["status"] == nil {
statuses["null"]++
} else {
statuses[fmt.Sprint(r["status"])]++
}
if e, ok := r["error"]; ok && e != nil {
msg := fmt.Sprint(e)
if len(msg) > 120 {
msg = msg[:120]
}
errors = append(errors, M{"url": r["url"], "status": r["status"], "error": msg})
}
if md, ok := r["markdown"].(string); ok && md != "" {
withMarkdown++
}
if price, ok := obj(r["extract"])["price"].(string); ok {
booksWithPrice++
if len(sample) < 2 {
sample = append(sample, M{"url": r["url"], "title": obj(r["extract"])["title"], "price": price})
}
}
if strings.Contains(str(r["url"]), "/catalogue/category/") {
categoryPages++
}
depthCount[fmt.Sprint(r["depth"])]++
}
// 6b. The same results as a zip (built when the job ended).
waitFor(id, func(j M) bool { s := str(obj(j["archive"])["status"]); return s == "ready" || s == "failed" }, 5*time.Minute)
_, _, archive := apiJSON("GET", "/v1/jobs/"+id+"/archive", nil)
_, _, zipBytes := call("GET", str(archive["url"]), nil, false) // a storage link, no key
zr := must(zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))))
names := map[string]bool{}
markdownFiles, extractFiles, indexLines := 0, 0, 0
for _, f := range zr.File {
names[f.Name] = true
if strings.HasSuffix(f.Name, "/index.md") {
markdownFiles++
}
if strings.HasSuffix(f.Name, "/extract.json") {
extractFiles++
}
if f.Name == "index.jsonl" {
rc := must(f.Open())
text := strings.TrimSpace(string(must(io.ReadAll(rc))))
rc.Close()
if text != "" {
indexLines = len(strings.Split(text, "\n"))
}
}
}
has := []string{}
for _, n := range []string{"index.jsonl", "errors.jsonl", "job.json"} {
if names[n] {
has = append(has, n)
}
}
// 7. The webhooks: every delivery is signed with the account's secret, as HMAC-SHA256(secret, "<t>.<raw body>").
_, _, sec := apiJSON("GET", "/v1/me/webhook-secret", nil)
secret := str(sec["secret"])
uuid := regexp.MustCompile(`^[0-9a-f-]{36}$`)
type delivery struct {
out M
signedAt int64
data any
}
check := func(d M) delivery {
parts := map[string]string{}
for _, kv := range strings.Split(str(d["signature"]), ",") {
if k, v, ok := strings.Cut(kv, "="); ok {
parts[k] = v
}
}
raw := str(d["body"])
var b M
_ = json.Unmarshal([]byte(raw), &b)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "." + raw))
want := hex.EncodeToString(mac.Sum(nil))
signedAt, _ := strconv.ParseInt(parts["t"], 10, 64)
return delivery{
out: M{
"event": d["event"], "status": obj(b["data"])["status"], "job_id_matches": str(b["job_id"]) == id,
"delivery_id_is_uuid": uuid.MatchString(str(d["delivery"])),
"signature_valid": parts["v1"] != "" && hmac.Equal([]byte(parts["v1"]), []byte(want)),
},
signedAt: signedAt, data: b["data"],
}
}
var deliveries []delivery
var final, archiveHook *delivery
for end := time.Now().Add(90 * time.Second); time.Now().Before(end); time.Sleep(3 * time.Second) {
deliveries = nil
for _, d := range hookItems(hook) {
deliveries = append(deliveries, check(d))
}
final, archiveHook = nil, nil
for i := range deliveries {
if deliveries[i].out["event"] == "job.done" && deliveries[i].out["status"] == "done" {
final = &deliveries[i]
}
if deliveries[i].out["event"] == "job.archive" {
archiveHook = &deliveries[i]
}
}
if final != nil && archiveHook != nil {
break
}
}
deliveryOut := []M{}
for _, d := range deliveries {
deliveryOut = append(deliveryOut, d.out)
}
endedAt := must(time.Parse(time.RFC3339Nano, str(done["ended_at"])))
var finalOut any
if final != nil {
finalOut = M{"data": final.data, "signed_seconds_after_end": final.signedAt - endedAt.Unix()}
}
// job.archive carries the zip's size and a download link, like GET /v1/jobs/{id}/archive.
var archiveHookOut any
if archiveHook != nil {
data := obj(archiveHook.data)
url, hasURL := data["url"].(string)
var downloaded any
if hasURL {
_, _, b := call("GET", url, nil, false)
downloaded = len(b)
}
archiveHookOut = M{
"status": data["status"], "bytes": data["bytes"], "has_url": hasURL, "url_downloads_bytes": downloaded,
"signed_seconds_after_end": archiveHook.signedAt - endedAt.Unix(),
}
}
// 8. What it cost: a run holds `concurrency` threads, billed_seconds is the total in thread-seconds.
billed := num(done["billed_seconds"])
runSeconds := billed / num(done["concurrency"])
pages := float64(len(rows))
depths := M{}
keys := make([]string, 0, len(depthCount))
for k := range depthCount {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
depths[k] = depthCount[k]
}
results := obj(done["results"])
stopReplyStatus := stopReply["status"]
out := M{
"language": "go",
"site": site,
"map": mapOut,
"crawl": M{"max_depth": 2, "max_pages": 100, "concurrency": 4, "delay_ms": 250, "formats": crawl["formats"], "extract": []string{"title", "price"}, "start_status": startStatus},
"stop": M{
"requested_at_done": num(obj(before["progress"])["done"]), "reply": stopReplyStatus,
"seconds_to_stopped": round1(tStopped.Sub(tStop).Seconds()), "status": stopped["status"], "progress": stopped["progress"],
"stored_results": len(atStop), "webhooks_by_then": hooksAtStop, "archive": archiveAtStop["status"],
},
"resume": M{"status": resumeStatus, "reply": resume["status"]},
"job": M{
"status": done["status"], "runs": done["runs"], "progress": done["progress"], "concurrency": done["concurrency"],
"billed_seconds": done["billed_seconds"], "results": M{"parts": results["parts"], "bytes": results["bytes"]},
"started_at": done["started_at"], "ended_at": done["ended_at"], "expires_at": done["expires_at"],
},
"results_jsonl": M{
"lines": len(rows), "distinct_urls": len(urls), "bytes": len(jsonl),
"status_200": status200, "statuses": statuses, "errors": errors, "with_markdown": withMarkdown, "books_with_price": booksWithPrice,
"category_pages": categoryPages, "depths": depths, "first_run": len(firstRun), "second_run_new": secondNew, "sample": sample,
},
"archive": M{
"status": archive["status"], "bytes": archive["bytes"], "downloaded_bytes": len(zipBytes), "files": len(zr.File),
"markdown_files": markdownFiles, "extract_files": extractFiles, "index_lines": indexLines, "has": has,
},
"webhook": M{"deliveries": deliveryOut, "final_done": finalOut, "archive": archiveHookOut},
"cost": M{
"billed_thread_seconds": billed, "pages": len(rows), "run_seconds": round1(runSeconds),
"pages_per_minute": round1(pages / (runSeconds / 60)),
"thread_seconds_per_page": round1(billed / pages),
"client_running_seconds": round1(tStopped.Sub(tStart).Seconds() + tDone.Sub(tResume).Seconds()),
},
"measured_at": time.Now().UTC().Format("2006-01-02"),
}
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
must(0, enc.Encode(out))
}
What we got
| Step | What happened | Measured |
|---|---|---|
| Map | 73 URLs in 6.4 s (sitemap 0, links 73); 51 category pages | 2026-11-10 |
| Crawl job | 202: max_depth 2, max_pages 100, concurrency 4, delay 250 ms, Markdown + extract | 2026-11-10 |
| Stop | DELETE at 30 pages → stopping; stopped 3.1 s later with 33 results stored; webhooks by then: job.done stopped | 2026-11-10 |
| Resume | 202 running → done after 2 runs; the second run added 67 new pages | 2026-11-10 |
| results.jsonl | 100 lines, 100 distinct URLs, 811 KB; 100 pages with Markdown (status 200 × 96, 407 × 4), 44 books with title and price, 0 failed | 2026-11-10 |
| Zip archive | 252 KB: 203 files, 100 index.md + 100 extract.json, index.jsonl 100 lines | 2026-11-10 |
| Webhooks | job.done (stopped) signature ok, job.done (done) signature ok, job.archive (ready) signature ok | 2026-11-10 |
| Speed | 100 pages in 246 s of running time: 24.4 pages/min | 2026-11-10 |
| Cost | 984 thread-seconds (9.8 per page) at concurrency 4 | 2026-11-10 |
From the Node.js run on 2026-11-10. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
{
"language": "node",
"site": "https://books.toscrape.com/",
"map": {
"status": 200,
"seconds": 6.4,
"urls": 73,
"sources": {
"sitemap": 0,
"links": 73
},
"partial": "fetch_errors",
"categories": 51
},
"crawl": {
"max_depth": 2,
"max_pages": 100,
"concurrency": 4,
"delay_ms": 250,
"formats": [
"markdown"
],
"extract": [
"title",
"price"
],
"start_status": 202
},
"stop": {
"requested_at_done": 30,
"reply": "stopping",
"seconds_to_stopped": 3.1,
"status": "stopped",
"progress": {
"done": 33,
"total": 100,
"failed": 0,
"queued": 67
},
"stored_results": 33,
"webhooks_by_then": [
"job.done stopped"
],
"archive": "none"
},
"resume": {
"status": 202,
"reply": "running"
},
"job": {
"status": "done",
"runs": 2,
"progress": {
"done": 100,
"total": 100,
"failed": 0,
"queued": 0
},
"concurrency": 4,
"billed_seconds": 984,
"results": {
"parts": 2,
"bytes": 830120
},
"started_at": "2026-11-10T10:17:45.403Z",
"ended_at": "2026-11-10T10:21:53.632Z",
"expires_at": "2026-11-24T10:21:53.632Z"
},
"results_jsonl": {
"lines": 100,
"distinct_urls": 100,
"bytes": 830120,
"status_200": 96,
"statuses": {
"200": 96,
"407": 4
},
"errors": [],
"with_markdown": 100,
"books_with_price": 44,
"category_pages": 53,
"depths": {
"0": 1,
"1": 73,
"2": 26
},
"first_run": 33,
"second_run_new": 67,
"sample": [
{
"url": "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
"title": "A Light in the Attic",
"price": "£51.77"
},
{
"url": "https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html",
"title": "Tipping the Velvet",
"price": "£53.74"
}
]
},
"archive": {
"status": "ready",
"bytes": 257662,
"downloaded_bytes": 257662,
"files": 203,
"markdown_files": 100,
"extract_files": 100,
"index_lines": 100,
"has": [
"index.jsonl",
"errors.jsonl",
"job.json"
]
},
"webhook": {
"deliveries": [
{
"event": "job.done",
"status": "stopped",
"job_id_matches": true,
"delivery_id_is_uuid": true,
"signature_valid": true
},
{
"event": "job.done",
"status": "done",
"job_id_matches": true,
"delivery_id_is_uuid": true,
"signature_valid": true
},
{
"event": "job.archive",
"status": "ready",
"job_id_matches": true,
"delivery_id_is_uuid": true,
"signature_valid": true
}
],
"final_done": {
"data": {
"status": "done",
"progress": {
"done": 100,
"failed": 0,
"queued": 0,
"total": 100
}
},
"signed_seconds_after_end": 0
},
"archive": {
"status": "ready",
"bytes": 257662,
"has_url": true,
"url_downloads_bytes": 257662,
"signed_seconds_after_end": 0
}
},
"cost": {
"billed_thread_seconds": 984,
"pages": 100,
"run_seconds": 246,
"pages_per_minute": 24.4,
"thread_seconds_per_page": 9.8,
"client_running_seconds": 249.8
},
"measured_at": "2026-11-10"
}Takeaways
- The whole round trip works, in all five languages. On 2026-11-10 (re-run after the Functions fixes of that day) each run mapped the site, started the crawl, stopped it, resumed it and finished with exactly 100 pages: 100 lines in
results.jsonl, 100 distinct URLs, every one with Markdown, none fetched twice. The depths were the same every time (1 start page, 73 at depth 1, 26 at depth 2); which 26 differed, so the book pages with a title and price came out 44 or 45. - Map first: 73 URLs in 4.6–10.6 s. books.toscrape.com has no sitemap, so the instant map is the start page's links (
sources: {sitemap: 0, links: 73}, andx-cdpfleet-partial: fetch_errorsfor the missing robots/sitemap files): 51 category pages, 20 books, the home page and page 2. For a site with sitemaps the same call lists up to 5,000 URLs;"job": truemaps deeper. - Stop is quick and loses nothing; resume continues, it doesn't restart.
DELETEat 30–38 pages answeredstopping; the job wasstopped3.1–12.5 s later with 33–45 results stored (the pages already in flight finish, at most 7 more).POST …/resumeanswered202 running, the second run fetched only URLs the first hadn't (55–67 of them) and the job endeddonewithruns: 2. No archive is built at a stop; it is built once, at the end. progresscounts againstmax_pages. At the stopprogressreadtotal: 100with 55–67queued, and at the end{done: 100, failed: 0, queued: 0, total: 100}: a progress bar can be drawn straight from it. (Until 2026-11-10totalcounted every link discovered, 1,600–1,800 here.)- The zip is about a third of the JSON lines.
results.jsonlwas 801–811 KB (Markdown inside JSON strings); the archive 250–252 KB with 203 files: anindex.mdand anextract.jsonper page, plusindex.jsonl(100 lines),errors.jsonlandjob.json. Use the JSON lines to stream results into code, the zip to hand a folder of Markdown to an LLM pipeline or keep it. - Three signed webhooks, each saying what happened. Every run received exactly three deliveries, all with a valid
x-cdpfleet-signature, the job's id and a uuid delivery id:job.donewithdata.status: "stopped"at the stop,job.donewithstatus: "done"and the final progress (signed in the same second asended_at), andjob.archive0–1 s later with the zip'sbytesand a downloadurl— the same size asGET …/archive, and the link downloaded the same zip without a key.job.archivecomes only becauseeventsasks for"archive"; with the default["done"]you get the twojob.done. (Before the 2026-11-10 fix the stop'sjob.donesaidinterrupted, andjob.archivecame unasked with only{status}.) - Check
markdown, not onlystatus. 2–4 of the 100 pages in three of the five runs came back withstatus: 407(Proxy Authentication Required — not what the site answered) while their Markdown and extracted price were complete (reported to Functions). Treat a page as fetched when it has its Markdown, and look aterrorfor the ones that weren't. - 24–29 pages a minute at concurrency 4, 8.4–9.8 thread-seconds a page. The two runs together ran 210–246 s for 100 pages and were billed 840–984 thread-seconds —
billed_secondsis exactlyconcurrency× running time (98–99% of the time the client saw the job running; the pause between stop and resume costs nothing). At this pace 1,000 pages take about 2.3–2.7 thread-hours of your plan; the Community proxy (the default) adds nothing. A page took 8–10 s in a pooled tab including the 250 ms delay, so the way to go faster is moreconcurrency, not a shorter delay.