Skip to content

add uspstf scraper - #26

Open
nahatav wants to merge 2 commits into
MedARC-AI:mainfrom
nahatav:add-uspstf-scraper
Open

add uspstf scraper#26
nahatav wants to merge 2 commits into
MedARC-AI:mainfrom
nahatav:add-uspstf-scraper

Conversation

@nahatav

@nahatav nahatav commented Sep 7, 2026

Copy link
Copy Markdown

Summary

Adds a USPSTF scraper, following the shared scraping base from #21. USPSTF is named in the project doc's data-sources list and isn't covered by any open PR. It yields 90 published recommendation statements, each carrying a letter grade (A/B/C/D/I) — a small, clinician-facing, high-authority corpus rather than an encyclopedic one.

Two site behaviours the scraper has to correct for

Worth calling out, because both are silent-data-loss traps rather than crashes.

1. The listing under-reports, and its status filter is ignored. The published-recommendations view shows a Hits: 108 counter but renders only 20 rows, with no working pager or page-size parameter (page=, items_per_page= all no-op). Filtering by one of the twelve topic categories does return that category's full set, so discovery walks all twelve and dedupes by slug.

Separately, topic_status=P stops being honoured once a category filter is applied: rows marked Inactive or Referred come back too, some dating to 1996 (retired guidance, or topics handed to another body). Those are dropped. Stale guidance in a verification corpus is worse than a smaller corpus, since it produces confident wrong verdicts — the exact failure the project doc flags when it says a stale index produces wrong "Refuted" labels. status is still in metadata if anyone wants them back.

That accounts for 108 → 107 reachable → 90 published. The 107/108 gap is the site's own: category 21 reports 21 hits but renders 20 rows, and the missing row has no link, so there is nothing to fetch.

2. A category can contribute nothing new. scrape_listing_documents treats an empty page as end-of-source. Walking categories lazily, one per listing page, would mean a category whose recommendations all appeared earlier terminates discovery and silently drops every later category. My first draft did exactly that and a test caught it, so categories are now walked eagerly up front. There's a regression test pinning that the last category is still reached when a middle one contributes nothing.

Content

Assembled from the recommendation's own outline: the population/recommendation/grade summary table, then each content panel (Importance, Assessment of Net Benefit, Practice Considerations, Supporting Evidence, tables and figures, References).

Skipped: the Preamble / Mission Statement (identical boilerplate across all 90 — 90 verbatim copies is noise) and the administrative Task Force membership and copyright panels. Verified no page chrome leaks into output.

Licensing

Not public domain, despite being federal work. AHRQ's copyright notice permits reproduction and redistribution "provided that it is reproduced without any changes to the work or portions thereof, except as permitted as fair use", prohibits redistribution for a fee or incorporation into a profit-making venture without written permission, and asks that the USPSTF page be cited when parts are quoted.

Every document carries license, license_url, and attribution in metadata, matching the shape used in #23 and #25 so a corpus can be filtered by source rights. The no-changes term deserves a look before this feeds claim decomposition or training, both derivative uses — that call belongs to whoever owns those pipelines, not to this scraper. Happy to gate it behind a flag or exclude it from default runs if that's the safer default.

Compliance

robots.txt allows the paths used and sets Crawl-delay: 5, applied both between recommendation pages and between the twelve category listing requests.

Components affected

  • datasets/amfv_datasets/scraping/uspstf.py (new)
  • datasets/amfv_datasets/scraping/cli.py — one import, one SCRAPERS entry
  • datasets/test/test_scraping_uspstf.py (new)
  • datasets/test/test_scraping_cli.py — the unknown-source test asserts the registered source list

Testing

  • uv run ruff check / uv run ruff format --check clean
  • uv run pytest — 51 passing, on Python 3.13 to match CI
  • Live verified: full discovery across all twelve categories (90 published, every one with a grade and title, years 2004–2025), a four-recommendation crawl, --url mode, boilerplate exclusion, and no chrome leakage
  • Not run: the full 90-document corpus end to end (~8 minutes at the mandated 5s delay)

Adds a US Preventive Services Task Force scraper, a source named in the
AMFV project doc and not yet covered.

Discovery works around the listing view: it reports a "Hits: N" total but
renders only 20 rows, with no working pager or page-size parameter. The
same view filtered by one of its twelve topic categories returns that
category's full set, so all twelve are walked up front and deduplicated by
slug, yielding 90 published recommendations.

Two things the site does that the scraper has to correct for:

- `topic_status=P` stops being honoured once a category filter is applied,
  so rows marked Inactive or Referred come back too, some dating to 1996.
  Those are dropped. Retired guidance in a verification corpus is worse
  than a smaller corpus: it produces confident, wrong verdicts, which is
  the failure the project doc warns about for stale indexes.
- A category can contribute nothing new, and the shared listing loop reads
  an empty page as the end of the source. Walking categories lazily one per
  page would therefore silently drop every later category, so they are
  walked eagerly instead. A test pins this.

Content is the recommendation's own outline: the population/recommendation/
grade summary table, then each content panel, skipping the preamble and
mission statement (identical across all 90) and the administrative
membership and copyright panels.

Licensing is not public domain despite being federal work. AHRQ's notice
permits redistribution only "without any changes", bars redistribution for
a fee, and asks to be cited. Documents carry license, license_url, and
attribution so this travels downstream; the no-changes term is worth
attention before the corpus feeds decomposition or training.
return clean_text(headings[0].text_content()) if headings else ""


def _recommendation_content(doc: lxml_html.HtmlElement, *, link_mode: LinkMode) -> tuple[str, int]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _recommendation_content helper in uspstf.py, the content collector, gives html_to_markdown the site root at both conversion calls. That changes the meaning of a link written relative to a recommendation page.

An accepted fix passes the actual recommendation URL into the content helper from both readers and uses it for both table and panel conversion. Add the relative-link fixture. Next we examine which input hostnames are accepted.

TLDR A reference can point to the wrong place because it is resolved from the site root.

"""
parsed = urlparse(url.strip())
host = parsed.netloc.lower()
if parsed.scheme not in {"http", "https"} or not host.endswith("uspreventiveservicestaskforce.org"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We move back to the top of the module: the recommendation_slug_from_url helper in uspstf.py, the source collector, validates addresses using a hostname suffix. The offline probe shows that notuspreventiveservicestaskforce.org is accepted because it ends with the expected text.

This is an input-validation issue, not an off-site fetch: recommendation_url reconstructs a canonical address before the reader requests it. An optional improvement accepts the exact expected host or a subdomain with a dot boundary. The closing step gathers the decision.

TLDR A mistyped lookalike domain is accepted, but the actual request still goes to the configured site.

…ost check

Two fixes from @zndr27's review.

_recommendation_content resolved relative links against the site root at both
conversion calls, so a reference written as `grade-definitions` on a
recommendation page came out as /grade-definitions instead of
/uspstf/recommendation/grade-definitions. Both readers now pass the page URL,
and both the summary table and the panel bodies use it. A recommendation URL
carries no trailing slash, so a relative reference resolves against
/uspstf/recommendation/, which is what a browser on that page does.

recommendation_slug_from_url validated the host with a bare suffix match, so
notuspreventiveservicestaskforce.org was accepted. It now takes the domain
itself or a subdomain of it, on a dot boundary. As the review notes this was
input validation rather than an off-site fetch, since recommendation_url
rebuilds a canonical address before anything is requested. Reading the host from
`hostname` rather than `netloc` also drops a port, so an explicit :443 no longer
fails the check.

Tests cover relative links on both readers across both conversions, the
lookalike domain, the domain appearing as a subdomain of somewhere else, and the
bare-domain and explicit-port forms that have to keep working. The three
regression cases fail against the previous code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants