"""Crawl a site with WebCrawlerAPI, split the markdown by headers, embed with local Ollama, write chunks.jsonl. Usage: WEBCRAWLERAPI_API_KEY=... python build_chunks.py https://example.com/docs --items-limit 50 \ --whitelist 'https://example\\.com/docs.*' --out chunks.jsonl Output format and chunking match the shared pipeline, so load.py reads the result as is. """ import argparse, hashlib, json, os, re, sys, time import requests API = "https://api.webcrawlerapi.com/v1" OLLAMA = "http://localhost:11434/api/embed" MODEL, DIM = "nomic-embed-text", 768 MIN_TOKENS, MAX_TOKENS = 40, 500 HARD_CAP = 60 # refuse to crawl more pages than this def tokens(text): return len(text.split()) * 4 // 3 # rough estimate, no tokenizer dependency def api(method, path, key, **kw): # Errors are re-raised without the request object so the key never reaches the output. try: r = requests.request(method, API + path, headers={"Authorization": f"Bearer {key}"}, timeout=60, **kw) except requests.RequestException as e: raise SystemExit(f"request to {path} failed: {type(e).__name__}") from None if r.status_code >= 400: raise SystemExit(f"{method} {path} returned HTTP {r.status_code}: {r.text[:200]}") return r.json() def crawl(url, items_limit, whitelist, key, poll=5, timeout=900): body = {"url": url, "items_limit": items_limit, "output_formats": ["markdown"], "respect_robots_txt": True} if whitelist: body["whitelist_regexp"] = whitelist job_id = api("POST", "/crawl", key, json=body)["id"] print(f"job {job_id} started", file=sys.stderr) t0 = time.time() while True: job = api("GET", f"/job/{job_id}", key) if job["status"] in ("done", "error"): return job if time.time() - t0 > timeout: raise SystemExit(f"job {job_id} still {job['status']} after {timeout}s") time.sleep(poll) def clean(text): text = re.sub(r"^\[[^\n]*?\]\(/docs[^)]*\)[^\n]*\n", "", text) # breadcrumb line text = re.sub(r"^\[Open in Claude.*\n", "", text, flags=re.M) # "Open in ..." links return re.sub(r"\[([^\]]+)#\]\(#[^)]*\)", r"\1", text) # heading anchors def split_sections(markdown): """Split on H1-H3 outside code fences. Returns [(heading_path, body)].""" sections, path, body, in_fence = [], [], [], False def flush(): text = "\n".join(body).strip() if text: sections.append((" > ".join(path), text)) body.clear() for line in markdown.splitlines(): if line.startswith("```"): in_fence = not in_fence m = None if in_fence else re.match(r"(#{1,3}) (.+)", line) if m: flush() path[:] = path[: len(m.group(1)) - 1] + [m.group(2).strip()] body.append(line) flush() return sections def split_paragraphs(text): """Break an oversized section on blank lines, never inside a code fence.""" parts, cur, in_fence = [], [], False for line in text.splitlines(): in_fence ^= line.startswith("```") if not line.strip() and not in_fence and cur: parts.append("\n".join(cur)); cur = [] else: cur.append(line) if cur: parts.append("\n".join(cur)) chunks, buf = [], "" for p in parts: if buf and tokens(buf + p) > MAX_TOKENS: chunks.append(buf); buf = "" buf = f"{buf}\n\n{p}" if buf else p return chunks + [buf] def chunk_page(markdown): merged = [] for heading, text in split_sections(markdown): if merged and tokens(text) < MIN_TOKENS: merged[-1] = (merged[-1][0], merged[-1][1] + "\n\n" + text) else: merged.append((heading, text)) return [(h, part) for h, t in merged for part in (split_paragraphs(t) if tokens(t) > MAX_TOKENS else [t])] def embed(texts, prefix): r = requests.post(OLLAMA, json={"model": MODEL, "input": [prefix + t for t in texts]}, timeout=300) r.raise_for_status() return r.json()["embeddings"] def main(): ap = argparse.ArgumentParser() ap.add_argument("url") ap.add_argument("--items-limit", type=int, default=50) ap.add_argument("--whitelist", default="", help="regexp: only matching URLs are crawled") ap.add_argument("--out", default="chunks.jsonl") args = ap.parse_args() if args.items_limit > HARD_CAP: raise SystemExit(f"items-limit above {HARD_CAP} refused") key = os.environ.get("WEBCRAWLERAPI_API_KEY") if not key: raise SystemExit("set WEBCRAWLERAPI_API_KEY") t0 = time.time() job = crawl(args.url, args.items_limit, args.whitelist, key) items = [i for i in job.get("job_items", []) if i.get("status") == "done" and i.get("markdown_content_url")] cost = sum(i.get("cost") or 0 for i in job.get("job_items", [])) print(f"job status {job['status']}: {len(job.get('job_items', []))} items, {len(items)} with markdown, " f"summed item cost {cost}", file=sys.stderr) rows = [] for item in sorted(items, key=lambda i: i["original_url"]): md = requests.get(item["markdown_content_url"], timeout=60) # public URL, no auth header md.raise_for_status() url = item["original_url"] for i, (heading, text) in enumerate(chunk_page(clean(md.text))): rows.append({"id": hashlib.sha1(f"{url}#{i}".encode()).hexdigest()[:16], "url": url, "heading": heading, "text": text}) print(f"{len(rows)} chunks from {len(items)} pages, embedding...", file=sys.stderr) with open(args.out, "w") as out: for i in range(0, len(rows), 32): batch = rows[i:i + 32] vectors = embed([f"{r['heading']}\n{r['text']}" for r in batch], "search_document: ") for r, vec in zip(batch, vectors): out.write(json.dumps({**r, "embedding": vec, "model": MODEL, "dim": DIM}) + "\n") print(f"wrote {args.out} ({len(rows)} chunks) in {time.time() - t0:.1f}s", file=sys.stderr) if __name__ == "__main__": main()