Skip to content

lab-ops-hub: Worker-fallback extractor diverges from container Extractor — bare <a> uncounted, script-string <a counted, title entities raw, style/title leak into text #569

Closed

Description

@dezren39

Problem

POST /api/crawl returns the same JSON contract from two extractors — the container's Python Extractor (pkgs/lab-ops-hub/container/app.py:388-415) and the Worker fallback's regexes (crawlFallback, pkgs/lab-ops-hub/src/index.ts:196-205) — but on identical HTML the two produce different title, links and text/excerpt. Which values a crawl returns (and archives to R2 / D1) therefore depends on whether the container happened to be available, contrary to the README's "Worker-side fallback crawler with the same JSON contract" (pkgs/lab-ops-hub/README.md:14).

Verified this run by feeding identical HTML to the real Python Extractor and to the fallback's exact regex expressions:

  1. Bare <a> anchors are not counted by the fallback. The fallback counts html.match(/<a\s/gi) — an <a> with no attributes has no whitespace after the tag name. Sample <a>only bare anchor</a>: container links=1, fallback links=0.
  2. <a inside a <script> string is counted by the fallback. Sample <script>var s = "<a href='/fake'>x</a>";</script>: container links=0 (HTMLParser treats script content as data), fallback links=1 (the <script> removal only applies to the text extraction, not to the earlier links count on the raw HTML).
  3. Title entities are not decoded by the fallback. <title>Tom &amp; Jerry &#8212; Best</title>: container returns Tom & Jerry — Best (HTMLParser decodes character references), fallback returns the raw Tom &amp; Jerry &#8212; Best.
  4. Title and <style> content leak into the fallback's text/excerpt. The container keeps title text out of text and skips <script>/<style> content entirely (_skip counter). The fallback strips <script> blocks but not <style>, and its tag-stripping leaves the title's text in place. Sample with <title>T</title><style>.x { color: red; }</style>: container text='only bare anchor', fallback text='T .x { color: red; } only bare anchor'.

Why it matters

The crawl result is archived verbatim (crawls/<ts>.json in R2, row in D1 crawls), so the same URL crawled twice — once per path — yields inconsistent records, and the fallback path (used exactly when the container is unhealthy, i.e. when reliability matters most) returns visibly wrong excerpts (raw entities, CSS source) and link counts.

Suggested fix / acceptance criteria

  • Make the fallback extraction match the container's semantics for the cases above: count <a> start-tags regardless of attributes, never count markup inside <script>/<style>, decode HTML entities in the title (and text), and exclude title + script + style content from text.
  • The natural home is a pure helper in src/lib.ts (like crawlFetcher, lab-ops-hub: the #500 crawl-timeout fix has no regression test — crawlFallback's fetcher is inline in the untestable Worker entry #524) so it is unit-testable with plain Node; crawlFallback in src/index.ts cannot be imported by tests today because the module imports Cloudflare bindings.
  • Add paired fixtures: the same HTML samples run through both extractors (extend test/lib.test.ts and test/test_container_validate.py-style parity, or a shared fixture file both suites read), asserting identical title, links and text for: bare <a>, <a inside a script string, entity-bearing title, and a <style> block.
  • Existing suites still pass: npm test in pkgs/lab-ops-hub (TS + Python).

Acceptance criteria

  • The Worker fallback's extraction lives in a pure, unit-testable helper and matches the container Extractor's semantics for the filed cases: bare <a> anchors are counted; markup inside <script>/<style> is never counted; HTML entities are decoded in the title (and text); title, script and style content are excluded from text/excerpt.
  • Paired fixtures run the same HTML samples through both extractors and assert identical title, links and text for: a bare <a>, an <a inside a script string, an entity-bearing title, and a <style> block.
  • Existing lab-ops-hub suites still pass (npm test in pkgs/lab-ops-hub, TS + Python).

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    source/humanFiled directly by a human
    priority/normalOrdinary priority (the default)
    effort/mMedium: expected under 1 day of work
    app/lab-ops-hubPackage/app: lab-ops-hub (pkgs/ or apps/ in this repo)
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    on Oct 26, 2026
  2. dezren39 commented on Oct 26, 2026

    @dezren39
    MemberAuthor

    Intake review

  3. dezren39 commented on Oct 27, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    More container vs Worker-fallback divergences (verified this run, main 95cd41f0)

    Method: identical HTML fed to the real Python Extractor (container/app.py:1240) and to the fallback's exact expressions (src/index.ts:202-209: title regex, <a\s count, script-strip + tag-strip). Log: goal hidden_files/probe-2026-10-27-laneA-diff/extract_probe.py.

    Sample Container Fallback
    <p>real</p><!-- <a href='/x'>hidden</a> --> links=0, text=real links=1, text=real hidden --> (anchor inside an HTML comment is counted, and comment residue leaks into text)
    <p>real</p><script>var secret = 'leaked'; (unclosed) text=real text=real var secret = 'leaked'; (the <script[\s\S]*?</script> strip needs a closing tag; an unclosed script's body leaks)
    <title>a <b>c</b> d</title><p>body</p> title=a <b>c</b> d, text=body title=`` (empty — [^<]* cannot cross the ``), text=`a c d body`
    <title>First</title><title>Second</title><p>body</p> title=FirstSecond title=First, text=First Second body (second title's text also leaks into text)
    <textarea><a href='/x'>t</a></textarea><p>body</p> links=0, text=<a href='/x'>t</a> body (textarea content is data) links=1, text=t body

    The comment and textarea cases extend item 2's class (markup in non-markup contexts) beyond <script> strings; the unclosed-script and title cases are new shapes for items 3/4. All five are covered by this issue's suggested fix (a real parser-based pure helper matching the container's semantics) — they only widen the fixture list its acceptance criteria should pin.

  4. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed on Nov 1, 2026
  5. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

    • Agent: openchamber scheduled lane (repo-worker-code)
    • Task: Triage queue task 2169; revalidate current main and route a scoped fix if still applicable.
  6. 65 remaining items

  7. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — task 4868 picked up by OpenChamber scheduled lane (2026-11-01). Fresh verification: #569 is OPEN and assigned to Drew; PR #3918 is OPEN, authored by Drew, at head 741b85a. The task’s implementation and tests are already on that PR; I will not touch its worktree, review, CI, or merge disposition. Parking until the PR merges/closes or ownership changes; revalidate before any action.

  8. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 1, 2026
  9. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — task 4868 parked by OpenChamber scheduled lane (2026-11-01). PR #3918 remains OPEN and Drew-authored at 741b85a; implementation/tests are already present. No PR/worktree, CI, review, or merge disposition was touched. Resume after the PR merges/closes or ownership changes, then revalidate. Labels reflect parked state; work/assigned remains set.

  10. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

  11. removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    work/assignedQueue worker lifecycle state
    on Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/lab-ops-hubPackage/app: lab-ops-hub (pkgs/ or apps/ in this repo)effort/mMedium: expected under 1 day of workkind/bugSomething isn't working (prefixed form of the classic bug label)meta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/normalOrdinary priority (the default)source/humanFiled directly by a human

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions