Changelog 3.1.0

What changed in SpiderHead, release by release. Each entry says what kind of change it is and whether it works today.

Releases on GitHub
Release

3.1.0

First tagged release after the rename from SEOHEAD Tools11 October 2026455 entries

11 October 2026

5 added54 changed2 fixed
  • ChangedWorksRebuild Project settings Schedule and Crawl with shared native canvas components, offline#1323

    Roboto fonts and Material Symbols, and a single-column layout at 800 px.

    #1323 (issue on GitHub)
  • ChangedWorksUse English throughout the application interface, retain existing schedule command and#1323

    document contracts, and display only core-backed scheduling data.

    #1323 (issue on GitHub)
  • ChangedWorksPreserve workspace identities and aliases while Project settings is open; prevent#1323

    duplicate saves while the core is responding.

    #1323 (issue on GitHub)
  • ChangedWorksDesktop project settings get a Schedule tab (weekdays, days of month, run window, time#1322

    zone, competitors) and a Crawl tab (crawl mode, User-Agent preset or custom string, pause between requests, URL budget, SpiderHeadBot or a custom user agent); both read and write the project through the core, and the project overview shows the next and last scheduled run. A --tray mode runs the core's due scans and keeps the Mac awake only while a scan runs.

    #1322 (issue on GitHub)
  • ChangedWorksScheduled project scans (#1322, core): schedule.json in the project directory holds the#1322

    schedule (weekdays and/or month days, a time window in the site's time zone, enable/disable, competitors, crawl mode spider/sitemap/list, per-run URL and request budgets, politeness delay, and slice mode that resumes the previous partial crawl). It is edited through schedule-set / seo_schedule_set with expected_revision, and schedule-show reports the last run and next due window. schedule-run-due is a headless entry point that runs only due scans, refuses overlap with a lease, and records every decision in the project journal. schedule-install and schedule-uninstall write or remove a launchd plist, cron line or Task Scheduler command only when called explicitly.

    #1322 (issue on GitHub)
  • ChangedWorksCrawl user agents (#1322): presets (spiderhead, googlebot, googlebot-desktop,#1322

    googlebot-smartphone, bingbot, yandexbot, yandexmobilebot, baiduspider, duckduckbot, applebot, gptbot, claudebot, perplexitybot, and desktop and mobile browsers) or one custom string. The default is now SpiderHeadBot/<version> (+https://github.com/PavloSEO/spiderhead), and robots.txt group matching uses the same identity. Sites that matched the former SEOHEAD-Tools robots group must name SpiderHeadBot in their rules.

    #1322 (issue on GitHub)
  • ChangedWorksA saved project crawl now marks the automatic checklist items it measured.#1314

    Each enabled check item resolves to one of three outcomes: succeeded when the saved audit ran the check, with the scan as evidence; unavailable when the crawl skipped or disabled it, with the crawl's reason kept; or unmeasured when the audit does not mention the check at all. An unmeasured item keeps its previous record and is reported by name in checklist_evidence, so it never counts as 0. A crawl that fails or is interrupted marks its automatic checks failed with the failure reason. The same recording path now serves the prepare flow, which previously carried its own copy of the loop. A project whose checklist is not initialized is skipped with a stated reason.

    #1314 (issue on GitHub)
  • ChangedWorksCompetitor workspaces keep the URL as entered: a competitor crawl starts from its www#1314

    URL instead of the bare host, and a crawl of a competitor URL no longer marks the owner's checklist items; the result is recorded only in the competitor's own workspace. Opening a project with a damaged competitor workspace reports that competitor as unavailable instead of failing.

    #1314 (issue on GitHub)
  • ChangedWorksCompetitor candidates can be recorded, listed and removed from the CLI#1314

    (project-competitors-add, project-competitors-list, project-competitors-remove) and the matching seo_project_competitors_* MCP tools. Each candidate keeps the URL as entered, including a www host, and www and bare hosts count as the same site for duplicate checks. A competitor with a www host no longer breaks preparation or makes project-observe fail.

    #1314 (issue on GitHub)
  • ChangedWorks--max-urls is now the crawl's page budget: an explicit page budget fills unset request#1314

    and time budgets (3 requests and 2 seconds per page), so 50-300 page crawls are admitted without approve_large_crawl; budgets above the project thresholds are still refused.

    #1314 (issue on GitHub)
  • ChangedWorksA deliberate URL-budget stop is saved as a finished capture with finish_reason#1314

    stopped_by_budget and a finished_at time, instead of interrupted with no finish time. Its queued URLs remain for a resume.

    #1314 (issue on GitHub)
  • ChangedWorksCompetitor URLs keep the host as entered (for example www. stays), instead of being#1314

    stored without it.

    #1314 (issue on GitHub)
  • ChangedWorksDesktop adds competitor sites from the clipboard or one by one through the core, sets a#1314

    per-site URL cap, and starts their scans in parallel; a competitor shows as scanned only after a finished retained scan.

    #1314 (issue on GitHub)
  • ChangedWorksNative scans measure HSTS, mixed content and structured data from their own stored#1314

    evidence, so a missing or stale SF export no longer reports them missing; duplicate-check marks a run with zero compared pairs as unmeasured; site-wide template findings (same chrome heading, low text ratio) are one finding with a page count (#1314).

    #1314 (issue on GitHub)
  • ChangedWorksAccepting an owner goal adds only that goal's checklist task.#1314

    It no longer reconciles the whole built-in catalogue into coverage.json; built-in rows are added on demand when one is touched.

    #1314 (issue on GitHub)
  • ChangedWorksA busy checklist names the live holder (for example a running native crawl and its pid)#1314

    instead of calling the lock a leftover. The lock records its writer pid; a lock with no live holder is still reported as a leftover.

    #1314 (issue on GitHub)
  • ChangedWorksA workflow run blocked by a failed, unavailable, skipped or interrupted step is still#1314

    resumable, and a later pending step may be checkpointed past a non-success step. Steps still must be checkpointed in registered order, and an interrupted run still refuses further steps.

    #1314 (issue on GitHub)
  • ChangedWorksAn agent-recorded review no longer makes a manual or deliverable item owner-complete.#1314

    The item stays waiting for owner signoff or a non-agent reviewer.

    #1314 (issue on GitHub)
  • ChangedWorksInbox references accept goal ids (inbox:<uuid>), so a note can cite the goal it#1314

    concerns.

    #1314 (issue on GitHub)
  • ChangedWorksChecklist items that were never attempted and are manual show state not_run instead of#1314

    review; only an attempt awaiting a decision stays in review.

    #1314 (issue on GitHub)
  • ChangedWorksThe Issues read model (observe findings evidence) reports per-check counts (by_check)#1314

    and a next_offset after the first 20 findings; findings_page pages through the rest.

    #1314 (issue on GitHub)
  • ChangedWorksNative crawl checks (#1314): image size (IMG_OVER_KB) is now measured from the crawl's#1314

    own image fetches, and sitemap status (SITEMAP_URL_4XX_5XX, SITEMAP_URL_3XX, SITEMAP_URL_NON_INDEXABLE) from the crawl's own fetch of each sitemap-listed URL. A listed URL the crawl never requested is counted as unmeasured, never read as clean. IMG_MISSING_ALT, IMG_MISSING_DIMENSIONS and HREFLANG_ERROR say on a native scan that the crawl does not measure them as the SF export defines them, instead of pointing at an SF filter. Missing hreflang and mixed-content evidence on a native scan is reported as not measured by the native crawl, not as a missing export.

    #1314 (issue on GitHub)
  • ChangedWorksProject journal (#1314): every project event is now also written as one bullet to#1314

    log.md, and the core records automatic entries for finished crawls (scans), built project reports and checklist attempts (agent). Entries go to events.jsonl and are read by project-event-page; the automatic hooks never fail the crawl, report or checklist step they describe. project-event-append and seo_project_event_append keep their interface and gain the log.md line.

    #1314 (issue on GitHub)
  • ChangedWorksThe native crawl now stores the security headers the checks read#1314

    (Strict-Transport-Security, Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). Before, they were dropped at capture, so a site that sent HSTS was reported as missing it. Set-Cookie headers store only their flags (secure, httponly, samesite=), never the cookie name or value. A security header sent more than once on the same page is reported as DUPLICATE_SECURITY_HEADER.

    #1314 (issue on GitHub)
  • ChangedWorksworkflow-start needs only an accepted owner goal: --goal-id (or context.goal_id) is#1314

    enough. The registered prompt defaults to skill:workflow/full-audit-v1 and the checklist task defaults to the one accepting the goal created (custom:goal-<entry>). Refusals name the owner command that fixes the missing link. The options are documented in seohead workflow-start --help.

    #1314 (issue on GitHub)
  • ChangedWorksChecklist populations accept partial objects: only kind and source are required, and#1314

    the other keys default to unset. A refused population names its missing or unknown keys.

    #1314 (issue on GitHub)
  • ChangedWorksNative project crawls fall back to the origin's /sitemap.xml when robots.txt declares#1314

    no sitemap. An absent fallback is recorded as incomplete but does not interrupt the crawl.

    #1314 (issue on GitHub)
  • ChangedWorksServer logs now retain exact disk-backed bot-IP totals instead of a capped sample, with#1311

    bounded per-IP records, visits, last visit and subnet filters. Explicit DNS verification checks each selected IP separately, records PTR and forward evidence, supports IPv6, and preserves unavailable/deadline outcomes as unchecked. Local caches expire after one hour; reads never trigger DNS.

    #1311 (issue on GitHub)
  • AddedWorksAdded local project log import, paged summary, bot-evidence and native-scan join commands#1311

    through the shared CLI/MCP handlers. Separate log artifacts retain date partitions and source coverage/provenance without changing frozen scan schemas. Missing or incomplete evidence withholds absence conclusions. Desktop log screens remain a separate presentation follow-up.

    #1311 (issue on GitHub)
  • ChangedWorksGate Google DataForSEO production calls before client construction, expose offline quotes#1310

    from supplied provider prices, account for SERP depth and search-operator charges, and enforce known per-call USD ceilings before transport. Expand bounded backlink-index operations and journal missing costs or malformed response shapes as uncertain receipts. Project/month currency limits remain pending an owner decision.

    #1310 (issue on GitHub)
  • ChangedWorksMerge explicit backlink source/import evidence into private local history snapshots with#1310

    full rel token observations, anchors, referring domains, filters and transparent spam review signals. Add CLI/MCP profile and reviewed disavow export routes without automatic submissions.

    #1310 (issue on GitHub)
  • AddedWorksAdd mixed rel-token and outbound sponsored-disclosure review notices from retained native#1310

    evidence, preserving unavailable context, null counts for entirely unparsed HTML populations, and sponsored/ugc/nofollow classes in reports and donor checks without changing the frozen retained-scan schema.

    #1310 (issue on GitHub)
  • ChangedWorksNative DOM and duplicate checks now read each page's selected retained document by exact#1307

    URL, with verified response/body lineage. Disabled retention, missing, invalid or over-budget bodies remain explicit omissions; partial findings carry coverage. Content-area exact hashes and bounded SimHash/LSH findings persist in the native audit without requiring an SF HTML directory. Threshold-adaptive LSH bands preserve qualifying-pair recall while reducing broad candidate buckets; near-analysis budget exhaustion does not discard exact-hash findings.

    #1307 (issue on GitHub)
  • ChangedWorksSF audits optionally run semantic similarity and title/H1 versus retained-body review#1307

    with an explicitly supplied local adapter or declared embeddings. No model or provider is selected automatically. Adapter identity, source hashes, scores, limits and omissions accompany review candidates; semantic similarity does not assert duplicate content, query relevance or cannibalization. The two semantic SF coverage rows stay gaps until a real adapter is selected and validated. External provider authorization requires literal boolean true; truthy strings or integers never authorize an embedding call.

    #1307 (issue on GitHub)
  • ChangedWorksNative retained audits now group broken internal links by observed target with#1306

    source/anchor evidence, record redirect chains and loops with per-hop statuses, distinguish path-based internal search and empty anchors, and expose captured external no-response outcomes. Sitemap and robots consistency findings use retained membership and response evidence without live refetches; missing and partial evidence remains explicit.

    #1306 (issue on GitHub)
  • AddedWorksAdded a bounded saved-scan link graph with position/section/local-neighbourhood filters,#1306

    section aggregation and scan-to-scan edge changes. Offline reanalysis backfills positions with resumable derived batches and reports unclassified evidence in link inspection and scan status, preserving the frozen scan.v1 schema.

    #1306 (issue on GitHub)
  • AddedWorksAdded local XML sitemap generation from canonical, indexable retained 200 pages with#1306

    observed lastmod, exclusions, protocol splitting, sitemap index and optional image sitemap. CLI and MCP share the same implementations; output publication requires a fresh local directory and never deploys files.

    #1306 (issue on GitHub)
  • ChangedWorksPreserve capture-time missing discovery targets when later sitemap or seed processing#1306

    adds the URL to the retained catalogue. Explained unresolved/excluded evidence stays immutable, while claimed known targets and non-null identity mismatches remain invalid.

    #1306 (issue on GitHub)
  • ChangedWorksImage sitemap generation excludes known non-200 resource responses even when the resource#1306

    was not stored as a crawled page, and marks unmeasured image responses as partial coverage.

    #1306 (issue on GitHub)
  • ChangedWorksRetain raw canonical and pagination attributes as versioned document context without#1305

    changing the frozen scan SQL. Canonical links with unsupported hreflang/lang/media/type attributes, malformed annotations and cross-host/protocol declarations now produce evidence-bound findings; unknown well-formed relation keywords remain unclassified.

    #1305 (issue on GitHub)
  • ChangedWorksExtend bounded same-host target probes to pagination and retain measured#1305

    noindex/canonical observations. Native canonical chains and canonical/hreflang noindex findings can use probes; legacy status-only probes stay readable and unmeasured for HTML directives.

    #1305 (issue on GitHub)
  • ChangedWorksJudge opaque native pagination chains for explicit reciprocal next/prev contradictions,#1305

    retaining numbered/stride rules and named unjudged states. Select saved finding URLs, including grouped members, through scan-url-query using temporary SQLite tables and streaming audit.v2 evidence.

    #1305 (issue on GitHub)
  • FixedWorksKeep unknown Unicode and punctuation-bearing relation tokens unclassified, normalize#1305

    equivalent IDN/default-port canonical authorities, and preserve exact native pagination target paths. Unreadable mixed page tokens remain unjudged; the shop pagination defect is recalled even when page one has no number in its URL.

    #1305 (issue on GitHub)
  • ChangedWorksRender the shop fixture's declared next/prev annotations so its D41 pagination ground#1305

    truth is actually served and can be validated by native capture.

    #1305 (issue on GitHub)
  • ChangedWorksRetain document-bound static AMP and accessibility evidence in existing scan context#1304

    rows, preserving the frozen scan.v1 SQL schema. Structural AMP findings inspect the original doctype, head/body, UTF-8 charset, viewport, runtime script and boilerplate declarations; paired AMP targets without an AMP root marker are identified separately. Full AMP validator rules remain unavailable pending a licence decision.

    #1304 (issue on GitHub)
  • ChangedWorksStart accessibility findings for document language, form labels, image alternatives,#1304

    heading jumps and the main landmark. Findings preserve bounded examples and full occurrence counts. Computed contrast and rendered axe-core rules remain unmeasured.

    #1304 (issue on GitHub)
  • ChangedWorksValidate Schema.org Microdata and common RDFa subjects/properties with the bundled#1304

    vocabulary and existing rich-result rules. Retained per-URL errors, warnings and missing required properties become native findings. Unsupported RDFa constructs are explicit partial evidence, not a clean validation result.

    #1304 (issue on GitHub)
  • ChangedWorksServe parsed JSON-LD trees alongside per-block source, position and syntax errors in#1304

    scan-structured-blocks; cap wide property trees and expose their limit. Read the latest document for a requested URL and representation without changing its scan.

    #1304 (issue on GitHub)
  • ChangedWorksPreserve partial/unavailable structured evidence without hiding confirmed sibling errors.#1304

    Resolve supported JSON-LD identities against the same document base as HTML markup while preserving source JSON; reject scalar/empty JSON blocks as valid graphs and share normalized live JSON-LD tag selection across parser and served projections.

    #1304 (issue on GitHub)
  • ChangedWorksNative audits now expose render-blocking head markup from complete retained HTML without#1303

    changing the frozen scan.v1 schema. Missing, incomplete and budget-limited bodies remain unavailable. Existing head resource detection and SF custom-extraction input are reused.

    #1303 (issue on GitHub)
  • AddedWorksAdd configurable native observed-navigation and individual resource byte budgets,#1303

    cache/font delivery signals, minification and legacy-script heuristics, and exact same-page script duplicates from retained scan.v2 evidence. Unfetched data and incomplete runtime payloads remain explicit; responsive declarations are not summed as runtime weight. HTML-only lower bounds, scalar coverage counts, font samples and source summaries stay explicit and bounded.

    #1303 (issue on GitHub)
  • ChangedWorksCollect bounded Chromium JS/CSS precise coverage on requested renders, retain UTF-16#1303

    usage summaries and rendered image-sizing findings, and read them after reopening or offline reanalysis. Unsupported engines and missing measurements do not become clean results.

    #1303 (issue on GitHub)
  • ChangedWorksAccept bounded matching per-URL Lighthouse JSON for LCP discovery, dependency trees,#1303

    layout-shift culprits, image delivery, forced reflow and script execution findings, with capture provenance and input hashes. No Lighthouse process, paid provider or field-CWV inference is added. Refresh registry, SF coverage and generated reference documentation.

    #1303 (issue on GitHub)
  • FixedWorksKeep additive asset thresholds out of the frozen native scan header and store their exact#1303

    values in a validated, versioned run context. Resume compares these values separately, and offline reanalysis restores them from retained evidence; older light readers can still inspect the unchanged header configuration shape.

    #1303 (issue on GitHub)
  • ChangedWorksChecklist record now accepts a succeeded automatic check whose saved scan is a streamed

    audit.v2 scan; previously it failed with 'NoneType' object is not subscriptable, because the audit lives in the companion rather than the scan's legacy table. The accepted record shapes are documented in seohead project checklist-record --help, the MCP seo_project_checklist_record schema, and docs/INPUTS.md.

  • ChangedWorksRun the full toolkit suite once on Python 3.15 instead of an older-version matrix,

    retaining four verified test groups and combined coverage. Use the same stable Python version for CI and release-build bootstraps.

  • ChangedWorksDesktop Work list reads every checklist page (100 per request) instead of the first 50

    rows; Issues names every core check from the registry instead of showing "Other check".

  • ChangedWorksDesktop Work reads every checklist page but shows the project tasks panel one page at a

    time, so a checklist of any size no longer fails with "at most 100 rows". Desktop opens the scan a full audit leaves behind (largest complete scan) when the owner has not picked one, and core discovery also checks the desktop interpreter's own environment (sys.prefix) before PATH.

  • ChangedWorksConsolidate retained audit checks and native desktop improvements.

    Preserve resource-budget settings without changing the frozen scan header, retain safe offline reanalysis and resume, and share native project-settings components, theme tokens and bundled fonts. Remove private design inputs from the distributed tree.

  • ChangedWorksBind remediation rechecks to the validated effective asset policy while preserving the

    original scan header and source bytes.

  • ChangedWorksProject checklist coverage now binds streamed audit.v2 reports: report-build --project

    and audit-workflow --action report attach the checklist snapshot to xlsx, docx, csv, md and PDF overview output instead of building an unbound report. A scan stopped by its URL budget (stopped_by_budget) is accepted as the project's latest audited scan and its report states the limited scope. report-build --input accepts the format key. report-build --project --format json on a streamed audit is refused with an explanation, because the JSON is written verbatim and cannot carry the checklist snapshot.

10 October 2026

16 added51 changed
  • ChangedWorksDesktop scan monitor follows the ScanRun design board: real percentage from fetched/found#1203

    counters, a 10 px progress bar, the collector rate limit, and an honest 120 px speed-plot placeholder (core rate history is tracked in #1203).

    #1203 (issue on GitHub)
  • ChangedWorkscheck_sample in seohead/checks/custom_extract.py runs CSS, XPath and regex extractors#1059

    against one saved HTML document, without a scan or a network request. Each result carries its values and a reason for an empty outcome (no_match, empty_html or budget_exceeded), so an empty answer is never presented as a clean result (#1059). It is a core call only; no CLI or MCP tool is registered yet, and fetching a live URL is deliberately not included.

    #1059 (issue on GitHub)
  • ChangedWorksPagination sequence check (PAGINATION_SEQUENCE_ERROR) now judges stride series: when#1057

    one step covers most of a series' steps, a break from that stride is reported (?page=1,3,5,8 breaks at 5 to 8). Irregular offset schemes and unnumbered series remain unjudged and named as skipped.

    #1057 (issue on GitHub)
  • AddedWorksNew crawl check INTERNAL_NOFOLLOW_OUTLINKS (notice): names each source page that#1052

    carries internal nofollow outlinks, with the count and a bounded destination sample. It closes the page-level gap for Screaming Frog's "Internal Nofollow Outlinks" row, which had been partial; it runs on native crawls and is skipped by name on export-only and URL-list runs.

    #1052 (issue on GitHub)
  • ChangedWorksParser reads media-qualified <link rel="alternate"> declarations without hreflang (a#1051

    separate mobile URL) into parse_html as mobile_alternates (#1051). The Mobile Alternate Link SF check is still a gap; the check that compares the mobile URL's canonical is not written yet.

    #1051 (issue on GitHub)
  • AddedWorksAdd the HREFLANG_UNLINKED_TARGET check (warning): an hreflang alternate target that no#1050

    ordinary hyperlink in the crawl points to is reported, so Screaming Frog's "Unlinked Hreflang URLs" is now covered. The check reads the All Hreflang export and the Internal:All Inlinks column, the same way UNLINKED_CANONICAL does; a self-reference is not counted, and a partial crawl withholds the finding. The registry grows from 182 to 183 checks.

    #1050 (issue on GitHub)
  • ChangedWorksRendered-route image sizing: render-check now records each image's intrinsic pixel size#1049

    against its rendered box and device pixel ratio (image_sizing: oversized and upscaled evidence). It is evidence only; no SF-analyzer check id yet, so "Incorrectly Sized Images" moves from gap to partial (#1049).

    #1049 (issue on GitHub)
  • ChangedWorksSemantically Similar: the status stays gap, and the note now states the blocker.#1041

    The semantic-similarity command and MCP tool exist, but no embedding adapter ships and the SF run does not call the module yet (#1041).

    #1041 (issue on GitHub)
  • AddedWorksAdd the INTERNAL_LINK_SPONSORED_UGC check (notice): a same-site link that carries#1040

    rel="sponsored" or rel="ugc" is reported with its full rel token set. It needs link_attributes.capture, like the other rel-based link checks, and is reported as not measured otherwise.

    #1040 (issue on GitHub)
  • AddedWorksAdd the core of XML sitemap generation from a retained scan: select_indexable keeps#1039

    only 200 HTML pages that are self-canonical, not noindex, and not robots-blocked, and reports the reason for every excluded URL; render_sitemaps writes a <urlset>, or split files plus a <sitemapindex> at 50,000 URLs or 52,428,800 bytes per file. CLI and MCP exposure follow.

    #1039 (issue on GitHub)
  • AddedWorksAdd cloudflare-traffic (CLI) and seo_cloudflare_traffic (MCP): a read-only Cloudflare#1026

    GraphQL Analytics source for the log analyser when origin logs are absent (#1026, #1009). It returns bots x URLs x status codes x cache status per day in the shape of log-analyze, labelled source: cloudflare-aggregated because it is grouped edge data, not raw logs (no client IPs, so no reverse-DNS verification). Days that fill the 10,000-row page cap are split by time window; the call is counted in the spend ledger as a free provider. The token comes from CLOUDFLARE_API_TOKEN or SEOHEAD_CLOUDFLARE_TOKEN_FILE and is never printed.

    #1026 (issue on GitHub)
  • ChangedWorksInternal Search Console findings evaluator, not yet wired into reports:#1025

    seohead/checks/search_console_findings.py joins crawl pages to a normalized GSC evidence document and evaluates GSC_ORPHAN, NO_SEARCH_DATA and NON_INDEXABLE_WITH_SEARCH_DATA. A check is skipped with its reason when the crawl is partial, the window is incomplete, the collection is sampled or truncated, or no evidence is supplied; a skipped check is never reported as clean.

    #1025 (issue on GitHub)
  • ChangedWorkscrawl-enrich reports four web-analytics findings from an offline URL-keyed CSV:#1024

    analytics orphan URLs, indexable pages with no analytics data, non-indexable pages with visits, and bounce rate above 70%. Named columns --visits-column and --bounce-column select the data; a missing column is refused by name, unreadable values are counted and never read as zero, and analytics orphans are withheld on a partial crawl. Live per-URL Metrika/GA4 sourcing is not included yet (#990, #984).

    #1024 (issue on GitHub)
  • ChangedWorksThe SF issue coverage map lists the four web-analytics issues (Orphan URLs, Bounce Rate#1024

    Above 70%, No GA Data, Non-Indexable with GA Data) as found by crawl-enrich from an offline CSV instead of out of scope. sf run still does not emit them.

    #1024 (issue on GitHub)
  • ChangedWorksLab coverage core for unused CSS and JavaScript (#1023, slice 1): a pure analyzer takes#1023

    Playwright page.coverage payloads and reports per-resource unused characters, with a finding when a resource is mostly unused and large. Collecting coverage from a rendered page is the next slice.

    #1023 (issue on GitHub)
  • ChangedWorksAMP pairing checks (#1020): AMP_NON_200, AMP_MISSING_CANONICAL,#1020

    AMP_MISSING_RETURN_LINK, AMP_NON_INDEXABLE_CANONICAL and AMP_INDEXABLE read the AMP target a desktop page declares with <link rel="amphtml">. Only AMP pages the crawl captured are judged; an uncaptured target is a named skip. The native crawl now records the resolved target as the AMP HTML column. The other ten SF AMP rules remain declined.

    #1020 (issue on GitHub)
  • AddedWorksAdd HTML_OVER_2MB, an absolute 2 MB ceiling on an HTML document that is separate from#1019

    the median-relative LARGE_HTML; Screaming Frog's "HTML Document Over 2MB" now maps to it instead of the 200 KB rule.

    #1019 (issue on GitHub)
  • AddedWorksAdd the URL_HAS_PARAMETERS notice to the crawl audit: an internal HTML page whose URL#1018

    carries query parameters is reported regardless of canonical state. URL_HAS_PARAMS keeps its narrower no-canonical meaning (#1018).

    #1018 (issue on GitHub)
  • ChangedWorksRobots directives (#1017): legacy noodp and noydir in meta robots or X-Robots-Tag#1017

    now raise NOODP and NOYDIR notices in the SF audit, previously listed as out of scope. none (noindex+nofollow) is covered by regression tests for the X-Robots-Tag path as well as the meta path.

    #1017 (issue on GitHub)
  • AddedWorksNew crawl check JS_CONSOLE_ERRORS (SF "Pages with JavaScript Errors"): a page whose#1015

    retained render console logged errors. Read offline from the saved console sidecars of a native scan, with no re-render; a record that cannot be read is a stated skip, not a clean page. Requires rendering.artifacts.console_errors.

    #1015 (issue on GitHub)
  • ChangedWorksCrawls now report four missing security headers per HTML page from the stored final#1013

    response: MISSING_CSP, MISSING_X_CONTENT_TYPE_OPTIONS, MISSING_X_FRAME_OPTIONS (satisfied by a CSP frame-ancestors directive) and MISSING_REFERRER_POLICY. Pages whose stored headers cannot be parsed are counted as unmeasured, not clean, and a crawl without a stored scan skips them with a reason.

    #1013 (issue on GitHub)
  • AddedWorksAdd a validator for closed page-action specs (click, scroll, wait_for_selector,#1011

    wait_ms) with a static time budget for rendered crawls. It is not wired into rendering yet, so crawl behavior is unchanged (#1011).

    #1011 (issue on GitHub)
  • ChangedWorkslog-analyze reads gzip-compressed access logs (rotated access.log.N.gz files and#1009

    renamed archives) directly, detecting the format by content rather than file extension. A truncated or corrupt archive is reported as an error with no partial counts.

    #1009 (issue on GitHub)
  • ChangedWorksscan-url-query gains ready-made view presets (--preset, for example status_4xx,#1007

    title_missing, title_over_60, h1_missing, noindex_meta) that AND-combine with the user's filters, and --export PATH with --export-format csv|xlsx writes every matching row to a new file. Export refuses above --export-max-rows (default 100,000) and never overwrites.

    #1007 (issue on GitHub)
  • ChangedWorksRich-result eligibility rules moved out of Python source into#1001

    seohead/data/rich_results_rules.json, a versioned data file bundled with the package. The file's version is the value stamped as rules_version on every rich-result verdict, so changing a rule means changing data, not code.

    #1001 (issue on GitHub)
  • ChangedWorksRich-result verdicts (check_schema and the stored structured evidence feature checks)#1001

    now carry rules_version, so each result names the offline rule set that produced it.

    #1001 (issue on GitHub)
  • ChangedWorksDocument the six maintenance and generator scripts that had no reference in the docs#997

    (scripts/generate_project_catalogue.py, generate_registry_inventory.py, render_readme_visuals.py, linux_lifecycle_smoke.py, capacity_watchdog.py, profile_million_audit.py) in docs/README.md, with what each writes and which ones run in CI (#997).

    #997 (issue on GitHub)
  • AddedWorksAdd the semantic-core pipeline as seohead/semantics with its own per-project SQLite#995

    database: seohead semantics <stage> --project DIR (init, import, collect, clean, graph, exact, cluster, competitors, synonyms, report, excel, mine, sitematch, relevance, status, export), the MCP tools seo_semantics_status, seo_semantics_run, seo_semantics_mine and seo_semantics_paid (paid stages need confirm_paid=true), Louvain clustering behind the new semantics extra, the packaged semantic-core skill and a synthetic example project. Provider charges of a semantic stage are tagged in the shared spend journal (#995).

    #995 (issue on GitHub)
  • ChangedWorksRemove the guided Telegram bot adapter seohead/integrations/bot; monitor delivery#995

    receipts now live in seohead/projects/service_delivery.py (#995).

    #995 (issue on GitHub)
  • ChangedWorksscan-url-query returns click_depth, the shortest internal link path from the scan's#992

    start page, as a column, filter and sort key. Pages no stored link reaches are NULL (orphans). crawl_depth is unchanged and remains the frontier depth, which is 0 for every sitemap-seeded URL. A scan that did not retain its links fails with reason_code: click_depth_unavailable instead of reporting a false NULL. The value is recomputed per query from the links table; persisting it as an indexed column at scan finish is still open because it needs a schema-version decision (#992).

    #992 (issue on GitHub)
  • ChangedWorksscan-url-query and its MCP tool accept an optional facets argument (a list of group#991

    ids, or all) that returns per-group counts (status_2xx–5xx, title_missing, canonical_missing, directives_noindex/nofollow, with_outlinks) over the same filters in one pass. Groups that need a projection (internal/external, sitemap, title duplicate/length, canonical self/other) are not included yet.

    #991 (issue on GitHub)
  • ChangedWorksProject source bindings, first slice (#990): project-sources-link,#990

    project-sources-unlink and project-sources-list (CLI, plus seo_project_sources_link|unlink|list over MCP) record which provider resource IDs a local project uses in sources.json beside project.json. Offline only: no discovery, no provider call, no credentials; project.json is never rewritten.

    #990 (issue on GitHub)
  • ChangedWorksNative crawl: new check IMG_BROKEN (#989, slice 1) reports <img> targets that answered#989

    4xx/5xx, with the source page as evidence. It reads the stored resource graph and skips honestly when no image was measured.

    #989 (issue on GitHub)
  • AddedWorksAdded a pure soft-404 classifier over the stored page body (classify_stored_soft404):#988

    empty text, an error phrase on a thin page, and a simhash match to a 404 template. It is not yet wired into the crawl and emits no findings; this is the first slice of issue #988.

    #988 (issue on GitHub)
  • ChangedWorksNative crawls probe canonical and hreflang targets that no link reached, once per target#987

    after the crawl (5000 at most). A 4xx/5xx or redirecting target then reports CANONICAL_TARGET_ERROR, CANONICAL_TO_REDIRECT or HREFLANG_BROKEN_TARGET, as it already did for crawled targets (#987).

    #987 (issue on GitHub)
  • ChangedWorksRedirect chain and loop findings from a native crawl now carry the full hop path#985

    (details.path), so a fix can name each hop to replace. Refs #985.

    #985 (issue on GitHub)
  • ChangedWorksRedirect chain and loop findings from a native crawl now also carry#985

    details.inbound_links (internal links still pointing at the chain's first URL, with anchors, capped at 20) and inbound_links_count; without an all_inlinks inventory the field is null with a reason. Refs #985.

    #985 (issue on GitHub)
  • ChangedWorksProjects can record a link to their semantic core as operator-entered facts#984

    (semcore_project, semcore_cluster_count, semcore_landing_mapping). The semantic core stays in its own database and is not merged into the project database; detection never writes these facts, and a supplied fact cannot claim detection provenance. Documented in docs/PROJECTS.md under "Linking a semantic core" (partial #984).

    #984 (issue on GitHub)
  • ChangedWorksDesktop: the Link graph screen follows the GraphFilters design (canvas and filter panel).#975

    The core does not yet return the whole link graph, so the canvas shows an honest waiting state and the filters stay inert until it does (#975).

    #975 (issue on GitHub)
  • ChangedWorksGSC OAuth: provider-auth --action cancel (CLI) and seo_provider_auth action=cancel#964

    (MCP) cancel a pending browser-consent flow record; status reports the pending flow when one exists. The flow record stores no grant material and expires after ten minutes. This is the first slice of #964: no browser start or callback yet, so no flow is created by the public surface and connected access is still never claimed.

    #964 (issue on GitHub)
  • ChangedWorkscrawl-site --urls/--urls-file --project now registers the URL-list crawl as an observer#962

    run (mode=list), so it appears in project-observe and project-scans with its artifact in the project scans/ directory. A failed collector is recorded as failed; progress and resume of list runs are not covered yet (#962).

    #962 (issue on GitHub)
  • AddedWorksAdd a structured playbook outline: skill-list and scenario-list return each#955

    playbook's ordered steps, and skill-show/scenario-show add its headings and steps, so an agent can read the method shape without parsing markdown (#955). New scenario-list CLI command and seo_scenario_list MCP tool.

    #955 (issue on GitHub)
  • ChangedWorksscan-content-search accepts --kind regex (MCP kind="regex") to search retained#954

    documents with a Python regular expression. Invalid expressions are rejected before scanning; a document that exceeds the regex time budget is reported unavailable, never absent.

    #954 (issue on GitHub)
  • ChangedWorksDataForSEO calls whose response is lost (network error after the request was sent) are#949

    journaled with cost_unknown and charge_status: unknown, so spend-report lists them as uncertain instead of counting them as a measured zero-cost call (#949).

    #949 (issue on GitHub)
  • AddedWorksNew command scan-structured-blocks (CLI) and MCP tool seo_scan_structured_blocks: for#948

    one retained URL, describes each JSON-LD block (its source line, status valid / errors / warnings / parse_error, types, a flat list of properties, bounded source JSON, and Schema.org graph errors). Works offline on the retained full page body only; no network requests and no artifact changes.

    #948 (issue on GitHub)
  • ChangedWorkscrawl-site without a project that writes a SQLite scan artifact now shares its per-host#942

    request ceiling with other concurrent crawls of the same host through a private schedule in the user state directory (~/.config/seohead/.origin-pacing.sqlite). Projectless crawls previously ran unpaced across processes.

    #942 (issue on GitHub)
  • ChangedWorkscrawl-site profiles (#941): crawl-profile list prints the saved profile names and#941

    crawl-profile delete NAME removes one; both print JSON and refuse unknown names with the list of saved profiles.

    #941 (issue on GitHub)
  • ChangedWorkscrawl-site profiles (#941): --save-profile NAME --config FILE stores a validated#941

    crawler config as a named profile (refused when it carries credential headers); --profile NAME crawls with it, and --set still applies on top. Profiles live in ~/.config/seohead/crawl-profiles or the directory named by SEOHEAD_CRAWL_PROFILES. Listing and deleting profiles are not in this slice.

    #941 (issue on GitHub)
  • ChangedWorkscompare.v1 now lists pages crawled on only one side (url_sets: only_in_before,#938

    only_in_after, counts). When either crawl is partial, url_sets.unproven is true, so absence is not presented as proof.

    #938 (issue on GitHub)
  • ChangedWorksRedirect and canonical chain hops record each hop's status, next Location (redirects) and#930

    response time, so a URL's chain reads from stored evidence without another request (#930).

    #930 (issue on GitHub)
  • ChangedWorksRedirect loops in per-URL chains end with a marker step ({"url": ..., "loop": true})#930

    that is not fetched again, so the repeated URL is visible in stored evidence; final_url still points at the last fetched hop (#930).

    #930 (issue on GitHub)
  • AddedWorksExpose the remediation ledger's create and scan-ingest steps through the CLI and MCP:#926

    remediation-create / seo_remediation_create makes one new empty ledger bound to a project (producer_build must be a full 40-character Git SHA; existing paths are refused), and remediation-ingest / seo_remediation_ingest ingests one saved audit as baseline/history without rerunning a crawl (re-ingest of the same revision is idempotent). The storage logic is unchanged; this is the first slice of #926.

    #926 (issue on GitHub)
  • ChangedWorkscrawl_site and scan runs report effective_max_requests_per_second and warnings: a#925

    warning when the effective rate is above 2 req/s (the threshold is strictly greater than 2). The CLI prints the same warning to stderr; JSON on stdout and defaults are unchanged. The pacer was not touched.

    #925 (issue on GitHub)
  • ChangedWorksStructured project event log (events.jsonl): append-only events with source, actor and#923

    bounded text, read newest-first in pages with source and text filters. Exposed as project-event-append / project-event-page in the CLI and seo_project_event_append / seo_project_event_page over MCP (issue #923).

    #923 (issue on GitHub)
  • AddedWorksAdd the ai-column command and seo_ai_column MCP tool, the first slice of the AI

    custom column (refs #1012). Over selected retained scan pages, or supplied normalized items, it prepares a bounded plan from the title, headings and a text excerpt, with a character-based size estimate (no price applied) and the consent scope that the calling agent must honour before any transfer. Supplied per-URL values are validated against each page's source hash; missing, stale, empty or oversized values are reported as such and never filled. An optional formula-safe CSV exports the column. SEOHEAD holds no provider key and makes no model call.

  • ChangedWorksShare the read-only and writer SQLite openers in seohead.core.sqlite.

    Storage readers, the scan importer, the GSC archive, the sources store, the evidence-join store and the two checks classes now open their databases through one helper for connect, row factory and PRAGMAs. The two checks classes close their connections explicitly. Behaviour is unchanged; path normalisation and PRAGMA order stay at each call site.

  • ChangedWorksCorrectness fixes from a review of checks, data sources and evidence import.

    Behaviour changes on purpose in these paths: - Redirect chains of exactly the hop cap (default 20) now resolve instead of being reported as unresolved. The loop check runs before the cap check, in both chain resolution and hop-path walks. - EEAT trust and citation checks compare destination hosts with their port (netloc), matching the internal-link host logic. Sites with explicit ports can change results. - llms.txt keyword checks match ASCII terms as whole words with an optional plural, so rapid, Showcase and Toolbar no longer pass. Cyrillic stems still match as substrings. - Navigation capture maps the reload reason to refresh_navigation. Unmapped requested reasons, such as pageBlockInterstitial, are recorded as frame observations that the validator accepts. - Technology detection treats a negated implication (["not", X]) as implying nothing. - Evidence import rejects duplicate declared metric names, and Search Console discovery uses the rows it is given. - Yandex Cloud retries sleep only between attempts. Yandex Webmaster query-analytics retries back off before the next attempt. - Schema build passes its timeout to the HTTP client (default unchanged). - The delivery receipt store is created with mode 0600 before SQLite opens it. - Semantic graph runs are serialised, because the stop-word set is module state. The semantic runner reports SQLite errors as ok: false instead of raising. - The CrUX error parser tolerates non-object response bodies. - Removed an unreachable sqlite3 is None guard in the ledger.

  • ChangedWorksDesktop: a Backlinks screen in the design-v2 layout.

    It shows the honest waiting state until the core stores a project link profile (#999); no sample rows, and the group filters are disabled with the same reason.

  • ChangedWorksDesktop Crawler screen: add the temporary-results banner with the selected address and

    status text (shown only when a project-less crawl returns results), move the clear and save-as-project actions into an overflow menu below 1000 px, keep the address field and check-tab strip usable at narrow widths, scroll the results header instead of squeezing its columns, and give the shell status bar the same 8 px right padding in dark and light themes.

  • ChangedWorksDesktop: fatal states screen per canvas ErrorsCore: missing core, low disk and no write

    access are shown from real facts; core version and scan integrity show the neutral "unavailable in this core version" pill until the core reports them.

  • ChangedWorksDesktop: the URL row menu and column-header menu, the menu-bar groups Edit, Scan and

    Window, and the finding and task context menus follow the canvas Menus board. Entries the core does not provide yet stay visible, disabled, with the reason in the tooltip.

  • ChangedWorksDesktop: the "Export and reports" screen follows the ProjExport design canvas (dataset

    and format choice, folder card, recent exports). Datasets and formats the core cannot export yet (compare, tasks, PDF) are shown as unavailable; the run button stays disabled until the export is wired (#1191).

  • ChangedWorksNeutralise leading TAB and CR in marketing-inventory identifiers and meta-description

    draft review CSVs with the same apostrophe prefix already applied to =, +, -, and @. Both guards now share seohead.core.tabular.neutralize_formula.

  • ChangedWorksCredential-bearing provider requests (Arsenkin, Metrika, Yandex Cloud, IndexNow,

    Topvisor, BI destinations) no longer follow HTTP redirects, so an Authorization header is never re-sent to another host. A redirect now surfaces as a provider HTTP error.

  • AddedWorksAdd a csv redirect format (source,target,301 rows, quoted when needed) to

    generate_rule and the redirects-generate CLI help; the wordpress-redirection format is not included because its import syntax has not been verified (#998).

  • ChangedWorksThe run journal (runs.jsonl) now rotates once it passes a size ceiling (5 MiB by

    default, SEOHEAD_RUN_LOG_MAX_BYTES overrides it): the previous generation is kept as runs.jsonl.1, and reads cover both files, so reuse lookups still see recent calls.

  • ChangedWorksLay out the desktop URL card's Schema tab in the design-v2 canvas structure (block list,

    block tree, checks). The core returns only per-URL JSON-LD block counts, so the columns show honest waiting states tied to core issue 948 and the known counts stay real; no sample blocks are shown.

9 October 2026

4 added4 changed
  • AddedWorksAdd locale-selected RU/EN terminal labels, a current-scan progress block, explicit scan#951

    selection and compact observer output.

    #951 (issue on GitHub)
  • ChangedWorksSaved scans open lightly for reads (#931).#931

    scan-status, scan-url-detail, scan-link-inspect and the project observer (project-scans, project-observe) no longer validate the whole artifact on every open: they check identity, schema, version and the header row, take counters from SQL (GROUP BY over pages_status), and say so in an additive validation field ("light" or "full"). Full validation runs on request (full_validation: true on scan-status, or --input '{"input_path": "...", "full_validation": true}' on the CLI) or when the same bytes (path, size, mtime, WAL state, header hash) were already fully validated in the process; a scan that fails the light checks is refused with the same error as before, and every writer or evidence-recording path still validates fully. scan-url-detail runs the redirect-target lookup (no index on responses.effective_url_id) under a 0.5 s budget and reports responses.redirect_target_lookup as skipped instead of scanning a large table.

    #931 (issue on GitHub)
  • AddedWorksAdd shared, audited MCP state with startup and live tool-call revocation; expose safe#929

    client presence through the CLI.

    #929 (issue on GitHub)
  • AddedWorksAdd explicit-permission client registration with previews, atomic writes, verified#929

    backups and conflict-safe restoration.

    #929 (issue on GitHub)
  • AddedWorksAdded scan-url-query (MCP seo_scan_url_query): a read-only, server-side filtered,#927

    sorted and paginated read of the whole page table of one saved scan, with total, filtered_total (null plus filtered_total_state: capped when the count exceeds its time budget), a column projection, scan coverage labels, the seohead.scan-url-query.v1 format and a machine-readable reason_code on every failure. At most 200 rows per call; sorting by url, status_code, page_ordinal or url_id uses indexes, and any other column needs a filter that leaves at most 100,000 rows (otherwise sort_not_indexed). The scan is opened with a light identity and schema-generation check, not full validation, and is never modified.

    #927 (issue on GitHub)
  • ChangedWorksscan-link-inspect --view links (MCP seo_scan_link_inspect) pages the outgoing or#924

    incoming hyperlinks of a URL, looked up by address rather than document ID: anchor, rel with nofollow/sponsored/ugc flags, internal/external, stored position, target attribute and the HTTP status of the other end (null when that page was not scanned). Filters by link type, follow, status class and substring; offset/limit up to 200; total/filtered_total are exact or flagged capped. The scan is opened read-only without full validation, and failures carry a machine reason_code (url_not_found, scan_not_available, url_not_scanned, links_not_retained). Existing views and fields are unchanged.

    #924 (issue on GitHub)
  • ChangedWorksPackage layout reorganised, with no compatibility shims: seohead.servers is now

    seohead.mcp, seohead.tools is now seohead.checks, seohead.bot and seohead.remote_api moved under seohead.integrations, the CLI lives in the seohead.cli package (with seohead.cli.terminal_progress), the shared root modules (models, graph, filesystem, runlog, verification, canonical_policy, input_contracts, job_contracts, provider_matrix) moved to seohead.core, and build provenance moved to seohead._build.provenance. examples/ and config.example.json moved to docs/examples/; PROVENANCE.md and TRADEMARKS.md moved to docs/legal/. The seohead and sf-analyzer commands are unchanged; Python imports of the old module paths must be updated.

  • ChangedWorksRewrite the README around install, three quick-start paths (native crawl to report,

    offline Screaming Frog exports, project plus agent), a feature map by area, a CLI overview, MCP setup for Claude Code and Claude Desktop, the companion desktop app and the measured-versus-not boundary, with real terminal screenshots under docs/assets/ captured from a local QA fixture and the synthetic export fixture. Add docs/CLI.md, which groups every registered command by job with its network/write/paid annotations (a drift test now fails when a command is missing), and docs/WORKFLOWS.md, which documents the previously reference-only workflow-run, inbox and monitor-record commands.

8 October 2026

1 added10 changed1 fixed
  • FixedWorksKeep retained HTML/DOM byte accounting transaction-local and incremental instead of#914

    rescanning all stored bodies for every unique response. Reopened writers rebuild the counter once; deduplication, rollback, savepoints and body retention limits remain intact.

    #914 (issue on GitHub)
  • ChangedWorksBI reporting keeps unverified provider collection states and quality flags unknown in#833

    metrics, coverage and manifests; confirmed sampling, thresholding or truncation is partial. Search-versus-sessions cohorts require explicit collection quality, nonnegative count measurements and qualified source scopes, and retain each axis's source identity, attribution, search scope and calendar policy. The synthetic reporting package remains offline; native Sheets and Looker acceptance is still separate.

    #833 (issue on GitHub)
  • ChangedWorksNative SQLite site scans accept max_urls=0 to collect until the queue is exhausted, and#818

    max_depth=-1 to disable the depth limit. Time/HTTP budgets remain separately configurable, storage guards and interruption/resume remain active, and legacy materialized routes refuse an unlimited URL population.

    #818 (issue on GitHub)
  • ChangedWorksSitemap-only scans also preserve the disabled URL limit instead of refusing their first#818

    declared member when the configured limit is zero.

    #818 (issue on GitHub)
  • ChangedWorksExplicit finite request rates above the default 2 requests/s now reach the shared#818

    project-origin pacing lane as well as the per-scan limiter. Parallel scans still reserve one common host schedule instead of multiplying its rate.

    #818 (issue on GitHub)
  • ChangedWorksInterrupting native analysis now returns an explicit resumable interrupted result and#754

    retains the captured pages instead of leaking a KeyboardInterrupt traceback.

    #754 (issue on GitHub)
  • ChangedWorksRegenerate the packaged project catalogue after the Bing provider scenario update.#754 (issue on GitHub)
  • ChangedWorksReject malformed Bing Webmaster response rows instead of reporting a complete empty#753

    result. Preserve raw response data, source rows and exact returned counts in saved provider evidence. Link-count responses retain their documented page envelope and report multi-page populations as partial when only one requested page was collected.

    #753 (issue on GitHub)
  • ChangedWorksRecognize the existing shared Google service-account fallback in GA4 provider readiness#753

    and credential discovery. Keep account permissions and target access unverified, report malformed credentials explicitly, and preserve OAuth bearer support without requiring both sources.

    #753 (issue on GitHub)
  • ChangedWorksBind headless capability references to native run isolation and the public external-crawl#743

    routing boundary, distinguishing internal implementation from CLI/MCP availability without changing crawl behavior or capacity claims.

    #743 (issue on GitHub)
  • AddedWorksAdd bounded terminal-run pagination to project-observe in CLI and MCP while keeping

    every stored running record visible. Pages preserve exact run IDs and reverse admission order; counts and retention metadata describe the current 100-record store, not lifetime history. Reading never changes stored runtime states. JSON CLI inputs retain their pagination and project scope unless an explicit named flag overrides them.

  • ChangedWorksHandle Ctrl+C across the complete native post-collection handoff, including opening the

    saved scan, optional follow-up phases, analysis and finalization. Unfinished captures retain their data and resumable checkpoint and return an explicit interrupted JSON result. An interrupt after the final commit preserves the completed artifact and reports its actual audit availability. Explicit resume completes unfinished post-collection work even with a drained frontier, preserving existing evidence-omission flags and retained sitemap inputs.

7 October 2026

4 added4 changed1 fixed
  • AddedWorksAdd a disposable Linux acceptance fixture for real key-based SSH worker execution,#754

    authenticated API/TLS downloads, and native-worker ENOSPC on a bounded job-only tmpfs. Preserve queue/evidence and require an explicit non-success result with no replay before a fresh JS recovery job. Clarify local lifecycle measurements versus SSH and OS containment evidence.

    #754 (issue on GitHub)
  • ChangedWorksPreserve partial JavaScript evidence in remote job results, including read-only#754

    correction of older finished jobs, without changing retained artifacts or render budgets. Verify real queue backup/restore through authenticated status, downloads and persistent idempotency; keep the two-page recovery fixture on full rendering within its existing finite budgets.

    #754 (issue on GitHub)
  • ChangedWorksReturn exit 130 without a traceback when SIGINT interrupts final JSON output after a

    handler has completed. Retained artifacts remain unchanged; a truncated output stream is never reported as successful.

  • AddedWorksRecord the content-area strategy used to produce retained hashes and token counts, rather

    than a parser's default-area strategy. Explicit selectors and exclusions now have matching extraction provenance even when the page parser used its default region.

  • FixedWorksKeep unpublished native SQLite staging files out of scan history and retention

    candidates; continue reporting invalid published files and allow ordinary user-named hidden scans.

  • AddedWorksAdded native SQLite sitemap-only collection: an explicit sitemap defines the complete

    requested URL population, with no start-page fallback, hyperlink expansion, or redirect-target expansion. Empty, incomplete, and over-budget sitemap populations stop explicitly and resume reuses the retained population without refetching XML.

  • ChangedWorksPreserve distinct project run records for simultaneous native captures and share a

    project-local 2 requests/second dispatch gate for the same public host; expose each run's source kind, origin, config fingerprint, and aggregate rate boundary to observers.

  • AddedWorksAdd offline literal search over retained HEAD, HTML, body text and CSS-selected markup,

    with explicit static/rendered coverage, unknown missing bodies, optional redacted snippets and integrity-checked pagination of new local result packages.

  • ChangedWorksPreserve a saved Screaming Frog crawl's own settings when exporting it: load-crawl no

    longer injects the current spider configuration. Live spider and list runs continue to receive their explicit per-run profiles.

6 October 2026

9 added14 changed9 fixed
  • ChangedWorksMake owned external-capture tests resolve their loopback virtual hosts consistently#896

    across operating systems, without changing production network policy.

    #896 (issue on GitHub)
  • FixedWorksKeep terminal input working above the select descriptor ceiling, and prevent Delete or#896

    unsupported control sequences from cancelling unsaved notes.

    #896 (issue on GitHub)
  • ChangedWorksRetain versioned browser navigation events with bounded omissions, capture state,#826

    renderer policy and URL provenance. Distinguish observed HTTP redirects, Chromium script/history/fragment navigation and unknown causes without asserting human clicks.

    #826 (issue on GitHub)
  • AddedWorksExpose the same evidence through render-check and bounded offline scan-navigation#826

    reports; preserve partial observations when rendering fails.

    #826 (issue on GitHub)
  • ChangedWorksPreserve rollback compatibility by retaining navigation evidence inside extensible#826

    renderer settings; validate the earlier prerelease encoding without weakening provenance checks or treating observed events as renderer policy.

    #826 (issue on GitHub)
  • FixedWorksKeep large nested summary populations, including depth histograms, in complete PDF#818

    companions without charging them to the bounded display-metadata budget. Preserve named destinations, outlines and internal links when rewriting portable companion links by cloning the complete PDF document.

    #818 (issue on GitHub)
  • AddedWorksAdd explicit pdf_policy="overview-v1" for retained audit.v2 PDF requests.#818

    The first page distinguishes full source, displayed and omitted counts; deterministic display prefixes retain full coverage states/reasons and link to complete streaming audit JSON and findings/pages/scope CSV companions. A hash/count manifest records provenance and resource limits. Existing destinations are refused, exports are staged, and the PDF completion marker is published only after all mandatory companions validate. Default PDF behavior is unchanged; the overview is never silently selected or described as an exhaustive PDF. See docs/PDF_OVERVIEW.md for exact bounds and crash semantics.

    #818 (issue on GitHub)
  • AddedWorksAdd the read-only GA4 page_views provider operation for date/hostname/visited-path view#782

    counts, independently of landing-page sessions. Reuse existing GA4 credentials, transport and paginated report reader with bounded retries, quota/permission outcomes, explicit timezone/sampling/threshold metadata and declared-origin URL binding. Saved restricted provider evidence normalizes through the existing join and BI pipeline; missing traffic remains unavailable rather than becoming zero.

    #782 (issue on GitHub)
  • FixedWorksKeep observer refreshes bounded by reusing per-site coverage, projecting live collector#777

    activity separately, and paging audit.v2 findings without materializing unrelated graph collections.

    #777 (issue on GitHub)
  • ChangedWorksPreserve every active collector in bounded history, publish timestamped measured counter#777

    windows, and distinguish unmeasured counters from zero. Stream verified Screaming Frog progress while bounding both diagnostic pipes.

    #777 (issue on GitHub)
  • AddedWorksAdd read-only checklist, task-detail and retained-scan pages, plus explicit#777

    applicable-method scope and a latest-inbox projection.

    #777 (issue on GitHub)
  • FixedWorksKeep explicitly associated sitemap runs distinct: underway counters remain unknown, and#777

    final measurements count parsed sitemap documents separately from declared URLs, errors and truncation.

    #777 (issue on GitHub)
  • ChangedWorksObserve checklist artifact receipts through guarded file identity metadata without#777

    rehashing retained bytes on refresh. Explicit coverage reads keep byte verification; changed or legacy-unverified evidence remains visible as stale or unverified.

    #777 (issue on GitHub)
  • FixedWorksKeep historical priority receipts bound to their original item scope when new tasks are#777

    added after preparation, and validate every complete coverage candidate before atomic publication. Scoped competitor detail reads preserve passive metadata-only verification.

    #777 (issue on GitHub)
  • AddedWorksAdd explicit disk-backed saved-audit comparison with complete NDJSON results,#762

    deterministic ordering, source identities, per-file checksums and before/after finding conservation. Large audit.v2 comparisons require a new output directory; duplicate identities and URL correspondence collisions fail rather than losing rows.

    #762 (issue on GitHub)
  • FixedWorksKeep targeted audit.v2 verification bounded when duplicate rows are present, retain#762

    validated scan identity in selected documents and refuse conflicting identities or invalid after crawls as proof of remediation.

    #762 (issue on GitHub)
  • AddedWorksAdd opt-in streaming gzip compression for complete compare.v2 NDJSON files, deterministic#762

    compressed bytes and exact compressed hashes with decoded row/byte counts. Add a bounded reader that validates plain or gzip exports and rejects damaged streams or mismatched manifest counts; plain output remains the default.

    #762 (issue on GitHub)
  • FixedWorksKeep saved-corpus comparison compatibility unknown when either completeness flag is#762

    absent, and explicitly warn on known partial corpora. Preserve skipped and disabled check IDs and reasons alongside finding deltas so missing measurements cannot be presented as verified remediation.

    #762 (issue on GitHub)
  • ChangedWorksMake the terminal project observer browse readable task, method, structured-data, scan,#752

    run, inbox and retained-evidence details with bounded pages and honest scope coverage. Keep storage reads and explicit note submissions off the input loop.

    #752 (issue on GitHub)
  • ChangedWorksPreserve separate note and goal drafts during a session, support cursor editing, retain#752

    oversized paste without truncation, expose save receipts in every layout, and print unsaved drafts after a normal terminal exit for recovery.

    #752 (issue on GitHub)
  • AddedWorksAdd explicit own-site and competitor coverage/artifact browsing, retain the selected site#752

    through scan/findings pages, surface recent note-backed competitor suggestions, and keep measured collection progress visible in compact terminals. Stale samples no longer animate as live progress.

    #752 (issue on GitHub)
  • AddedWorksAdd a versioned headless capability inventory generated from the real crawler settings,#743

    with implementation, CLI/MCP, test and explicit acceptance-gap references.

    #743 (issue on GitHub)
  • AddedWorksAdd an opt-in owned-origin HTML/JavaScript benchmark with frozen phase, process-tree#743

    memory, disk and recovery budgets. Actual browsers create the measured DOMs; the profile does not itself establish large-site JavaScript capacity.

    #743 (issue on GitHub)
  • FixedWorksFix the native JavaScript bridge's unsupported streaming argument and preserve already#743

    observed render counters across graceful cancellation and resume.

    #743 (issue on GitHub)
  • ChangedWorksLet log-scan consume retained native scan paths and audit.v2 companions without#711

    materializing legacy JSON. Stream consistency findings, keep native page membership and finding deduplication on disk, and preserve exact anomaly counts while limiting only examples. Explicitly report the absent native exclusion-decision trace.

    #711 (issue on GitHub)
  • ChangedWorksRecognize a site-wide sitemap lastmod finding at its measured site's bare origin when no#711

    homepage was crawled. The exception requires matching sitemap summary evidence and does not exempt page findings, unrelated hosts, or unsupported target paths.

    #711 (issue on GitHub)
  • ChangedWorksCompare retained audit.v2 scans through an exact key and ordinal index without

    duplicating page and finding payloads in temporary SQLite storage. Full rows, deterministic plain/gzip exports, source identities and legacy inputs remain supported. Audit readers pin the validated snapshot through indexed row reads.

  • ChangedWorksRoll back owned comparison output members on late publication failure and refuse raced-in

    destinations; the manifest remains the final package commit marker.

  • ChangedWorksMake the public entry path describe configurable SEO engineering with agents and link the

    full capability surface to its methods, inputs and evidence boundaries. Correct stale native/SF, project-coverage and remediation guidance; document the agent-authored developer workbook separately from factual report output, including reviewed acceptance criteria and source links.

  • FixedWorksRestore human-report parity between streamed audit.v2 inputs and equivalent audit JSON.

    Markdown now shares one incremental renderer, including unavailable-check explanations, exclusion records and trailing coverage notes. XLSX shares one metadata layout and writes findings/pages with bounded write-only worksheets, preserving all worksheets, columns, filters, charts and source counts. Retained suppressed findings are replayable without materializing their collection, and missing run URLs use the same first-page fallback.

  • ChangedWorksLabel PDF audit provenance independently of the shared audit schema: native crawler,

    Screaming Frog exports or an explicitly identified SF collector, and unknown collector. A native audit using the SF Analyzer schema no longer receives a Screaming Frog heading.

  • ChangedWorksStream suppressed-finding scope rows to CSV instead of accumulating their rendered

    explanations in memory. Complete exclusion exports retain their order and source facts while keeping allocation bounded for large retained exclusion collections.

5 October 2026

1 changed
  • ChangedWorksReconcile every coverage-gap row from its actual Mode cell, validate covered check/tool#830

    references against the public registries, and generate priorities from unresolved rows.

    #830 (issue on GitHub)

4 October 2026

56 added75 changed5 fixed
  • AddedWorksAdd an offline branded/non-branded GSC progress projection with explicit Unicode-aware#880

    aliases, compatible weighted CTR and labelled average-position bands.

    #880 (issue on GitHub)
  • AddedWorksAdded optional check_assignment task grouping for declared template/component URL#878

    mappings or validated crawl segments. Grouped work items retain occurrence and URL counts, capped evidence, grouping provenance, verification scope, and declared/candidate/confirmed/unassigned states.

    #878 (issue on GitHub)
  • AddedWorksAdd opt-in url-correspondence.v1 release comparison maps with declared origin and URL#877

    pairs, collision refusal, page-fact provenance, and release_review.v1 output.

    #877 (issue on GitHub)
  • AddedWorksAdd an offline, evidence-qualified publication-cohort projection with date provenance,#876

    author inventory, labelled provider observations and optional age windows.

    #876 (issue on GitHub)
  • AddedWorksAdd an offline, occurrence-preserving CTA and embedded-form inventory for supplied HTML,#875

    with nullable missing targets, bounded JSON/CSV output, and explicit incomplete-body coverage.

    #875 (issue on GitHub)
  • ChangedWorksPreserve remote timeout classification while redacting transport errors, and replay#866

    direct proxy manifests safely for targeted verification.

    #866 (issue on GitHub)
  • ChangedWorksRead attribution evidence from the retained SQL link graph, preserve explicit null#866

    attribution on export, and retain spooled external-link checks across resume.

    #866 (issue on GitHub)
  • ChangedWorksRegenerate integrated catalogues and report contracts; install the PDF validator with the#866

    all extra and keep fake-browser tests independent of local Chrome selection.

    #866 (issue on GitHub)
  • AddedWorksAdd an offline optional BigQuery load plan that exposes schema-versioned tables, billing#837

    requirements, and explicit authorization prerequisites without cloud access.

    #837 (issue on GitHub)
  • AddedWorksAdd an allowlisted BigQuery REST transport with deterministic staging load jobs,#837

    row-count reconciliation, and documented atomic publication per table rather than a fictional cross-table transaction.

    #837 (issue on GitHub)
  • ChangedWorksRequire a separate host-owned BigQuery cost_authorized approval before any token,#837

    dataset check, or staging write.

    #837 (issue on GitHub)
  • AddedWorksAdd a local-only validated Looker Studio copy-link builder for the synthetic five-page#836

    reporting pack; it requires an original report ID and exact Sheets or BigQuery source aliases without publishing a report or inventing a template.

    #836 (issue on GitHub)
  • AddedWorksAdd the committed synthetic worksheet fixture (examples/reporting-pack/worksheets/):#836

    one CSV per BI dataset plus the manifest, generated byte-deterministically from examples/audit.json and two synthetic GSC/GA4 inputs. It demonstrates measured zero, suppressed/missing, external-only, unkeyable, skipped-check, unavailable-dataset and incomplete-quadrant states, and is verified reproducible by scripts/generate_reporting_pack_worksheets.py --check.

    #836 (issue on GitHub)
  • AddedWorksAdd examples/reporting-pack/SETUP.md: the mechanical owner-side guide for importing the#836

    six worksheets into Google Sheets, wiring the reserved data-source aliases, building the five blueprint pages with the declared controls and calculated fields, enabling copy permission, and verifying a fresh copy. The native Looker Studio template itself remains an owner-authorized external step; no report ID, link or Google write is included.

    #836 (issue on GitHub)
  • ChangedWorksPreserve the generated BI package's partition names, counts and checksums so the#836

    worksheet fixture passes the existing Sheets preflight and filtered-export consumers. Document the separate Linking API alias fields and require fresh-copy verification through the generated URL without manual source repair.

    #836 (issue on GitHub)
  • ChangedWorksReject duplicate Linking API aliases after whitespace normalization and return the exact#836

    aliases used in URL parameters. Define shared run-field IDs, grouped provider date controls, and coverage charts that exclude overlapping populations. Native template creation and fresh-copy verification remain external acceptance requirements.

    #836 (issue on GitHub)
  • AddedWorksAdd explicit, evidence-qualified URL cohort rows to local BI packages, including#835

    measured-zero-safe Search Console/GA4 quadrants only for one-to-one complete compatible observations.

    #835 (issue on GitHub)
  • ChangedWorksCalculate observed inlink shares from unique in-scope linking pages with completed HTML#835

    extraction, preserving repeated occurrences separately and labelling partial populations.

    #835 (issue on GitHub)
  • AddedWorksAdd typed, deterministic offline BI packages for saved scans/audits and supplied provider#834

    evidence, with partition bounds, null states, row conservation and formula-safe CSV cells.

    #834 (issue on GitHub)
  • ChangedWorksPreserve findings from bounded audit.v2 companions, join up to 1,000,000 retained scan#834

    URLs through a durable SQLite evidence artifact, and stream that artifact into BI without materializing page or matched-URL lists. This does not deliver a copyable Looker Studio template.

    #834 (issue on GitHub)
  • ChangedWorksProject finding groups, provider quadrant candidates and eligible inlink sources through#834

    a bounded disposable SQLite index. Keep dynamic provider dimensions valid through selected exports and offline Sheets/BigQuery plans; bound partition counts and disk consumption.

    #834 (issue on GitHub)
  • ChangedWorksPreserve native audit finding target URLs and messages as typed BI columns, not only#834

    inside the retained source JSON, so exact URL selections conserve affected findings.

    #834 (issue on GitHub)
  • AddedWorksAdd scan-fragment-links (seo_scan_fragment_links) and the BROKEN_BOOKMARK audit#827

    check: every fragment-bearing <a href> in retained complete HTML/DOM is resolved against the destination document's element ids and <a name> targets using the WHATWG scroll-to-the-fragment algorithm (the serialized fragment is tried against ids and names first, then the percent- and UTF-8-decoded value against both; + stays a literal plus; # and #top resolve to the document top). Static, rendered, and legacy-fragment representations are evaluated independently, redirect and <base href> rules are honored, and a different query string is a different document. Missing, truncated, unsupported, failed, or budget-exhausted bodies are reported as named skips -- never as broken findings -- and Screaming Frog exports and legacy imports name the check unavailable rather than claiming it ran clean. Results are bounded and deterministically paginated (total, returned, offset/limit, next_offset, coverage state), identical through the CLI, MCP, and the retained-scan audit summary. Nothing is fetched: the evaluation is read-only and offline.

    #827 (issue on GitHub)
  • ChangedWorksValidate native hreflang return links against complete retained target declarations,#825

    distinguishing a known missing return from an uncrawled or body-unavailable target. Report observed noindex alternates, preserve raw labels and normalized URL identities with duplicate/conflict context, and mark partial population relationships unmeasured. The existing audit, CLI, MCP and report surfaces carry the same findings without additional fetching.

    #825 (issue on GitHub)
  • AddedWorksAdd CANONICAL_TARGET_ERROR to report fetched canonical targets that return 4xx/5xx,#824

    carrying source, target, response, and indexability evidence. Unknown or status-incomplete targets remain unavailable; known 2xx variants clear the error verdict, and 3xx targets remain under CANONICAL_TO_REDIRECT.

    #824 (issue on GitHub)
  • AddedWorksAdd CANONICAL_HOMEPAGE_GROUP for groups of at least three fetched, indexable pages#824

    across two or more path sections with distinct titles and H1s that canonicalize to the fetched homepage. Self/home, query, recognizable pagination, configured pagination/filter patterns, incomplete homepage evidence, and partial native crawls do not produce a site-wide finding.

    #824 (issue on GitHub)
  • AddedWorksAdd objective authorship, date and trust-page evidence to scoped audits (issue #823),#823

    eight notice-severity checks in seohead/sf/core/eeat.py that report what a crawl can observe and never an E-E-A-T score. NO_AUTHOR_BYLINE and NO_CONTENT_DATES fire only on content-shaped pages -- a JSON-LD article type, og:type=article, an <article> element, or a conventional content path -- and name the carriers they read (meta_author, rel_author, itemprop=author, byline class, <time datetime>, JSON-LD author/datePublished/dateModified, a date-shaped URL path), each with a declared confidence. MISSING_ABOUT_PAGE, MISSING_CONTACT_PAGE, MISSING_PRIVACY_POLICY and MISSING_TERMS_PAGE report one of found_indexable (silent), found_non_indexable, found_error, discovered_not_crawled or not_discovered, so a page that exists but does not serve never reads the same as one nobody linked. FEW_CITATIONS reports only what link positions can prove -- an outbound link inside the body copy, or a declared count of zero -- and YMYL_REVIEW_CANDIDATE flags deterministic whole-word path/title matches for human review, never a classification. Unmeasured stays distinct from absent throughout: a page whose body was never parsed keeps trust_signals NULL and is counted in the skip reason, and summary.trust_evidence records the scope evaluated, the per-kind page state, and a partial-crawl caveat for not_discovered verdicts.

    #823 (issue on GitHub)
  • ChangedWorksClassify CrUX current-window field LCP, INP and CLS p75 against versioned official#822

    thresholds while retaining URL/origin, form-factor and collection-period scope. Missing, incomplete and failed field evidence stays unavailable or partial.

    #822 (issue on GitHub)
  • AddedWorksAdd explicit bounded URL sampling with optional private cache, and accept saved CrUX#822

    evidence in site audits and reports without automatic provider requests. Bound response/cache bytes and reject inconsistent saved sample counts or repeated URL records instead of reporting complete field coverage.

    #822 (issue on GitHub)
  • ChangedWorksDocument the current one-shot Docker CLI boundary separately from the optional explicitly#820

    constructed remote API and worker service.

    #820 (issue on GitHub)
  • AddedWorksAdd the persistent-workspace SSH run recipe and explain that CLI exit/artifact state is#820

    not an HTTP service health check.

    #820 (issue on GitHub)
  • ChangedWorksAdmit explicit native SQLite crawl configurations up to 1,000,000 URLs with a separate#818

    2,000,000 HTTP request budget and the existing storage-only synthetic profile. CLI, MCP, remote queue and workers preserve the same declared budgets. Materialized legacy/list APIs retain an explicit 50,000-URL safety bound; list robots, retries, redirect and canonical hops now share their request budget. Native audits and offline reanalysis use the bounded audit.v2 path without the former 10,000-page or 20,000-form bridge refusal. Transactional writer counters avoid rescanning the growing frontier on each batch. A manual acceptance driver records loaded source identities, interruption/resume and downstream consumers; admission is not itself evidence of completed million-URL acceptance.

    #818 (issue on GitHub)
  • ChangedWorksRun each manual native acceptance stage under a subprocess resource watchdog, retain#818

    interrupted artifacts, verify every CSV/XLSX row and task coverage, and exercise guarded owned-loopback HTTP with unchanged default pacing separately.

    #818 (issue on GitHub)
  • ChangedWorksPermit explicitly authorized remote crawl durations up to 14 days, retaining the one-hour#818

    default and independent project, request, disk, lease and render budgets. This allows a million-page target to fit its unchanged polite request rate; values above the duration ceiling and invalid types fail before collection.

    #818 (issue on GitHub)
  • ChangedWorksOffline reanalysis uses a fresh deadline for each SQLite execute/fetch operation,#818

    preserving query interruption and cancellation without charging Python parsing or derived writes against the original scan-opening validation deadline.

    #818 (issue on GitHub)
  • ChangedWorksLegacy crawl-site --out-dir now keeps page, link and form evidence in append-only JSONL#817

    sidecars during collection and resume instead of retaining duplicate in-memory lists. Checkpoints verify sidecar counts; older v4 inline-form checkpoints migrate without losing form findings. The existing audit bridge names its page/form/link materialization bounds and preserves collected evidence when an audit is unavailable. A prior report is retained under a stale filename rather than presented as the current run. Native JavaScript escalation reads page records through a re-iterable SQLite view instead of rebuilding all page objects. Full large-audit streaming remains tracked by #816/#817.

    #817 (issue on GitHub)
  • ChangedWorksStore oversized native audits in ordered audit.v2 companions instead of discarding audit#817

    availability.

    #817 (issue on GitHub)
  • ChangedWorksStream native audit normalization into its context without an intermediate record list.#817 (issue on GitHub)
  • FixedWorksKeep native audit page maps and per-page derived metrics in a temporary SQLite context#817

    store.

    #817 (issue on GitHub)
  • FixedWorksKeep native findings on disk until ordered aggregation assigns their report identities.#817 (issue on GitHub)
  • FixedWorksKeep native duplicate/content groups in ordered SQLite storage during analysis.#817 (issue on GitHub)
  • ChangedWorksBound repeated page lookups, batch finding backlinks, and resolve native discovery depths#817

    without rebuilding every route or loading every node into Python. Long routes retain their exact depth and explicit truncated endpoints.

    #817 (issue on GitHub)
  • ChangedWorksStream retained native page input and inlink lookups before context construction.#817

    Derive nonempty corpus and hreflang relations through disk-backed indexes, retain complete evidence collections in audit.v2, and label bounded relation finding samples with their exact occurrence counts.

    #817 (issue on GitHub)
  • ChangedWorksBound the page cache by estimated decoded Python bytes as well as entry count; uncached#817

    oversized pages remain readable and mutations invalidate their old cache weight. Keep HTML size ranks as stable scalar URL/size pairs instead of retaining every full page model, and avoid retaining serialized page JSON in mutation callbacks.

    #817 (issue on GitHub)
  • ChangedWorksRetain only scalar URL, status, indexability, canonical and redirect facts while checking#817

    trailing-slash pairs, and close durable adapter and remote-job SQLite connections explicitly.

    #817 (issue on GitHub)
  • ChangedWorksStream unavailable-body and charset coverage counts, retain scalar trust-page selection#817

    state instead of candidate page populations, and process stored-HTML DOM matches in two passes over the fixed file index. Preserve normalized-variant representative ordering, existing URL examples, complete matched-source coverage and every finding.

    #817 (issue on GitHub)
  • ChangedWorksValidate legacy crawl checkpoint JSONL row by row before appending, refusing missing,#817

    corrupt or unterminated evidence without replacing it. Compare older inline form checkpoints with their sidecar without allocating a second form list.

    #817 (issue on GitHub)
  • AddedWorksAdd provenance-checked JavaScript benchmark continuation between completed pairs,#817

    preserving earlier receipts and disk budgets. Interrupted cases remain explicit blockers. Require successful query/reanalysis and complete page CSV evidence before marking a benchmark case as passed.

    #817 (issue on GitHub)
  • ChangedWorksRetain explicit URL lists with the native SQLite writer while preserving exact input#817

    order, multiple hosts and no-discovery semantics. Stream exchange-file input, preserve request/time budgets across collection resume, retain bounded response/hop evidence and keep graph/form checks explicitly unavailable. Large directory lists use the same spool with a separately bounded JSON audit bridge.

    #817 (issue on GitHub)
  • AddedWorksAdd a versioned audit.v2 SQLite companion for complete ordered audit collections beyond#816

    the inline JSON size ceiling, with scan provenance binding and end-to-end integrity validation.

    #816 (issue on GitHub)
  • ChangedWorksStream audit.v2 JSON, CSV, XLSX, Markdown and DOCX reports, compare high finding#816

    populations, and keep snapshot and prune operations paired with the companion while preserving the scan.v1 schema.

    #816 (issue on GitHub)
  • ChangedWorksRetain large ordered group memberships once in audit.v2 storage revision 3 and ledger#816

    revision 5, with backward-compatible audit reads, atomic ledger migration, explicit member paging through CLI/MCP, and complete source-bound BI membership companions.

    #816 (issue on GitHub)
  • ChangedWorksPreserve the source audit.v2 digest during offline reanalysis; persist incomplete ledger#816

    group scope and close ingest resources on failed validation.

    #816 (issue on GitHub)
  • ChangedWorksReuse the exact corpus capability summary while a native metadata-only scan has no#815

    response, document, body, or resource evidence, avoiding repeated whole-corpus reads on every sparse page commit without changing scan storage.

    #815 (issue on GitHub)
  • AddedWorksAdd an opt-in offline native scan capacity profiler with independently sized links,#815

    retained HTML and rendered DOM, explicit resource budgets, and a process-loss/resume exercise. It reports the existing 50,000-URL admission ceiling and incomplete finalization as blocked evidence rather than capacity.

    #815 (issue on GitHub)
  • ChangedWorksName pinned browser request cancellation as render_cancelled and close the isolated#811

    remote context and browser on cancellation or disconnect without local fallback.

    #811 (issue on GitHub)
  • ChangedWorksAllow trusted remote project configuration to supply host-bound environment credential#810

    references, with redacted retained provenance and failure before collection when a reference is unavailable.

    #810 (issue on GitHub)
  • ChangedWorksAllow a service-owned, version-checked remote Playwright transport for a queued project#809

    while keeping its endpoint outside submissions and preserving pinned, policy-bound browser requests.

    #809 (issue on GitHub)
  • AddedWorksExpose saved-scan observed paths, reverse inlinks and occurrence context through one#807

    bounded, read-only shared CLI/MCP query with explicit scan identity and error results.

    #807 (issue on GitHub)
  • AddedWorksAdd bounded, read-only occurrence-level link placement and nearest-heading context from#806

    retained static HTML and rendered DOM, preserving duplicate link IDs and explicit unavailable/partial evidence.

    #806 (issue on GitHub)
  • AddedWorksNew semantic-inputs command (seo_semantic_inputs over MCP) builds the reproducible#801

    normalized-input manifest the semantic-similarity work will consume: per document it records the retained body hash, the exact decoded input hash, the normalized output hash, the content-area strategy, and language evidence (the page's own <html lang> plus letter-script shares) — never the normalized text itself. --scan streams retained complete bodies offline under the crawl's recorded content-area config with no refetch, and a missing or partial body stays an explicit omission rather than a clean empty result (#801).

    #801 (issue on GitHub)
  • ChangedWorksValidate technical-audit PDF page count, byte size, extractable text, and source-model#800

    record conservation before a rendered report is published.

    #800 (issue on GitHub)
  • AddedWorksAdd offline bilingual Chromium PDF output to shared report-build CLI and MCP paths,#799

    validating and sanitizing the temporary file before atomic replacement.

    #799 (issue on GitHub)
  • AddedWorksAdd self-contained English and Russian technical-audit PDF layouts with neutral brand#798

    tokens, severity and coverage charts, explicit incomplete-run states, and long-content page flow.

    #798 (issue on GitHub)
  • FixedWorksKeep PDF layout separate from audit model building and CLI/MCP integration.#798 (issue on GitHub)
  • AddedWorksAdd a versioned, offline technical-audit PDF content model that preserves source records,#797

    exact source/projected counts, scope and coverage states for both supported audit contracts.

    #797 (issue on GitHub)
  • ChangedWorksPersist registered workflow scenario versions, site and competitor context, per-step#796

    evidence and review gates. Interrupted runs can be resumed by a later local session only after their current dependencies remain valid.

    #796 (issue on GitHub)
  • ChangedWorksRequire a controller worklist for workflow starts and retain explicit specialist-note#796

    triage receipts for task, goal, competitor, blocked, or rejected outcomes.

    #796 (issue on GitHub)
  • ChangedWorksRequire successful workflow checkpoints to verify project-bound retained artifact bytes#796

    against their SHA-256 receipts. Missing or changed evidence makes prior completion stale without rewriting history; verification budgets never silently certify evidence or discard its receipt.

    #796 (issue on GitHub)
  • ChangedWorksAllow explicit, bounded evidence-hash policy profiles while normalizing older policies.#796

    Passive workflow observation checks retained verification metadata without rehashing artifacts; changed or unverified receipts stay explicit.

    #796 (issue on GitHub)
  • AddedWorksAdd a bounded, paginated project progress CLI command and seo_project_progress MCP#795

    tool with checklist state counts and next actionable items. The view reuses scoped coverage results and reports only an explicitly labelled audit-task completion percentage when every included site has an agreed plan and a measured, non-empty task denominator; URL-population coverage remains separate.

    #795 (issue on GitHub)
  • ChangedWorksProject checklists can now record the agreed audit scope and report honest coverage#794

    ratios (#794). project-checklist-init / seo_project_checklist_init accept an optional plan — a reviewer, a URL population declared as a complete_set, a named sample, or unknown, with its source, size or enumerated urls, and optional per-templates populations, plus an optional tasks agreement (all_agreed, or a sourced selection of existing checklist item IDs) — bound to the project site identity and stamped with the write revision. Recording a plan upgrades the checklist to seohead.coverage.v3 and appends to a retained plan history; re-recording an identical agreement is an idempotent no-op, a changed agreement stale-marks earlier evidence instead of re-evaluating it under a changed denominator, and single-plan v3 documents migrate on read. Reading, reconciling, or applying priorities never upgrades or invents a population, and v1/v2 documents keep loading unchanged. Items outside a selection are reported as not_agreed, visible outside every denominator.

    #794 (issue on GitHub)
  • ChangedWorksChecklist completion now follows the measured scope: a site-scoped automatic item backed#794

    by a limited (partial-scan) measurement stays unfinished instead of counting as complete, while an item scoped to explicit sample URLs still finishes when the artifact covers exactly that scope. The top-level complete flag now means the scoped audit is finished — every applicable agreed task done, no pending exclusions, and the agreed URL population fully covered by verified measurements — so unknown or unmeasured scope can never report a completed audit.

    #794 (issue on GitHub)
  • ChangedWorksChecklist status now returns coverage: five separate ratios — audit_tasks, checks#794

    (with measured_urls), manual_review, deliverable_review, and url_population — each with its own basis, numerator, denominator and state, so a URL count is never mixed with a check or task count. A denominator that cannot be justified stays null with state: "unknown" and a reason instead of becoming 0/0 or 100%; measurements that cannot be verified against the agreed population keep the URL axis partial and count only verified eligible URLs. URL membership is verified against the agreed enumeration, so a population declared only by size counts no measured URLs and the numerator can never exceed its denominator. Per-template populations are validated as agreed sub-populations of the site population when a plan is recorded or read: enumerated template URLs must belong to the enumerated site set, and declared template membership can never exceed the agreed site size, so an incoherent plan is refused rather than trimmed or counted past the denominator. Saved check evidence now carries the measured URL enumeration, so the URL numerator is an auditable set rather than a row count. Multi-site aggregation sums the axes per site and degrades to unknown whenever a site cannot justify its denominator.

    #794 (issue on GitHub)
  • ChangedWorksA not_applicable record is now a reviewed exclusion: it requires a reason, a reviewer,#794

    and an inspectable evidence basis — a project-relative digested artifact or an explicit evidence reference. Reviewed exclusions keep their stable ID and history, leave the task denominators only through that recorded decision, and are listed separately under exclusions. A disabled item, a stale exclusion record, or a legacy not_applicable record written before the evidence-basis rule stays inside the denominator as pending_exclusion until reviewed again, so disabling, catalogue removal, or reclassification can never silently shrink agreed counts; a fresh reviewed exclusion on a catalogue-removed item resolves normally instead of staying pending forever.

    #794 (issue on GitHub)
  • AddedWorksAdd the addressable workflow/full-audit-v1 prompt and full-audit scenario through#793

    existing skill/scenario CLI and MCP catalogue routes. The workflow reuses shipped collection and method paths, gates verification and reporting on actual evidence, and separates project preparation from full-audit completion.

    #793 (issue on GitHub)
  • ChangedWorksBind remediation decisions to immutable verification artifacts and exact before/after#792

    evidence hashes, including streamed audit.v2 baselines.

    #792 (issue on GitHub)
  • ChangedWorksReport retained remediation outcomes with represented and full-scope coverage states,#791

    per-task denominators and bounded case pages for large ledgers.

    #791 (issue on GitHub)
  • AddedWorksAdd ledger-keyed bounded remediation rechecks that reuse the guarded verifier, stream#790

    audit.v2 selections and refuse scope drift.

    #790 (issue on GitHub)
  • ChangedWorksBind remediation verification to the retained audit identity and original#790

    results-affecting policy; a reused scan UUID alone can no longer resolve an older case.

    #790 (issue on GitHub)
  • AddedWorksAdded the ledger.v1 remediation ledger (seohead.storage.ledger, SEOL application#788

    id): a separate project-bound SQLite artifact that records findings, per-occurrence identity, affected-URL membership and append-only observation history from validated scan.v1/scan.v2 audits without mutating the source scans. Ingestion binds each source by scan UUID plus evidence revision, audit digest and producer/configuration identity; exact replays are idempotent, conflicting bytes under the same revision are refused atomically, and capped/aggregate_only coverage plus unavailable evidence are retained as data rather than silently treated as resolved. Includes transactional 0→1 migration, full schema/integrity validation on open, and docs/LEDGER.md (#788).

    #788 (issue on GitHub)
  • AddedWorksAdd a disposable loopback TLS reverse-proxy profile that verifies authenticated artifact#787

    isolation, backup/restore, expiry, and atomic upgrade rollback without deploying a service.

    #787 (issue on GitHub)
  • ChangedWorksDocument one-shot SSH CLI jobs, artifact/incident records, synthetic recovery and the#787

    separate future-service health, proxy, TLS and firewall boundary.

    #787 (issue on GitHub)
  • AddedWorksAdd the self-hosted remote-service operator profile for private binds, authenticated#787

    health evidence, secret-file references, incident records, SQLite-consistent restore, release rollback and explicit terminal-artifact expiry.

    #787 (issue on GitHub)
  • AddedWorksAdd a project-bound remote target policy for future queued jobs.#786

    When bound by a worker, it validates targets and settings, rechecks DNS at dispatch, pins HTTP and browser resource requests, and enforces per-origin and total request budgets. Remote proxy and alternate browser backends fail closed until their egress is proven safe.

    #786 (issue on GitHub)
  • AddedWorksAdd a project-isolated SQLite queue and leased native scan worker for the optional#785

    authenticated remote API. Jobs retain native scans and JSON/Markdown reports, support idempotent submission, cancellation, restart failure recovery, scoped artifact downloads and explicit retention without enabling a listener by default.

    #785 (issue on GitHub)
  • ChangedWorksDefine an optional authenticated, versioned remote scan API contract with bounded native#784

    crawl settings, project-scoped permissions, idempotent submission, OpenAPI schema and scan-status-compatible evidence. It requires separate durable workers and target/egress policy; local CLI/MCP installs remain unchanged (#784).

    #784 (issue on GitHub)
  • AddedWorksAdd a pinned Ubuntu SSH runbook and a disposable Linux lifecycle smoke that proves#783

    install, JavaScript rendering, artifact discovery, upgrade, version evidence, and rollback without a remote service.

    #783 (issue on GitHub)
  • ChangedWorksNormalize imported analytics and search evidence for scan joins (#781).#781

    evidence-normalize reads a supplied CSV/XLSX/JSON export or a saved seohead.provider-evidence.v1 envelope fully offline and produces a versioned seohead.normalized-evidence.v1 document against an optional seohead.evidence-mapping.v1 manifest, keeping per-field provenance (envelope, declared, or unknown) for source, reporting identity, period, timezone, attribution, engine, collection coverage, and URL binding. A measured 0 stays zero while missing rows, null or blank cells, formula cells, suppressed values, uncollected periods, and failed or skipped collection stay explicitly unavailable. evidence-join joins normalized evidence to a scan, audit, or page list under the strict external_join.v1 key policy with explicit relaxations only, preserves matched, crawl-only, external-only and unkeyable populations at their declared grain, and reports key collisions instead of multiplying rows; --compare plus a declared policy adds a pure compatible/incompatible/unknown decision across period, timezone boundary, identity, attribution, engine, and grain, and reports measured-zero quadrant eligibility for downstream cohort work. Restricted sources keep rows and identifiers inside a private local artifact and return counts only.

    #781 (issue on GitHub)
  • AddedWorksAdd an offline provider-readiness experience with redacted credential references,#780

    operation-route schemas, quota/privacy metadata, and explicit target-access states; credential presence alone never verifies permission.

    #780 (issue on GitHub)
  • ChangedWorksExpand seohead watch into a read-only project observer with retained finding#775

    navigation, evidence detail, saved-view/review state, and explicitly scoped MCP inbox notices.

    #775 (issue on GitHub)
  • AddedWorksAdd authorized synthetic report delivery with durable idempotency receipts and retry-safe#773

    restart behavior.

    #773 (issue on GitHub)
  • ChangedWorksPreserve uncertain delivery claims after process exit or malformed document confirmation,#773

    preventing an automatic second upload when acceptance cannot be established.

    #773 (issue on GitHub)
  • ChangedWorksMeasure report previews from retained evidence, including rendered size, source digest,#772

    population counts, coverage, and profile; report missing renderers and oversize inputs before sending. Match the thorough wizard JS budget to the shared queue contract.

    #772 (issue on GitHub)
  • ChangedWorksRetain exact confirmed bot dispatch identities across queue/ownership crashes,#771

    deduplicate trusted updates, and isolate status, cancellation, recovery, and delivery by initiating actor/chat. Identical intentional reruns remain separate jobs.

    #771 (issue on GitHub)
  • FixedWorksRestore the last confirmed actor/chat job after service restart, preserving cancellation#771

    and re-confirmed rerun behavior without resuming an unconfirmed draft.

    #771 (issue on GitHub)
  • AddedWorksAdd the software-review evidence pack for self-hosting, legal, and procurement review#761

    (#761). scripts/generate_review_pack.py generates and verifies a versioned dependency/license inventory (one profile per install group, with resolved versions and license sources measured from the generating environment, and marker-excluded, spec-unsatisfied, and uninstalled requirements recorded rather than dropped), a release-level provenance document tying the built distributions to a tag and the embedded build manifest's source revision, and a SHA256SUMS.txt covering all of it. Generation refuses a tag that disagrees with pyproject.toml; verification re-checks formats, recorded hashes, and re-extracts each distribution to re-validate its embedded manifest, the name/version its filename declares, and the Name/Version fields of its own *.dist-info/METADATA or PKG-INFO against pyproject.toml instead of trusting the record. A SEOHEAD_REVIEW_CANARY_* sentinel scan proves pack files and diagnostics carry no planted credential or client markers. CI regenerates and verifies the pack with a disposable tag on every run; the release workflow verifies it and attaches it to a real v* release.

    #761 (issue on GitHub)
  • AddedWorksAdd docs/SOFTWARE_REVIEW.md: the outbound-endpoint and data-flow map (what each feature#761

    contacts, what it sends, whether the call is optional), the local/remote boundary, credential and artifact storage, telemetry status, security reporting and maintenance status, and how to review release changes -- linking to the install and deployment runbooks (#783, #787) rather than duplicating them.

    #761 (issue on GitHub)
  • AddedWorksAdd an offline Google Sheets capacity and checksum preflight for complete local BI#760

    packages; it makes no Google request or spreadsheet write.

    #760 (issue on GitHub)
  • AddedWorksAdd an explicit allowlisted Google Sheets replace transport with per-dataset worksheet#760

    mappings, bounded staging/readback, and one atomic value-only publication that preserves existing worksheet IDs.

    #760 (issue on GitHub)
  • ChangedWorksCheckpoint Google Sheets staging before any remote stage creation and reconcile a lost#760

    begin response by its deterministic stage-sheet identities before retrying.

    #760 (issue on GitHub)
  • ChangedWorksPreserve typed numeric/boolean values and sparse Google readbacks, reject duplicate#760

    worksheet targets and dataset contract drift, bind recovery to the exact configured target, and retain resumable staging after quota exhaustion. Budget target-grid growth before the atomic replacement and report unconfirmed row counts on interrupted runs.

    #760 (issue on GitHub)
  • ChangedWorksValidate complete source-bound group companions before destination writes, preserve them#760

    with selected findings, and disclose local-only membership references in destination and workbook receipts without claiming the companion was uploaded.

    #760 (issue on GitHub)
  • ChangedWorksSplit large scan-export XLSX record sets into deterministic numbered worksheets with a#759

    row-conservation Partitions sheet instead of failing at Excel's per-sheet row limit.

    #759 (issue on GitHub)
  • ChangedWorksSplit report-build Findings and Pages worksheets at Excel's row limit and emit a hashed#759

    .xlsx.index.json with exact record ranges, source population, selection and coverage references. Preserve the previous report on caught write failures and clean streaming spools on interruption.

    #759 (issue on GitHub)
  • ChangedWorksRefuse XLSX cells beyond 32,767 characters before the spreadsheet library can truncate#759

    them, protect Summary metadata from formula injection, and reject malformed retained audit rows before an incomplete export can claim success. Complete CSV/JSON and the bounded PDF overview policy remain available.

    #759 (issue on GitHub)
  • AddedWorksAdd bounded selected BI CSV delivery with exact source/selected/omitted row accounting,#759

    retained source coverage, streamed hashes and a durable XLSX CSV-to-worksheet range index. Oversized Excel cells fail before truncation and interrupted output is not marked complete.

    #759 (issue on GitHub)
  • ChangedWorksPreserve complete, immutable check-coverage rows beside selected finding BI exports,#759

    including skipped-check reasons, and support exact finding selection by retained crawl scope segment.

    #759 (issue on GitHub)
  • AddedWorksAdd offline crawl diagnostics for saved native scans and legacy run artifacts, with#758

    bounded decision evidence, actionable explanations, read-only CLI/MCP access, and a separate opt-in redacted export command/tool.

    #758 (issue on GitHub)
  • AddedWorksAdded versioned project finding views with closed filters, severity-aware sorting, column#757

    projection, and bounded pagination. Descending severity puts critical findings first; ascending goes from notice to critical. CLI, MCP, reports, and future terminal navigation can reuse the same read-only view without changing source evidence, scoring, tasks, or coverage.

    #757 (issue on GitHub)
  • AddedWorksAdd disabled-by-default local monitor planning with explicit incremental and full-refresh#756

    budgets, overlap refusal, recovery claims, immutable observation provenance, partial-result retention, deduplicated alerts and recovery notices. The monitor starts neither a timer, crawl nor delivery channel.

    #756 (issue on GitHub)
  • AddedWorksAdd an explicit injected service-delivery adapter for retained complete monitor notices.#756

    It requires an authorized project-bound destination and durable receipt store; no transport, account, timer, or destination is enabled by default.

    #756 (issue on GitHub)
  • ChangedWorksMake retained monitoring claims, coverage, cache provenance, derived recovery evidence,#756

    and optional service delivery restart-safe.

    #756 (issue on GitHub)
  • AddedWorksAdd an explicit, lease-bounded one-shot monitor collector with cache-aware retained#756

    evidence and local-only delivery receipts.

    #756 (issue on GitHub)
  • AddedWorksAdded verify-fixes / seo_verify_fixes for bounded, selected-URL rechecks from saved#755

    finding IDs, a verification view, or an explicit URL list. It reuses recorded crawl policy, writes a separate no-clobber verification artifact and focused report, and distinguishes resolved, persisting, changed, and not-verifiable findings. An existing after audit can be verified without network access only when it has a distinct scan UUID and a later recorded observation time; unmeasured or site-wide evidence is never labeled fixed.

    #755 (issue on GitHub)
  • ChangedWorksNative URL crawls can filter discovered routes by final filename extension before#749

    requests and by actual response Content-Type before parsing or body retention. Both filter stages record their decisions while preserving link, status, header and redirect evidence; filtered HTML metadata remains explicitly unmeasured. Script/stylesheet resources keep their independent selection and byte budgets.

    #749 (issue on GitHub)
  • ChangedWorksWhen an HTML body is unavailable, body-derived findings now retain explicit skipped#749

    coverage instead of treating parser defaults as clean or as observed missing metadata.

    #749 (issue on GitHub)
  • AddedWorksAdd auditable post-analysis URL-pattern exclusions for findings.#748

    Suppressed findings remain in audit JSON and report-build exports with their matching rule and reason, while active finding totals, score, and task backlog reflect the configured policy. Native scan artifacts preserve the policy for offline reanalysis; rule count and field lengths are bounded.

    #748 (issue on GitHub)
  • AddedWorksAdd scan-export (seo_scan_export): a read-only, offline scan-data export under the#747

    versioned scan_export.v1 contract. It reads a validated scan.v1 SQLite artifact or an SF Analyzer audit.json and writes CSV (one file per record type plus a manifest), XLSX (write-only workbook), a streaming JSON envelope, or a documented XML document. Each output carries provenance, exported row counts, run statistics, and explicit coverage — including which checks ran, were skipped, were disabled, or stayed unaccounted — none of which field selection can remove. --records and --fields provide validated projection across page, link and finding records; unknown record types or field names are rejected before any output file is created. Absent values stay explicit (JSON null, XML state="absent", CSV/XLSX \N) rather than becoming fabricated zeros. XML output is generated serialization with correct UTF-8 escaping; it never parses caller-supplied XML, DTDs, stylesheets, or entities. CSV/XLSX keep the existing formula-injection defense; XLSX refuses Excel row/column/cell-text overflows instead of truncating, and any serialization failure leaves no plausible partial artifact.

    #747 (issue on GitHub)
  • ChangedWorkscrawl-site gains an opt-in bounded check of recorded external destinations:#746

    discovery.external.crawl runs a separate phase after the internal frontier closes, fetching each distinct outlink target once under its own external_checks.max_targets/max_hosts/max_requests/max_depth/max_redirects budgets — never recursive off-host traversal. Outcomes (fetched, failed, blocked, skipped with the rule that declined) append to external_checks.jsonl in the run directory, keep source-page provenance, persist across resume without reopening spent budgets, and report their own coverage in discovery.external_checks distinct from internal crawl completeness. Requires discovery.external.store; refused by name on the native SQLite route (--scan-out) until that collector gains the phase.

    #746 (issue on GitHub)
  • AddedWorksAdd an explicit, validated HTTP forward-proxy policy for native crawling across page,#745

    sitemap, resource, and pinned-browser requests, with redacted route provenance.

    #745 (issue on GitHub)
  • ChangedWorksThe native crawler's rendering step now exposes its browser through configuration instead#744

    of hard-coding Chromium: rendering.browser.engine selects chromium (still the default), firefox or webkit through the same guarded Playwright path, an unknown name fails during settings validation rather than at launch, and a requested engine whose binary is not installed reports a dependency error that names the fix (playwright install <engine>) instead of silently falling back to Chromium. Firefox's documented limitation is enforced honestly: it rejects rendering.browser.mobile_emulation (is_mobile is unsupported there) while the independent rendering.browser.touch_emulation (has_touch) still reaches the context. Probes and full renders share one effective configuration, so a pattern is never escalated by a browser that differs from the one producing its evidence.

    #744 (issue on GitHub)
  • ChangedWorksrendering.browser.viewport_width and rendering.browser.viewport_height add arbitrary#744

    viewport dimensions alongside the existing desktop/mobile presets, which keep deciding the profile exactly as before. The pair is all-or-nothing and bounded to 1..16384 px; a custom pair replaces the preset's dimensions only -- it does not imply mobile or touch emulation, which remain explicit flags an operator states.

    #744 (issue on GitHub)
  • ChangedWorksrendering.browser.page_concurrency bounds how many browser fetches may be in flight#744

    during escalation, separately from speed.concurrency, which keeps governing HTTP crawl politeness. The default of 1 preserves the sequential behaviour and builds no pool at all; above 1, probes and renders run on a bounded worker pool while every verdict, commit and SQLite write stays on the orchestrator thread in submission order. The render deadline, URL budget and sampled/full policies hold under concurrency, a stopped batch waits out the pages it already started and never schedules new ones, and interrupted or failed workers leave no thread, page, context or browser alive behind the call.

    #744 (issue on GitHub)
  • ChangedWorksThe selected engine, its actual reported version, the effective viewport and the#744

    configured page concurrency are recorded in the crawl manifest and fingerprint and in every rendered document's renderer provenance -- including failed attempts, which record what was requested without inventing a successful render. Both escalation paths honour the settings: the directory crawl and the native SQLite scan, where rendered-link candidates still enter the frontier through the existing deduplicating admission once rendering.rendered_links.crawl is enabled.

    #744 (issue on GitHub)
  • AddedWorksAdd opt-in canonical-policy checks for configured pagination and filter URL patterns to#727

    the SF export and native crawl audit pipelines. Pagination rules support self, first-page, or landing targets; filter rules support self or landing targets. Absent policies and targets missing from crawl evidence remain unmeasured, and destination response-status checks stay separate.

    #727 (issue on GitHub)
  • AddedWorksAdd resumable GSC Search Analytics archives in the shared local provider-history SQLite#718

    store, exposed consistently through gsc-archive and seo_gsc_archive. Offline status, explicit preparation, bounded collection and verified snapshots share one core.

    #718 (issue on GitHub)
  • ChangedWorksPreserve provider/engine provenance and nullable metrics, queue only uncovered#718

    availability ranges, and persist quota waits and bounded transient retries.

    #718 (issue on GitHub)
  • AddedWorksAdd one versioned local SQLite history for GA4, Metrika, GSC web query/page, and Yandex#716

    Webmaster daily evidence, with atomic day replacement and explicit requested, empty, partial, failed and lagged coverage.

    #716 (issue on GitHub)
  • AddedWorksExpose deterministic bounded sync, offline status, and JSON/CSV export through the shared#716

    CLI and local MCP, including metric additivity and conservative timezone metadata. No credentials or provider data are stored in the repository.

    #716 (issue on GitHub)
  • ChangedWorksSF Mode A now verifies the installed CLI's requested export capabilities and requires a#715

    complete fresh export set before analysis. Full-profile provenance records the verified export manifest and crawl-rate readback; an explicit rate limit fails before crawling when its base config is missing or invalid.

    #715 (issue on GitHub)
  • ChangedWorksmetrika-report (seo_metrika_report) and MetrikaClient.report(): a Reporting API#714

    "Query is too complicated" refusal is now retried in calendar-month slices with a 5–15 s backoff; a slice that still refuses is resubmitted at a sampled accuracy=0.1. Only metrics provably additive over disjoint periods (ym:s:visits, ym:s:pageviews) are merged — a query for unique-visitor, percentage, average, or unverifiable metrics is refused before the first slice request rather than returning a summed value that would be wrong. Slices are merged as they stream in under one global row cap that also bounds downloads: once the cap is exhausted, remaining partitions are never requested and their date spans are named in split.unfetched — a slice stopped mid-way lists its own span there with partial — whole-period total_rows/totals stay unknown when the unqueried periods make them unknowable, and the returned window is marked partial. Merged answers re-apply the caller's sort (dimensions and metrics, or the API default of first metric descending) and take the offset/limit window from the global merged set. Sampling fields reflect what the API actually reported (sampled, worst sample_share), and a slice that does not report them leaves the whole-period value unknown — null in the CLI/MCP answer, never an invented false. contains_sensitive_data is the OR over slices, min/max are recomputed on merged rows, and missing or malformed metric cells and totals mark the body incomplete instead of reading as zero. The split block keeps the requested and resolved periods, the timezone used, and per-slice accuracy, attempts, dropped rows, and sampling metadata; a partition that fails names the completed spans and the accuracies exhausted. Relative dates (today, NdaysAgo) resolve in the request timezone or the counter's own zone (time_zone_name, else its minute offset) — never the host date. A period that cannot be mapped to dates (a shorthand such as month) is still retried and sampled before the error is re-raised. An explicit accuracy is honored in every documented form (low/medium/high/full or a numeric share such as "1"): anything above the last-resort sample keeps the accuracy=0.1 fallback, while a caller's already-lower setting (low or a share at/below it) is preserved rather than resampled upward. Every attempted request — including the refused whole-range call — reaches the spend journal.

    #714 (issue on GitHub)
  • ChangedWorksYandex Webmaster: the indexing operation now reads the real v4 endpoint#713

    /indexing/samples instead of the nonexistent /search-urls, new broken_links_samples reads /links/internal/broken/samples, and paging honors each endpoint's documented page-size cap (500 for queries, 100 for the sample lists) instead of sending limit=500 everywhere (#713).

    #713 (issue on GitHub)
  • ChangedWorksYandex Webmaster paged collection now validates each page's documented count and list#713

    shape — including a count below the returned rows — requests at most the remaining row budget per page so a max_rows cut can never overread into a false complete, exposes returned/truncated for a single non-paginated page as a bounded slice, reports partial whenever rows remain unread, and fails malformed pages instead of claiming a complete empty result (#713).

    #713 (issue on GitHub)
  • ChangedWorksRender-check now uses platform trust plus an explicitly selected CA, replays pinned POST#709

    subrequests, and reports certificate verification failures without transport detail.

    #709 (issue on GitHub)
  • ChangedWorksRecognize the pinned proxy transport's TLS verification failure and verify explicit#709

    fixture-CA trust, POST replay, response caps and private-target rejection through an owned synthetic CONNECT interceptor without changing platform trust.

    #709 (issue on GitHub)

3 October 2026

5 added8 changed1 fixed
  • AddedWorksAdd crawl-import for explicitly mapped third-party crawl CSV bundles, preserving#819

    foreign source identity, per-field coverage, duplicate observations, and unavailable data in third_party_crawl.v1 without treating the result as a native scan or SF audit.

    #819 (issue on GitHub)
  • AddedWorksAdd an opt-in remote Playwright browser transport with explicit endpoint and version#808

    checks, safe failure states, and no local-launch fallback while preserving local rendering defaults.

    #808 (issue on GitHub)
  • AddedWorksAdd bounded, deterministic read-only shortest observed path and reverse-inlink Python#805

    queries over retained scan links, with per-hop scan record identity and explicit partial-graph coverage.

    #805 (issue on GitHub)
  • ChangedWorksAlign the provider workflow routing regression with the corrected Arsenkin Wordstat#779

    frequency request contract (#720, #779).

    #779 (issue on GitHub)
  • AddedWorksAdd docs/PROVIDERS.md, a generated provider capability and specialist workflow matrix#779

    (#779, epic #753). Rendered from the live provider registry plus a curated workflow catalogue in seohead/core/provider_matrix.py via scripts/generate_provider_matrix.py, it records per workflow the shipped commands, auth, cost/quota, privacy class, limitations, and CSV fallback through provider-join. Statuses distinguish supported, partial, unsupported, and unverified; no declared provider is labelled live-verified, explicitly unsupported work is listed by name, and a phased first release is selected without claiming every service. Drift between the committed document and the registries is caught by tests/test_docs_drift.py and tests/test_provider_matrix.py.

    #779 (issue on GitHub)
  • FixedWorksFix keywords-exact / seo_keywords_exact, which could never return a result: the task#720

    was created for an Arsenkin tool named keywords_frequency that does not exist, so every call answered 404 WRONG_TOOL. Exact !W now goes to the wordstat tool with type=1, the phrase array under queries, and the region as a single-item list.

    #720 (issue on GitHub)
  • ChangedWorksRequest the overal selector for !W: Arsenkin's ws values are distinct operators —#720

    base is broad (WS), quoted is the quoted phrase ("WS"), overal is the exact wordform (!WS) !W asks for, and exact is the strict-order form ([!WS]).

    #720 (issue on GitHub)
  • ChangedWorksStrip punctuation the provider refuses before sending: one phrase containing / or#720

    quotes made it reject the whole batch with 422 JSON_VALIDATION_ERROR. Rewritten phrases are reported back in cleaned so a caller can see which ones were measured in a different form.

    #720 (issue on GitHub)
  • ChangedWorksReturn frequencies as {phrase: {"base": N, "overal": N}} beside the raw provider#720

    payload. A phrase whose result names only other regions is omitted and listed in warnings instead of borrowing another region's numbers.

    #720 (issue on GitHub)
  • AddedWorksAdd bounded read-only Topvisor access through the shared CLI and MCP handler, with#719

    central credential files, explicit pagination and no paid-check operations.

    #719 (issue on GitHub)
  • ChangedWorksAlign the legacy sources.gsc block in sources-doctor with provider_status.gsc:#717

    ready and the new components field now come from the same credential components (OAuth bearer, durable grant, or service account) instead of only the bearer file, so a configured service account no longer reads as "GSC is not set up". The service-account component now validates the JSON document itself (private regular file, bounded size, service_account type, the exact Google token URI, and required fields) and both doctor blocks report a safe service_account_status enum (missing, unsafe_file, too_large, unreadable, malformed_json, unsupported_shape, or configured_unverified), so an oversized, unreadable, or malformed file stays distinguishable from a configured-but-unverified one without exposing its contents.

    #717 (issue on GitHub)
  • ChangedWorkscrawl-site scan artifacts now stamp finished_at and a finished lifecycle once every#712

    accepted frontier row reaches a terminal state, including runs whose retained evidence is partial: crawl_partial already names omitted observations, so a drained queue no longer reads as an interrupted capture in scan-status, scan-list, or history (#712). An explicitly stopped run -- aborted before it accepted work, cancelled, or halted by the error circuit -- keeps interrupted and its recorded reason even after the queue drains, including through resume_or_finalize, so stop paths never receive a false finished timestamp.

    #712 (issue on GitHub)
  • ChangedWorksduplicate-check --scan and boilerplate-report --scan now stream a retained corpus#710

    instead of dying on normal-size sites: the scan's open-and-validate deadline is budgeted by artifact size, and each document read gets its own deadline, so a 1.5k-page corpus no longer ends in interrupted. The corpus input-byte budget meters what the analyzer actually retains — extracted Markdown for duplicates, one digest per page for boilerplate — not raw HTML size, and reaching a corpus bound reports partial coverage with the exact prepared/eligible counts and the bound that stopped the read (#710).

    #710 (issue on GitHub)
  • ChangedWorksMetrika report() and by_time() now reject offset < 1 locally with a ValueError#707

    instead of forwarding it to the Reporting API, which answers offset=0 with HTTP 400; new offline tests pin the 1-based contract across metrika-report, seo_metrika_report, and provider-collect aggregate_report, and verify that paginated stop conditions (total_rows, ROW_CAP) count the rows skipped before a non-default offset (#707).

    #707 (issue on GitHub)

2 October 2026

1 added2 changed
  • ChangedWorksmetrika-traffic-pdf on macOS: Chrome writes the PDF and keeps running; the browser is#731

    now started without waiting and stopped once the PDF is verified.

    #731 (issue on GitHub)
  • ChangedWorksYandex Webmaster: user_id is resolved from the token; new read-only operations#713

    summary, diagnostics, sqi_history, search_history, query_history, in_search_history, events_history, indexing_history, important_urls, broken_links_history, external_links_history; params for dates and indicators; paging of query and crawl-sample lists (#713).

    #713 (issue on GitHub)
  • AddedWorksNew technical skill traffic-report: a client-facing monthly or annual report from

    Metrika, Webmaster and Search Console (conversions counted as visits, goal sanity checks, products, buyer portrait, slide rules).

1 October 2026

1 added1 changed
  • AddedWorksAdd metrika-traffic-pdf (seo_metrika_traffic_pdf): a static, A4-landscape traffic#731

    report in the layout of a analytics dashboard, built from Yandex Metrika. Collection and rendering are separate stages. Collection reads the Reporting API (about 40 free, read-only requests) into one seohead.metrika-traffic/1 document: nine KPI cards (users, visits, pageviews, pages per visit, visits per day, bounce rate, average visit duration, share of visitors with two or three visits, share of new visitors) with changes against the previous period of equal length and the same dates a year earlier; daily dynamics; 3/6/12-month windows against the windows before them; search engines, cities, countries, devices, age, gender, top landing pages and search phrases; and all traffic channels. Rendering formats that document into self-contained HTML with inline SVG charts and prints it to PDF through a locally installed Chrome, Edge or Chromium (SEOHEAD_CHROME overrides discovery). Branding (name, colours, font stack, logo text) and English or Russian labels are inputs; Search Console query rows can be added as an optional page. Attribution defaults to the last significant source (ym:s:lastSignTrafficSource and its engine and phrase dimensions), which is what Metrika's interface reports; the last-click dimensions differ from it by a few percent and remain an explicit attribution=last_click. Every request passes offset=1 because the API counts rows from 1. Missing data stays visible: each block carries ok, unavailable or skipped with a reason, a change without a baseline is null rather than zero, a missing browser leaves the HTML and reports the PDF as skipped, and existing files in out_dir are replaced only with overwrite=true.

    #731 (issue on GitHub)
  • ChangedWorksMetrika reports start at offset=1: the Reporting API is 1-based and answered every#707

    request sent with offset=0 with HTTP 400 (#707, #728, #729).

    #707 (issue on GitHub)

26 September 2026

1 added
  • AddedWorksAdd native-crawl HEADING_SKIP evidence for H1–H6 level jumps in the named content#725

    region. Header, footer, navigation, sidebar, and explicitly hidden or closed-dialog headings are excluded so template UI cannot create a false hierarchy finding; Screaming Frog export-only runs report the check as unavailable.

    #725 (issue on GitHub)

23 September 2026

1 changed
  • ChangedWorksAccept private secret files on Windows, where POSIX permission bits do not exist, and add#708

    a browser consent script that stores a read-only Search Console grant.

    #708 (issue on GitHub)

13 September 2026

2 added3 changed2 fixed
  • AddedWorksSupport native SQLite writers, history, retry, snapshots, reanalysis, and project#705

    publication on Windows with OS-backed byte-range locks and portable free-space checks. Preserve POSIX directory flushing; Windows file flushing and atomic publication remain available without a POSIX directory-fsync guarantee. Exercise portable workflows in Windows CI and an isolated Linux runtime.

    #705 (issue on GitHub)
  • FixedWorksKeep raw-text HTML scanning compatible with Python versions that lack the newer#705

    HTMLParser scripting option, including the Windows Python 3.12 distribution.

    #705 (issue on GitHub)
  • FixedWorksCorrect and source Google structured-data and Googlebot guidance, including FAQ/HowTo#704

    availability and structured-data policy consequences.

    #704 (issue on GitHub)
  • ChangedWorksDiscover sitemaps from a site root through robots.txt with /sitemap.xml fallback, and#703

    report all-failed sitemap expansion as ok: false.

    #703 (issue on GitHub)
  • ChangedWorksAllow native crawl resume after server-set anonymous cookies and record the fresh session#702

    without retaining cookie values; credentialed and persistent-profile crawls remain non-resumable.

    #702 (issue on GitHub)
  • ChangedWorksStandardize saved scan paths as --scan and inline JSON as --input; legacy scan-path#701

    --input and --json-input continue for one release with a warning.

    #701 (issue on GitHub)
  • AddedWorksAdd an explicit, off-by-default acknowledgement for retaining bounded anonymous#700

    Cache-Control: no-store evidence, while continuing to omit credentialed bodies.

    #700 (issue on GitHub)

9 September 2026

10 added9 changed2 fixed
  • AddedWorksAdd offline project-priority previews and explicit versioned policy application using#694

    saved stack facts, with preserved operator choices, atomic policy history, and matching priority explanations in human reports.

    #694 (issue on GitHub)
  • FixedWorksCorrect JavaScript rendering, canonical, URL-hygiene, and notranslate guidance without#692

    changing runtime checks or severities.

    #692 (issue on GitHub)
  • FixedWorksKeep documentation fixture sitemap and robots URLs on the ephemeral loopback origin.#690 (issue on GitHub)
  • AddedWorksAdd portable project checklist coverage with a packaged source-derived catalogue,#686

    reusable custom templates, definition and evidence history, optimistic concurrent writes, explicit manual and deliverable review, and offline pending/stale/blocked views shared by CLI, MCP, and project-backed human reports.

    #686 (issue on GitHub)
  • AddedWorksAdd a read-only native scan status summary for frontier populations and committed HTTP#685

    outcomes.

    #685 (issue on GitHub)
  • AddedWorksAdd portable local project workspaces with explicit pending checklist and preparation#678

    states.

    #678 (issue on GitHub)
  • AddedWorksRecord the Google Search Central guidance review in the repository: 175 read guides,#677

    their labels, exclusions, snapshot date, and the three repairs it produced.

    #677 (issue on GitHub)
  • ChangedWorksURL-mode crawl-site now writes a collision-safe native SQLite scan under the caller's#676

    scans/ directory by default. --out-dir remains the explicit legacy directory route, while list input and cache live/replay configurations name that route rather than silently changing collection mode.

    #676 (issue on GitHub)
  • ChangedWorksClean distribution builds now embed validated producer provenance for native SQLite scans#675

    when no source checkout is available.

    #675 (issue on GitHub)
  • ChangedWorksReplace unsupported fixed 301 signal-loss and categorical 302 no-equity claims in the#671

    packaged audit skill with permanent/temporary intent and canonical-target guidance, citing Google Search Central. Temporary redirects remain valid for temporary changes; a status code alone does not measure a ranking loss.

    #671 (issue on GitHub)
  • ChangedWorksrender-check --viewport mobile now uses one stable smartphone diagnostic identity for#670

    its raw and browser requests. An explicit --user-agent is available through CLI and MCP, and every completed or partial comparison records the effective identity and viewport size.

    #670 (issue on GitHub)
  • AddedWorksAdd read-only --scan inputs for duplicate and boilerplate corpus analysis, with#669

    retained-body coverage and provenance.

    #669 (issue on GitHub)
  • AddedWorksAdded a generated command-input catalogue and documented the scan, HTTP-cache, and#668

    run-journal store decision.

    #668 (issue on GitHub)
  • ChangedWorksClient-facing Markdown, CSV, XLSX, DOCX, and task reports now use reader-facing finding#660

    titles and saved reproduction evidence instead of registry identifiers or collector names. Historic records without sufficient evidence state that limitation explicitly while JSON audit artifacts remain unchanged.

    #660 (issue on GitHub)
  • AddedWorksAdd project-facts to record project stack facts after creation, optionally from one#648

    explicit robots-respecting tech-detect pass, keeping operator-entered decisions above detected evidence and leaving an unavailable or ambiguous detection absent with its reason.

    #648 (issue on GitHub)
  • ChangedWorksLead the README with the project controller, and frame Screaming Frog as the cross-check#648

    second pass rather than a parity standard.

    #648 (issue on GitHub)
  • AddedWorksShip examples/project-skeleton with an initialized, result-free checklist, so project status#648

    on it reports coverage counts instead of not_initialized, and gate that file against the live catalogue.

    #648 (issue on GitHub)
  • AddedWorksAdd project preparation, provider-evidence, saved-corpus, bounded extraction,#354

    scan-evidence, and workflow/catalogue contracts with explicit retained-evidence and progress boundaries.

    #354 (issue on GitHub)
  • ChangedWorksMCP profiles and metadata-preserving progress wrappers require the optional MCP SDK 1.29#354

    or newer.

    #354 (issue on GitHub)
  • ChangedWorksSimplified the README around task-to-command-to-artifact workflows, removed stale visual

    embeds and marketing counters, and retained links to generated references for the live interface surface.

  • ChangedWorksExpand the README with practical input-to-result workflows, matching CLI/MCP calls and

    accurate retained-corpus and browser/provider boundaries.

8 September 2026

8 changed2 fixed
  • ChangedWorksReconcile the SQLite artifact documentation with the shipped native body, history and#666

    offline-reanalysis contracts (#666). The documented late-page field table is now checked against the importer mapping, so an older JSONL observation remains SQL NULL instead of becoming a fabricated clean value.

    #666 (issue on GitHub)
  • ChangedWorksNative audits now name meta keywords, secondary canonical, HTTP version, and amphtml#659

    checks as skipped when their source fields were not collected.

    #659 (issue on GitHub)
  • FixedWorksStop reading a cookie the site set as the operator's credential in the JavaScript#656

    rendering lane, and say out loud when a run discards rendered DOMs (#656). A browser carries back whatever a site's own Set-Cookie gave it, so the moment a page set a session cookie and then asked for one same-origin subresource -- a script, a stylesheet, an image, the ordinary shape of the web -- the renderer's request hook read Cookie: off the wire and stored that page's serialized DOM as omitted/credentialed, on a run with http.credential_headers = []. Each render builds its own browser context, so the loss is per page rather than cumulative: every page that sets a cookie loses its own DOM, and the run reports its pages, links and escalation counts exactly as a complete one would. credentials_used for a rendered document now comes only from what the run was configured to send -- http.credential_headers, or a persistent browser profile -- which is what crawl/sqlite_render.py::_policy_facts already derives and hands the renderer; the renderer builds its own network client with no credential of its own, so the wire had nothing to add. A crawl with a configured credential still omits those DOMs under credentialed, unchanged, and the cookie's value is still never stored.

    #656 (issue on GitHub)
  • ChangedWorksThe rendered counts a partial flag cannot carry are now named.#656

    rendered_bodies in a crawl-site result reports how many rendered DOMs were retained and the reason for each one that was not, and the CLI prints that beside the finish line rather than leaving it to a capability flag that says "partial" for one missing DOM and for every one of them. A run that discarded nothing prints nothing extra. The counts are read back out of the scan artifact, so a finished scan still answers the same question afterwards.

    #656 (issue on GitHub)
  • ChangedWorksCollect og:url on a native crawl, instead of reporting every page as missing it (#654).#654

    rules.check_og lists og:url among a page's missing_tags whenever the field reads falsy, and the native path never read the tag at all: no og_url on PageRecord, no og_url column in scan_v1.sql, no OG:URL in the Internal:All projection crawl/evidence.py builds. The column existed only on the Screaming Frog side, where normalize has always resolved OG:URL, so an export of a site saw the real value and a native crawl of the same site reported a defect -- an unmeasured field presented as a measured one, on every page of every native crawl, whatever the markup said. The tag is now read where the other three are (the parser already returned it as og:url), stored in a nullable pages.og_url -- nullable like every other late-added column, so a record written before it was collected stays distinguishable from a page that declares none -- and projected as OG:URL. This is the collection side of the seam #646 fixed the read side of. tests/test_open_graph_seam.py gains a third fixture site whose pages have Open Graph but no og:title, so the finding actually fires and its list can be read: one page declares og:url and must not be named missing it, the other declares none and must be. The test that matters audits both of those pages twice, once from the crawl and once from an Internal:All export describing the same two pages, and requires the same verdict -- #646 and #654 were both two paths disagreeing with no test spanning them.

    #654 (issue on GitHub)
  • FixedWorksStop reading a cookie the site set as the operator's credential, and say out loud when a#647

    run discards bodies (#647). A crawl shares one HTTP client, and that client keeps a cookie jar: the moment any response carried Set-Cookie, every later request went out with Cookie: and the corpus writer classified it as an authenticated fetch. On a public site with http.credential_headers = [] and credentials_acknowledged = false, that discarded 33 001 of 40 920 page bodies, and the run reported its pages and links exactly as a complete one would. Everything computed from stored HTML on that crawl -- near-duplicate detection, DOM depth and node counts, boilerplate share, in-body heading structure, offline reanalysis -- then covered 7 903 pages while every page-level percentage in the same report covered 40 920: two populations, presented as one. A capture is credentialed when the run was configured to send a credential header for that host, which is the only way a sensitive header can reach a request the crawler builds -- http.headers is refused one by configuration validation. A crawl with a configured credential still omits those bodies under credentialed, unchanged.

    #647 (issue on GitHub)
  • ChangedWorksThe counts a partial flag cannot carry are now named.#647

    html_bodies in a crawl-site result reports how many fetched HTML page bodies were retained and the reason for each one that was not, and the CLI prints that beside the finish line rather than leaving it to a capability flag that says "partial" for one missing body and for four fifths of them. A run that discarded nothing prints nothing extra. Redacted request and response header lists now keep the names of the headers redaction removed, under x-seohead-redacted-headers and never their values: two responses whose bodies were treated differently used to record byte-identical header lists, so an operator reading the artifact could not tell why a body was missing.

    #647 (issue on GitHub)
  • ChangedWorksMove changelog entries out of CHANGELOG.md and into one file per change under#638

    changelog.d/, so a pull request no longer conflicts with every other pull request that landed before it (#638). Every branch added its entry at the top of ## Unreleased, which made that one line the file's contention point: nine hand-resolutions across eight pull requests in two sessions, each a re-push and a fresh CI run, and the resolution was "keep our block, then theirs" every single time. A conflict whose answer never varies carries no information and is a chance to drop a paragraph while thinking about something else. The .gitattributes fix the issue proposed -- CHANGELOG.md merge=union -- was measured and rejected before this: union merges line by line, so when both entries contain an identical line (a blank line inside an entry is enough) it keeps one copy and reports success, deleting the other entry's line with no conflict and nobody prompted to look. A first test with deliberately distinct lines merged cleanly and hid that. Trading a loud conflict for silent data loss is strictly worse than the conflict, so the structural fix is the one that shipped.

    #638 (issue on GitHub)
  • ChangedWorksA fragment is named for the issue it closes (changelog.d/638.md), the key that is#638

    unique to a branch by construction: one pull request closing two issues writes two fragments, one per issue, and a change that closes nothing writes no-issue-<slug>.md. A fragment holds the entry exactly as it belongs in the changelog -- one or more top-level bullets with their continuation lines, no front matter and no wrapper -- so the multi-paragraph prose entries this file carries round-trip byte for byte rather than being reformatted into one-liners. python scripts/build_changelog.py folds them into ## Unreleased at release time, ordered by issue number rather than by whatever order the filesystem hands back, and --prune removes the fragments it folded in. Assembly replaces the whole region between two marker comments instead of inserting into it, so running it twice produces the same file both times whether or not the fragments were pruned in between. CHANGELOG.md itself is not regenerated on every pull request, deliberately: a gate that kept it continuously in sync would put both branches back in the same file and rebuild the conflict this removes.

    #638 (issue on GitHub)
  • ChangedWorksThe doc gates that read CHANGELOG.md were the risk, because a gate that quietly stops#638

    checking anything is worse than the conflicts. Both were found and both now cover the fragments: tests/test_docs_drift.py carried CHANGELOG.md in PUBLIC_MARKDOWN, so the English-only gate, the "references only existing commands" gate and the stale-count gate now read changelog.d/*.md as well, and the two count-drift gates skip a fragment for the same reason they skip the changelog -- an entry records what was true when it was written. tests/test_doc_counts.py deliberately excludes the changelog from its live-count scan and its file list never reaches changelog.d/, which is the same answer for the same reason; tests/test_doc_commands.py, tests/test_docs_commands_execute.py and tests/test_examples_gate.py never read the changelog at all. A new tests/test_changelog_fragments.py pins each of those answers, along with fragment naming, deterministic order, idempotent assembly, and the fact that two simulated concurrent branches adding a fragment each merge without a conflict.

    #638 (issue on GitHub)

Earlier (no date recorded)

23 added30 changed17 fixed
  • FixedWorksKeep fingerprinting a page whose site sends a valueless Set-Cookie header (#651).

    A header with no name=value pair at all -- Set-Cookie: Secure; HttpOnly, which emall.by sends beside two well-formed cookies -- becomes Cookie(name='Secure', value=None) in the jar, and httpx.Cookies.__getitem__ raises KeyError for it. dict(resp.cookies) therefore failed on a name the mapping's own iterator had produced: tech-detect exited 1 with a bare error: 'Secure' and no JSON on stdout for a page that had been fetched successfully, MCP seo_tech_detect raised the KeyError to its caller, and site-audit quietly came back with its entire technology section missing. detect_tech now walks the cookie jar instead. A valueless name is kept with an empty value rather than dropped, because several fingerprints (BITRIX_SM_GUEST_ID, craft_session, wfvt_) match on cookie name alone and dropping the entry would lose that signal -- but it is never silently normalized into an ordinary cookie: the names are reported in malformed_cookies and named in a finding, so a header the site sent broken stays a fact about the site. A response with only well-formed cookies is unaffected and reports an empty list.

  • ChangedWorksrender-check measures compressed sites again instead of calling them broken (#650).

    The pinned render route read the origin with httpx's undecoded stream and handed those bytes to Playwright together with the origin's content-encoding. route.fulfill never applies a content coding to the body it is given, so Chromium parsed gzip as text/html and built a DOM out of the compressed stream: no title, no h1, no canonical, no links, and a rendered size equal to the transfer. Every site that compresses its HTML -- nearly all of them -- came back ok: false, reason: incomplete_render, and the message blamed the render, suggesting --wait domcontentloaded or a longer --timeout when the render had finished perfectly. seohead.tech reported 22 435 rendered bytes against 121 793 raw; it now reports 123 048 with the real title and 31 links. The route now reads the decoded entity, drops content-encoding from what it forwards (content-length was already dropped as hop-by-hop, so Playwright states the length of the body it actually received), and asks the origin only for the codings this client can decode rather than forwarding Chromium's own invitation to br and zstd. A coding that arrives undecoded anyway aborts the request and names itself, because passing it through would reproduce the bug in silence. The byte cap now counts decoded bytes, which is the body the renderer sees -- a compression bomb previously passed the cap compressed.

  • ChangedWorksRead Open Graph off a native crawl by the key the parser actually writes (#646).

    parse_html stores each tag under its full property name -- {"og:title": ...}, as its own docstring says -- and crawl/collect.py asked the same dict for "title", "description" and "image". dict.get answered None every time, so og_title, og_description and og_image were empty on every page of every native crawl ever run: 0 of 40 920 pages carried one on the run that surfaced this, on a site whose pages do publish Open Graph. That is the repository's central rule inverted -- an unmeasured field read as a measured one -- and it travelled, because crawl/evidence.py projects those columns into OG:Title, OG:Description and OG:Image of the Internal:All frame, so a check saw blank on a native crawl and the real value on a Screaming Frog export of the same site. check_og's honesty contract hid the damage rather than showing it: with no OG column populated anywhere it skipped, so a site with Open Graph audited identically to a site without it. The parser is unchanged; only the read side moved. A new tests/test_open_graph_seam.py crawls two loopback fixture sites and follows the value from parser to pages.jsonl to the pages table of the scan artifact to the OG:* frame columns, crawls a second site with no Open Graph to hold "empty because absent" apart from "empty because unread", and pins the parser's key shape in the same file -- the failure was two correct modules disagreeing about a key name with no test spanning both.

  • ChangedWorksAnswer "is this site linked well, and where is it linked badly", taking the registry from

    157 to 161 checks and adding an internal-linking skill (#634). The pieces existed -- every edge with its position, anchor and nofollow flag in the links table, ORPHAN_PAGE, INLINK_BOILERPLATE_ONLY, LOW_LINK_SCORE -- and nothing turned them into an answer about the graph. A 40 920-page audit carried the wrong headline for hours because of it: 66% of the sitemap was reported unreachable by internal links, a figure produced by subtracting a crawl's URL count from the sitemap's, which measures "the crawler did not get there". Walking the artifact's own 5 252 235 edges from the home page put reachability at 91.9%. The real defect was depth: 23 742 pages -- 58% of the site -- were more than ten clicks from the home page, and the deepest was 3 005, because the archive is a linked list of "next post" links rather than a tree. Click depth is now the headline the audit prints. summary.internal_linking carries the histogram, the maximum, the reachable/unreachable split, internal edges by page position, and how many edges repeat another edge, and audit.md renders it beside the health summary. The walk starts at the URL the crawl actually began from, never at pages.crawl_depth: that column records where the crawler happened to reach a URL, and a sitemap-seeded crawl records 0 for most of a site, so a walk seeded from it describes somewhere else. When no start URL was recorded and more than one page claims depth 0, the measurement refuses to run and says which.

  • ChangedWorksFour checks read those numbers.

    DEEP_CLICK_DEPTH fires on an indexable page whose shortest route from the start URL exceeds thresholds.click_depth_max (default 10, and every finding names the number it used). DUPLICATE_INTERNAL_LINK fires on a page that writes the same destination and anchor more than once: on the site above, 509 744 of 1 134 307 edges -- 44.9% -- were repeats, and they were one masthead emitted twice with the second copy removed by JavaScript, a template defect that had taken a hand-written script to find. LINK_INSIDE_HEADING and IMAGE_LINK_WITHOUT_TEXT need what a link's page region cannot carry -- the anchor's own ancestor chain and contents -- so a native crawl now records a small, capped per-page link_placement_json beside the heading outline, and both checks skip by name on a Screaming Frog export rather than run clean on nothing.

  • ChangedWorksThe honesty rules the rest of the analyzer holds apply throughout.

    An edge with no recorded position is counted as unclassified and never folded into content: link_position.classify defaults to off, which leaves that on every edge, and a run in that state must read as "nobody looked" rather than as a site whose links are all body copy. A position absent from the distribution is reported as "no edges classified here", not as a region the site lacks -- rules are tried in order and a menu inside <header> matches nav first. A partial crawl withdraws the click-depth verdict whether or not it fired, because a frontier never fetched may hold the shorter route, and a check silently finding nothing would read as "every page is within the floor".

  • FixedWorksStop reporting a requirement-gated check as clean when it was never evaluated (#635).

    H2_MISSING only fires when a configuration sets requirements.require_h2, and the default is false, so on an ordinary run its branch is unreachable. It left no trace of that: coverage counted it among the silent checks -- the bucket that means "ran over every page and found nothing" -- and a reader was told the site passed a check nobody ran. On the audit that surfaced this, 61% of the site's pages had no H2 at all and 5 066 of those were longer than 800 words, so the check would have fired thousands of times. It is now declared skipped by name, with a reason naming the setting that turned it off, so the answer is "not evaluated, and here is how to evaluate it" rather than silence. require_canonical is the same shape and gets the same treatment; its default is true, so it was not producing a wrong reading yet, and now cannot start.

  • FixedWorksFix the task backlog heading reading "Audit Tasks — None" for any audit whose run

    recorded no project (#640). render_tasks_md built the heading with src.get("project", "site"), but "project" is always a present key in the backlog's source block -- it is set at build time from run.get("project"), so a run with no project stores the value None there rather than leaving the key absent, and dict.get's default only fires for a missing key, never for one holding None. A native crawl and a reanalysis both leave project unset, so every backlog built from either got the literal word "None" as its heading. The heading now falls back with or, which does catch a stored None, and reaches for the site's start_url or source before giving up -- the same order seohead.reports.facts.crawl_domain already used to resolve a site's name from its run block, now exported and reused here instead of reinvented, so a backlog built from a project-less audit names the actual site instead of the word "site". seohead.reports._normalize_sf_audit had the same latent shape one line over (run.get("project") or "", at reports/__init__.py) -- already guarded against a stored None, but its fallback was an empty domain rather than the URL the run also knows, so the docx/md "SEO Audit: " heading it feeds went blank in the same situations. It now falls back through the same crawl_domain resolution rather than staying empty.

  • ChangedWorksRead the heading outline as a sequence and as a map of the page, taking the registry from

    155 to 157 checks (#632). The eight existing heading checks judge headings as a set: they can say a page has one H1 and four H2s, and nothing more. So a page whose DOM order is H2, H2, ..., H1, H2 satisfied every one of them, and so did a page whose H2s were all menu labels in its masthead -- found on a live site as an article template with eighteen headings standing before its own H1, reported as clean on headings. What was missing was the evidence, not the rule: pages stored h1, h1_2 and h2 -- three strings with neither order nor place. A native crawl now records the whole h1-h6 outline in DOM order, each heading with its level, text and page region, in a new nullable heading_outline_json column, and HEADING_BEFORE_H1 and HEADING_IN_PAGE_CHROME read it. The region reuses the taxonomy link classification already uses (nav, header, sidebar, footer, content, other), so "in the header" means the same thing about a heading as it already does about a link. A heading no position rule matched is placed against the content area only when the document named one -- a <main>, a [role=main], an <article> or a configured selector; where the content root falls back to the whole <body>, calling a heading "content" would mean nothing more than "somewhere on the page", so the region is recorded as unmeasured and the page is named among the run's skipped checks with that reason rather than passed as clean or flagged as a defect. Order needs no region, so HEADING_BEFORE_H1 still answers for those pages. A Screaming Frog export carries no outline at all, and both checks skip there by name.

  • FixedWorksKeep a finished collection when the audit that follows it crashes (#627).

    A native crawl of a live site spent twenty minutes gathering 600 pages into a --scan-out artifact and then printed error: 'rel_next_2' -- a KeyError's str() is the key and nothing else. No phase, no traceback, and no sign that every page it had collected was sitting complete and re-analysable in the file. Collection commits its rows and closes before the audit begins, so anything raised after that point is the analyzer misbehaving and never the crawl; an unexpected exception there is now named by phase, carries the exception's type as well as its message, and points at the retained artifact and the command that re-analyses it, exactly as the oversized-audit path already did. The run reports audit_available: false with that reason rather than exiting on the exception, and crawl-site exits 2 for it -- the same third answer the log-scan contradiction already uses, because 0 would read as "the audit ran and found this" and 1 as "the command produced nothing", and neither is true of a crawl whose evidence survived. The KeyError itself did not reproduce: the same command against the same site on current main completed, so this changes what happens when an audit fails, not what made that one fail.

  • FixedWorksStop losing a completed crawl's audit to a fixed 30-second inspection budget on reopen

    (#631). NativeScan.open() reads a native scan's header through inspect(), which also runs PRAGMA quick_check and PRAGMA foreign_key_check over the whole database -- a real safety check, but one whose cost scales with the artifact while the budget did not. A 3.5-hour, 40 920-page crawl finished collecting everything it set out to collect and then produced no audit at all, because reopening the resulting 1 GB artifact needed roughly 48 seconds of validation against a budget fixed at 30. Worse, the failure gave no reason to suspect a budget: the deadline abort surfaces from SQLite as a bare sqlite3.OperationalError("interrupted"), and that was wrapped verbatim into cannot inspect native scan: interrupted, indistinguishable from a genuinely corrupt file. Both are fixed. The default budget is now derived from the artifact's size (a 30-second floor for small files, plus a generous per-byte allowance well under the ~22 MB/s measured validation rate, since this is a ceiling against a hung read and not a performance target) -- an explicit timeout_seconds from a caller still overrides it. And a deadline abort now raises a ScanError that names the budget, the elapsed time and the artifact's size, so an operator can tell a slow large file from a corrupt one instead of reading "interrupted" as something they did wrong.

  • AddedWorksAdd crawl-site --resume <scan.sqlite> (#619).

    NativeScan.resume_snapshot(), the resume_state table and the writer that fills it had existed since the scan artifact was designed, and crawl_to_scan already continued an existing file -- but only for a caller that reproduced the original effective settings byte for byte, and no interface offered that. Pointing --scan-out at an interrupted artifact with any changed flag was refused as a configuration mismatch, so in practice an interrupted crawl (6 516 of 34 635 pages, over somebody else's site) had to start from zero. --resume takes the artifact and nothing else: the start URL and the complete effective configuration are read back from the file, because they are what the stored frontier was built under, and any other crawl-shaping flag passed with it is refused rather than applied. A scan written by a different producing build, recorded for a different start URL, already finished, derived from a reanalysis, or crawled with credential headers it can only store redacted is refused by name from the file alone, before a single request is dispatched. crawl-site now also prints one line to stderr on exit saying whether the crawl finished or stopped early, why, and how to continue it -- except after a URL or duration budget, which a resume reads back too and therefore cannot pass, where the line says that instead of offering a no-op. run.crawl_resumed remains the durable record: a resumed crawl of the offline fixture site fetches each URL exactly once across both processes and produces an audit identical to an uninterrupted crawl's in every field but that one.

  • FixedWorksStop render-check calling an unrendered page clean, and stop it discarding raw-HTML

    evidence with a failed render (#642). Two consequences of #623's guard, both in render.py. First, a render that timed out at its load milestone falls back to reading the DOM at domcontentloaded -- sound in itself -- but when no script had run by then the rendered DOM equalled the raw HTML, the comparator found nothing, and the run reported ok: True, js_dependent: False and the single finding "Raw HTML and rendered DOM are materially equivalent; JavaScript rendering does not determine SEO-visible content", graded a notice, about a page nobody had rendered. wait_reached recorded the truth, but the site audit carries findings text and nothing else into its report, so the truth never arrived. A run that fell back to an earlier milestone now says so in the findings list itself, withholds the all-clear, and reports js_dependent: None -- this run does not know -- instead of False. It fired under the default --wait load, not only under an explicitly requested networkidle. Second, the guard returned its single "the comparison is unavailable" statement before reaching the empty-shell branch, and render_check's early return omitted the empty_shell key altogether -- so an empty single-page-application mount point, which detect_empty_shell() reads out of the raw server response and which needs no browser at all, was thrown away together with the render that failed. Since an SPA shell is exactly the page whose render times out, the finding was lost precisely where it mattered. A finding derived from the raw half now survives an unfinished render; only findings derived from the rendered half are suppressed. In a crawl, a render probe that reached no verdict now lands in patterns_unprobed with its reason (#626's channel) rather than counting as a pattern measured and found not to need rendering.

  • ChangedWorksReport an unreadable robots.txt as a fact about the site, not as an internal error (#629).

    EMPTY_ROBOTS -- the stand-in _fetch_robots returns on every path where robots.txt could not be read -- was still the parsed-robots shape from before the parser grew groups and sitemaps. The success path returns parse_robots's shape, and that is the only shape the scan artifact's robots_summary context accepts, so in --scan-out mode a crawl aborted with native parsed robots summary is invalid whenever robots.txt was unreachable, answered 429 or 5xx, redirected off-host, into a loop, past the hop budget or to a non-text/plain body -- or was a plain 404, which is not an error at all but the RFC 9309 "no restrictions" case and the most common of the seven. Both documented outcomes were lost behind that one line: a missing robots.txt now crawls unrestricted again, and an unavailable one stops the run with the note saying which of the six ways it failed. The legacy directory-mode crawl never noticed because nothing reads the old keys. Found on a live site whose robots.txt 302s to a cookie-sync endpoint for any user agent it does not recognise.

  • FixedWorksFix escalate() counting a failed render probe as "this pattern needs no rendering"

    (#626). It samples one URL per template pattern to decide whether that pattern needs a fuller, JavaScript-rendered fetch, but when every sample for a pattern failed to probe at all — a browser launch failure, a timeout, or an incomplete render, the ordinary failure modes of a real crawl — the for/else still recorded the pattern as probed, so it ended up neither escalated nor listed among the patterns the run could not evaluate. That inverted this project's central rule that missing or unmeasured evidence must never read as clean: a pattern nothing was learned about was silently crawled static and reported the same as one that was genuinely measured and found not to need rendering. EscalationResult now reports such a pattern in patterns_unprobed, alongside the deadline-cut patterns already tracked there, and records the probe's own failure reason in the new patterns_unprobed_reasons, so a report can say "this pattern was not evaluated" instead of implying it was. A pattern with at least one successful probe is unaffected: this only changes what happens when every probe for a pattern comes back ok: False.

  • FixedWorksStop render-check reporting an unfinished render as a site defect (#623).

    When the render did not complete, the rendered snapshot came back with no title, no h1, no canonical, zero links and a fifth of the raw response's bytes -- and the comparator read that emptiness as evidence about the site, emitting "the title changes after JavaScript" about a title the render never read and "the canonical is injected by JavaScript" about a canonical it never saw. On one live audit that was four confident wrong findings in a row, each of the kind that sends a developer hunting a JavaScript bug that does not exist. The pair is now judged before it is compared: when the raw response carries landmarks and the rendered document carries none of them at all, at a fraction of its size, the check returns ok: false with reason: "incomplete_render", a named reason carrying both byte counts, and both snapshots for inspection -- no findings, and js_dependent: null rather than false, because the run does not know. site-audit files that in summary.tools_failed, where an unmeasured check belongs. The detection is deliberately narrow: one surviving landmark, or a rendered document at least half the raw response's size, is compared as before, and a page with no title and no links on either side is measured and merely empty rather than unmeasured. A title a script genuinely rewrites still fires its finding. The wait strategy copes with the ordinary cause too: a site whose long-polling analytics, chat or ad scripts never let it go network-idle timed the whole check out under --wait networkidle, and now falls back to reading the DOM at domcontentloaded -- without a second navigation -- with wait_reached recording which milestone the snapshot actually came from, plus a short settle for deferred scripts. A page whose DOMContentLoaded never fired at all remains a rendering failure, not an incomplete one.

  • ChangedWorksGive crawl-site a live progress line (#619, progress half).

    A native crawl printed its effective request rate and then nothing at all until it finished; on a 34 000-page site the only way to tell it was still working was to watch the scan file grow in ls -la. The line now refreshes in place on a TTY with pages fetched, the URLs known so far, the percentage of that set, the current request rate over a trailing window, elapsed time, and the scan artifact's size when --scan-out is writing one. What it deliberately does not do is imply a total it cannot know: a crawler discovers its own workload, so the denominator is fetched + queued capped at the URL budget, it is labelled known rather than total, and a header line printed once says the frontier is still growing and that the percentage is not an estimate of when the run will finish. An unmeasurable rate reads as rate n/a, never as 0.0 req/s. Both numbers come from the crawl's own structures -- for a SQLite scan, from the same resume_snapshot query the collection loop steers by -- so what is on screen is what the artifact holds at that moment rather than a parallel tally that can drift from it. A non-TTY (a pipe, a log file, CI) gets a plain line every 30 seconds instead of carriage-return redraws, and a new -q/--quiet silences both the progress line and the startup rate line while leaving the JSON result on stdout untouched.

  • ChangedWorksClose the three pagination rows #385 left open, taking the registry from 152 to 155

    checks. PAGINATION_MULTIPLE and PAGINATION_URL_NOT_IN_ANCHOR report a page that declares two different successors, or one whose declared URL is not also an anchor on the same page. Successors are compared after URL normalization, the same identity the anchor half uses, so /blog/page/2 and /blog/page/2/ from two plugins are one successor spelled twice rather than an ambiguous series. PAGINATION_URL_NOT_IN_ANCHOR needs a page's whole link inventory and reads the All Inlinks export for it; PAGINATION_MULTIPLE does not, and now reads the lighter Internal:All rel="next" 2 / rel="prev" 2 occurrence columns first, the same way CANONICAL_MULTIPLE already answers from Canonical Link Element 2 — All Inlinks is only a fallback for a profile whose Internal:All was written without those columns. PAGINATION_SEQUENCE_ERROR reports a break in a page-number run the series otherwise follows, per the issue's own caveat: a series may start at a number other than one, a stride is not a break, and a series whose URLs do not state a page number is declared unevaluated rather than judged against a numbering that would have had to be invented. Every series left unjudged is named among the run's skipped checks with the reason true of *that* series -- a stride, a cycle, a series too short to hold a run and an unreadable page number are four different statements, and three of them describe series whose URLs all state their number. The count is per series, not per run, so the ordinary WordPress shape (page one at an unnumbered /blog/, the rest at /blog/page/N/) cannot disappear behind one judgeable series elsewhere on the same crawl.

  • AddedWorksAdd a check-verdict coverage gate (#98): test_check_producer_gate.py proved every check

    ID is registered and every check_* function is dispatched, but said nothing about whether a check's conclusion was ever proven true against markup that actually has the defect, and stays silent on markup that does not -- exactly the gap that let #94, #95 and #96 pass every existing test while still misfiring on live sites. test_check_verdict_coverage.py scans tests/ structurally (AST, not a fixed call-site list) for both halves per check, and fails the build for any check newly added to the registry without a two-sided test or a named, reasoned exemption. Of the 152 checks in the registry, 88 already had two-sided proof; a new test_check_verdict_gaps.py closes 22 more of the cheaply-testable gaps the scan surfaced (title/description length and duplication, H1 duplication, H2 presence, URL hygiene, directive and markup checks, response codes, and the two native-filter-export checks). The remaining 64 are named, not hidden, in the gate's KNOWN_UNCOVERED ratchet -- shrink that list as coverage lands; it must never grow to admit a check added after this gate existed.

  • FixedWorksFix a robots.txt group-selection defect that let a blank User-agent value void a site's

    default policy (#566). A bare User-agent: line, or one whose bot name an inline comment swallowed (User-agent: # old bot rule), parsed to an empty token. _rules_for treated that token like any other literal name: it is a zero-length prefix of every agent, so it matched all of them, and because it counted as a *named* match it outranked the file's real User-agent: * group for every crawler the file never mentions. A robots.txt whose only substantive rule was User-agent: * / Disallow: / therefore read as fully crawlable -- both in the AI-bots access report and in the live crawler's own fetch gating, which calls the same is_allowed. Blank tokens are now skipped, so such a group names nobody and the wildcard group applies; the most specific named group still wins, and a robots.txt naming nobody still means everything is allowed.

  • FixedWorksFix four export-selection and run-validation defects that let an audit present a result

    it could not support (#209, #210, #215, #216). internal_all's matcher required only the filename token internal, so a partial per-type Internal tab (e.g. internal_html.csv, missing every non-HTML row) satisfied the required Internal:All master export with no missing-master warning; the matcher now also requires all. discover_exports picked the first candidate for a logical key in sorted filename order when two files matched it (e.g. internal_all.csv and internal-all.csv both present with different rows), silently discarding the other with nothing in run metadata to say a choice was made; it now raises, naming every candidate. run_sf picked the newest timestamped subfolder under --out regardless of when it was created, so a re-run that exited 0 without writing anything (a startup failure Screaming Frog does not report as a nonzero exit) could return a prior invocation's exports as if they were fresh; it now compares against a snapshot taken before the process starts and fails loudly when nothing new appears. build_command added --auth-config only when the given profile file already existed, silently starting an unauthenticated crawl on a typo'd or deleted path — appropriate for the optional seospiderconfig default, wrong for a profile the caller explicitly requested; a missing explicit sf_cli.auth_config now raises before Screaming Frog starts.

  • FixedWorksFix two content-extraction defects in seohead/checks/parser.py (#138, #140).

    collapse_whitespace decoded HTML entities a second time on top of the single decode BeautifulSoup's lxml parser already performs on every tag.get_text()/tag.get(attr) value it hands to that helper — a silent no-op on ordinary markup, but on a page whose CMS or import pipeline already double-escaped its entities (a real, common artifact) it turned visibly-broken entity soup into clean-looking text and shortened the reported length below what a browser tab or a SERP snippet actually renders, flipping length-based title/description checks in both directions. The helper no longer decodes at all. Separately, link and URL-source extraction walked the whole document unconditionally, so an <a>/<img> that existed only inside an inert <template> — never part of the rendered document per the HTML spec, never requested by a browser or a crawler unless a script clones it in — was reported as a real, on-page link and actually fetched by spider.py, the same phantom-URL failure mode closed for <base href> in #4; both extractors now skip <template> descendants. <noscript> is deliberately left reachable there, unlike in text extraction: it is real, spec-defined fallback markup a JS-disabled client (and search engines' non-rendering crawl pass) does load, and excluding it would hide a genuinely fetchable URL from the auditor. Word count also no longer counts <svg>/<math> descendant text (an icon sprite's accessibility <title>, glyph <text>, MathML notation) as body copy — a 20-icon header could double a page's reported word count against its real content — and that exclusion list now lives in one place (content_area.TEXT_EXCLUDED_TAGS) shared by both text extractors instead of two copies that had already drifted out of sync.

  • FixedWorksKeep a link's full rel tokens, its target attribute, and its raw (pre-resolution)

    href, and extract <form> elements — method, action, whether a password field is present (#125). Six catalogued issues depended on those three facts being discarded: unsafe cross-origin links (target="_blank" without rel="noopener"/"noreferrer"), protocol-relative links (//host/path, before resolution), outlinks to localhost, a page receiving both a followed and a nofollow internal link, an insecure form action, and a password form served from a plain-HTTP page. All six are now UNSAFE_CROSS_ORIGIN_LINK, PROTOCOL_RELATIVE_LINK, OUTLINK_TO_LOCALHOST, FOLLOW_AND_NOFOLLOW_INLINKS, FORM_URL_INSECURE and FORM_ON_HTTP_URL in the registry. The form/localhost/nofollow-mix checks need only fields every crawl already recorded, so those four run unconditionally; the cross-origin and protocol-relative pair need link_attributes.capture, off by default, because the extra per-edge data measured roughly +50% on a synthetic 3387-page, 150-link-per-page crawl — about +95 bytes/edge, +46 MiB total, raw_href alone accounting for most of it. Registry grows from 121 to 127 checks.

  • FixedWorksFix report-build silently rendering a zero-findings report for an SF Analyzer

    audit.json (#151). The documented recipe — sf run --tasks piped into report-build --format docx/xlsx/csv/md — read findings/flat page keys, which the SF schema does not use (findings live under issues, page facts under pages[].metrics); the mismatch produced a confident 0/0/0/0 summary for an audit that found real critical issues, with no findings sections at all. build_report now recognizes both the native seohead.site-audit/1 shape and the SF Analyzer shape, normalizing the latter into the flat contract the four human-facing writers already understand before rendering. --format json is untouched — it already relayed the original document correctly, which is what proved the data was never missing. A document matching neither contract is refused with ok: False naming the schema mismatch instead of being rendered as an empty deliverable, and the dead if ... : pass conditional that looked like this validation but always no-opped is gone. tests/test_docs_commands_execute.py now asserts the documented recipe's rendered summary against audit.json's own totals, not just its exit code.

  • ChangedWorksClose two money-safety gaps in seohead/data_sources/ (#157, #159).

    geo_guard checked only the advisory country string, so search_volume/keyword_ideas/keyword_difficulty/serp could still reach DataForSEO's live endpoint for location_code=2643 (Russia) or 2112 (Belarus) whenever a caller supplied the numeric geo-target without also filling in country; the guard now checks location_code first, since that is the field actually billed on. Separately, a network-level exception (URLError/TimeoutError/SSLError) during a billed call — DataForSEO's post, Arsenkin's /set, and Yandex Cloud's wordstat.topRequests/wordstat.dynamics/ web.searchAsync — used to retry the identical payload with no idempotency key and log only the attempt that finally returned a response. None of the three providers offers an idempotency mechanism for these endpoints, so a lost response is no longer retried: the attempt is recorded in the spend ledger (cost unknown, flagged attempt_failed: network_error) and the call fails outright instead of risking a second charge. Idempotent reads (Arsenkin /check//get, Yandex Cloud operation polling) are unaffected and keep retrying. yandex_cloud.WebSearch.search_batch isolates a lost submission to its own query instead of aborting the batch. metrika.py retries network errors the same way but is not billed money and was left unchanged; flagged for a follow-up if its Logs API export creation should get the same treatment for quota reasons.

  • ChangedWorksClose a DNS-rebinding gap in http_client() (#142): pinned_target(), the fix for the

    TOCTOU window described in #14, protected only collect.py's list-mode fetch — the other fourteen call sites, including spider.py's crawl-site engine, let httpx resolve DNS a second, independent time to open the socket, so a hostile resolver answering that second lookup differently than the guard's own reached 169.254.169.254 or any RFC 1918 address regardless of the guard's verdict. http_client() now builds every client on a transport that pins the connection to the address it resolves itself, on the first request and on every redirect hop, keeping the hostname only for the Host header and TLS SNI — so the fix is structural rather than a discipline every caller had to remember. SEOHEAD_ALLOW_PRIVATE_NETWORKS/SEOHEAD_ALLOW_PRIVATE_HOSTS are unchanged.

  • FixedWorksFix the CLI/MCP exit-code contract for a handler's own-reported failure, and complete

    SOURCE_FLAGS (#155, #156). A handler returning {"ok": false, ...} — the tool layer's documented way of reporting a fetch, parse, or provider failure without raising — used to print that JSON and exit 0 on the CLI and return a normal (non-isError) result over MCP, so a pipeline gating on $? or a client checking isError alone could not detect it. cli.py now exits 1 for that case (log-scan's own exit 2 for a self-contradicting run stays a separate, documented signal — see docs/USAGE.md), and mcp_server.py raises ToolError from a shared _checked() wrapper so a client sees isError instead. Both call a single handlers.handler_failed() so the two interfaces cannot drift on what counts as a failure. Separately, SOURCE_FLAGS gained --phrase, --keywords, --query/--queries, --seed, --counter, and --before/--after — each already identifies a command's whole input the way --url does, but was missing, so a per-line loop over any of them silently processed only its first line. The set is now built by _source_flag() at the point each flag is declared instead of hand-listed separately, so it cannot drift out of sync again.

  • ChangedWorksExpand docs/scenarios/ from ten chains to fifty-six, grouped by the question a reader

    arrives with (#120). Which scenarios exist is decided by the coverage map rather than by taste: each declares the catalogued issues it resolves, and tests/test_scenario_coverage.py asserts that every issue this toolkit claims to find appears in at least one, that no scenario names an issue the map does not list, and that none omits its limits. All 143 findable issues are covered. Every command in every file is still executed against the fixture site in CI — the suite runs 2008 tests, of which the doc-command gate is now the largest single group.

  • ChangedWorksAssert the sitemap protocol's own limits (#124): SITEMAP_TOO_MANY_URLS above 50,000

    entries in one file, SITEMAP_TOO_LARGE above 50 MB uncompressed, and SITEMAP_URL_DUPLICATED for a URL declared in more than one sitemap, naming both documents. A file over either limit is invalid rather than merely large — a search engine may read part of it and discard the rest with nothing the site owner can see — and the thresholds come from the protocol, not from config. Both parsers now record the per-document byte size and declared count, and the findings name the child sitemap rather than the index. Registry grows from 118 to 121 checks.

  • AddedWorksAdd docs/GUIDELINE.md, the document a person reads first (#121): what this is and is

    not, a first run end to end, how to choose a crawl rate and how to tell whether the errors are yours, how to read audit.json without being misled by it, what the config file changes and what it only costs, the six mistakes everybody makes first, what to do when the tool is wrong, and what it cannot answer at all. Linked first from the README, ahead of the reference.

  • AddedWorksAdd docs/COVERAGE_SF_ISSUES.md, generated from seohead/sf/core/sf_issue_map.py: every

    one of the 320 issues in the field's published catalogue, each with exactly one status — found by a named check, found by a named command, found in part with the missing part stated, a named gap, or a decision with its reason (#119). Of the 212 in-scope issues, 126 are found today and 17 in part; 41 are gaps and 28 need something deliberately not built. Accessibility (92) and AMP (16) are declined as single decisions, with their full lists shown so the decision is auditable. A test asserts every referenced check id and command still exists, so a rename breaks the build rather than the document. Supersedes docs/CHECKLIST_AUDIT.md, which was written without the catalogue in hand.

  • ChangedWorksThe sitemap seeder requests the address the sitemap published, not its normalised form

    (#115). sitemap.crawl() returned only the normalised loc, so a sitemap declaring /a/ caused a fetch of /a — on most CMSes a 301 the crawler invented and then reported as a fact about the site, and a 404 wherever the slashless form is not routed at all. Each entry now carries loc as published alongside loc_normalized for the consumers that compare on it. Redirect statistics from earlier sitemap-seeded crawls are suspect for this reason.

  • ChangedWorksRestructure the operator skill into a controller directory (#111).

    .claude/skills/ now has two tiers: 21 method skills, each covering one thing well, and control/, which decides which of them to run on a site nobody has looked at yet. The controller routes rather than restating, and carries five loadable sub-skills (scoping, rate and load, reading an audit, verifying, deliverables) and a three-file reference archive (defects found on live sites and what gave each away, which population each check describes, and what the toolkit cannot answer at all). The English-only gate and the doc-command gate now cover every Markdown file under a skill directory, not only SKILL.md.

  • AddedWorksAdd tests/chains/: a fixture site built out of the shapes that actually break chains —

    both slash forms of one URL, a body that is not valid UTF-8, a windows-1251 page, a masthead outside <main>, an off-host link, a robots-disallowed path — crawled over loopback, with seventeen assertions about the run as a whole rather than about any one module (#112). Four properties: conservation (a number does not change meaning as it travels), population (a finding is about a member of the set it describes), determinism (two crawls, two concurrency levels, one answer) and representation (a page says how it was measured). The population rules are logscan's own, so a contradiction the scanner can name is a chain test that asserts it.

  • ChangedWorksreconcile_sitemap reports each URL as it was written rather than as it was normalised.

    Comparison still happens on the normalised key, but a finding that named a normalised form named a URL appearing nowhere in the crawl — unactionable for a reader, and indistinguishable to the anomaly scanner from a finding about a page never fetched.

  • AddedWorksAdd docs/scenarios/: ten end-to-end chains, each with the real commands in order, the

    artifact that comes out, what it costs, and what that chain cannot answer (#110). The rest of the documentation lists what the toolkit has; this describes what it does. Linked from the README above the tool list, so an agent evaluating the repository finds the chains before the inventory. Every command shown is executed against the fixture site by tests/test_docs_commands_execute.py, whose extractor and whose English-only and count gates now walk docs/ at every level rather than only its top.

  • AddedWorksAdd log-scan (CLI) and seo_log_scan (MCP): read a finished run's own artifacts and

    report claims that cannot all be true at once (#109). Eight rules, each written from a defect that shipped past the whole test suite — a recorded size that disagrees with the file on disk, a text ratio over 100%, a check firing more often than there are pages to fire on, a finding about a URL the run never fetched, a canonical called a redirect while that URL answered 2xx in the same run, a summary that disagrees with its own rows, words counted on a zero-byte page, and pages measured two ways where only some say which. Each anomaly names both values and where each was read from. The CLI exits 2 when a run contradicts itself, so a pipeline stops instead of publishing the numbers.

  • ChangedWorksThe cross-worker pacing test no longer measures the wall clock (#107).

    _DispatchGate now reads the crawl's injected clock instead of time.monotonic() directly, so the test drives it with a virtual clock that advances only when something sleeps: the dispatch instants are the crawler's own arithmetic and the assertion is exact. The old form compared real elapsed gaps against a 0.024s floor and failed on unchanged code whenever the machine was busy.

  • ChangedWorksClose the second half of the unwired-settings audit (#91).

    http.headers is merged into every request beside the credential headers; speed.adaptive gates the throttle's delay and concurrency adjustment (the timeout and server-error counters keep running — giving up is a separate mechanism from backing off); discovery.hyperlinks.store / .crawl, discovery.external.store and discovery.redirects.crawl now decide what the crawl records and what it follows. discovery.canonicals.*, discovery.external.crawl and discovery.redirects.store are removed rather than wired: they named capability the spider does not have (canonical-chasing, cross-host crawling) or state it cannot withhold, and a setting that appears in --config-help and the run manifest while changing nothing is worse than no setting. The coverage canary's exemption set is now empty: every DEFAULTS path changes an observable outcome and is named by a test.

  • ChangedWorksDetect the content area from the document's own semantics when nothing is configured

    (#96): main, then [role="main"], then article, recording which one matched as auto_main / auto_role_main / auto_article. The previous default — the whole body minus the nav and footer tags — counted 126 template words out of 433 on a live WordPress post (29%), including a skip-to-content link, and that inflation feeds THIN_CONTENT and LOW_TEXT_RATIO in the same direction on every page of a template. header and aside join nav and footer in DEFAULT_EXCLUDE_TAGS for the fallback path. A configured selector still wins, and one that matches nothing still falls back to fallback_default_body rather than silently auto-detecting a different region.

  • ChangedWorksURL_NOT_IN_SITEMAP now compares pages with pages (#94).

    It compared a sitemap's URLs against every destination in the crawl's link graph, so on a live 124-page site it fired 392 times — 362 image files a gallery links to directly, five off-host links, and 30 URLs the crawl never fetched — which was 74% of that report and buried the findings that were real. The observed side is now the pages a sitemap is supposed to declare: fetched, 2xx, HTML, same-host and indexable. reconcile_sitemap takes that population as a separate comparable argument, so SITEMAP_ORPHAN keeps asking about reachability against every link destination and cannot invent orphans; what was set aside is returned under linked_not_comparable rather than dropped.

  • FixedWorksFix the canonical checks on a site that serves both slash forms of a URL (#95).

    norm_url folds a trailing slash away on purpose, so a canonical written without one matches the page that has it — but the normalised index kept only one page per key, and a crawl of a typical WordPress site holds two: /x (301) and /x/ (200). Reading whichever was inserted first made CANONICAL_TO_REDIRECT report 78 live pages whose canonical answers 200. AuditContext now exposes pages_by_norm with every page under a key, page_by_norm returns the variant that answered 2xx, and CANONICAL_TO_REDIRECT and CANONICAL_NON_INDEXABLE only fire when no variant contradicts them.

  • FixedWorksFix size_bytes: it is now the response body as it arrived on the wire, measured before

    the body is decoded (#99). It was measured from the decoded string, so every byte that is not valid UTF-8 became U+FFFD and re-encoded to three — a 739 KB WebP from a real crawl was recorded as 1.27 MB, and the inflation factor differs per file. Images, PDFs, fonts and HTML served in a legacy charset (windows-1251) were all over-counted, and so was the text ratio computed against that denominator. The HTTP cache stores the wire size with the entry, so a replayed page reports what the live fetch reported; its schema moves to http_cache.v2 and v1 entries are re-fetched once rather than replayed without a size.

  • AddedWorksAdd docs/TOOL_REFERENCE.md, generated from the MCP tool definitions

    (seohead/mcp/tool_reference.py, scripts/generate_tool_reference.py): every seo_*/sf_* tool's arguments with type and default, its cost (network/writes/ idempotent/spend, read from its ToolAnnotations profile), and its own docstring's behavior and failure-mode notes. tests/test_docs_drift.py fails the build if it drifts from the tool definitions or is missing a tool.

  • AddedWorksAdd tests/test_docs_commands_execute.py: extracts every seohead ... command shown in

    README/docs/skills/examples (scripts/doc_commands.py) and runs each one offline, against a loopback fixture site (tests/doc_fixtures/) and materialized copies of examples/, asserting a clean exit. Commands that need real infrastructure (RDAP/DNS, a licensed SF binary, a paid provider credential, the never-returning mcp server) are at least parsed against the live argument parser. A documented command that no longer works now fails CI instead of shipping stale.

  • ChangedWorksReshape all 21 technical workflow skills (.claude/skills/*/SKILL.md) into a shared

    shape: Trigger, Anti-trigger, Preconditions (as a checklist), the existing Workflow, Decision points, Definition of done (as a checklist), and Cost. Fix the stale command-coverage count at the bottom of docs/SKILLS.md (the real count, recomputed by scanning every skill file, is asserted by a new drift test).

  • FixedWorksFix several tool/test counts that had silently drifted from the real registries (a stale

    CLI command count in docs/USAGE.md/docs/COMPARISON.md/README.md/ docs/TESTING.md, a stale MCP tool count in docs/TOOLS.md, a stale tool-reference count in docs/README.md, and the offline test count) and pin the fixed ones with a regression test (test_stale_tool_counts_do_not_reappear).

  • AddedWorksAdd the four static Lighthouse audits that need no browser and no third-party API (#59):

    MISSING_CHARSET, MISSING_DOCTYPE, VIEWPORT_MISSING and NO_COMPRESSION, each computed from evidence a crawl already holds. content_encoding, meta_charset, doctype and viewport join the normalized column vocabulary, so an SF export that happens to carry them as Custom Extraction columns feeds the same checks. Registry grows from 114 to 118 checks.

  • ChangedWorksWire the ten remaining crawler settings that were validated, written into the run

    manifest, and described by --config-help but read by nothing (#63): limits.max_response_bytes, speed.max_delay_seconds, robots.user_agent_token, and speed.stop_after_consecutive_timeouts now configure behaviour that was previously hardcoded; robots.unavailable_means_stop now governs whether an unreachable or 5xx robots.txt stops the crawl or is treated as unrestricted (previously an unreachable robots.txt never stopped the crawl regardless of policy, while a 5xx one always did — now both are the same "unavailable" case, gated by the setting); limits.max_url_length, limits.max_query_variants_per_path, http.retry_on_timeout, and discovery.follow_nofollow are newly-implemented behaviour. http.user_agent is now applied to real requests instead of always sending the toolkit's default. Add crawl-describe-settings (CLI) and seo_crawl_describe_settings (MCP) so an agent can discover the configuration surface without a filesystem (#23).

  • AddedWorksAdd custom search (tools/custom_search.py) and custom extraction

    (tools/custom_extract.py) over an already-crawled corpus: presence/absence filters (raw source, visible text, a named CSS element, or an XPath node) and CSS/XPath/regex extractors, both reporting which representation (static markup vs. rendered DOM) they ran against. Absence is counted honestly: a page whose fetch failed is excluded from both the numerator and the denominator rather than counted as missing. Extraction runs each (document, extractor) pair under a wall-clock budget (SIGALRM on POSIX): a pathological expression aborts only that document, and the run still finishes.

  • AddedWorksAdd link position classification (tools/link_position.py):

    nav/header/sidebar/footer/content, by ordered rule over a link's ancestor path, reusing content_area.py's notion of content rather than inventing a second one. Wired into crawl/spider.py's link recording behind link_position.classify (default off — a position per link costs memory on a large crawl) and aggregated site-wide by crawl/linkgraph.py's inlink_composition, which now feeds a new INLINK_BOILERPLATE_ONLY audit finding for pages linked only from boilerplate. Registry grows from 104 to 105 checks.

  • AddedWorksAdd eight post-crawl second-pass computations that only become answerable once a crawl is

    complete (issue #15): an internal link score computed from the all_inlinks edge graph (LOW_LINK_SCORE); a canonical target no hyperlink ever points to (UNLINKED_CANONICAL); rel="next" loop and unlinked-series detection (PAGINATION_LOOP, UNLINKED_PAGINATION_SERIES); hreflang reciprocity (HREFLANG_MISSING_RETURN_LINK); inlink-composition aggregates (ONLY_NOFOLLOW_INLINKS, ONLY_NONINDEXABLE_SOURCE_INLINKS); the concrete shortest discovery path from the crawl seed (DEEP_DISCOVERY_PATH); a self-computed mixed-content fallback (INSECURE_SUBRESOURCE); and near-duplicate clustering from stored page text (NEAR_DUPLICATE/DUPLICATE_BY_HASH), wiring tools/duplicate.py and tools/content_area.py into the audit for the first time. ORPHAN_PAGE, SITEMAP_ORPHAN and the two new "unlinked" checks are now withheld — reported as a named skip, not a finding — on a crawl the aggregator has marked partial, since "nothing links here" is unprovable on a truncated crawl. Registry grows from 104 to 114 checks.

  • ChangedWorksAudit the crawl registry against an external technical-SEO checklist and close eight

    cheap, verified gaps: five hreflang checks (invalid language/region codes, missing self-reference, missing x-default, duplicate entries, non-canonical targets), two robots directives (notranslate, unavailable_after), and canonical URLs containing a fragment. Registry grows from 96 to 104 checks. See docs/CHECKLIST_AUDIT.md.

  • AddedWorksAdd asset-weight-check: fetches a page's linked CSS/JS and reports render-blocking

    resources, oversized files, duplicate libraries (by content hash), missing minification, missing font-display, legacy polyfilled JS, and missing compression/long-lived caching.

  • AddedWorksAdd crawl-site --sitemap <url> (and sitemaps.auto_discover in --config): seed the

    native crawler from a sitemap's declared URLs, follow links from each, and reconcile the declared and observed sets into audit.json's summary.sitemap, under the same SITEMAP_ORPHAN / URL_NOT_IN_SITEMAP check ids the Screaming Frog pipeline already reports.

  • AddedWorksAdd crawl-site --config-help, generated from seohead/crawl/config.py, and hide

    --max-depth and --min-delay from --help (still accepted) so the flag surface stops growing with every crawler setting.

  • ChangedWorksSplit technology fingerprinting into a fetch step and a pure analyze_tech step, capture

    analytics/tag-manager ids instead of only names, and add a tag_coverage report that groups presence by URL template and stamps how each page was measured.

  • ChangedWorksResolve redirect chains and loops as a second pass over a finished crawl's own redirect

    targets, so REDIRECT_CHAIN/REDIRECT_LOOP no longer require the native Screaming Frog Redirect Chains report — a light-profile export or a crawl-site run gets the same findings for free.

  • AddedWorksAdd a configurable content area (content_area.py) that scopes word count to the main

    region, excluding navigation and footer by default, without affecting link discovery; the resolved strategy is reported per page.

  • ChangedWorksSeparate exact from near duplicates in duplicate.py: exact matches are hashed from

    extracted text (not raw bytes) and excluded from near-duplicate clusters, and comparisons default to indexable pages only.

  • AddedWorksAdd a boilerplate-consistency report (boilerplate_report.py) that hashes

    header/nav/footer per page and flags minority template groups.

  • AddedWorksAdd dependency-free Markdown extraction (markdown_extract.py): a content-area-only

    rendering and a full-document one.

  • ChangedWorksWire markdown_extract and boilerplate_report into the CLI and MCP surface as

    markdown-extract/seo_markdown_extract and boilerplate-report/seo_boilerplate_report (47 core tools, up from 45), and add the only_indexable flag duplicate_check already had at the handler layer to seo_duplicate_check's MCP signature, where it had been missed. Rescope citability-check's URL path from the parser's whole-document text field (a single collapsed line with no paragraph or heading breaks at all) to markdown_extract's content-area Markdown, fixing both the boilerplate dilution the issue raised and a latent bug where the flat text silently zeroed the Answer-Blocks and Structure-Quality dimensions for every live URL. Left unscoped, deliberately: the parser's text field itself, still whole-document, because page_facts.py's schema-evidence extraction (sameAs social links, breadcrumbs, price/rating regexes) depends on facts that legitimately live in header/footer widgets the content area excludes; and the Screaming-Frog-driven THIN_CONTENT/LOW_TEXT_RATIO checks in sf/core/rules.py, whose word_count/text_ratio come from Screaming Frog's own export columns — third-party data the toolkit has no raw HTML to rescope without re-fetching every page, defeating the zero-request offline-corpus design of the SF audit path. The toolkit's own crawler (crawl-site) already inherits the content-area scoping for free, since its word count reads straight from parser.parse_html.

  • ChangedWorksWrite down a naming convention (docs/NAMING.md) and resolve the module-basename

    collisions and process-named test files it found; no CLI command, handler, or MCP tool name changed.

  • AddedWorksAdd community, citation, and no-key agent onboarding files.
  • AddedWorksAdd the permissioned analytics-console-review workflow skill and three practical

    recipes.

  • ChangedWorksDocument support for the current 3.x security line.
  • ChangedWorksRequire TLS 1.2 or newer and pin direct certificate probes to prevalidated public

    addresses.

  • AddedWorksAdd an HTTP response cache for crawl-site (seohead/crawl/cache.py, opt-in via

    cache.mode — default off, so no side effect appears behind a default): real HTTP freshness semantics (max-age/Expires, ETag/Last-Modified revalidation, Vary-aware variants, no-store/no-cache honoured), a replay mode for debugging that is stamped in the manifest and never the default, and an invalidate flag for an explicit hard refresh. Every fetched page carries cache_status (hit/revalidated/miss); the run carries cache_stats and cache_replay in both the handler output and audit.json's run block, so a report built partly from cache says so. A cache hit costs no request and consumes no throttle delay or concurrent dispatch-gate slot either — the wait is issued from inside fetch_one itself, only once a real network round trip is actually about to happen.

  • AddedWorksAdd journal-driven reuse to seohead/core/runlog.py (SEOHEAD_REUSE_POLICY, a per-tool

    maximum age in seconds; default empty, meaning nothing is ever reused). A configured, still-fresh, successful prior answer is returned instead of calling the tool again, marked reused: true with reused_from_ts in both the result and the new journal entry it still writes — freshness is always measured against when the value was actually fetched, never extended by reuse itself.

Planned

Roadmap

108 open
Open the roadmap

Date = the day the change landed (git date of its changelog.d/ fragment). Number = the issue it closes. Title = first sentence, details on click.