Skip to content

add medlineplus scraper - #23

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

add medlineplus scraper#23
nahatav wants to merge 2 commits into
MedARC-AI:mainfrom
nahatav:add-medlineplus-scraper

Conversation

@nahatav

@nahatav nahatav commented Aug 26, 2026

Copy link
Copy Markdown

Summary

Adds a MedlinePlus source scraper to datasets, following the shared scraping base from #21. MedlinePlus is listed as an encyclopedic-fallback source in the AMFV project doc.

Topics are discovered from the sitemap MedlinePlus advertises in its own robots.txt, then each topic page is scraped for its summary and Dublin Core metadata (MeSH headings, alternate titles, created/modified dates, publisher).

On the bulk XML export

MedlinePlus also publishes a daily bulk XML export of every topic, which would be a single request instead of one per topic. I started there, then backed it out: it is served from /xml/, which medlineplus.gov/robots.txt disallows. The sitemap and topic pages are both allowed.

Content is not lost by taking the compliant path — I diffed page-scraped output against the bulk export for the same topics and the markdown is byte-identical (a1c, abdominalpain, abortion, asthma; delta +0 on all four).

Happy to switch back if maintainers read that Disallow differently, since NLM separately documents those files as available for download.

Licensing / redistributability

Relevant since the project doc separates redistributable from non-redistributable sources — MedlinePlus sits on the redistributable side, with one condition:

  • Health topic summaries, which is all this scraper collects, are public domain federal government work and may be freely redistributed.
  • NLM asks redistributors to carry an acknowledgement, so every scraped document has metadata["attribution"] = "Courtesy of MedlinePlus from the National Library of Medicine". That way the credit survives into any corpus built from this.
  • Content NLM licenses from third parties is excluded by construction: A.D.A.M. encyclopedia articles (/ency/) and ASHP drug monographs (/druginfo/) cannot be redistributed without licensing from those vendors, and neither matches the flat topic path this scraper accepts. There is a test pinning that.

Terms: https://medlineplus.gov/about/using/usingcontent/

Components affected

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

Notes on the implementation

  • Summary content comes from the topic-summary container NLM marks syndicate. Pages that share the topic URL shape but have no such container (indexes, tools, directories) return None and are skipped, so they never enter the corpus.
  • Only flat English topic paths (/a1c.html) are scraped; /spanish/, /ency/, /druginfo/, and /lab-tests/ are filtered out at discovery, and --url rejects them with a message naming the expected shape.
  • DOCUMENT_DELAY_SECONDS = 1.0robots.txt sets no Crawl-delay, so this is a politeness floor rather than a required interval.
  • section_count stays at the default 1, which is accurate here: a topic summary is a single section.

Testing

  • uv run ruff check / uv run ruff format --check clean
  • uv run pytest — 41 passing, run on Python 3.13 to match CI
  • Live scrapes: 40 topics (40 unique, no empty content or titles, 100% coverage on MeSH headings and creation dates), single-URL mode, --format markdown, non-topic skip behavior (healthchecktools.html → skipped), and sitemap enumeration (1080 candidate URLs)
  • Not run: the full ~1017-topic corpus end to end

Discovers topics from the sitemap MedlinePlus advertises in its robots.txt,
then scrapes each topic page for its summary and Dublin Core metadata
(MeSH headings, alternate titles, dates).

Deliberately avoids the daily bulk XML export, which would be a single
request but is served from /xml/, a path MedlinePlus robots.txt disallows.
Pages that share the topic URL shape but carry no summary container are
skipped, so indexes and tools do not enter the corpus.

Health topic summaries are public domain, and each document carries the
acknowledgement NLM asks redistributors for. Content NLM licenses from third
parties is excluded by construction: A.D.A.M. articles and ASHP drug
monographs live off the flat topic path this scraper accepts.

MedlinePlus is listed as an encyclopedic-fallback source in the AMFV
project doc.
@CLAassistant

CLAassistant commented Aug 26, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

their visible text (default: LinkMode.KEEP).
"""
response = client.get(url)
response.raise_for_status()

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.

raise_for_status() here raises httpx.HTTPStatusError, which nothing catches, so a single 503 or timeout ends a full 1,080-topic run wherever it lands. It is also not MedlineplusFetchError, whose docstring says it is raised “when a MedlinePlus topic cannot be fetched or parsed”, so a caller catching the module’s own error type misses the most likely failure there is.

Measured on a three-topic sitemap with a 503 on the second: run ends with httpx.HTTPStatusError, 1 of 3 documents written.

Two asks. Wrap both this fetch and the sitemap fetch at line 95 so MedlineplusFetchError is what escapes. Then consider logging and returning None here instead of raising: scrape_listing_documents already treats None as “skip this item”, so one unreachable page would cost one document rather than the rest of the crawl. The sitemap fetch should stay fatal.

list_topic_urls,
scrape_topic,
topic_slug_from_url,
)

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 11 tests here cover list_topic_urls, scrape_topic and topic_slug_from_url well, but none of them imports scrape_medlineplus, which is the only function SCRAPERS can reach. So the arrangement that actually terminates the scrape, first_page_items=topic_urls with list_page returning [], and the www. normalization in the --url branch, are both unexercised.

One test calling scrape_medlineplus against a mock transport serving a two-topic sitemap plus two topic pages would cover both, and asserting the sitemap is requested exactly once would pin the “already-fetched first page” behaviour.

Worth adding a CLI dispatch test too. test_scraping_aafp.py on #22 does this with monkeypatch.setitem(SCRAPERS, ...), which is the form that works now that sources are dictionary entries.

with default_client() as client:
topic_urls = list_topic_urls(client)

return ScrapeRun(

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.

Optional. total=documents is None on a full run, so the progress bar has no denominator for about eighteen minutes, even though list_topic_urls returned the complete list one line above.

total=documents if documents is not None else len(topic_urls),

It would be an upper bound, since pages with no summary are skipped, but it beats no total. Fine to leave as is if you would rather not show a count that overshoots.

(`/druginfo/`) cannot be redistributed without licensing from those vendors,
and neither matches the flat topic path this scraper accepts. See
https://medlineplus.gov/about/using/usingcontent/.
"""

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.

Optional, and not a defect. Eight topics scraped live come out at 751 to 6,135 characters, median 1,634. The NICE scraper already on main has a median of 23,817 and its clinical guidelines reach 97,167, so this source is roughly one fifteenth the size and written for patients rather than clinicians.

Worth one sentence in the module docstring saying what a full run yields and how large a document is, next to the licensing paragraph. It saves the next person measuring it, and it is the figure whoever tunes retrieval will want. My own WikiDoc branch has the same gap in the other direction.

Per @zndr27's review on MedARC-AI#23:

- list_topic_urls and scrape_topic now raise/return MedlinePlusFetchError-
  wrapped failures instead of a raw httpx error escaping. The sitemap fetch
  stays fatal (discovery can't proceed without it); a single topic page
  fetch failure is logged and skipped, since scrape_listing_documents
  already treats None as "skip this item". A 503 partway through a run
  now costs one document instead of the rest of the crawl.
- total is now an upper bound from the discovered topic count instead of
  always None, so the progress bar has a denominator on a full run.
- Documented typical document size next to the licensing paragraph.
- Added the missing coverage: scrape_medlineplus end to end (including
  that the sitemap is fetched exactly once), the --url branch's www. host
  normalization, and a CLI dispatch test for the medlineplus source.
@nahatav

nahatav commented Sep 2, 2026

Copy link
Copy Markdown
Author

@zndr27 Pushed a fix for all four. Wrapped fetch failures into MedlineplusFetchError with the sitemap staying fatal, ran your exact 503 mid-crawl scenario and now get 2 of 3 instead of 1 of 3 and a crash, added the scrape_medlineplus/www normalization/CLI dispatch tests, and the total + docstring notes. Let me know

@nahatav nahatav mentioned this pull request Sep 3, 2026
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.

3 participants