siteone-crawler
CLAUDE.md938 starsChanged 5 months agoMITView on GitHub
Adapted from Andrej Karpathy's four CLAUDE.md principles — read these before touching code. They target reasoning failures (wrong assumptions, over-engineering, scope creep, weak success criteria), not formatting.
- Commits and pushes
The file
Show its rules and text
We split this CLAUDE.md into the rules it gives an agent
16 rules. About 0 are common, 9 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈9
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 12
Call out inconsistencies, confusion, and tradeoffs as you find them.
- Distinctive: line 15
No features beyond what was asked.
- Distinctive: line 16
No abstractions for single-use code.
- Distinctive: line 18
No error handling for scenarios that cannot occur.
- Distinctive: line 22
Don't refactor unbroken adjacent code that's orthogonal to the task.
- Distinctive: line 23
Only remove what your change rendered obsolete.
- Very specific: line 88
Run all registered analyzers (AnalysisManager::run_analyzers()): each analyzer gets read access to Status (all crawled data) and write …
- Distinctive: line 89
Add content processor stats table
- Very specific: line 166
Create a sample website directory, e.g.
- Distinctive: line 167
Add HTML files and assets simulating the desired scenario (spaces in filenames, special characters, redirect chains, broken links, specific …
- Very specific: line 168
Start the built-in server: ./target/release/siteone-crawler --serve-offline=./tmp/sample-website-xyz/ --serve-port=8888
- Distinctive: line 170
Verify the crawler handles the scenario correctly (output, offline export, analysis results)
- Very specific: line 207
Use Conventional Commits: feat:, fix:, refactor:, perf:, docs:, style:, ci:, chore:, test:.
- Very specific: line 215
Update version in Cargo.toml (version = "X.Y.Z")
- Very specific: line 217
Run cargo check so that Cargo.lock is updated with the new version
- Very specific: line 218
Commit all three files (Cargo.toml, src/version.rs, Cargo.lock): git commit -m "chore: bump version to X.Y.Z"
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Core Working Principles
Adapted from Andrej Karpathy's four CLAUDE.md principles — read these before touching code. They target reasoning failures (wrong assumptions, over-engineering, scope creep, weak success criteria), not formatting.
- Think Before Coding — Don't assume. Don't hide confusion. Surface tradeoffs.
- State assumptions explicitly; if uncertain, ask rather than guess.
- Present competing interpretations instead of silently picking one.
- Call out inconsistencies, confusion, and tradeoffs as you find them.
- Simplicity First — Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or configurability that wasn't requested.
- No error handling for scenarios that cannot occur.
- Surgical Changes — Touch only what you must. Clean up only your own mess.
- Match the surrounding style and conventions.
- Don't refactor unbroken adjacent code that's orthogonal to the task.
- Only remove what your change rendered obsolete.
- Goal-Driven Execution — Define success criteria. Loop until verified.
- Turn tasks into verifiable goals: "add validation" → "write tests, then make them pass"; "fix the bug" → "reproduce it in a test, then fix"; "refactor X" → "ensure tests pass before and after".
- Iterate against those criteria instead of asking for constant clarification.
Setup After Clone
git config core.hooksPath .githooks # enable pre-commit hook (fmt + clippy + tests)Build & Test Commands
cargo fmt # auto-format code (always run before build)
cargo build # debug build
cargo build --release # release build (~11s)
cargo test # unit tests + offline integration tests (~300 tests)
cargo test --test integration_crawl -- --ignored --test-threads=1 # network integration tests (crawls crawler.siteone.io)
cargo test scoring::ci_gate::tests::all_checks_pass # run a single test by name
cargo clippy -- -D warnings # lint (CI enforces zero warnings)
cargo fmt -- --check # format check
# Browser rendering (`--browser`, chromiumoxide/CDP) is a DEFAULT feature — `cargo build`,
# `cargo test`, `cargo clippy` all include it. Build/test the lean variant (no chromiumoxide):
cargo build --release --no-default-features # lean build, ~6 MB smaller, no browser
cargo test --no-default-features # tests without the browser feature
cargo clippy --no-default-features -- -D warnings # lint the lean variantQuick Run
./target/release/siteone-crawler --url=https://example.com --single-page
./target/release/siteone-crawler --url=https://example.com --output=json --http-cache-dir= # no cache
./target/release/siteone-crawler --html-to-markdown=page.html # convert local HTML to markdown (stdout)
./target/release/siteone-crawler --html-to-markdown=page.html --html-to-markdown-output=page.md # convert to file
./target/release/siteone-crawler --url=https://example.com --ai-provider=openai-compatible \
--ai-endpoint=http://localhost:8000/v1 --ai-model=my-model --ai-profile # chapter-based subject profile (MD+JSON+HTML)
./target/release/siteone-crawler --url=https://example.com --ai-profile --ai-dry-run # plan only, no API callsArchitecture
Crawl Lifecycle (in order)
- CLI Parsing (
Initiator→CoreOptions::parse_argv()): Parses 120+ CLI options, merges config file if present, validates. Exits with code 101 on error, code 2 on--help/--version. Non-crawl utility modes (--serve-markdown,--serve-offline,--html-to-markdown,--ai-list-models,--ai-check) exit early inmain.rsbefore creating the Manager. - Analyzer Registration (
Initiator::register_analyzers()): Creates all 18 analyzer instances (Accessibility, BestPractice, BrowserConsole, Caching, ContentType, DNS, ExternalLinks, Fastest, Headers, Page404, Redirects, Security, SeoAndOpenGraph, SkippedUrls, Slowest, SourceDomains, SslTls, Technologies) and registers them withAnalysisManager. Some analyzers receive config from CLI options (e.g.fastest_top_limit,max_heading_level);BrowserConsoleis active only in--browsermode. - Manager Setup (
Manager::run()): CreatesStatus(result storage),Output(text/json/multi),HttpClient(with optional proxy, auth, cache),ContentProcessorManager(HTML, CSS, JS, XML, Astro, Next.js, Svelte processors), and theCrawlerinstance. The crawl loop fetches through anArc<dyn Fetcher>(src/engine/fetcher.rs): by default theHttpClient; with thebrowserfeature +--browser, aBrowserRendererthat renders each HTML page in Chromium and returns the sameHttpResponse(plusbrowser_diagnostics). Everything downstream is unchanged. - Robots.txt Fetch (
Crawler::fetch_robots_txt()): Before crawling starts, fetches and parses/robots.txtfrom the initial domain. Respects--ignore-robots-txtoption. It also records per origin how the file was fetched (Status::set_robots_txt_state:RobotsFetchState::{Ok, NotFound, Unavailable, Skipped, NotAttempted}, redirects followed for the state only), which--ai-georeads; with--ai-geothe manager fetches, after the crawl, the robots.txt of the key-page origins the crawl did not read (≤ 10, e.g. thewwworigin the homepage redirects to; not in a dry run). - Crawl Loop (
Crawler::run()): Breadth-first concurrent URL processing:- URL queue (
DashMap) seeded with initial URL - Tokio tasks limited by
Semaphore(=--workerscount) + rate limiting (--max-reqs-per-sec) - Per-URL flow: check robots.txt → HTTP request → on error, store with negative status code → on success, run content processors → extract links from HTML → enqueue discovered URLs
- Content processors (
HtmlProcessor,CssProcessor, etc.) transform response bodies during crawl — used by offline/markdown exporters for URL rewriting - Each visited URL's response is stored in
Statusfor post-crawl analysis - Per-URL data collected: status code, headers, body, response time, content type, size, redirects
- URL queue (
- Post-Crawl Analysis (
Manager::run_post_crawl()): Sequential pipeline after crawling ends:- Transfer skipped URLs from crawler to
Status - Run all registered analyzers (
AnalysisManager::run_analyzers()): each analyzer gets read access toStatus(all crawled data) and write access toOutput(adds tables/findings) - Add content processor stats table
- Transfer skipped URLs from crawler to
- Exporters (
Manager::run_exporters()): Generate output files based on CLI options:SitemapExporter: XML/TXT sitemap filesOfflineWebsiteExporter: Static website copy with rewritten relative URLsMarkdownExporter: HTML→Markdown conversion with relative .md linksFileExporter: Save text/JSON output to fileHtmlReport: Self-contained HTML report (also used by Mailer and Upload)MailerExporter: Email HTML report via SMTPUploadExporter: Upload report to remote serverAnimationExporter(featurebrowser): Assemble per-page screenshots into a GIF/MP4 animation (GIF via the embeddedimagecrate, MP4 via external ffmpeg)AiReportExporter(JSON + HTML): when theextractAI action (--ai-report) produced anAiReportModel, atomically writes a no-clobber pair namedai-report.<preset>.<host>.<run-id>.json+.htmlAiElaborateExporter(Markdown + JSON + HTML): when--ai-elaborateproduced aBrandDoc, atomically writes a no-clobber triple namedai-elaborate.<template>.<host>.<run-id>.md+.json+.html(rolls back earlier files if a later one fails)AiConsistencyExporter(Markdown + JSON + HTML + CSV): when--ai-consistencyproduced aConsistencyDoc, writes the no-clobber setai-consistency.<host>.<run-id>.md+.json+.html+.csvviaexport/utils/atomic_files.rs::write_files_no_clobber(exclusive creation of the final paths; a failure removes only the files this call created)AiGeoExporter(Markdown + JSON + HTML + a kit directory): when--ai-geoproduced aGeoDoc, publishes the reportai-geo.<host>.<run-id>.{md,json,html}(viawrite_files_no_clobber) together with the kitai-geo-kit.<host>.<run-id>/(create_dirof the kit dir andcreate_newof every file, only into directories this call created; a failed report removes exactly the kit entries this call created). With--ai-consistencytoo, the manager exports consistency first and puts its HTML file name into theGeoDoc(ConsistencyLink) before this export
- Scoring (
scorer::calculate_scores()): Computes quality scores (0–10) across 5 weighted categories (Performance 20%, SEO 20%, Security 25%, Accessibility 20%, Best Practices 15%). Deductions come from summary findings (criticals, warnings) and stats (404s, 5xx, slow responses). - CI/CD Gate (
ci_gate::evaluate()): When--ciis active, checks scores and stats against configurable thresholds (--ci-min-score,--ci-max-404, etc.). Returns exit code 10 on failure. - Summary & Output (
Output::add_summary(),Output::end()): Prints summary table with OK/Warning/Critical counts, finalizes output. Exit code: 0 = success, 3 = no pages crawled, 10 = CI gate failed.
How Analyzers Work
Each analyzer implements the Analyzer trait (analysis/analyzer.rs). Analyzers are post-crawl only — they don't run during crawling. The AnalysisManager calls each analyzer's analyze(&Status, &mut Output) method after all URLs have been visited. Analyzers whose runs_without_working_urls() returns true (SSL/TLS) also run when no URL could be crawled at all, e.g. to explain why an HTTPS site failed. Analyzers read crawled data from Status (visited URLs, response headers, bodies, skipped URLs) and produce SuperTable instances that get added to Output. Analyzers also add Item entries to the Summary (OK, Warning, Critical, Info findings) which feed into scoring.
How Content Processors Work
Content processors implement ContentProcessor (content_processor/content_processor.rs) and run during crawl on each URL's response body. They serve two purposes: (1) transform content for offline/markdown export (rewrite URLs to relative paths), and (2) extract metadata (links, assets). Processors are type-specific: HtmlProcessor handles HTML, CssProcessor handles CSS url() references, etc. The ContentProcessorManager dispatches to the right processor based on content type.
Concurrency Model
The crawler uses tokio for async I/O with a semaphore-based worker pool (options.workers). Shared state uses:
Arc<DashMap<...>>for lock-free concurrent maps (URL queue, visited URLs, skipped URLs)Arc<Mutex<...>>for sequential-access state (Status, Output, AnalysisManager)Arc<AtomicBool/AtomicUsize>for simple flags and counters
Key Traits
Analyzer(analysis/analyzer.rs): Post-crawl analysis (SEO, security, headers, etc.). Each analyzer gets&Statusand&mut Output.Exporter(export/exporter.rs): Output generators (HTML report, offline website, markdown, sitemap, mailer, upload).Output(output/output.rs): Formatting backend. Implementations:TextOutput,JsonOutput,MultiOutput.ContentProcessor(content_processor/content_processor.rs): Per-URL content transformation during crawl (HTML, JS, CSS, XML processors).
Options System
CLI options are defined in options/core_options.rs via get_options() which returns an Options struct with typed option groups. Parsing flow: parse_argv() → merge config file → parse flags → CoreOptions::from_options() → apply_option_value() for each option. New CLI options require: adding the field to CoreOptions, a case in apply_option_value(), and an entry in the appropriate option group.
Exit Codes
| Code | Meaning |
| 0 | Success (with --ci: all thresholds passed) |
| 1 | Runtime error |
| 2 | Help/version displayed |
| 3 | No pages successfully crawled (DNS failure, timeout, etc.) |
| 10 | CI/CD quality gate failed |
| 101 | Configuration error |
HTTP Response Body
HttpResponse.body is Option<Vec<u8>> (not String) to preserve binary data for images, fonts, etc. Use body_text() for string content. Failed HTTP requests return Ok(HttpResponse) with negative status codes (-1 connection error, -2 timeout, -4 send error), not Err.
The crawler's HTTP client has automatic decompression off (HttpClient::client_builder(), also for the fallback client): the body is decoded by http_client::decode_body, while the headers stay as received, including Content-Encoding and the compressed Content-Length (#107). Reported sizes are the decoded length (Crawler::get_body_size, which trusts an asset's Content-Length only without Content-Encoding). Never switch reqwest's automatic decompression back on: it would strip those headers.
utils::get_flat_response_headers joins the instances of a repeated header with ", ", except set-cookie and x-robots-tag, which it joins with "\n" so that each X-Robots-Tag instance (with its own googlebot: scope) survives; utils::header_value_for_display maps the "\n" to " | " wherever a header value is shown (header analyzer tables, --extra-columns, JSON extras).
Testing Structure
- Unit tests: In-file
#[cfg(test)] mod testsblocks (standard Rust convention) - Integration tests:
tests/integration_crawl.rswith shared helpers intests/common/mod.rs - Network-dependent integration tests are
#[ignore]— run explicitly with--ignored
Testing Complex Scenarios with Sample Websites
The crawler has a built-in HTTP server (--serve-offline=<dir>) that can serve any local directory as a static website. This enables efficient local testing of edge cases without deploying a real site:
- Create a sample website directory, e.g.
./tmp/sample-website-xyz/ - Add HTML files and assets simulating the desired scenario (spaces in filenames, special characters, redirect chains, broken links, specific heading structures, etc.)
- Start the built-in server:
./target/release/siteone-crawler --serve-offline=./tmp/sample-website-xyz/ --serve-port=8888 - In another terminal, crawl the local site:
./target/release/siteone-crawler --url=http://127.0.0.1:8888/ - Verify the crawler handles the scenario correctly (output, offline export, analysis results)
This approach is useful for reproducing bug reports, testing regex edge cases (e.g. URLs with spaces, HTML entities, unusual attribute quoting), validating offline/markdown export for specific HTML structures, and any scenario that would be hard to find on a live website.
Key Files
src/engine/crawler.rs(~1700 lines): Core crawl loop, URL queue management, HTML/content parsingsrc/options/core_options.rs(~2500 lines): All 120+ CLI options, parsing, validationsrc/export/utils/offline_url_converter.rs(~1400 lines): URL-to-file-path conversion for offline exportsrc/export/html_report/report.rs: HTML report generation with embedded templatesrc/scoring/scorer.rs: Quality score calculation from summary findingssrc/scoring/ci_gate.rs: CI/CD threshold evaluationsrc/engine/fetcher.rs:Fetchertrait — the single seam the crawl loop fetches through;HttpClient(direct HTTP) andBrowserRendererboth implement itsrc/browser/(featurebrowser):BrowserRenderer(renderer.rs), Chromium detection/download/launch (launcher.rs), CDP diagnostics collection (diagnostics.rs), screenshots in every--screenshot-viewportsize + pre-capture animation settling (screenshot.rs),--screenshot-viewportparsing:WxH/presets, up to 5 sizes (viewport.rs), pre-capture auto-scroll for lazy-loaded and scroll-triggered content (auto_scroll.rs,--browser-auto-scroll, on by default), cookie-banner dismissal/hiding (cookie_consent.rs).diagnostics.rsdata types are always compiled soHttpResponsecan carry an inertOption<BrowserDiagnostics>, and so isviewport.rs, because option validation runs in every build. With--ai-geo,BrowserRenderer::fetchrecordsBrowserDiagnostics.raw_text_chars(the Main-region text of the HTML as fetched,ai::geo::render::own_text_chars, dialogs and cookie banners left out) before it replaces the body with the rendered DOM, so--ai-geocompares raw and rendered text without a refetchsrc/export/animation_exporter.rs(featurebrowser): builds GIF/MP4 animations from per-page screenshots (GIF via the embeddedimagecrate, MP4 via external ffmpeg; frames streamed to disk for O(1) memory)src/analysis/browser_console_analyzer.rs: reports browser console/JS/network/security diagnostics (the browser-mode analyzer; active only in--browsermode)src/ai/report/(--ai-report): first-class AI report engine —model.rs(AiReportModel: typed schema + per-page rows + derived rollups;FieldType::Findings+ReportCell::Findingsfor audit presets;ReportRow::erroredcarries an honest_error, never fabricated values, and is excluded from rollups),schema.rs(--ai-extract-fieldsDSL +--ai-schema-file→FieldSpecs + JSON Schema for native enforcement),coerce.rs(LLM-output → typed cells),presets.rs(ia,quality,topics,compliance— the last grounded in EU CCD2 loan-advertising rules). Theextractaction (src/ai/actions/extract.rs, dispatched inrunner.rs::run_extract_action) runs it per page with up to 3 parse attempts (each first runsnormalize::repair_json, a mechanical LLM-JSON repairer);src/export/ai_report_exporter.rs+ai_report_html.rsrender the JSON + self-contained HTML. Native JSON-schema enforcement plugs intoprovider.rs::shape_request(ChatRequest.json_schema);autoonly enforces for hosted OpenAI/Gemini (vLLMguided_jsonis unreliable — returns only the first field on some models)src/ai/elaborate/(--ai-elaborate): builds one large, richly structured brand profile (Markdown + JSON + self-contained HTML). Post-crawl pipeline (mirrorssrc/ai/summary/), orchestrated bymod.rs::run:selection.rs::build_candidates(full ranked universe) →cluster.rs(mass-entity URL clustering, e.g./blog/*) +nav.rs(global-nav detection) → optionalgapfill.rs(bounded, robots-honoring, mask-respecting fetch of nav pages the crawl missed) →select.rs(two id-constrained LLM rounds: the model only ever returns integer ids into numbered lists, so hallucinated URLs are impossible; a deterministic floor/cluster-rep union guards a weak round) →extract.rs(per-page typedPageEssence, verbatim entities) →merge.rs(deterministic dedup into an un-hallucinatableBrandModel— the crawler renders the structured lists, the LLM never does) →synthesize.rs(prose-only, single-shot or output-cap-driven sectioned map-reduce) into a fixed per-templates.rsskeleton → optionalcorrect.rspass (safe{from,to}edits applied to PROSE ONLY, unique-match, never touching verbatim data). Output language reuses--ai-report-language+report::locale::ReportLocale.doc.rs(BrandDoc) renders MD/JSON/HTML; failed pages are counted, listed, and excluded — never fabricated.--ai-elaborateis its own pipeline (not an--ai-actionsvalue): used alone it clears the default actions; combined with explicit--ai-actionsboth run.src/export/ai_elaborate_exporter.rswrites the paired MD+JSON+HTMLsrc/ai/profile/(--ai-profile): a second, independent elaborate-style pipeline that builds a polished, chapter-based subject profile (MD + JSON + self-contained HTML). Post-crawl (mod.rs::run): candidate universe →summarize.rs(≤1000-char site description) →classify.rs(id-constrained pick of one of 13 subject types; failure →general) →describe.rs(≤300-char per-page descriptions, parallel) → per-chapterselect.rs(numbered-id page selection, un-hallucinatable) +synthesize.rs(chapter markdown) +correct.rs(universal{from,to}edits with exact/regex:/ellipsis forms and delete guards), all chapters in parallel under--ai-max-concurrency→ executive summary. Prompt content is embedded from repo-rootprompts/profile/**viainclude_str!(registry.rs/embedded.rs, generated byscripts/gen_profile_prompts.py; parser inpromptpack.rs). Input budgets scale from--ai-context-window(reference 128K) inbudget.rs.doc.rs(ProfileDoc) renders MD/JSON/HTML;src/export/ai_profile_exporter.rswrites the no-clobber tripleai-profile.<type>.<host>.<run-id>.{md,json,html}. Left the existing--ai-elaborate(v1) untouched.src/ai/consistency/(--ai-consistency): finds possible contradictions in hard facts (contacts, prices, rates, fees, conditions, dates, figures, identifiers) across the crawled pages and their shared header/footer; Markdown + JSON + HTML + CSV. Post-crawl (mod.rs::run), fail-soft:sources.rs(numberedB12evidence blocks per page, scored and cut to the extraction budget in document order; the unique fact-bearing header/footer linesL7with their EXACT page sets, one-digit variants surviving the 300-line cap, ≤ 5 lines per chrome source) →extract.rs(one call per source; ≤ 5 facts, each kept only when it cites a supplied block, its quote is in that block and its value is token-bounded in the quote; the evidence is the crawler's copy, never model text) →keys.rs(perAttributeKey::bucket—feesharesprice,other_figuresharesspec— the model groups(subject, attribute)labels in chunks bounded by input bytes and output tokens, over up to 4 levels with rotating sort keys; incompleteness is recorded) →judge.rs(deterministic candidates at occurrence level ongrounding::ValueKey—Uncertainis never "consistent"; cohorts of ≤ 6 values; round-robin review allocation capped at 250; batched review (≤ 8 groups and an output ceiling of max(8,000, 2,000 + 1,500 × groups) tokens per call, split on truncation, groups an answer left out asked once more, dated articles judged against the crawl date) that may return several results per group; code gates: critical only for likely + money/legal-identity keys, numbers in prose must occur in the group, forbidden words → deterministic prose) →doc.rs(ConsistencyDoc, schemasiteone-crawler/ai-consistency/2: disclaimer and completeness state before the findings, a fixed caution line on each finding, by-page view, full audit trail; EN/CS fixed texts intext()). Static prompts in repo-rootprompts/consistency/{extract,group,judge}.md(prompts.rs); all website data goes in the user message inside<page_data>/<chrome_data>/<labels>/<groups>. Budgets are bytes of the final escaped message fromContextBudget(--ai-context-window). Progress tasksconsistency:extract|group|review;src/export/ai_consistency_exporter.rswrites the filessrc/ai/geo/(--ai-geo): AI search readiness (GEO) — how well AI search and answer engines can reach, read, understand and quote the site; report (Markdown + JSON + HTML, schemasiteone-crawler/ai-geo/2) + a deployable kit. Post-crawl (mod.rs::run, fail-soft; the deterministic part runs even whenbuild_configfails, the LLM categories then "not assessed"):keys.rs(key pages: homepage, homepage-linked, sitemap-listed, ≤ 200 — independent of--ai-max-pages) →robots_ai.rs+agents.rs(RFC 9309 matcher per origin and per AI crawler with purposes/compliance,explain,policy_equivalent) →access.rs(4xx/5xx/transport, redirect chains, suspected challenge / soft 404 on short pages, slow pages; in--browsermode without the render time) →controls.rs(scoped snippet directives per engine from meta + eachX-Robots-Taginstance,data-nosnippet, canonical) →render.rs(plain: app shell;--browser: raw vs rendered text fromraw_text_chars, overlays and collapsed text left out; a failed render gets the plain check) →signals.rs,jsonld.rs(existing markup; typed kit templates built from verified blocks),discovery.rs(sitemaps,lastmod, hreflang;sitemap_proposalonly for a crawl that followed every link it found —CrawlEndfrom the manager (--max-visited-urls,--max-queue-lengthviaCrawler::dropped_urls_at_queue_limit,--max-url-lengthviaCrawler::dropped_urls_over_max_length,--max-non200-responses-per-basenameviaCrawler::dropped_urls_for_basenameand the skipped (-6) links, interruption), no failed, refused (403) or dangling-redirect linked URL, no URL skipped for robots.txt that Googlebot or Bingbot may crawl (the manager passes the crawler's skipped list, which reachesStatusonly after the AI phase) — of a site without a declared or crawled sitemap) →analyze.rs(one call per page with numbered blocks in<page_data>, promptprompts/geo/page.md; the model returns block ids,verify_analysischecks every id, lead numbers, FAQ pair order, byline position and authorship (the name of a name-level author node or after a byline label, which the kit publishes; otherwise review-only), entity values; a lead only for pages that do not state their offer early) →findings.rs(statuses, evidence Strong/Moderate/Weak, priorities ≤ 10) →kit.rs(robots snippet withoutAllow,robots.proposed.txtonly under the safety rule, JSON-LD for indexable pages, FAQPage from ≥ 2 real questions, leads, entity drafts, llms.txt, sitemap) →doc.rs(GeoDoc, EN/CS fixed texts intext()). Progress taskgeo:pages, usage categoryAI GEO (page);src/export/ai_geo_exporter.rswrites the filessrc/ai/blocks.rs: DOM evidence blocks shared by AI pipelines —blocks_from_htmlwalks the parsed HTML in document order intoBlock { region: Main|Chrome, kind, heading_path, text, collapsed, hidden, landmark }(landmarknumbers the outermost chrome landmark of a Chrome block, 0 for Main, so a header/footer label never names a line of another landmark) (siteheader/footer/nav/aside= Chrome, but aheader/footerinsidearticle/mainis content; table rows carry their column headers; hidden/collapsed content is flagged, and content no visitor can open ([hidden]butuntil-found, inlinedisplay:noneorvisibility:hidden, not on a tab panel) is alsohidden, table rows, row groups and cells included, and hidden content inside a table cell becomes a block of its own, never text of its row, which GEO never builds markup from; each text node counted once)src/ai/grounding.rs: meaning-preserving matching shared by AI pipelines —normalize_for_match(with an offset map back to the original),contains_normalized,find_token_bounded(5 %never inside15 %,290never inside1 290),fact_signals, andvalue_key→ValueKey::{Exact, Uncertain}(full international phones,site_countryfor national ones, language-aware numbers keeping operators/ranges/units/time base, CS/EN dates; the LLM's normalized value is never used for the key). An uncertain equivalence never establishes "consistent"src/ai/telemetry.rs+src/ai/progress.rs: every LLM HTTP attempt and cache hit goes throughAiClient::complete_cached(src/ai/client.rs), which builds oneRequestRecordper attempt (tokens incl. reasoning/cached, send→body time, tok/s) andtelemetry::reports it: a line on stderr (AI ✓ #12 SEO 12/40 · /blog/post · 3,412 in · 812 out (540 reasoning) · 6.8 s · 119 tok/s, hidden by--hide-progress-bar) plus anaiRequestevent. The page/stage comes from a tokio task-local set bytelemetry::scope/progress::unitin each pipeline;progresscounts units per task (seo,report:<preset>,profile:chapters, …, oneadvanceper unit, failed ones too) for the12/40and theaiProgressevents. Usage is parsed shape-first (provider::parse_usage,parse_reasoning); an unknown shape only means missing statistics, never a panicsrc/ai/tools.rs(--ai-list-models,--ai-check): one-shot utility modes for the GUI's model picker, run frommain.rsbefore any crawl (no--url). Each prints exactly one JSON object on stdout, also on failure ({"ok":false,"error":…}, exit 1; exit 101 for a configuration error, answered in JSON without the help text) and never a credential.provider::models_request/parse_model_listshape the model-list GET and readdata[]/models[](context window frommax_model_len/max_input_tokens/inputTokenLimit, …);--ai-checksends one prompt through the normalAiClientwith the cache offsrc/events.rs+docs/EVENTS.md: the NDJSON event stream of--events-file(protocol 1, additive only): run/phase/URL/artifact/issue events and the AI eventsaiRequest,aiProgress,aiUsage.docs/EVENTS.mddocuments every event with an example line; keep it in step withevents.rs
Edition & Rust Version
Project uses edition = "2024" (Rust 1.85+) with rust-version = "1.94". Edition 2024 features used throughout: unsafe extern blocks, if let chaining (if let ... && ...), unsafe { std::env::set_var() }.
Commit Policy
Never commit automatically. Commits are only allowed on explicit user request. Before every commit, always run git status, review the changes, and stage only the relevant files — never use git add -A or git add . blindly.
Commit Messages
Use Conventional Commits: feat:, fix:, refactor:, perf:, docs:, style:, ci:, chore:, test:. Examples:
feat: add built-in HTTP server for markdown/offline exportsfix: correct non-ASCII text corruption in heading ID generationperf: eliminate heap allocation in content_type_for_extensionchore: bump version to 2.0.3
Releasing a New Version
- Update version in
Cargo.toml(version = "X.Y.Z") - Update version in
src/version.rs(pub const CODE: &str = "X.Y.Z.YYYYMMDD";) - Run
cargo checkso thatCargo.lockis updated with the new version - Commit all three files (
Cargo.toml,src/version.rs,Cargo.lock):git commit -m "chore: bump version to X.Y.Z" - Tag and push:
git tag vX.Y.Z && git push && git push --tags
Important Conventions
- Tables, column order, and formatting must stay consistent across versions. The HTML parser uses the
scrapercrate. - HTTP cache lives in
tmp/http-client-cache/by default. Delete it for fresh crawls or use--http-cache-dir=to disable. rustlsrequires explicitringCryptoProvider installation inmain.rs.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

