CanSpider — LLM-Planned Crawling Platform
The bottleneck
Faculty and research-funding data lives behind 143 different site structures, none of them stable, none of them documented. The existing process was one person collecting five or six university departments a day by hand. The obvious fix — write a spider per site — fails on contact with reality: every hand-written spider breaks the first time a department redesigns its people page, and you end up maintaining N brittle scrapers instead of doing the work.
The idea, and the part that matters
The idea is not "use an LLM to scrape." Anyone can do that, and the result is a system that silently produces garbage.
The idea is that the model proposes a crawl plan and the system decides whether to
trust it. gpt-5.1 reads a compressed DOM digest of a site and emits a structured
plan. Before a single real request is made, that plan goes through three gates:
- A static linter — checks selector-type homogeneity, that every selector actually
translates through
cssselect, that no content-specific string literals leaked into what should be a general rule, and that regex host scoping can't wander off-domain. - A dry run against the live page, fetching a handful of sample profiles and reporting per-field coverage. A plan that extracts names but silently misses affiliations fails here, not in production.
- An auto-repair loop, up to two rounds, feeding the failures back to the model.
Only a plan that survives all three gets executed. This is the same instinct behind everything else I build: the model is allowed to be creative, and then something deterministic has to be accountable for what actually happened.
Execution
One generic PlanSpider (Scrapy + scrapy-playwright, five pagination strategies)
runs every plan — so there is exactly one crawler to maintain, not 143. Output lands on
a Kafka topic, where a digestion consumer running 50 async workers behind a 12-way LLM
semaphore turns raw pages into a structured ~40-field record via gpt-5-nano, then
resolves entities with RapidFuzz and upserts idempotently into a curated table.
Cost was a first-class design constraint, not an afterthought: a prompt-hash response cache in Qdrant (161K digestion + 189K classification cached records) and a token-bucket limiter pinned at 75% of TPM and 95% of RPM held the marginal cost near CAD 0.03 per researcher record.
Results
- 64,572 curated researcher records across 143 institutions in 7 countries
- 209,753 pages crawled · 209,162 classifications · 156,748 digests
- 3,119 crawl jobs at 97.3% completion, peaking at 12,879 digests in one day
- 167K interest and expertise labels, clustered into 152 subfields under 15 domains
- ~CAD 0.03 per record — roughly CAD 2,000 for the full corpus
The honest version of the headline: the first 40 days produced about 32,000 profiles across 53 institutions. Reaching 64K took roughly four months as coverage widened.
What I'd fix
There are no automated tests and no CI on this repo, and the operator console has a 5,000-line page component that should have been decomposed three refactors ago. Both are consequences of building it alone under time pressure, and both are the first things I'd address with a second engineer.