Skip to content

Cache url: HTML pages as readable text, so quotes through links and bold text match - #103

Merged
caufieldjh merged 11 commits into
mainfrom
url-cache-readable-text
Oct 12, 2026
Merged

caufieldjh merged 11 commits into
mainfrom
url-cache-readable-text

Conversation

@caufieldjh

Copy link
Copy Markdown
Contributor

Fixes #102.

Since #92, URLSource cached sanitized markup. A quote could only match text inside a single text node, so a sentence that ran through a link, bold or emphasis failed even when copied exactly. Examples: the Flint press release, Wikipedia lead sentences, ZFIN's license line. A whole page also sat on one unreadable line.

Commits (best reviewed one at a time)

  1. html_to_text (etl/extract/html.py). It flattens a whole page:

    • inline tags are unwrapped in place;
    • block-level tags and <br> start a new line;
    • a table row becomes one line, with its cells separated by |;
    • <pre> keeps its whitespace, and entities are unescaped;
    • non-content elements and comments are dropped exactly as sanitize_html drops them, through a shared helper.

    As the bot comment on url: pages: a quote that runs through a link or bold text fails; cache readable text instead of markup #102 suggested, it does not reuse HTMLExtractor. That extractor keeps only <p> text when a page has any, and narrows to <article>/<main>; either would lose text that matches today.

  2. URLSource uses it, and URL_SOURCE_CACHE_VERSION goes 1 → 2. The title is still read from the raw page's citation_title first. Two old tests asserted that markup survives; they are replaced by one that checks the readable-text shape.

  3. Docs: validate-urls.md and troubleshooting.md, including a migration note.

The invariant

"Every quote that matches today still matches." A property test checks this over the HTML fixtures (PLOS, DSpace, IRIS) and two synthetic pages: every text node of the old sanitized markup, normalized, appears in the normalized flattened text.

Open questions from #102

  • Cell separator: |. Normalization drops punctuation, so a quote copied from a rendered row with spaces between cells still matches. There's a test for this.
  • Migration: a troubleshooting note says the refresh rewrites every cached HTML page, which makes a large diff in a committed cache. It also re-fetches each page, so a page that has changed may stop supporting a quote. An unreachable page keeps its old entry, as before.

Verification

  • The issue's repro, run against the live page: This press release was produced by the City of Flint. now gives Valid: True. The cache entry is 107 lines with no markup.
  • New and updated tests, all doctests, mypy and ruff all pass.
  • Full suite, one file at a time: 1,364 of 1,367 passed. The 3 failures are in test_pmid_network (2) and test_html_fulltext_acceptance (1). They pass when rerun alone, and the same files also fail on main on this machine (5 and 1 failures), from network and local-server timeouts.

🤖 Generated with Claude Code

caufieldjh and others added 3 commits October 10, 2026 16:50
Inline tags (a, b, em, span, ...) are unwrapped in place, block-level
tags and <br> start a new line, a table row becomes one line with its
cells joined by " | ", <pre> keeps its whitespace, and entities are
unescaped. Non-content elements and comments are dropped exactly as
sanitize_html drops them, through a shared helper.

Unlike HTMLExtractor, nothing is narrowed to <article>/<main> and no
<p>-only shortcut is taken, so every text node of the sanitized markup
survives, contiguous. A property test checks that over the HTML
fixtures. Not yet used; URLSource switches to it next (#102).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since #92, URLSource cached sanitized body markup, so a quote could only
match text inside a single text node: any sentence that ran through a
link, bold or emphasis failed even when copied exactly from the page
(the Flint press release, Wikipedia lead sentences, ZFIN's license
line), and a whole page sat on one unreadable line.

URLSource now flattens HTML with html_to_text, still after reading the
citation_title from the raw page. URL_SOURCE_CACHE_VERSION goes to 2 so
version 1 entries are re-fetched; an unreachable page keeps its old
entry.

The two sanitize tests that asserted markup survives are replaced by
one asserting the readable-text shape; everything they dropped is
still dropped.

Fixes #102

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validate-urls.md describes what an HTML page becomes in the cache
(inline tags unwrapped, one line per block and per table row, cells
separated by " | ", <pre> kept, nothing narrowed to the article).
troubleshooting.md records url_source_version 2 and warns that the
refresh rewrites every cached HTML page and re-fetches it, so a page
that has since changed can stop supporting a quote (#102).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @caufieldjh's task in 1m 13s —— View job


Review of PR #103: cache url: HTML as readable text

  • Read the PR diff
  • Check how html_to_text fits with the existing code
  • Probe edge cases (I couldn't run tests: this job has no permission for uv run pytest, so everything below comes from reading the code)
  • Post the review

Overall: the change is well focused and the design holds up. The PR rightly does not reuse HTMLExtractor. Moving the drop step into _without_non_content means caching and flattening can't drift apart. The property test (every text node of the old sanitized markup still appears in the flattened text) checks the right invariant. Raising the cache version to 2 and adding the stale-entry test makes old caches refresh correctly. I found no blocking problems, only a few edge cases and small fixes.

Possible bugs and edge cases

  1. Text in a <tr> that isn't inside a <td>/<th> is lost (html.py, the tr branch of _flatten). The branch reads only child.find_all(["td", "th"], recursive=False). Any other child is dropped: stray text, or a wrapper element. Older layout pages often use <tr><form><td>…</td></form></tr>, and there every cell is lost. The old sanitized markup kept all of that text, so this breaks the stated invariant. The property test misses it only because no fixture contains it.
    One fix is to walk child.children and use _cell_text for cells and _lines(...) for everything else, then join the parts with CELL_SEPARATOR. A test with <table><tr><form><td>a</td><td>b</td></form></tr></table> would cover it.
    Fix this →

  2. A <br> inside <pre> joins the words on either side. child.get_text() ignores <br>, so <pre>a<br>b</pre> becomes "ab". The invariant still holds, because each old text node is a substring, but a quote of a b won't match. Normalization keeps word boundaries, so this matters. It's rare, but some code and log viewers do put <br> inside <pre>. Replacing each br in the pre with "\n" before calling get_text() would fix it.

  3. <pre> output doesn't match _lines' docstring. splitlines() keeps blank lines and trailing whitespace from inside <pre>, but _lines says it returns "the non-blank lines". Keeping them is probably intended, since it preserves the block's whitespace. If so, update the docstring. If not, filter the blank lines.

  4. Deep nesting can hit the recursion limit. _flatten uses one Python stack frame per nesting level. A page nested more than about 990 tags deep would raise RecursionError from URLSource.fetch, and the page would fail instead of being cached. Some generated or broken pages do nest that deeply, but it's uncommon. Converting to an explicit stack is optional; at least a short note in the docstring would help.

Code quality

  • TEXT_BREAK_TAGS is a frozenset built from the BLOCK_LEVEL_TAGS tuple. That's fine, but td/th now appear in both the break set and the special tr path. That's correct: a cell outside a <tr> breaks a line. A short comment saying so would help readers.
  • Dropping empty cells (if cell) means a row's columns no longer line up across rows. That's fine for matching, but worth noting in the CELL_SEPARATOR docstring.
  • Skipping PreformattedString but keeping CData is correct, and the type(text) is NavigableString filter in the property test is a good touch.
  • Docstrings and doctests follow the repo conventions (CLAUDE.md), with no unneeded try/except.

Performance

The page is parsed once (before, sanitizing also re-serialized it), and the walk is linear in the page size. I see no problems. The cached entries are also much smaller than the old markup.

Security

This improves on the old cache, which kept markup. Nothing from scripts, styles, comments or attributes reaches the cache. Entities are unescaped into plain text, so the result is safe as long as nothing renders the cached content as HTML. I found nothing that does.

Test coverage

Coverage is good: the three repros from #102, quotes copied from table rows, pre, entities, the stale cache version, and the title still being read from the raw page. Gaps worth adding:

  • A <tr> with a non-cell child (item 1).
  • <br> inside <pre> (item 2).
  • A cell containing a nested table, to pin down how _cell_text flattens it.

On the open questions

  • A | separator looks right, and it is tested with a quote that has no separators.
  • The migration note is good. You could also suggest a separate commit for the cache refresh, so its large diff doesn't hide content changes in review.

caufieldjh and others added 8 commits October 10, 2026 16:55
The tr branch read only direct td/th children, so a row's stray text
was lost, and so was every cell of an older layout that wraps them,
as in <tr><form><td>. The sanitized markup kept that text, so this
broke the invariant that every quote matching today still matches.
_row_cells now walks the whole row, descending into wrappers that hold
cells and keeping anything else as a part of its own.

Addresses review on #103 (item 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_text() ignores <br>, so <pre>a<br>b</pre> came out as "ab" and a
quote of "a b" could not match. _pre_text reads the block's strings
itself, with each <br> as a line break, and leaves the tree untouched.

Addresses review on #103 (item 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_flatten took one Python frame per level of nesting, so a page nested
past the recursion limit raised RecursionError out of URLSource.fetch.
Parsing such a page works, so only the walk was at fault. _lines now
keeps its own stack, with a marker for the end of each block. Output on
the HTML fixtures is byte-identical. Only a table nested in a table
cell still recurses.

Addresses review on #103 (item 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docstring promised only non-blank lines, but a <pre> block's lines
are kept as written, blank ones included, since its whitespace is part
of its text. That is intended; the docstring now says so and a test
pins it.

Addresses review on #103 (item 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reader seeing td/th in TEXT_BREAK_TAGS and in the tr path could take
one for dead code. Both are live: the tr path joins a row's cells, and
a cell outside any <tr> still gets a line of its own.

Addresses review on #103 (code quality, first note).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses review on #103 (code quality, second note).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The whole inner table folds into the outer cell's line, its cells
separated by " | " and its rows by a space, so nothing is lost and a
quote across it still matches once normalization drops the pipes.

Addresses review on #103 (test coverage, third gap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every cached HTML page is rewritten by the version 2 refresh, and that
diff would bury any content change made alongside it. The migration
note now says to run validation on an otherwise unchanged checkout
right after upgrading and commit the rewritten cache files by
themselves.

Addresses review on #103 (open questions, migration note).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @caufieldjh's task in 47s —— View job


Re-review of PR #103 after the latest push

  • Read the commits since the last review (11546fa..39c4d42)
  • Check each earlier review item against the new code and tests
  • Look for new edge cases
  • Post the review

I couldn't run tests in this job, because uv run pytest and uv run python need approval here. Everything below comes from reading the code. To let me run them, add Bash(uv run:*) to --allowedTools.

Overall: every item from the last review is fixed, each in its own commit and each with a test. I see no blocking problems, so this looks ready to merge from my side.

Earlier review items

# Item Status
1 Text in a <tr> outside its cells was lost ✅ Fixed. _row_cells now walks every child of the row and steps into wrappers that contain cells. Tests cover cells inside a <form>, stray text and a stray element.
2 <br> inside <pre> joined the words on either side ✅ Fixed. _pre_text turns each <br> into \n. Covered by a doctest and test_br_inside_pre_breaks_a_line.
3 _lines docstring didn't match how <pre> is handled ✅ The docstring now says what is kept, and test_blank_lines_are_kept_only_inside_pre checks it.
4 Deep nesting could hit the recursion limit ✅ _lines now walks the page with its own stack, using a _BLOCK_END marker to end a block's last line. A test checks 5,000 nested <div>s.
— Comment explaining why td/th are in TEXT_BREAK_TAGS ✅ Added
— Note in CELL_SEPARATOR's docstring that empty cells are dropped ✅ Added
— Test for a table nested inside a table cell ✅ test_a_table_nested_in_a_cell_folds_into_that_cell
— Advise committing the cache refresh on its own ✅ Added to troubleshooting.md, including that entries refresh only as validations use them

Remaining notes (none blocking)

  1. _row_cells still uses recursion. It calls itself for wrappers inside a row (html.py, the child.find(["td", "th"]) branch). A table inside a cell also recurses, through _cell_text → _lines → _row_cells. So one of these could still raise RecursionError:

    • a row whose cells sit under hundreds of nested wrappers;
    • tables nested hundreds of levels deep.

    The _lines docstring already says only nested tables recurse. Real pages almost never nest like this, so I'd leave the code as it is. You could add "or wrappers inside a row" to that sentence so it stays accurate.

  2. find(["td", "th"]) also finds cells of a nested table. In <tr><div><table><tr><td>x</td><td>y</td></tr></table></div></tr>, the <div> is treated as a wrapper, so x and y become cells of the outer row (x | y). This doesn't break matching, because normalization drops |. It also follows the same idea as the nested-in-a-cell case. I'm mentioning it so the behaviour is a known choice.

  3. A <pre> inside a table cell loses its line breaks. _cell_text joins the cell's lines with a space, so the <pre> collapses to one line, though spaces inside each line stay. This is fine for matching. The html_to_text docstring says "<pre> keeps its whitespace" without exceptions; you could add "except in a table cell".

  4. Small point: _lines turns each text node's whitespace runs into one space, and _end_line collapses runs of spaces again. Both steps are needed, because separate nodes such as "x " + " y" produce a double space. It's correct, just worth knowing it's on purpose.

Code quality, performance, security

  • The switch from recursion to a loop is clean. Pushing children in reverse and putting _BLOCK_END beneath a block's children is easy to follow, and the comment explains it.
  • _row_cells calls child.find(...) on each non-cell child of a row. That search is linear in the child's size and happens only once per child, so the cost doesn't grow in a worrying way.
  • Security is unchanged since my last review: nothing from scripts, styles, comments or attributes reaches the cache.
  • The docstrings and doctests follow CLAUDE.md, and there is no try/except.

Test coverage

Coverage now includes every edge case I raised, alongside the property test over the fixtures. One optional addition: a test for a <pre> inside a <td>, to pin down the behaviour in note 3.

@caufieldjh
caufieldjh merged commit 35fac0f into main Oct 12, 2026
5 checks passed
@caufieldjh
caufieldjh deleted the url-cache-readable-text branch October 12, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

url: pages: a quote that runs through a link or bold text fails; cache readable text instead of markup

1 participant