Skip to content

Unified scraper stack - #56

Merged
deandevz merged 6 commits into
deandevz:mainfrom
nelsonmfinda:unified-scraper-stack
May 14, 2026
Merged

Unified scraper stack#56
deandevz merged 6 commits into
deandevz:mainfrom
nelsonmfinda:unified-scraper-stack

Conversation

@nelsonmfinda

Copy link
Copy Markdown
Contributor

Summary

Consolidates PRs #51, #52, #53, #54 into one stack per #55. Lands king-scrape update <name> (ADR-0014) with the six safety fixes you flagged in the live e2e run, plus the upsert and enrich resilience pieces that the update flow depends on, plus the --no-fetch-cache flag wired through every subcommand.

Related issue

Closes #47
Closes #48
Closes #50
Refs #55

Supersedes #51, #52, #53, #54.

Type of change

  • Bug fix
  • New feature
  • Documentation update

How it was tested

pytest -q
816 passed, 1 skipped

The six fixes have direct regression tests in tests/test_scraper/test_update.py: legacy _meta refuse, fetch failure threshold abort, discover-divergence guard, growth allowed counter case, cost prompt short-circuit, fetch_failed in the report. --no-fetch-cache covered on all three subcommands (cli.main, audit_main, update_main) plus direct unit tests
for _cache_mode helpers.

Ran a senior review pass (two voltagent reviewers in parallel) on the staged diff before opening. They caught a regression of your earlier 4a50531 audit signature fix that my patch
reverted by accident — re-applied. Also tightened the env restore window in cli.py so a load_config failure can't leak SCRAPE_CACHE_MODE=bypass.

Checklist

  • English only code, comments, identifiers
  • pytest passes locally
  • Tests added for every new guard
  • docs/CLI_GUIDE.md updated with the cache mode footgun note for update
  • Contributing guide read
  • Commit style: conventional, one concern per commit
  • No secrets in the diff

Additional notes

Three design decisions you locked in are implemented as-is, with no opt-in flag for any of them:

  • Legacy _meta-absent corpora: hard refuse, no flag. Re-scrape from scratch.
  • Fetch-failure threshold: strict > 10%, fixed constant. 1-of-10 passes; 2-of-10 aborts.
  • Discover divergence: asymmetric. > 50% loss aborts; any amount of growth is fine.

Trade offs I accepted rather than fixed:

  • update does not auto-set SCRAPE_CACHE_MODE=bypass. A user running king-scrape update foo without remembering --no-fetch-cache may still hit stale provider cache for unchanged URL but changed-content pages. Documented in CLI_GUIDE.md next to step 3. Auto bypass on update is one CLI guarantee I'd rather have you sign off on before changing.
  • _interleave_in_chunk_order now silently dedupes repeated content_hash from fresh_chunks (boilerplate sections that recur across pages). One section per unique content, fresh order preserved. Documented in the helper.

Follow-up that I'd ship separately, not in this PR:

  • embeddings.npy pruning on re-seed (you acknowledged this as pre existing). Happy to file the issue.

Happy to split the stack if you'd rather review the pieces independently.

New module ``king_context.scraper.url_utils`` exposes ``canonicalize_url``,
lifted verbatim from ``audit._canonicalize``. ``audit.py`` re-exports the
private name for compatibility with its own callsites.

Pure refactor, no behaviour change. Sets up shared use from ``update.py``
in a follow-up commit where URL diff parity between corpus_urls and
fresh_urls needs the same normalisation.
chunk_pages used md_file.stem as source_url, so every chunk produced by
the scraper carried slug-form URLs like docs-example-com-quickstart
instead of the real https://docs.example.com/quickstart. Pre-existing
since the chunk module landed; the consequence surfaced in deandevz#51 as
noisy added_urls / removed_urls diffs when update compared
corpus_urls (slug-form, written by this path) against fresh_urls
(real URLs, from the provider).

Fix: read the real URL from the page sidecar fetch.py writes alongside
each <slug>.md as <slug>.meta.json. When the sidecar is absent or
unreadable (legacy _temp/ from pre-deandevz#46 scrapes), fall back to the
slug so existing work directories continue to chunk.

Backward compat at the corpus boundary: committed data/*.json files
keep their slug-form URLs until the next refresh writes through the
new path. The audit command's URL canonicalisation absorbs the
transient mismatch in the meantime.

Four new tests cover the sidecar-present, sidecar-missing,
sidecar-malformed, and url-key-missing branches.
@deandevz

Copy link
Copy Markdown
Owner

Hey Nelson, took the unified PR through the same e2e path
as the original four. All six fixes land cleanly and the
three design decisions are implemented exactly as we
agreed (hard refuse, fixed 10%, asymmetric 50%). Nice work
on the test coverage, the boundary case for the fetch
threshold (1/10 = exactly 10% does NOT abort) is
particularly thoughtful and pins the strict-> semantics
where they belong.

What I verified

  • pytest passes in the project venv: 817 passed, 0 failed,
    matches your "816 passed, 1 skipped" within environment
    noise.
  • ADR-0014 indexes cleanly via kctx adr index and the doc
    body now correctly references _atomic_write_json in
    update.py rather than save_and_index. Drift fixed at
    the source, appreciate that.
  • --no-fetch-cache plumbs through all three entrypoints
    (king-scrape, audit, update) and the shared
    _cache_mode helper keeps the setdefault semantics
    consistent. Direct unit tests for set / restore /
    prior-env / no-op / defensive RuntimeError are all there.
  • Legacy guard live e2e against data/minimax-audio.json:
    exits 1 with a clear "re-scrape to anchor" message,
    file on disk is byte-identical before and after (sha256
    matched). The bug that cost $0.06 last time costs $0
    now. Exactly what we wanted.

Code-level read

The commentary explaining the why across the diff is
consistently strong. A few I noted: the int(exc.code or 1)
note in update_main catching the 0 or 1 == 1 trap, the
cache invariant docstring on _enrich_one (schema fallback
explicitly NOT cached under the primary key), the FTS5
'delete' protocol byte-exact match note in db.py, the
embeddings-flushed-after-commit comment. Senior-grade care.

One small nit, not blocking

_resolve_source_url at update.py:185 would crash with
AttributeError if a corpus JSON ever contained
"_meta": null (null, not missing). The legacy guard above
only checks "_meta" not in corpus, so the explicit-null
shape slips past it into the chained .get(...) call.
Requires someone hand-writing null into the JSON, so
probably never in practice, but a one-line corpus.get("_meta") or {}
would close it. Happy to take it as a follow-up rather than
churn this PR.

I will also file a small cosmetic cleanup issue from a few
other minor things I spotted (_canonicalize re-export in
audit.py, a couple of urllib edge cases). Pure my-side
follow-up, none of it touches your PR.

Approved. Ship it whenever CI is green on your side.

@nelsonmfinda

Copy link
Copy Markdown
Contributor Author

Hey @deandevz folded the null _meta nit into the stack ced2e52. One liner fix at update.py:185, regression test at test_update.py:78. Suite 817 / 1 skipped. Ready when you are.

@deandevz

Copy link
Copy Markdown
Owner

Validated ced2e52 locally. Diff is surgical (one line in
_resolve_source_url, one regression test), suite at 818
passed (your +1 from the new test). Walked the helper
through the six relevant shapes manually too: _meta
missing, null, empty dict, with source_url, with null
source_url, totally empty corpus. All resolve as expected.

Comment in the code explains the why nicely (legacy guard
uses not in so explicit-null slips past), which is exactly
the kind of context that keeps the next reader from
"simplifying" the or {} back into the default form.

Approved, ready to merge whenever you are.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants