Sanitize URLSource HTML and recover PDF titles - #98
Conversation
PDFExtractor.extract_title returns the document information /Title, or
None when it is absent, unreadable, or a placeholder that authoring tools
stamp in ("Microsoft Word - x.doc", bare filenames, "Untitled").
Nothing calls it yet. It is the offline fallback for #93.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
<title> is often the site or journal name. The citation_title meta tag (Highwire / Google Scholar) names the article itself, so _extract_title now checks it first. Attribute order does not matter and entities are decoded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
URLSource set title=url on every PDF, so consumers that read title as bibliographic metadata got the address back as the paper's name. The PDF branch now tries, in order: the citation_title of a publisher landing page found by LANDING_PAGE_RULES (J-STAGE _pdf -> _article to start), then the PDF's embedded /Title, then the URL. The URL fallback is kept, so title == url still means no title was found. Closes #93 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
URLSource stored the response body verbatim, so page scripts, comments and tag attributes (signed asset URLs, API-key parameters) went into caches that projects commit to public repositories (#92). HTML now passes through sanitize_html: script, style, noscript and template elements and comments are dropped, and every attribute except rowspan, colspan and scope is removed. meta, link and base are dropped too, since they hold nothing but attributes and would remain only as empty tags. Body markup and text are kept. Whitespace left by removed elements is collapsed, outside <pre>, so the result is stable under a second pass. Plain text and XML are stored as fetched. The title is read before sanitizing, since citation_title lives in a <meta> attribute. Refs #92 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sanitizing HTML (#92) and recovering PDF titles (#93) change what URLSource writes, and neither rewrites what it already wrote. Those entries carry a current extractor_version, so raw page scripts and URL-as-title PDFs would be served forever. url: entries now carry url_source_version, stamped only on a fresh fetch, the way html_full_text_version is. A missing or older stamp makes the entry stale, so it is re-fetched once. Offline, the old entry is still served and not rewritten. Other sources are unaffected. The horizontal-rule frontmatter test saved a url: entry by hand with no stamp, which now reads as stale by design. It now carries the stamp a fresh fetch writes, which still sits below reference_id and so still tests the truncation it was written for. Closes #92 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validate-urls.md said url: pages were cached as raw content with the title taken from <title>. It now describes sanitization, citation_title, and the PDF title order. use-local-files-and-urls.md said PDFs were unsupported; that holds for file: references only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Claude finished @caufieldjh's task in 1m 25s —— View job Review of PR #98: Sanitize URLSource HTML and recover PDF titles
The PR is well scoped and each part is documented. The staleness stamp follows the same pattern as 🔴 The landing-page request can raise and lose a PDF that was already fetched
page, content_type = ContentAcquirer().fetch_bytes(landing, config)
Elsewhere the codebase treats Suggested fix: try: # external system boundary: the title is best-effort, the PDF is not
page, content_type = ContentAcquirer().fetch_bytes(landing, config)
except requests.RequestException as e:
logger.debug(f"Landing page {landing} failed for {url}: {e}")
page = NoneAdd a test in which 🟡 "Still served when offline" only covers a
|
fetch_bytes lets requests exceptions through. The landing-page request in _recover_pdf_title was unguarded, so a timeout or refused connection on the J-STAGE article page failed the whole fetch, though the PDF and its text were already in hand. The title is best-effort; it now falls through to the embedded /Title, then the URL. Review of #98. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
URLSource.fetch let requests exceptions through, so a timeout or a refused connection failed the fetch before _stale_fallback could serve the cached entry. That gap predates #98, but #98 makes every unstamped url: entry go back to the network once, so the first offline run after upgrading would have hit it on every url: reference. The request error now returns None, as clinicaltrials.py already does. The offline test raised nothing before; it now raises ConnectionError and Timeout as well as covering the (None, None) refusal. Review of #98. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The meta regexes anchored on \b, which also matches after the hyphen in data-name= and data-content=. A page carrying either could have its title read from the wrong attribute. Attribute names are now matched only after whitespace. Review of #98. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page served as text/plain or with no content type was sanitized only if sniff_format saw <!doctype html or <html> at its start. A byte order mark, a leading comment (saved pages often carry "<!-- saved from url -->"), or a page starting at <head> or <body> sent it to the cache raw, scripts and all. URLSource._looks_like_html skips a UTF-8 BOM and leading comments, then also accepts <head> and <body>. sniff_format is unchanged, since other paths use it for format resolution. Text that only mentions a tag is still left alone. Review of #98. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pypdf returns /Title as whatever object the file holds. A PDF whose
/Title was a number, array or dictionary got "5", "['a']" or "{}" as
its title, since extract_title called str() on it. Only a string is now
accepted.
The review of #98 suggested widening the except to KeyError, ValueError
and TypeError for malformed metadata. Probing pypdf with non-text
/Title values and a non-dictionary /Info found no case that raises
outside PyPdfError, which is already caught, and found this instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_PLACEHOLDER_TITLE rejected any /Title ending in .pdf, .docx, .tex and the like, so a real title that happens to end that way was dropped. The extension rule now needs the whole title to be one token with no spaces. The trade: a filename with spaces in it, such as "final draft v2.pdf", is now accepted as a title. Review of #98. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some publisher pages keep a fallback copy of the abstract in <noscript>. A quote that matched only that copy stops matching once the url: entry is re-fetched and sanitized. Review of #98. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules that change when a publisher changes its pages were spread over five modules, and their examples were repeated across the tests. They now live in etl/rules.py, each beside its examples and counterexamples: - LANDING_PAGE_RULES (from sources/url.py), now keyed by publisher - PDF_PLACEHOLDER_TITLE (from extract/pdf.py) - ARTICLE_BODY_SELECTORS (from extract/html.py, which joins them) - PMC_ARTICLE_BODY_CLASSES (from fulltext/pmc.py) - STUB_NOTICE_PHRASES (from extract/xml.py; the length gate that makes them safe stays there, and each side points to the other) tests/test_rules.py runs every example and counterexample through the code that consumes the rule, checks that each consumer imports the rule rather than a copy, and fails for a rule with no example. ARTICLE_BODY_SELECTORS had no examples before; it has one per selector now. Tests that exercise a rule through a larger path (a real PDF, a whole JATS document, a fetch) now take their inputs from rules.py. test_pmc_modern_container.py only restated the PMC rule, so its cases moved into rules.py and the file is gone. Generic format rules (NON_CONTENT_TAGS, KEPT_ATTRIBUTES, BLOCK_LEVEL_TAGS, content-type maps) stay with their extractors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Claude finished @caufieldjh's task in 1m 34s —— View job Re-review of PR #98 after the new commits
The follow-up commits fix every blocking item from the last review, each with a test. I found one new behaviour worth deciding on before merge (🟡). Everything else is small. Fixed since the last review
🟡 The one-time refetch can replace a good
|
|
Thanks for the review. Each point is its own commit.
No change:
Also in this push, 4c69591. Publisher and site rules now live in Tests. Locally, the targeted run over the changed areas passed 411 of 411, and mypy and ruff are clean. The full suite could not finish on my machine: tests that make real network calls stall there on connection timeouts. CI is the full check for this push. |
URLSourceis the one source that did no extraction. It stored the response body as fetched, and it named every PDF after its own URL. dismech carries a runtime patch for each problem. These are the last two patches it carries.Closes #92. Closes #93.
#93: a PDF fetched by URL gets a real title
The PDF branch now tries three things in order:
citation_title. The page is found by a rule inLANDING_PAGE_RULES. There is one rule, for J-STAGE:.../_pdf[/-char/ja]becomes.../_article[/-char/ja]. That covers the 53 dismech entries./Title. Placeholders are rejected:Microsoft Word - x.doc, bare filenames,Untitled.title == urlstill means no title was found.A page's bare
<title>is not used at step 1, because it is often the site's name. On ordinary HTML fetches,citation_titlenow wins over<title>too.#92: HTML is sanitized before caching
sanitize_htmlremoves these:script,style,noscriptandtemplateelements, and all comments.meta,linkandbase. They hold nothing but attributes.rowspan,colspanandscope.Body markup and text are kept. Plain text and XML are stored as fetched. The title is read before sanitizing, because
citation_titlelives in a<meta>attribute. A second pass gives the same output, and whitespace inside<pre>is left alone.This is kept apart from
HTMLExtractor. That extractor flattens a page to plain text, andurl:pages are stored as HTML.Old entries are refetched once
Neither fix changes entries already on disk. Those entries carry a current
extractor_version, so they would be served as they are forever.url:entries now carryurl_source_version: 1. It is written only on a fresh fetch, the wayhtml_full_text_versionis. An entry with no stamp, or an older one, is stale and is fetched again once. If the fetch fails, the old entry is still served and is not rewritten. Other sources are unaffected. Withtrust_cached_entriesset, nothing is refreshed.For dismech, expect one diff on every
url:entry in the cache: a stamp line, and sanitized bodies on the HTML pages.One existing test changed.
test_entry_whose_id_contains_a_horizontal_rule_is_not_perpetually_stalesaved aurl:entry by hand with no stamp, and under the new rule that entry is stale. The test entry now carries the stamp a real fetch writes. The stamp line sits belowreference_id, so the test still checks the frontmatter truncation it was written for.Commits
Each commit passes its own tests.
/TitleinPDFExtractorcitation_titleover<title>for URL pagesURLSourcebefore caching (URLSource caches raw page HTML: scripts, comments and attributes reach a committed cache #92)url:entries written before theURLSourcefixesNot done
<img>,<iframe>and similar tags lose their attributes but stay in the page as empty tags.Tests
test_pdf_url_title.py,test_url_html_sanitize.pyandtest_url_source_cache_version.py, plus doctests.just test: 1223 passed, 10 failed. One failure was the horizontal-rule test above, and it now passes.test_pmid_network.pyandtest_html_fulltext_acceptance.py. They time out connecting to a local test server at127.0.0.1. A different set fails on each run, and they fail onmaintoo.meta/link/basewere added. After that change I reran the targeted tests at each of the last three commits: 120 passed each time.🤖 Generated with Claude Code