From 5a1429a624a80f00fc3d9579bcd58d54c5b66584 Mon Sep 17 00:00:00 2001 From: Ashu Date: Wed, 1 Jul 2026 12:16:35 -0700 Subject: [PATCH 01/15] add who guideline scraper --- .../amfv_datasets/scraping/LICENSE_NOTES.md | 30 ++ datasets/amfv_datasets/scraping/__init__.py | 18 + datasets/amfv_datasets/scraping/cli.py | 6 +- datasets/amfv_datasets/scraping/who.py | 379 ++++++++++++++++++ datasets/test/fixtures/who_listing_api.json | 20 + .../fixtures/who_publication_overview.html | 17 + datasets/test/test_scraping_who.py | 155 +++++++ 7 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 datasets/amfv_datasets/scraping/LICENSE_NOTES.md create mode 100644 datasets/amfv_datasets/scraping/who.py create mode 100644 datasets/test/fixtures/who_listing_api.json create mode 100644 datasets/test/fixtures/who_publication_overview.html create mode 100644 datasets/test/test_scraping_who.py diff --git a/datasets/amfv_datasets/scraping/LICENSE_NOTES.md b/datasets/amfv_datasets/scraping/LICENSE_NOTES.md new file mode 100644 index 00000000..a98b822b --- /dev/null +++ b/datasets/amfv_datasets/scraping/LICENSE_NOTES.md @@ -0,0 +1,30 @@ +# Source licensing notes for scraped corpora + +## WHO (World Health Organization) + +Publications on [who.int](https://www.who.int/publications) published since +November 2016 are licensed under **Creative Commons Attribution-NonCommercial- +ShareAlike 3.0 IGO** (CC BY-NC-SA 3.0 IGO). + +- **Non-commercial use and adaptation** are permitted. +- **Attribution** to WHO is required. +- **Share-alike**: derivatives must use the same or a similar licence. + +Pre-2017 publications were not reissued under this licence. Use +`metadata.publication_date` to filter when building corpora. + +### Suggested attribution + +> © World Health Organization {year}. *{publication title}*. +> Licensed under CC BY-NC-SA 3.0 IGO. +> https://creativecommons.org/licenses/by-nc-sa/3.0/igo/ + +Each scraped document also records `metadata.license` and +`metadata.attribution`. + +### Milestone 1 content scope + +The WHO scraper collects the HTML **Overview** section from each publication +landing page (`metadata.content_scope = "overview"`). Full guideline text is +typically available only as a linked PDF (`metadata.download_url`). PDF +extraction is intentionally deferred. diff --git a/datasets/amfv_datasets/scraping/__init__.py b/datasets/amfv_datasets/scraping/__init__.py index cd7cdb06..110f6093 100644 --- a/datasets/amfv_datasets/scraping/__init__.py +++ b/datasets/amfv_datasets/scraping/__init__.py @@ -27,6 +27,16 @@ scrape_guideline, scrape_nice, ) +from amfv_datasets.scraping.who import ( + WhoFetchError, + WhoListingPage, + WhoPublicationRef, + build_publication_text, + list_publications, + publication_ref_from_url, + scrape_publication, + scrape_who, +) __all__ = [ "GuidanceRef", @@ -39,8 +49,12 @@ "ScrapedDocument", "ScraperSource", "USER_AGENT", + "WhoFetchError", + "WhoListingPage", + "WhoPublicationRef", "absolute_unique_urls", "build_guideline_text", + "build_publication_text", "clean_text", "document_title", "default_client", @@ -48,7 +62,11 @@ "guidance_ref_from_url", "html_to_markdown", "list_published_guidance", + "list_publications", + "publication_ref_from_url", "scrape_guideline", "scrape_listing_documents", "scrape_nice", + "scrape_publication", + "scrape_who", ] diff --git a/datasets/amfv_datasets/scraping/cli.py b/datasets/amfv_datasets/scraping/cli.py index e8aece34..59e69bd7 100644 --- a/datasets/amfv_datasets/scraping/cli.py +++ b/datasets/amfv_datasets/scraping/cli.py @@ -27,6 +27,7 @@ from amfv_datasets.scraping.base import ScrapedDocument, ScrapeRun from amfv_datasets.scraping.html import LinkMode from amfv_datasets.scraping.nice import scrape_nice +from amfv_datasets.scraping.who import scrape_who class ScraperSource(StrEnum): @@ -34,6 +35,7 @@ class ScraperSource(StrEnum): ALL = "all" NICE = "nice" + WHO = "who" class OutputFormat(StrEnum): @@ -72,6 +74,8 @@ def scrape_documents( match selected_source: case ScraperSource.NICE: return scrape_nice(documents=documents, link_mode=link_mode, url=url) + case ScraperSource.WHO: + return scrape_who(documents=documents, link_mode=link_mode, url=url) case ScraperSource.ALL: raise AssertionError("expanded source cannot be all") raise AssertionError(f"unsupported source: {source}") @@ -125,7 +129,7 @@ def write_markdown_files(documents: Iterable[ScrapedDocument], output_path: Path def _expand_source(source: ScraperSource) -> tuple[ScraperSource, ...]: if source is ScraperSource.ALL: - return (ScraperSource.NICE,) + return (ScraperSource.NICE, ScraperSource.WHO) return (source,) diff --git a/datasets/amfv_datasets/scraping/who.py b/datasets/amfv_datasets/scraping/who.py new file mode 100644 index 00000000..a2412511 --- /dev/null +++ b/datasets/amfv_datasets/scraping/who.py @@ -0,0 +1,379 @@ +"""Scrape WHO guideline publications into normalized markdown documents. + +WHO (World Health Organization) publishes guidelines primarily as PDFs hosted on +iris.who.int. The publication landing pages expose a short HTML Overview section +plus bibliographic metadata. For Milestone 1 we scrape that Overview text rather +than PDFs, because PDF extraction loses reading order and interleaves +headers/footers. Full-text PDF extraction is intentionally deferred. + +Discovery uses WHO's Sitefinity OData publications hub API, filtered to the +Guidelines publishing office. Extraction fetches each publication HTML page and +converts the Overview block to markdown. + +Attribution: +The publishing-office filter UUID and PDF-first approach in Meditron's WHO +scraper (epfLLM/meditron, gap-replay/guidelines/scrapers/scrapers.py) informed +discovery scope. Source license: Apache License 2.0. +""" + +from __future__ import annotations + +import json +import logging +import re +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +import httpx +from lxml import html as lxml_html + +from amfv_datasets.scraping.base import ( + ScrapedDocument, + ScrapeError, + ScrapeRun, + default_client, + scrape_listing_documents, +) +from amfv_datasets.scraping.html import LinkMode, clean_text, document_title, html_to_markdown + +BASE_URL = "https://www.who.int" +GUIDELINES_LISTING_URL = f"{BASE_URL}/publications/who-guidelines" +GUIDELINES_PUBLISHING_OFFICE = "c09761c0-ab8e-4cfa-9744-99509c4d306b" +SF_SITE = "15210d59-ad60-47ff-a542-7ed76645f0c7" +PUBLICATIONS_API_PATH = "/api/hubs/publications" +LISTING_PAGE_SIZE = 25 +DOCUMENT_DELAY_SECONDS = 5.0 +WHO_LICENSE = "CC BY-NC-SA 3.0 IGO" +WHO_ATTRIBUTION = "© World Health Organization. Licensed under CC BY-NC-SA 3.0 IGO." +CONTENT_SCOPE = "overview" + +logger = logging.getLogger(__name__) + +_PUBLICATION_PATH_RE = re.compile( + r"^/publications/i/item/(?P[^/?#]+)(?:/|$)", + re.IGNORECASE, +) +_ISBN_RE = re.compile(r"ISBN:\s*([\d\-]+)", re.IGNORECASE) + +class WhoFetchError(ScrapeError): + """Raised when a WHO publication cannot be fetched or parsed.""" + + +@dataclass(frozen=True) +class WhoPublicationRef: + """A WHO guideline publication reference from the listing.""" + + publication_id: str + title: str + page_url: str + publication_date: str | None = None + tag: str | None = None + download_url: str | None = None + + +@dataclass(frozen=True) +class WhoListingPage: + """A page of WHO guideline publication references.""" + + refs: list[WhoPublicationRef] + total: int | None + + +def publication_ref_from_url(url: str) -> WhoPublicationRef: + """Parse a WHO publication URL into a canonical publication reference. + + Args: + url: WHO publication URL to parse. + """ + parsed = urlparse(url.strip()) + if parsed.scheme not in {"http", "https"} or parsed.netloc.lower() not in { + "www.who.int", + "who.int", + }: + raise WhoFetchError(f"Enter a WHO publication URL from who.int; got {url!r}") + + match = _PUBLICATION_PATH_RE.match(parsed.path.rstrip("/") + "/") + if not match: + raise WhoFetchError( + f"Enter a URL like https://www.who.int/publications/i/item/9789240121805; got {url!r}" + ) + + publication_id = match.group("publication_id") + return WhoPublicationRef( + publication_id=publication_id, + title=publication_id, + page_url=_page_url(publication_id=publication_id), + ) + + +def _page_url(*, publication_id: str) -> str: + return f"{BASE_URL}/publications/i/item/{publication_id}" + + +def _publications_api_params(*, page: int) -> dict[str, str]: + skip = (page - 1) * LISTING_PAGE_SIZE + return { + "sf_site": SF_SITE, + "sf_provider": "OpenAccessProvider", + "sf_culture": "en", + "$orderby": "PublicationDateAndTime desc", + "$select": "Title,ItemDefaultUrl,FormatedDate,Tag,DownloadUrl", + "$filter": f"publishingoffices/any(s:s eq {GUIDELINES_PUBLISHING_OFFICE})", + "$top": str(LISTING_PAGE_SIZE), + "$skip": str(skip), + "$count": "true", + } + + +def _publication_id_from_item_url(item_default_url: str) -> str: + return item_default_url.strip("/").split("/")[-1] + + +def _parse_api_listing(payload: dict[str, Any]) -> WhoListingPage: + """Parse a WHO publications OData response into listing refs.""" + try: + items = payload["value"] + except KeyError as exc: + raise WhoFetchError("Could not parse WHO publications API JSON") from exc + + refs: list[WhoPublicationRef] = [] + for item in items: + item_default_url = (item.get("ItemDefaultUrl") or "").strip() + title = (item.get("Title") or "").strip() + if not item_default_url or not title: + continue + publication_id = _publication_id_from_item_url(item_default_url) + refs.append( + WhoPublicationRef( + publication_id=publication_id, + title=title, + page_url=_page_url(publication_id=publication_id), + publication_date=(item.get("FormatedDate") or None), + tag=(item.get("Tag") or None), + download_url=(item.get("DownloadUrl") or None), + ) + ) + + total = payload.get("@odata.count") + return WhoListingPage(refs=refs, total=total if isinstance(total, int) else None) + + +def _parse_html_listing(html_text: str) -> WhoListingPage: + """Parse server-rendered WHO guideline cards from the listing page.""" + doc = lxml_html.fromstring(html_text) + refs: list[WhoPublicationRef] = [] + seen: set[str] = set() + for link in doc.xpath("//a[contains(@href,'/publications/i/item/')]"): + href = link.get("href", "").split("?")[0] + match = _PUBLICATION_PATH_RE.match(href.rstrip("/") + "/") + if not match or href in seen: + continue + seen.add(href) + publication_id = match.group("publication_id") + title = clean_text(link.xpath("string(.)")) or publication_id + refs.append( + WhoPublicationRef( + publication_id=publication_id, + title=title, + page_url=_page_url(publication_id=publication_id), + ) + ) + return WhoListingPage(refs=refs, total=None) + + +def list_publications(client: httpx.Client, page: int = 1) -> WhoListingPage: + """Return one page of WHO guideline publication refs. + + Args: + client: HTTP client used to fetch the publications API. + page: Listing page number (default: 1). + """ + response = client.get(f"{BASE_URL}{PUBLICATIONS_API_PATH}", params=_publications_api_params(page=page)) + if response.status_code >= 400: + logger.warning("WHO publications API returned %s; falling back to HTML listing", response.status_code) + listing = client.get(GUIDELINES_LISTING_URL) + listing.raise_for_status() + return _parse_html_listing(listing.text) + + try: + payload = response.json() + except json.JSONDecodeError as exc: + raise WhoFetchError("Could not decode WHO publications API JSON") from exc + return _parse_api_listing(payload) + + +def _overview_html(section: lxml_html.HtmlElement) -> str: + """Return HTML for the Overview block within a publication section.""" + overview_headings = section.xpath(".//h3[normalize-space()='Overview']") + if not overview_headings: + return lxml_html.tostring(section, encoding="unicode") + + heading = overview_headings[0] + parts = [lxml_html.tostring(heading, encoding="unicode")] + sibling = heading.getnext() + while sibling is not None and sibling.tag.lower() not in {"h2", "h3"}: + parts.append(lxml_html.tostring(sibling, encoding="unicode")) + sibling = sibling.getnext() + return "".join(parts) + + +def _page_isbn(html_text: str) -> str | None: + doc = lxml_html.fromstring(html_text) + for node in doc.xpath("//*[contains(normalize-space(.), 'ISBN')]"): + match = _ISBN_RE.search(node.text_content()) + if match: + return match.group(1) + return None + + +def _page_publication_date(doc: lxml_html.HtmlElement) -> str | None: + values = doc.xpath("//*[contains(@class,'dynamic-content__date')]/text()") + return clean_text(values[0]) if values else None + + +def _page_tag(doc: lxml_html.HtmlElement) -> str | None: + values = doc.xpath("//*[contains(@class,'dynamic-content__tag')]/text()") + text = clean_text(" ".join(values)) + return text.lstrip("| ").strip() if text else None + + +def _page_download_url(doc: lxml_html.HtmlElement) -> str | None: + for href in doc.xpath("//a[contains(@href,'iris.who.int')]/@href"): + if "bitstreams" in href: + return href.split("?")[0] + return None + + +def build_publication_text( + client: httpx.Client, + ref: WhoPublicationRef, + *, + link_mode: LinkMode = LinkMode.KEEP, +) -> tuple[str, int, str, dict[str, Any]]: + """Scrape a publication Overview into markdown text and bibliographic metadata. + + Args: + client: HTTP client used to fetch the publication page. + ref: WHO publication reference to scrape. + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + """ + response = client.get(ref.page_url) + response.raise_for_status() + html_text = response.text + doc = lxml_html.fromstring(html_text) + + sections = doc.xpath("//section[contains(@class,'dynamic-content__section')]") + if not sections: + raise WhoFetchError(f"No publication content section for '{ref.publication_id}'") + + title = ( + ref.title + if ref.title != ref.publication_id + else document_title(html_text, fallback=ref.publication_id) + ) + overview_html = _overview_html(sections[0]) + markdown = html_to_markdown(overview_html, link_mode=link_mode, base_url=BASE_URL).strip() + if not markdown: + raise WhoFetchError(f"No readable Overview content for '{ref.publication_id}'") + + extra_metadata: dict[str, Any] = { + "publication_date": ref.publication_date or _page_publication_date(doc), + "tag": ref.tag or _page_tag(doc), + "isbn": _page_isbn(html_text), + "download_url": ref.download_url or _page_download_url(doc), + "content_scope": CONTENT_SCOPE, + "license": WHO_LICENSE, + "attribution": WHO_ATTRIBUTION, + "listing_category": "who-guidelines", + } + return markdown, 1, title, extra_metadata + + +def scrape_publication( + client: httpx.Client, + ref: WhoPublicationRef, + *, + link_mode: LinkMode = LinkMode.KEEP, +) -> ScrapedDocument: + """Scrape a WHO publication into a normalized document. + + Args: + client: HTTP client used to fetch the publication page. + ref: WHO publication reference to scrape. + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + """ + content, section_count, title, metadata = build_publication_text(client, ref, link_mode=link_mode) + return ScrapedDocument( + source="who", + external_id=f"who-{ref.publication_id}", + title=title, + url=ref.page_url, + content=content, + section_count=section_count, + metadata={ + "publication_id": ref.publication_id, + **metadata, + }, + ) + + +def scrape_who( + *, + documents: int | None, + link_mode: LinkMode = LinkMode.KEEP, + url: str | None = None, +) -> ScrapeRun: + """Scrape WHO documents from a URL or guideline listing pages. + + Args: + documents: Number of documents to scrape. Ignored when `url` is set. + When unset, WHO listing pages are fetched until a page returns no + items (default: None). + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + url: WHO publication URL to scrape as a single document (default: None). + """ + if url is not None: + + def scrape_url() -> Iterable[ScrapedDocument]: + with default_client() as client: + yield scrape_publication(client, publication_ref_from_url(url), link_mode=link_mode) + + return ScrapeRun(documents=scrape_url(), total=1) + + with default_client() as client: + first_page = list_publications(client, page=1) + total = first_page.total if documents is None or first_page.total is None else min(documents, first_page.total) + return ScrapeRun( + total=total, + documents=scrape_listing_documents( + documents=documents, + client_factory=default_client, + first_page_items=first_page.refs, + list_page=lambda client, page: list_publications(client, page).refs, + scrape_item=lambda client, ref: scrape_publication(client, ref, link_mode=link_mode), + document_delay_seconds=DOCUMENT_DELAY_SECONDS, + ), + ) + + +__all__ = [ + "BASE_URL", + "CONTENT_SCOPE", + "DOCUMENT_DELAY_SECONDS", + "GUIDELINES_LISTING_URL", + "WHO_ATTRIBUTION", + "WHO_LICENSE", + "WhoFetchError", + "WhoListingPage", + "WhoPublicationRef", + "build_publication_text", + "list_publications", + "publication_ref_from_url", + "scrape_publication", + "scrape_who", +] diff --git a/datasets/test/fixtures/who_listing_api.json b/datasets/test/fixtures/who_listing_api.json new file mode 100644 index 00000000..a4392d3b --- /dev/null +++ b/datasets/test/fixtures/who_listing_api.json @@ -0,0 +1,20 @@ +{ + "@odata.context": "https://www.who.int/api/hubs/$metadata#publications(Title,ItemDefaultUrl,FormatedDate,Tag,DownloadUrl)", + "@odata.count": 356, + "value": [ + { + "ItemDefaultUrl": "/9789240121805", + "Title": "Guidelines for the prevention of bloodstream infections and other infections associated with the use of intravascular catheters: part 2: central venous catheters", + "FormatedDate": "28 May 2026", + "Tag": "Guideline", + "DownloadUrl": "https://iris.who.int/server/api/core/bitstreams/f750f24d-c0c2-425c-85fd-ec310d2ce994/content" + }, + { + "ItemDefaultUrl": "/9789240121744", + "Title": "WHO guideline for screening and treatment of cervical pre-cancer lesions for cervical cancer prevention", + "FormatedDate": "8 May 2026", + "Tag": "Guideline", + "DownloadUrl": "https://iris.who.int/server/api/core/bitstreams/32214b73-0e95-4243-9e83-617948510dcd/content" + } + ] +} diff --git a/datasets/test/fixtures/who_publication_overview.html b/datasets/test/fixtures/who_publication_overview.html new file mode 100644 index 00000000..b6f16f29 --- /dev/null +++ b/datasets/test/fixtures/who_publication_overview.html @@ -0,0 +1,17 @@ + +
+

Guidelines for the prevention of bloodstream infections and other infections associated with the use of intravascular catheters: part 2: central venous catheters

+
+ + +
+
+ Download (1.2 MB) +

Overview

+

These WHO guidelines provide evidence-based recommendations for the prevention of bloodstream infections (BSIs) and other infections associated with the use of central venous catheters (CVCs) across health care settings.

+

WHO Team

+

Infection Prevention and Control (IPC)

+

ISBN: 978-92-4-012180-5

+
+
+ diff --git a/datasets/test/test_scraping_who.py b/datasets/test/test_scraping_who.py new file mode 100644 index 00000000..d3db37b3 --- /dev/null +++ b/datasets/test/test_scraping_who.py @@ -0,0 +1,155 @@ +"""Tests for WHO scraping helpers.""" + +import json +from pathlib import Path + +import httpx +import pytest + +from amfv_datasets.scraping.html import LinkMode +from amfv_datasets.scraping.who import ( + BASE_URL, + WHO_LICENSE, + WhoFetchError, + WhoListingPage, + WhoPublicationRef, + build_publication_text, + list_publications, + publication_ref_from_url, + scrape_publication, +) + +_FIXTURES = Path(__file__).parent / "fixtures" +_CVC_GUIDELINE_TITLE = ( + "Guidelines for the prevention of bloodstream infections and other infections " + "associated with the use of intravascular catheters: part 2: central venous catheters" +) +_CERVICAL_GUIDELINE_TITLE = ( + "WHO guideline for screening and treatment of cervical pre-cancer lesions " + "for cervical cancer prevention" +) + + +def test_list_publications_parses_api_listing() -> None: + """WHO listing payloads are parsed from the publications OData API.""" + payload = json.loads((_FIXTURES / "who_listing_api.json").read_text(encoding="utf-8")) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/hubs/publications" + assert request.url.params["$filter"] == "publishingoffices/any(s:s eq c09761c0-ab8e-4cfa-9744-99509c4d306b)" + assert request.url.params["$skip"] == "0" + return httpx.Response(200, json=payload) + + client = httpx.Client(transport=httpx.MockTransport(handler), base_url=BASE_URL) + + listing_page = list_publications(client) + + assert listing_page == WhoListingPage( + total=356, + refs=[ + WhoPublicationRef( + publication_id="9789240121805", + title=_CVC_GUIDELINE_TITLE, + page_url="https://www.who.int/publications/i/item/9789240121805", + publication_date="28 May 2026", + tag="Guideline", + download_url="https://iris.who.int/server/api/core/bitstreams/f750f24d-c0c2-425c-85fd-ec310d2ce994/content", + ), + WhoPublicationRef( + publication_id="9789240121744", + title=_CERVICAL_GUIDELINE_TITLE, + page_url="https://www.who.int/publications/i/item/9789240121744", + publication_date="8 May 2026", + tag="Guideline", + download_url="https://iris.who.int/server/api/core/bitstreams/32214b73-0e95-4243-9e83-617948510dcd/content", + ), + ], + ) + + +def test_publication_ref_from_url_normalizes_publication_url() -> None: + """WHO publication URLs are normalized to canonical refs.""" + assert publication_ref_from_url("https://www.who.int/publications/i/item/9789240121805") == WhoPublicationRef( + publication_id="9789240121805", + title="9789240121805", + page_url="https://www.who.int/publications/i/item/9789240121805", + ) + + +def test_build_publication_text_scrapes_overview() -> None: + """Publication pages are converted into Overview markdown.""" + html = (_FIXTURES / "who_publication_overview.html").read_text(encoding="utf-8") + page_url = "https://www.who.int/publications/i/item/9789240121805" + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == page_url + return httpx.Response(200, text=html) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + ref = WhoPublicationRef( + publication_id="9789240121805", + title=_CVC_GUIDELINE_TITLE, + page_url=page_url, + publication_date="28 May 2026", + tag="Guideline", + download_url="https://iris.who.int/server/api/core/bitstreams/f750f24d-c0c2-425c-85fd-ec310d2ce994/content", + ) + + content, section_count, title, metadata = build_publication_text(client, ref) + + assert title.startswith("Guidelines for the prevention of bloodstream infections") + assert section_count == 1 + assert "### Overview" in content + assert "central venous catheters (CVCs)" in content + assert "WHO Team" not in content + assert metadata["publication_date"] == "28 May 2026" + assert metadata["tag"] == "Guideline" + assert metadata["isbn"] == "978-92-4-012180-5" + assert metadata["content_scope"] == "overview" + assert metadata["license"] == WHO_LICENSE + assert metadata["listing_category"] == "who-guidelines" + + +def test_scrape_publication_builds_scraped_document() -> None: + """A WHO publication ref is normalized into a ScrapedDocument.""" + html = (_FIXTURES / "who_publication_overview.html").read_text(encoding="utf-8") + page_url = "https://www.who.int/publications/i/item/9789240121805" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=html) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + ref = WhoPublicationRef( + publication_id="9789240121805", + title=_CVC_GUIDELINE_TITLE, + page_url=page_url, + ) + + document = scrape_publication(client, ref, link_mode=LinkMode.STRIP) + + assert document.source == "who" + assert document.external_id == "who-9789240121805" + assert document.url == page_url + assert document.section_count == 1 + assert document.content.strip() + assert document.metadata["publication_id"] == "9789240121805" + assert document.metadata["content_scope"] == "overview" + + +def test_build_publication_text_raises_when_overview_missing() -> None: + """Missing publication markup raises WhoFetchError.""" + html = "

No publication section here.

" + page_url = "https://www.who.int/publications/i/item/9789240121805" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=html) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + ref = WhoPublicationRef( + publication_id="9789240121805", + title="Example guideline", + page_url=page_url, + ) + + with pytest.raises(WhoFetchError, match="No publication content section"): + build_publication_text(client, ref) From b6964d0391a702f2908fba06384a54dae031f23a Mon Sep 17 00:00:00 2001 From: Muhamed Kouate Date: Wed, 8 Jul 2026 18:36:00 +0200 Subject: [PATCH 02/15] add IDSA scraper --- datasets/amfv_datasets/scraping/__init__.py | 28 ++ datasets/amfv_datasets/scraping/cli.py | 46 ++- datasets/amfv_datasets/scraping/idsa.py | 399 ++++++++++++++++++++ datasets/test/test_scraping_cli.py | 132 +++++++ datasets/test/test_scraping_idsa.py | 320 ++++++++++++++++ 5 files changed, 921 insertions(+), 4 deletions(-) create mode 100644 datasets/amfv_datasets/scraping/idsa.py create mode 100644 datasets/test/test_scraping_idsa.py diff --git a/datasets/amfv_datasets/scraping/__init__.py b/datasets/amfv_datasets/scraping/__init__.py index cd7cdb06..d8a0aa01 100644 --- a/datasets/amfv_datasets/scraping/__init__.py +++ b/datasets/amfv_datasets/scraping/__init__.py @@ -17,6 +17,23 @@ first_matching_urls, html_to_markdown, ) +from amfv_datasets.scraping.idsa import ( + IDSA_DATASET_DISPLAY_NAME, + IDSA_DATASET_NAME, + LISTING_URL, + IDSAFetchError, + IDSAGuidelineListingPage, + IDSAGuidelineRef, + idsa_ref_from_url, + list_practice_guidelines, + scrape_idsa, +) +from amfv_datasets.scraping.idsa import ( + build_guideline_text as build_idsa_guideline_text, +) +from amfv_datasets.scraping.idsa import ( + scrape_guideline as scrape_idsa_guideline, +) from amfv_datasets.scraping.nice import ( GuidanceListingPage, GuidanceRef, @@ -31,7 +48,13 @@ __all__ = [ "GuidanceRef", "GuidanceListingPage", + "IDSAFetchError", + "IDSA_DATASET_DISPLAY_NAME", + "IDSA_DATASET_NAME", + "IDSAGuidelineListingPage", + "IDSAGuidelineRef", "LinkMode", + "LISTING_URL", "NiceFetchError", "OutputFormat", "ScrapeError", @@ -40,6 +63,7 @@ "ScraperSource", "USER_AGENT", "absolute_unique_urls", + "build_idsa_guideline_text", "build_guideline_text", "clean_text", "document_title", @@ -47,8 +71,12 @@ "first_matching_urls", "guidance_ref_from_url", "html_to_markdown", + "idsa_ref_from_url", + "list_practice_guidelines", "list_published_guidance", "scrape_guideline", + "scrape_idsa", + "scrape_idsa_guideline", "scrape_listing_documents", "scrape_nice", ] diff --git a/datasets/amfv_datasets/scraping/cli.py b/datasets/amfv_datasets/scraping/cli.py index e8aece34..73170450 100644 --- a/datasets/amfv_datasets/scraping/cli.py +++ b/datasets/amfv_datasets/scraping/cli.py @@ -8,6 +8,7 @@ from collections.abc import Iterable from dataclasses import asdict from enum import StrEnum +from itertools import chain from pathlib import Path from typing import Annotated, TextIO @@ -26,6 +27,7 @@ from amfv_datasets.scraping.base import ScrapedDocument, ScrapeRun from amfv_datasets.scraping.html import LinkMode +from amfv_datasets.scraping.idsa import scrape_idsa from amfv_datasets.scraping.nice import scrape_nice @@ -33,6 +35,7 @@ class ScraperSource(StrEnum): """Supported scraper sources.""" ALL = "all" + IDSA = "idsa" NICE = "nice" @@ -53,6 +56,8 @@ def scrape_documents( documents: int | None, link_mode: LinkMode, url: str | None = None, + include_archived: bool = False, + include_in_development: bool = False, ) -> ScrapeRun: """Configure a scrape for a source. @@ -64,17 +69,37 @@ def scrape_documents( link_mode: Whether links are kept as markdown links or stripped to their visible text. url: Source URL to scrape as a single document (default: None). + include_archived: Whether IDSA archived guidelines are included + (default: False). + include_in_development: Whether IDSA in-development guidelines are + included (default: False). """ if documents is not None and documents < 1: raise ValueError(f"documents must be at least 1; got {documents}") + scrape_runs: list[ScrapeRun] = [] for selected_source in _expand_source(source): match selected_source: + case ScraperSource.IDSA: + scrape_runs.append( + scrape_idsa( + documents=documents, + link_mode=link_mode, + url=url, + include_archived=include_archived, + include_in_development=include_in_development, + ) + ) case ScraperSource.NICE: - return scrape_nice(documents=documents, link_mode=link_mode, url=url) + scrape_runs.append(scrape_nice(documents=documents, link_mode=link_mode, url=url)) case ScraperSource.ALL: raise AssertionError("expanded source cannot be all") - raise AssertionError(f"unsupported source: {source}") + if not scrape_runs: + raise AssertionError(f"unsupported source: {source}") + if len(scrape_runs) == 1: + return scrape_runs[0] + total = None if any(run.total is None for run in scrape_runs) else sum(run.total or 0 for run in scrape_runs) + return ScrapeRun(documents=chain.from_iterable(run.documents for run in scrape_runs), total=total) def write_jsonl(documents: Iterable[ScrapedDocument], output: TextIO) -> int: @@ -125,7 +150,7 @@ def write_markdown_files(documents: Iterable[ScrapedDocument], output_path: Path def _expand_source(source: ScraperSource) -> tuple[ScraperSource, ...]: if source is ScraperSource.ALL: - return (ScraperSource.NICE,) + return (ScraperSource.NICE, ScraperSource.IDSA) return (source,) @@ -135,6 +160,8 @@ def run( url: Annotated[str | None, typer.Option("--url", help="Source URL to scrape as a single document.")] = None, documents: Annotated[str, typer.Option("--documents", help="Number of documents to scrape, or 'all'.")] = "1", link_mode: Annotated[LinkMode, typer.Option("--links", help="Whether to keep markdown links or strip links to text.")] = LinkMode.KEEP, # noqa: E501 + include_archived: Annotated[bool, typer.Option("--include-archived/--exclude-archived", help="Include archived guidelines for sources that expose archival status.")] = False, # noqa: E501 + include_in_development: Annotated[bool, typer.Option("--include-in-development/--exclude-in-development", help="Include in-development guidelines for sources that expose development status.")] = False, # noqa: E501 output_format: Annotated[OutputFormat, typer.Option("--format", "-f", help="Output format.")] = OutputFormat.JSONL, output_path: Annotated[Path | None, typer.Option("--output", "-o", help="Output JSONL file, markdown directory, or Hugging Face dataset directory. JSONL defaults to stdout.")] = None, # noqa: E501 progress: Annotated[bool, typer.Option("--progress/--no-progress", help="Show a Rich progress bar.")] = True, @@ -147,6 +174,10 @@ def run( documents: Number of documents to scrape, or "all" (default: "1"). link_mode: Whether links are kept as markdown links or stripped to their visible text (default: LinkMode.KEEP). + include_archived: Whether archived guidelines are included for sources + that expose archival status (default: False). + include_in_development: Whether in-development guidelines are included + for sources that expose development status (default: False). output_format: Output format to write (default: OutputFormat.JSONL). output_path: Output JSONL file, markdown directory, or Hugging Face dataset directory. When unset, JSONL is written to stdout (default: @@ -154,7 +185,14 @@ def run( progress: Whether to show a Rich progress bar (default: True). """ parsed_documents = _parse_documents(documents) - scrape_run = scrape_documents(source, documents=parsed_documents, link_mode=link_mode, url=url) + scrape_run = scrape_documents( + source, + documents=parsed_documents, + link_mode=link_mode, + url=url, + include_archived=include_archived, + include_in_development=include_in_development, + ) scraped_documents = scrape_run.documents if progress: scraped_documents = _progress_documents(scraped_documents, total=scrape_run.total) diff --git a/datasets/amfv_datasets/scraping/idsa.py b/datasets/amfv_datasets/scraping/idsa.py new file mode 100644 index 00000000..9941b3f1 --- /dev/null +++ b/datasets/amfv_datasets/scraping/idsa.py @@ -0,0 +1,399 @@ +"""Scrape IDSA practice guidelines into normalized markdown documents.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import dataclass +from urllib.parse import urljoin, urlparse + +import httpx +from lxml import html as lxml_html + +from amfv_datasets.scraping.base import ( + ScrapedDocument, + ScrapeError, + ScrapeRun, + default_client, + scrape_listing_documents, +) +from amfv_datasets.scraping.html import LinkMode, absolute_unique_urls, clean_text, document_title, html_to_markdown + +BASE_URL = "https://www.idsociety.org" +LISTING_URL = f"{BASE_URL}/practice-guideline/all-practice-guidelines" +IDSA_DATASET_NAME = "idsa-webscrape" +IDSA_DATASET_DISPLAY_NAME = "IDSA Webscrape" +DOCUMENT_DELAY_SECONDS = 5.0 +SHORT_CONTENT_CHARS = 10_000 + +_IDSA_PATH_RE = re.compile(r"^/practice-guideline/(?P[^/]+)(?:/|$)", re.IGNORECASE) +_BACK_TO_TOP_RE = re.compile(r"^\s*back to top\s*$", re.IGNORECASE) +_TABLE_OF_CONTENTS_RE = re.compile(r"^\s*table\s+of\s+contents\s*$", re.IGNORECASE) +_PDF_TEXT_RE = re.compile(r"\b(download\s+)?pdf\b", re.IGNORECASE) +_WHITESPACE_RE = re.compile(r"\s+") + + +class IDSAFetchError(ScrapeError): + """Raised when a practice guideline cannot be sourced from IDSA.""" + + +@dataclass(frozen=True) +class IDSAGuidelineRef: + """An IDSA practice guideline reference from the A-Z listing.""" + + title: str + slug: str + page_url: str + year: int | None + statuses: tuple[str, ...] + + +@dataclass(frozen=True) +class IDSAGuidelineListingPage: + """An IDSA practice guideline listing page.""" + + refs: list[IDSAGuidelineRef] + total: int | None + + +def idsa_ref_from_url(url: str) -> IDSAGuidelineRef: + """Parse an IDSA practice guideline URL into a canonical guideline reference. + + Args: + url: IDSA practice guideline URL to parse. + """ + parsed = urlparse(url.strip()) + if parsed.scheme not in {"http", "https"} or parsed.netloc.lower() not in { + "www.idsociety.org", + "idsociety.org", + }: + raise IDSAFetchError(f"Enter an IDSA practice guideline URL from idsociety.org; got {url!r}") + + match = _IDSA_PATH_RE.match(parsed.path.rstrip("/") + "/") + if not match: + raise IDSAFetchError(f"Enter a URL like https://www.idsociety.org/practice-guideline/example/; got {url!r}") + + slug = match.group("slug").lower() + return IDSAGuidelineRef( + title=slug.replace("-", " ").title(), + slug=slug, + page_url=_page_url(slug), + year=None, + statuses=(), + ) + + +def list_practice_guidelines( + client: httpx.Client, + *, + include_archived: bool = False, + include_in_development: bool = False, +) -> IDSAGuidelineListingPage: + """Return IDSA practice guideline refs from the A-Z listing. + + Args: + client: HTTP client used to fetch the listing page. + include_archived: Whether archived guidelines are included (default: False). + include_in_development: Whether in-development guidelines are included (default: False). + """ + response = client.get(LISTING_URL) + response.raise_for_status() + refs = _parse_listing( + response.text, + include_archived=include_archived, + include_in_development=include_in_development, + ) + return IDSAGuidelineListingPage(refs=refs, total=len(refs)) + + +def build_guideline_text( + client: httpx.Client, + ref: IDSAGuidelineRef, + *, + link_mode: LinkMode = LinkMode.KEEP, +) -> tuple[str, int, str, dict[str, list[str]]]: + """Scrape a guideline page into markdown text, section count, title, and links. + + Args: + client: HTTP client used to fetch the guideline page. + ref: IDSA guideline reference to scrape. + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + """ + response = client.get(ref.page_url) + response.raise_for_status() + title = document_title(response.text, fallback=ref.title) + content_html = _guideline_content_html(response.text) + content = html_to_markdown(content_html, link_mode=link_mode, base_url=BASE_URL) + if not content: + raise IDSAFetchError(f"No readable content for IDSA guideline '{ref.slug}'") + return content, _section_count(content_html), title, _links_metadata(content_html) + + +def scrape_guideline( + client: httpx.Client, + ref: IDSAGuidelineRef, + *, + link_mode: LinkMode = LinkMode.KEEP, +) -> ScrapedDocument: + """Scrape an IDSA practice guideline into a normalized document. + + Args: + client: HTTP client used to fetch the guideline page. + ref: IDSA guideline reference to scrape. + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + """ + content, section_count, title, links_metadata = build_guideline_text(client, ref, link_mode=link_mode) + return ScrapedDocument( + source="idsa", + external_id=f"idsa-{ref.slug}", + title=title, + url=ref.page_url, + content=content, + section_count=section_count, + metadata={ + "year": ref.year, + "statuses": list(ref.statuses), + "slug": ref.slug, + "listing_url": LISTING_URL, + "content_length_chars": len(content), + "quality_flags": _quality_flags(content), + **links_metadata, + }, + ) + + +def scrape_idsa( + *, + documents: int | None, + link_mode: LinkMode = LinkMode.KEEP, + url: str | None = None, + include_archived: bool = False, + include_in_development: bool = False, +) -> ScrapeRun: + """Scrape IDSA practice guidelines from a URL or the A-Z listing. + + Args: + documents: Number of documents to scrape. Ignored when `url` is set. + When unset, every included IDSA listing item is scraped (default: + None). + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + url: IDSA source URL to scrape as a single document (default: None). + include_archived: Whether archived guidelines are included (default: False). + include_in_development: Whether in-development guidelines are included + (default: False). + """ + if url is not None: + + def scrape_url() -> Iterable[ScrapedDocument]: + with default_client() as client: + yield scrape_guideline(client, idsa_ref_from_url(url), link_mode=link_mode) + + return ScrapeRun(documents=scrape_url(), total=1) + + with default_client() as client: + listing = list_practice_guidelines( + client, + include_archived=include_archived, + include_in_development=include_in_development, + ) + total = listing.total if documents is None or listing.total is None else min(documents, listing.total) + return ScrapeRun( + total=total, + documents=scrape_listing_documents( + documents=documents, + client_factory=default_client, + first_page_items=listing.refs, + list_page=lambda _client, _page: (), + scrape_item=lambda client, ref: scrape_guideline(client, ref, link_mode=link_mode), + document_delay_seconds=DOCUMENT_DELAY_SECONDS, + ), + ) + + +def _parse_listing( + html_text: str, + *, + include_archived: bool, + include_in_development: bool, +) -> list[IDSAGuidelineRef]: + doc = lxml_html.fromstring(html_text) + items = doc.xpath( + "//div[contains(concat(' ', normalize-space(@class), ' '), ' alpha-listing ')]" + "//li[.//a[contains(concat(' ', normalize-space(@class), ' '), ' list-pages__link ')]]" + ) + refs: list[IDSAGuidelineRef] = [] + for item in items: + link = item.xpath(".//a[contains(concat(' ', normalize-space(@class), ' '), ' list-pages__link ')][1]") + if not link: + continue + statuses = tuple( + clean_text(status, drop_numeric_citations=False) + for status in item.xpath( + ".//*[contains(concat(' ', normalize-space(@class), ' '), ' category-dot ')]/text()" + ) + ) + if not _included_statuses( + statuses, + include_archived=include_archived, + include_in_development=include_in_development, + ): + continue + href = link[0].get("href") + if not href: + continue + page_url = urljoin(BASE_URL, href).split("#")[0].split("?")[0] + match = _IDSA_PATH_RE.match(urlparse(page_url).path.rstrip("/") + "/") + if not match: + continue + refs.append( + IDSAGuidelineRef( + title=clean_text(link[0].text_content(), drop_numeric_citations=False), + slug=match.group("slug").lower(), + page_url=page_url, + year=_parse_year(item), + statuses=statuses, + ) + ) + return refs + + +def _included_statuses( + statuses: tuple[str, ...], + *, + include_archived: bool, + include_in_development: bool, +) -> bool: + has_current = "Current" in statuses + has_archived = "Archived" in statuses + has_in_development = "In Development" in statuses + if has_archived and not include_archived: + return False + if has_in_development and not include_in_development: + return False + return has_current or (has_archived and include_archived) or (has_in_development and include_in_development) + + +def _parse_year(item: lxml_html.HtmlElement) -> int | None: + values = item.xpath(".//*[contains(concat(' ', normalize-space(@class), ' '), ' list-pages__year ')]/text()") + if not values: + return None + value = clean_text(values[0], drop_numeric_citations=False) + return int(value) if value.isdigit() else None + + +def _guideline_content_html(html_text: str) -> str: + doc = lxml_html.fromstring(html_text) + candidates = doc.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' idsaPracticeGuidelinePage ')]") + if not candidates: + candidates = doc.xpath("//*[contains(concat(' ', normalize-space(@class), ' '), ' body-container ')]") + if not candidates: + candidates = doc.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' standardpage-col-left ')]") + if not candidates: + raise IDSAFetchError("Page markup changed (no IDSA guideline content container)") + content = candidates[-1] + _remove_noise(content) + content_html = lxml_html.tostring(content, encoding="unicode") + if not clean_text(content.text_content(), drop_numeric_citations=False): + raise IDSAFetchError("Page markup changed (empty IDSA guideline content container)") + return content_html + + +def _remove_noise(content: lxml_html.HtmlElement) -> None: + noise_xpath = ( + ".//*[self::script or self::style or self::noscript or self::svg or self::button" + " or contains(concat(' ', normalize-space(@class), ' '), ' table-of-contents ')" + " or contains(concat(' ', normalize-space(@class), ' '), ' toc ')" + " or contains(concat(' ', normalize-space(@class), ' '), ' TableOfContents ')" + " or contains(concat(' ', normalize-space(@class), ' '), ' status-section ')" + " or contains(concat(' ', normalize-space(@class), ' '), ' view-all-guidance ')" + " or contains(concat(' ', normalize-space(@class), ' '), ' share ')" + " or contains(concat(' ', normalize-space(@class), ' '), ' addthis ')" + " or contains(concat(' ', normalize-space(@class), ' '), ' social ')]" + ) + for element in content.xpath(noise_xpath): + _drop_element(element) + _remove_table_of_contents(content) + for element in content.xpath(".//*[self::a or self::p or self::div or self::span]"): + if _BACK_TO_TOP_RE.match(element.text_content()): + _drop_element(element) + + +def _remove_table_of_contents(content: lxml_html.HtmlElement) -> None: + headings = content.xpath(".//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]") + for heading in headings: + if not _TABLE_OF_CONTENTS_RE.match(heading.text_content()): + continue + for sibling in list(heading.itersiblings()): + if _heading_level(sibling) is not None: + break + _drop_element(sibling) + _drop_element(heading) + + +def _heading_level(element: lxml_html.HtmlElement) -> int | None: + tag = element.tag.lower() if isinstance(element.tag, str) else "" + if len(tag) == 2 and tag.startswith("h") and tag[1].isdigit(): + return int(tag[1]) + return None + + +def _drop_element(element: lxml_html.HtmlElement) -> None: + parent = element.getparent() + if parent is not None: + parent.remove(element) + + +def _links_metadata(content_html: str) -> dict[str, list[str]]: + doc = lxml_html.fromstring(content_html) + external_links: list[str] = [] + pdf_links: list[str] = [] + for link in doc.xpath(".//a[@href]"): + url = urljoin(BASE_URL, link.get("href")).split("#")[0].split("?")[0] + if urlparse(url).netloc.lower() in {"www.idsociety.org", "idsociety.org"}: + continue + external_links.append(url) + link_text = _WHITESPACE_RE.sub(" ", link.text_content()).strip() + if url.lower().endswith(".pdf") or _PDF_TEXT_RE.search(link_text): + pdf_links.append(url) + return { + "external_links": absolute_unique_urls(external_links, base_url=BASE_URL), + "pdf_links": absolute_unique_urls(pdf_links, base_url=BASE_URL), + } + + +def _section_count(content_html: str) -> int: + doc = lxml_html.fromstring(content_html) + headings = doc.xpath(".//*[self::h1 or self::h2 or self::h3]") + return max(1, len(headings)) + + +def _quality_flags(content: str) -> list[str]: + flags: list[str] = [] + if len(content) < SHORT_CONTENT_CHARS: + flags.append("short_content") + return flags + + +def _page_url(slug: str) -> str: + return f"{BASE_URL}/practice-guideline/{slug}/" + + +__all__ = [ + "BASE_URL", + "DOCUMENT_DELAY_SECONDS", + "IDSA_DATASET_DISPLAY_NAME", + "IDSA_DATASET_NAME", + "IDSAFetchError", + "IDSAGuidelineListingPage", + "IDSAGuidelineRef", + "LISTING_URL", + "SHORT_CONTENT_CHARS", + "build_guideline_text", + "idsa_ref_from_url", + "list_practice_guidelines", + "scrape_guideline", + "scrape_idsa", +] diff --git a/datasets/test/test_scraping_cli.py b/datasets/test/test_scraping_cli.py index 44f714fa..bc4eccd2 100644 --- a/datasets/test/test_scraping_cli.py +++ b/datasets/test/test_scraping_cli.py @@ -70,11 +70,15 @@ def fake_scrape_documents( documents: int | None, link_mode: LinkMode, url: str | None = None, + include_archived: bool = False, + include_in_development: bool = False, ) -> ScrapeRun: assert source is ScraperSource.NICE assert documents == 3 assert link_mode is LinkMode.STRIP assert url is None + assert include_archived is False + assert include_in_development is False return ScrapeRun([_document()], total=3) monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_documents", fake_scrape_documents) @@ -105,6 +109,8 @@ def fake_scrape_documents( documents: int | None, link_mode: LinkMode, url: str | None = None, + include_archived: bool = False, + include_in_development: bool = False, ) -> ScrapeRun: return ScrapeRun([_document()], total=None) @@ -137,6 +143,8 @@ def fake_scrape_documents( documents: int | None, link_mode: LinkMode, url: str | None = None, + include_archived: bool = False, + include_in_development: bool = False, ) -> ScrapeRun: return ScrapeRun([_document()], total=7) @@ -204,11 +212,15 @@ def fake_scrape_documents( documents: int | None, link_mode: LinkMode, url: str | None = None, + include_archived: bool = False, + include_in_development: bool = False, ) -> ScrapeRun: assert source is ScraperSource.ALL assert documents is None assert link_mode is LinkMode.KEEP assert url is None + assert include_archived is False + assert include_in_development is False return ScrapeRun([_document()], total=12) monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_documents", fake_scrape_documents) @@ -219,6 +231,115 @@ def fake_scrape_documents( assert json.loads(result.stdout.splitlines()[0])["external_id"] == "nice-ng1" +def test_cli_run_accepts_idsa_status_flags(monkeypatch: pytest.MonkeyPatch) -> None: + """The CLI forwards IDSA status inclusion flags.""" + runner = CliRunner() + + def fake_scrape_documents( + source: ScraperSource, + *, + documents: int | None, + link_mode: LinkMode, + url: str | None = None, + include_archived: bool = False, + include_in_development: bool = False, + ) -> ScrapeRun: + assert source is ScraperSource.IDSA + assert documents == 2 + assert link_mode is LinkMode.KEEP + assert url is None + assert include_archived is True + assert include_in_development is True + return ScrapeRun([_idsa_document()], total=2) + + monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_documents", fake_scrape_documents) + + result = runner.invoke( + app, + ["--source", "idsa", "--documents", "2", "--include-archived", "--include-in-development"], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout.splitlines()[0])["external_id"] == "idsa-current-guideline" + assert "scraped 1 documents from idsa" in result.stderr + + +def test_scrape_documents_dispatches_idsa(monkeypatch: pytest.MonkeyPatch) -> None: + """The scrape dispatcher calls the IDSA scraper for the IDSA source.""" + + def fake_scrape_idsa( + *, + documents: int | None, + link_mode: LinkMode, + url: str | None, + include_archived: bool, + include_in_development: bool, + ) -> ScrapeRun: + assert documents == 1 + assert link_mode is LinkMode.STRIP + assert url == "https://www.idsociety.org/practice-guideline/current-guideline/" + assert include_archived is True + assert include_in_development is False + return ScrapeRun([_idsa_document()], total=1) + + monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_idsa", fake_scrape_idsa) + + from amfv_datasets.scraping.cli import scrape_documents + + scrape_run = scrape_documents( + ScraperSource.IDSA, + documents=1, + link_mode=LinkMode.STRIP, + url="https://www.idsociety.org/practice-guideline/current-guideline/", + include_archived=True, + ) + + assert list(scrape_run.documents) == [_idsa_document()] + + +def test_scrape_documents_all_includes_idsa(monkeypatch: pytest.MonkeyPatch) -> None: + """The all source combines NICE and IDSA scrape runs.""" + + def fake_scrape_nice( + *, + documents: int | None, + link_mode: LinkMode, + url: str | None, + ) -> ScrapeRun: + assert documents == 1 + assert link_mode is LinkMode.KEEP + assert url is None + return ScrapeRun([_document()], total=1) + + def fake_scrape_idsa( + *, + documents: int | None, + link_mode: LinkMode, + url: str | None, + include_archived: bool, + include_in_development: bool, + ) -> ScrapeRun: + assert documents == 1 + assert link_mode is LinkMode.KEEP + assert url is None + assert include_archived is False + assert include_in_development is False + return ScrapeRun([_idsa_document()], total=1) + + monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_nice", fake_scrape_nice) + monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_idsa", fake_scrape_idsa) + + from amfv_datasets.scraping.cli import scrape_documents + + scrape_run = scrape_documents(ScraperSource.ALL, documents=1, link_mode=LinkMode.KEEP) + + assert scrape_run.total == 2 + assert [document.external_id for document in scrape_run.documents] == [ + "nice-ng1", + "idsa-current-guideline", + ] + + def _document() -> ScrapedDocument: return ScrapedDocument( source="nice", @@ -230,6 +351,17 @@ def _document() -> ScrapedDocument: ) +def _idsa_document() -> ScrapedDocument: + return ScrapedDocument( + source="idsa", + external_id="idsa-current-guideline", + title="Current Guideline", + url="https://www.idsociety.org/practice-guideline/current-guideline/", + content="content", + metadata={"slug": "current-guideline"}, + ) + + class _TextSink: def __init__(self) -> None: self.value = "" diff --git a/datasets/test/test_scraping_idsa.py b/datasets/test/test_scraping_idsa.py new file mode 100644 index 00000000..a83c4ae9 --- /dev/null +++ b/datasets/test/test_scraping_idsa.py @@ -0,0 +1,320 @@ +"""Tests for IDSA scraping helpers.""" + +import httpx +import pytest + +from amfv_datasets.scraping.html import LinkMode +from amfv_datasets.scraping.idsa import ( + BASE_URL, + LISTING_URL, + IDSAFetchError, + IDSAGuidelineRef, + build_guideline_text, + idsa_ref_from_url, + list_practice_guidelines, + scrape_guideline, + scrape_idsa, +) + + +def test_list_practice_guidelines_parses_and_filters_default_statuses() -> None: + """The IDSA listing includes only current non-development guidelines by default.""" + client = httpx.Client(transport=httpx.MockTransport(_listing_handler), base_url=BASE_URL) + + listing = list_practice_guidelines(client) + + assert listing.total == 2 + assert listing.refs == [ + IDSAGuidelineRef( + title="Current Guideline", + slug="current-guideline", + page_url="https://www.idsociety.org/practice-guideline/current-guideline/", + year=2024, + statuses=("Current",), + ), + IDSAGuidelineRef( + title="Current Endorsed Guideline", + slug="current-endorsed-guideline", + page_url="https://www.idsociety.org/practice-guideline/current-endorsed-guideline/", + year=2023, + statuses=("Current", "Endorsed"), + ), + ] + + +def test_list_practice_guidelines_can_include_archived_statuses() -> None: + """Archived guidelines are included only when requested.""" + client = httpx.Client(transport=httpx.MockTransport(_listing_handler), base_url=BASE_URL) + + listing = list_practice_guidelines(client, include_archived=True) + + assert [ref.slug for ref in listing.refs] == [ + "current-guideline", + "current-endorsed-guideline", + "archived-guideline", + ] + + +def test_list_practice_guidelines_can_include_in_development_statuses() -> None: + """In-development guidelines are included only when requested.""" + client = httpx.Client(transport=httpx.MockTransport(_listing_handler), base_url=BASE_URL) + + listing = list_practice_guidelines(client, include_in_development=True) + + assert [ref.slug for ref in listing.refs] == [ + "current-guideline", + "current-endorsed-guideline", + "development-guideline", + ] + + +def test_list_practice_guidelines_requires_both_flags_for_archived_development_statuses() -> None: + """Guidelines marked both archived and in development require both inclusion flags.""" + client = httpx.Client(transport=httpx.MockTransport(_listing_handler), base_url=BASE_URL) + + listing = list_practice_guidelines(client, include_archived=True, include_in_development=True) + + assert [ref.slug for ref in listing.refs] == [ + "current-guideline", + "current-endorsed-guideline", + "archived-guideline", + "development-guideline", + "archived-development-guideline", + ] + + +def test_idsa_ref_from_url_normalizes_practice_guideline_url() -> None: + """IDSA practice guideline URLs are normalized to canonical refs.""" + assert idsa_ref_from_url("https://www.idsociety.org/practice-guideline/Current-Guideline/?utm=1") == ( + IDSAGuidelineRef( + title="Current Guideline", + slug="current-guideline", + page_url="https://www.idsociety.org/practice-guideline/current-guideline/", + year=None, + statuses=(), + ) + ) + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com/practice-guideline/current-guideline/", + "https://www.idsociety.org/news/current-guideline/", + ], + ids=["wrong-domain", "wrong-path"], +) +def test_idsa_ref_from_url_rejects_non_guideline_urls(url: str) -> None: + """Only IDSA practice guideline URLs are accepted.""" + with pytest.raises(IDSAFetchError): + idsa_ref_from_url(url) + + +def test_build_guideline_text_extracts_content_and_link_metadata() -> None: + """Guideline page content is converted to markdown and noisy UI is stripped.""" + client = httpx.Client(transport=httpx.MockTransport(_guideline_handler), base_url=BASE_URL) + + content, section_count, title, links_metadata = build_guideline_text( + client, + IDSAGuidelineRef( + title="Current Guideline", + slug="current-guideline", + page_url="https://www.idsociety.org/practice-guideline/current-guideline/", + year=2024, + statuses=("Current",), + ), + ) + + assert title == "Current Guideline" + assert section_count == 3 + assert "# Current Guideline" in content + assert "## Abstract" in content + assert "## Recommendations" in content + assert "Recommendation text with [evidence](https://doi.org/10.1093/cid/example)." in content + assert "Back to top" not in content + assert "Table of Contents" not in content + assert "https://www.idsociety.org#abstract" not in content + assert links_metadata == { + "external_links": [ + "https://academic.oup.com/example.pdf", + "https://doi.org/10.1093/cid/example", + ], + "pdf_links": ["https://academic.oup.com/example.pdf"], + } + + stripped_content, _section_count, _title, _links_metadata = build_guideline_text( + client, + IDSAGuidelineRef( + title="Current Guideline", + slug="current-guideline", + page_url="https://www.idsociety.org/practice-guideline/current-guideline/", + year=2024, + statuses=("Current",), + ), + link_mode=LinkMode.STRIP, + ) + assert "Recommendation text with evidence." in stripped_content + + +def test_scrape_idsa_returns_normalized_document() -> None: + """The IDSA scraper returns normalized scraped documents.""" + + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == LISTING_URL: + return httpx.Response(200, text=_listing_html()) + if str(request.url) == "https://www.idsociety.org/practice-guideline/current-guideline/": + return httpx.Response(200, text=_guideline_html()) + raise AssertionError(f"Unexpected URL: {request.url}") + + original_client_factory = "amfv_datasets.scraping.idsa.default_client" + transport = httpx.MockTransport(handler) + + class ClientFactory: + def __call__(self) -> httpx.Client: + return httpx.Client(transport=transport) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(original_client_factory, ClientFactory()) + scrape_run = scrape_idsa(documents=1) + documents = list(scrape_run.documents) + + assert scrape_run.total == 1 + assert len(documents) == 1 + assert documents[0].source == "idsa" + assert documents[0].external_id == "idsa-current-guideline" + assert documents[0].metadata["statuses"] == ["Current"] + assert documents[0].metadata["year"] == 2024 + assert documents[0].metadata["pdf_links"] == ["https://academic.oup.com/example.pdf"] + assert documents[0].metadata["content_length_chars"] == len(documents[0].content) + assert documents[0].metadata["quality_flags"] == ["short_content"] + + +def test_scrape_guideline_does_not_flag_long_content() -> None: + """Long guideline content records length without short-content quality flags.""" + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "https://www.idsociety.org/practice-guideline/long-guideline/" + return httpx.Response(200, text=_long_guideline_html()) + + client = httpx.Client(transport=httpx.MockTransport(handler), base_url=BASE_URL) + + document = scrape_guideline( + client, + IDSAGuidelineRef( + title="Long Guideline", + slug="long-guideline", + page_url="https://www.idsociety.org/practice-guideline/long-guideline/", + year=2024, + statuses=("Current",), + ), + ) + + assert document.metadata["content_length_chars"] == len(document.content) + assert document.metadata["quality_flags"] == [] + + +def _listing_handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == LISTING_URL + return httpx.Response(200, text=_listing_html()) + + +def _guideline_handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "https://www.idsociety.org/practice-guideline/current-guideline/" + return httpx.Response(200, text=_guideline_html()) + + +def _listing_html() -> str: + return """ + +
+ +
+ + """ + + +def _guideline_html() -> str: + return """ + + Current Guideline | IDSA +
+ +

Intro column noise.

+
+
+

Download PDF

+

Current Guideline

+

Table of Contents

+ + +

Published January 1, 2024

+

Abstract

+

Useful abstract.

+

Recommendations

+

Recommendation text with evidence.

+

Back to top

+
+ + """ + + +def _long_guideline_html() -> str: + long_text = "Recommendation text. " * 700 + return f""" + + Long Guideline | IDSA +
+

Long Guideline

+

Recommendations

+

{long_text}

+
+ + """ From 5a157e47eaa6c4dcf169404531bfc03bccd20d6f Mon Sep 17 00:00:00 2001 From: Muhamed Kouate Date: Wed, 8 Jul 2026 19:04:51 +0200 Subject: [PATCH 03/15] simplify IDSA scraper --- datasets/amfv_datasets/scraping/__init__.py | 12 - datasets/amfv_datasets/scraping/idsa.py | 22 +- datasets/test/test_scraping_idsa.py | 318 ++++++++------------ 3 files changed, 132 insertions(+), 220 deletions(-) diff --git a/datasets/amfv_datasets/scraping/__init__.py b/datasets/amfv_datasets/scraping/__init__.py index d8a0aa01..199e053a 100644 --- a/datasets/amfv_datasets/scraping/__init__.py +++ b/datasets/amfv_datasets/scraping/__init__.py @@ -18,19 +18,12 @@ html_to_markdown, ) from amfv_datasets.scraping.idsa import ( - IDSA_DATASET_DISPLAY_NAME, - IDSA_DATASET_NAME, - LISTING_URL, IDSAFetchError, - IDSAGuidelineListingPage, IDSAGuidelineRef, idsa_ref_from_url, list_practice_guidelines, scrape_idsa, ) -from amfv_datasets.scraping.idsa import ( - build_guideline_text as build_idsa_guideline_text, -) from amfv_datasets.scraping.idsa import ( scrape_guideline as scrape_idsa_guideline, ) @@ -49,12 +42,8 @@ "GuidanceRef", "GuidanceListingPage", "IDSAFetchError", - "IDSA_DATASET_DISPLAY_NAME", - "IDSA_DATASET_NAME", - "IDSAGuidelineListingPage", "IDSAGuidelineRef", "LinkMode", - "LISTING_URL", "NiceFetchError", "OutputFormat", "ScrapeError", @@ -63,7 +52,6 @@ "ScraperSource", "USER_AGENT", "absolute_unique_urls", - "build_idsa_guideline_text", "build_guideline_text", "clean_text", "document_title", diff --git a/datasets/amfv_datasets/scraping/idsa.py b/datasets/amfv_datasets/scraping/idsa.py index 9941b3f1..1feffcc5 100644 --- a/datasets/amfv_datasets/scraping/idsa.py +++ b/datasets/amfv_datasets/scraping/idsa.py @@ -21,10 +21,8 @@ BASE_URL = "https://www.idsociety.org" LISTING_URL = f"{BASE_URL}/practice-guideline/all-practice-guidelines" -IDSA_DATASET_NAME = "idsa-webscrape" -IDSA_DATASET_DISPLAY_NAME = "IDSA Webscrape" DOCUMENT_DELAY_SECONDS = 5.0 -SHORT_CONTENT_CHARS = 10_000 +_SHORT_CONTENT_CHARS = 10_000 _IDSA_PATH_RE = re.compile(r"^/practice-guideline/(?P[^/]+)(?:/|$)", re.IGNORECASE) _BACK_TO_TOP_RE = re.compile(r"^\s*back to top\s*$", re.IGNORECASE) @@ -106,20 +104,12 @@ def list_practice_guidelines( return IDSAGuidelineListingPage(refs=refs, total=len(refs)) -def build_guideline_text( +def _build_guideline_text( client: httpx.Client, ref: IDSAGuidelineRef, *, link_mode: LinkMode = LinkMode.KEEP, ) -> tuple[str, int, str, dict[str, list[str]]]: - """Scrape a guideline page into markdown text, section count, title, and links. - - Args: - client: HTTP client used to fetch the guideline page. - ref: IDSA guideline reference to scrape. - link_mode: Whether links are kept as markdown links or stripped to their - visible text (default: LinkMode.KEEP). - """ response = client.get(ref.page_url) response.raise_for_status() title = document_title(response.text, fallback=ref.title) @@ -144,7 +134,7 @@ def scrape_guideline( link_mode: Whether links are kept as markdown links or stripped to their visible text (default: LinkMode.KEEP). """ - content, section_count, title, links_metadata = build_guideline_text(client, ref, link_mode=link_mode) + content, section_count, title, links_metadata = _build_guideline_text(client, ref, link_mode=link_mode) return ScrapedDocument( source="idsa", external_id=f"idsa-{ref.slug}", @@ -372,7 +362,7 @@ def _section_count(content_html: str) -> int: def _quality_flags(content: str) -> list[str]: flags: list[str] = [] - if len(content) < SHORT_CONTENT_CHARS: + if len(content) < _SHORT_CONTENT_CHARS: flags.append("short_content") return flags @@ -384,14 +374,10 @@ def _page_url(slug: str) -> str: __all__ = [ "BASE_URL", "DOCUMENT_DELAY_SECONDS", - "IDSA_DATASET_DISPLAY_NAME", - "IDSA_DATASET_NAME", "IDSAFetchError", "IDSAGuidelineListingPage", "IDSAGuidelineRef", "LISTING_URL", - "SHORT_CONTENT_CHARS", - "build_guideline_text", "idsa_ref_from_url", "list_practice_guidelines", "scrape_guideline", diff --git a/datasets/test/test_scraping_idsa.py b/datasets/test/test_scraping_idsa.py index a83c4ae9..2c6073f7 100644 --- a/datasets/test/test_scraping_idsa.py +++ b/datasets/test/test_scraping_idsa.py @@ -9,19 +9,25 @@ LISTING_URL, IDSAFetchError, IDSAGuidelineRef, - build_guideline_text, idsa_ref_from_url, list_practice_guidelines, scrape_guideline, scrape_idsa, ) +_LISTING_CASES = ( + ("current-guideline", "Current Guideline", 2024, ("Current",)), + ("current-endorsed-guideline", "Current Endorsed Guideline", 2023, ("Current", "Endorsed")), + ("endorsed-guideline", "Endorsed Guideline", 2022, ("Endorsed",)), + ("archived-guideline", "Archived Guideline", 2021, ("Archived",)), + ("development-guideline", "Development Guideline", 2020, ("In Development",)), + ("archived-development-guideline", "Archived Development Guideline", 2019, ("Archived", "In Development")), +) -def test_list_practice_guidelines_parses_and_filters_default_statuses() -> None: - """The IDSA listing includes only current non-development guidelines by default.""" - client = httpx.Client(transport=httpx.MockTransport(_listing_handler), base_url=BASE_URL) - listing = list_practice_guidelines(client) +def test_list_practice_guidelines_parses_listing_fields() -> None: + """The IDSA listing parser normalizes title, URL, year, and statuses.""" + listing = list_practice_guidelines(_listing_client()) assert listing.total == 2 assert listing.refs == [ @@ -42,45 +48,36 @@ def test_list_practice_guidelines_parses_and_filters_default_statuses() -> None: ] -def test_list_practice_guidelines_can_include_archived_statuses() -> None: - """Archived guidelines are included only when requested.""" - client = httpx.Client(transport=httpx.MockTransport(_listing_handler), base_url=BASE_URL) - - listing = list_practice_guidelines(client, include_archived=True) - - assert [ref.slug for ref in listing.refs] == [ - "current-guideline", - "current-endorsed-guideline", - "archived-guideline", - ] - - -def test_list_practice_guidelines_can_include_in_development_statuses() -> None: - """In-development guidelines are included only when requested.""" - client = httpx.Client(transport=httpx.MockTransport(_listing_handler), base_url=BASE_URL) - - listing = list_practice_guidelines(client, include_in_development=True) - - assert [ref.slug for ref in listing.refs] == [ - "current-guideline", - "current-endorsed-guideline", - "development-guideline", - ] - - -def test_list_practice_guidelines_requires_both_flags_for_archived_development_statuses() -> None: - """Guidelines marked both archived and in development require both inclusion flags.""" - client = httpx.Client(transport=httpx.MockTransport(_listing_handler), base_url=BASE_URL) +@pytest.mark.parametrize( + ("kwargs", "expected_slugs"), + [ + ({}, ["current-guideline", "current-endorsed-guideline"]), + ( + {"include_archived": True}, + ["current-guideline", "current-endorsed-guideline", "archived-guideline"], + ), + ( + {"include_in_development": True}, + ["current-guideline", "current-endorsed-guideline", "development-guideline"], + ), + ( + {"include_archived": True, "include_in_development": True}, + [ + "current-guideline", + "current-endorsed-guideline", + "archived-guideline", + "development-guideline", + "archived-development-guideline", + ], + ), + ], + ids=["default", "include-archived", "include-in-development", "include-both"], +) +def test_list_practice_guidelines_filters_statuses(kwargs: dict[str, bool], expected_slugs: list[str]) -> None: + """Status flags preserve the intended IDSA listing inclusion policy.""" + listing = list_practice_guidelines(_listing_client(), **kwargs) - listing = list_practice_guidelines(client, include_archived=True, include_in_development=True) - - assert [ref.slug for ref in listing.refs] == [ - "current-guideline", - "current-endorsed-guideline", - "archived-guideline", - "development-guideline", - "archived-development-guideline", - ] + assert [ref.slug for ref in listing.refs] == expected_slugs def test_idsa_ref_from_url_normalizes_practice_guideline_url() -> None: @@ -110,71 +107,47 @@ def test_idsa_ref_from_url_rejects_non_guideline_urls(url: str) -> None: idsa_ref_from_url(url) -def test_build_guideline_text_extracts_content_and_link_metadata() -> None: +def test_scrape_guideline_extracts_content_and_link_metadata() -> None: """Guideline page content is converted to markdown and noisy UI is stripped.""" - client = httpx.Client(transport=httpx.MockTransport(_guideline_handler), base_url=BASE_URL) + document = scrape_guideline(_guideline_client(_guideline_html()), _ref()) + + assert document.title == "Current Guideline" + assert document.section_count == 3 + assert "# Current Guideline" in document.content + assert "## Abstract" in document.content + assert "## Recommendations" in document.content + assert "[Download PDF](https://academic.oup.com/example.pdf)" in document.content + assert "Recommendation text with [evidence](https://doi.org/10.1093/cid/example)." in document.content + assert "Back to top" not in document.content + assert "Table of Contents" not in document.content + assert "https://www.idsociety.org#abstract" not in document.content + assert document.metadata["external_links"] == [ + "https://academic.oup.com/example.pdf", + "https://doi.org/10.1093/cid/example", + ] + assert document.metadata["pdf_links"] == ["https://academic.oup.com/example.pdf"] + assert document.metadata["content_length_chars"] == len(document.content) + assert document.metadata["quality_flags"] == ["short_content"] - content, section_count, title, links_metadata = build_guideline_text( - client, - IDSAGuidelineRef( - title="Current Guideline", - slug="current-guideline", - page_url="https://www.idsociety.org/practice-guideline/current-guideline/", - year=2024, - statuses=("Current",), - ), - ) - assert title == "Current Guideline" - assert section_count == 3 - assert "# Current Guideline" in content - assert "## Abstract" in content - assert "## Recommendations" in content - assert "Recommendation text with [evidence](https://doi.org/10.1093/cid/example)." in content - assert "Back to top" not in content - assert "Table of Contents" not in content - assert "https://www.idsociety.org#abstract" not in content - assert links_metadata == { - "external_links": [ - "https://academic.oup.com/example.pdf", - "https://doi.org/10.1093/cid/example", - ], - "pdf_links": ["https://academic.oup.com/example.pdf"], - } - - stripped_content, _section_count, _title, _links_metadata = build_guideline_text( - client, - IDSAGuidelineRef( - title="Current Guideline", - slug="current-guideline", - page_url="https://www.idsociety.org/practice-guideline/current-guideline/", - year=2024, - statuses=("Current",), - ), - link_mode=LinkMode.STRIP, - ) - assert "Recommendation text with evidence." in stripped_content +def test_scrape_guideline_strips_links_when_requested() -> None: + """The IDSA scraper honors the shared link-mode option.""" + document = scrape_guideline(_guideline_client(_guideline_html()), _ref(), link_mode=LinkMode.STRIP) + + assert "Recommendation text with evidence." in document.content + assert "[evidence]" not in document.content def test_scrape_idsa_returns_normalized_document() -> None: """The IDSA scraper returns normalized scraped documents.""" - - def handler(request: httpx.Request) -> httpx.Response: - if str(request.url) == LISTING_URL: - return httpx.Response(200, text=_listing_html()) - if str(request.url) == "https://www.idsociety.org/practice-guideline/current-guideline/": - return httpx.Response(200, text=_guideline_html()) - raise AssertionError(f"Unexpected URL: {request.url}") - - original_client_factory = "amfv_datasets.scraping.idsa.default_client" - transport = httpx.MockTransport(handler) + transport = httpx.MockTransport(_scrape_idsa_handler) class ClientFactory: def __call__(self) -> httpx.Client: return httpx.Client(transport=transport) with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.setattr(original_client_factory, ClientFactory()) + monkeypatch.setattr("amfv_datasets.scraping.idsa.default_client", ClientFactory()) scrape_run = scrape_idsa(documents=1) documents = list(scrape_run.documents) @@ -184,111 +157,80 @@ def __call__(self) -> httpx.Client: assert documents[0].external_id == "idsa-current-guideline" assert documents[0].metadata["statuses"] == ["Current"] assert documents[0].metadata["year"] == 2024 - assert documents[0].metadata["pdf_links"] == ["https://academic.oup.com/example.pdf"] - assert documents[0].metadata["content_length_chars"] == len(documents[0].content) - assert documents[0].metadata["quality_flags"] == ["short_content"] + assert documents[0].metadata["slug"] == "current-guideline" + assert documents[0].metadata["listing_url"] == LISTING_URL -def test_scrape_guideline_does_not_flag_long_content() -> None: - """Long guideline content records length without short-content quality flags.""" +@pytest.mark.parametrize( + ("paragraph", "expected_flags"), + [ + ("Useful abstract.", ["short_content"]), + ("Recommendation text. " * 700, []), + ], + ids=["short", "long"], +) +def test_scrape_guideline_sets_content_quality_flags(paragraph: str, expected_flags: list[str]) -> None: + """Short content is flagged while long content is left unflagged.""" + document = scrape_guideline(_guideline_client(_guideline_html(paragraph=paragraph)), _ref()) - def handler(request: httpx.Request) -> httpx.Response: - assert str(request.url) == "https://www.idsociety.org/practice-guideline/long-guideline/" - return httpx.Response(200, text=_long_guideline_html()) + assert document.metadata["content_length_chars"] == len(document.content) + assert document.metadata["quality_flags"] == expected_flags - client = httpx.Client(transport=httpx.MockTransport(handler), base_url=BASE_URL) - document = scrape_guideline( - client, - IDSAGuidelineRef( - title="Long Guideline", - slug="long-guideline", - page_url="https://www.idsociety.org/practice-guideline/long-guideline/", - year=2024, - statuses=("Current",), - ), - ) +def _listing_client() -> httpx.Client: + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == LISTING_URL + return httpx.Response(200, text=_listing_html()) - assert document.metadata["content_length_chars"] == len(document.content) - assert document.metadata["quality_flags"] == [] + return httpx.Client(transport=httpx.MockTransport(handler), base_url=BASE_URL) -def _listing_handler(request: httpx.Request) -> httpx.Response: - assert str(request.url) == LISTING_URL - return httpx.Response(200, text=_listing_html()) +def _guideline_client(content_html: str) -> httpx.Client: + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "https://www.idsociety.org/practice-guideline/current-guideline/" + return httpx.Response(200, text=content_html) + return httpx.Client(transport=httpx.MockTransport(handler), base_url=BASE_URL) -def _guideline_handler(request: httpx.Request) -> httpx.Response: - assert str(request.url) == "https://www.idsociety.org/practice-guideline/current-guideline/" - return httpx.Response(200, text=_guideline_html()) + +def _scrape_idsa_handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == LISTING_URL: + return httpx.Response(200, text=_listing_html()) + if str(request.url) == "https://www.idsociety.org/practice-guideline/current-guideline/": + return httpx.Response(200, text=_guideline_html()) + raise AssertionError(f"Unexpected URL: {request.url}") def _listing_html() -> str: - return """ - -
- -
- + items = "\n".join( + _listing_item(slug=slug, title=title, year=year, statuses=statuses) + for slug, title, year, statuses in _LISTING_CASES + ) + return f'
    {items}
' + + +def _listing_item(*, slug: str, title: str, year: int, statuses: tuple[str, ...]) -> str: + categories = "".join(f'
  • {status}
  • ' for status in statuses) + return f""" +
  • +
      {categories}
    + {title} + {year} +
  • """ -def _guideline_html() -> str: - return """ +def _guideline_html(*, title: str = "Current Guideline", paragraph: str = "Useful abstract.") -> str: + return f""" - Current Guideline | IDSA + {title} | IDSA

    Intro column noise.

    Download PDF

    -

    Current Guideline

    +

    {title}

    Table of Contents

    • Abstract
    • @@ -297,7 +239,7 @@ def _guideline_html() -> str:

      Published January 1, 2024

      Abstract

      -

      Useful abstract.

      +

      {paragraph}

      Recommendations

      Recommendation text with evidence.

      Back to top

      @@ -306,15 +248,11 @@ def _guideline_html() -> str: """ -def _long_guideline_html() -> str: - long_text = "Recommendation text. " * 700 - return f""" - - Long Guideline | IDSA -
      -

      Long Guideline

      -

      Recommendations

      -

      {long_text}

      -
      - - """ +def _ref() -> IDSAGuidelineRef: + return IDSAGuidelineRef( + title="Current Guideline", + slug="current-guideline", + page_url="https://www.idsociety.org/practice-guideline/current-guideline/", + year=2024, + statuses=("Current",), + ) From 54e9f712894f346ff4e5610c963bf40a9a901638 Mon Sep 17 00:00:00 2001 From: mkrastev Date: Wed, 15 Jul 2026 13:18:03 +0300 Subject: [PATCH 04/15] add cps statement scraper --- datasets/amfv_datasets/scraping/__init__.py | 18 ++ datasets/amfv_datasets/scraping/cli.py | 38 ++- datasets/amfv_datasets/scraping/cps.py | 285 ++++++++++++++++++++ datasets/amfv_datasets/scraping/html.py | 2 + datasets/test/test_scraping_cli.py | 45 ++++ datasets/test/test_scraping_cps.py | 159 +++++++++++ datasets/test/test_scraping_html.py | 9 + 7 files changed, 548 insertions(+), 8 deletions(-) create mode 100644 datasets/amfv_datasets/scraping/cps.py create mode 100644 datasets/test/test_scraping_cps.py diff --git a/datasets/amfv_datasets/scraping/__init__.py b/datasets/amfv_datasets/scraping/__init__.py index cd7cdb06..827edb37 100644 --- a/datasets/amfv_datasets/scraping/__init__.py +++ b/datasets/amfv_datasets/scraping/__init__.py @@ -9,6 +9,16 @@ scrape_listing_documents, ) from amfv_datasets.scraping.cli import OutputFormat, ScraperSource +from amfv_datasets.scraping.cps import ( + CpsFetchError, + CpsStatementRef, + build_statement_text, + list_statements, + listing_page_url, + scrape_cps, + scrape_statement, + statement_ref_from_url, +) from amfv_datasets.scraping.html import ( LinkMode, absolute_unique_urls, @@ -31,6 +41,8 @@ __all__ = [ "GuidanceRef", "GuidanceListingPage", + "CpsFetchError", + "CpsStatementRef", "LinkMode", "NiceFetchError", "OutputFormat", @@ -41,6 +53,7 @@ "USER_AGENT", "absolute_unique_urls", "build_guideline_text", + "build_statement_text", "clean_text", "document_title", "default_client", @@ -48,7 +61,12 @@ "guidance_ref_from_url", "html_to_markdown", "list_published_guidance", + "list_statements", + "listing_page_url", "scrape_guideline", "scrape_listing_documents", "scrape_nice", + "scrape_cps", + "scrape_statement", + "statement_ref_from_url", ] diff --git a/datasets/amfv_datasets/scraping/cli.py b/datasets/amfv_datasets/scraping/cli.py index e8aece34..bb46d846 100644 --- a/datasets/amfv_datasets/scraping/cli.py +++ b/datasets/amfv_datasets/scraping/cli.py @@ -25,6 +25,7 @@ ) from amfv_datasets.scraping.base import ScrapedDocument, ScrapeRun +from amfv_datasets.scraping.cps import scrape_cps from amfv_datasets.scraping.html import LinkMode from amfv_datasets.scraping.nice import scrape_nice @@ -34,6 +35,7 @@ class ScraperSource(StrEnum): ALL = "all" NICE = "nice" + CPS = "cps" class OutputFormat(StrEnum): @@ -68,13 +70,33 @@ def scrape_documents( if documents is not None and documents < 1: raise ValueError(f"documents must be at least 1; got {documents}") - for selected_source in _expand_source(source): - match selected_source: - case ScraperSource.NICE: - return scrape_nice(documents=documents, link_mode=link_mode, url=url) - case ScraperSource.ALL: - raise AssertionError("expanded source cannot be all") - raise AssertionError(f"unsupported source: {source}") + selected_sources = _expand_source(source) + if url is not None and len(selected_sources) != 1: + raise ValueError("--url requires one specific scraper source, not 'all'") + runs = tuple( + _scrape_source(selected_source, documents=documents, link_mode=link_mode, url=url) + for selected_source in selected_sources + ) + if len(runs) == 1: + return runs[0] + total = sum(run.total for run in runs) if all(run.total is not None for run in runs) else None + return ScrapeRun(documents=(document for run in runs for document in run), total=total) + + +def _scrape_source( + source: ScraperSource, + *, + documents: int | None, + link_mode: LinkMode, + url: str | None, +) -> ScrapeRun: + match source: + case ScraperSource.NICE: + return scrape_nice(documents=documents, link_mode=link_mode, url=url) + case ScraperSource.CPS: + return scrape_cps(documents=documents, link_mode=link_mode, url=url) + case ScraperSource.ALL: + raise AssertionError("expanded source cannot be all") def write_jsonl(documents: Iterable[ScrapedDocument], output: TextIO) -> int: @@ -125,7 +147,7 @@ def write_markdown_files(documents: Iterable[ScrapedDocument], output_path: Path def _expand_source(source: ScraperSource) -> tuple[ScraperSource, ...]: if source is ScraperSource.ALL: - return (ScraperSource.NICE,) + return (ScraperSource.NICE, ScraperSource.CPS) return (source,) diff --git a/datasets/amfv_datasets/scraping/cps.py b/datasets/amfv_datasets/scraping/cps.py new file mode 100644 index 00000000..4b9ba1e9 --- /dev/null +++ b/datasets/amfv_datasets/scraping/cps.py @@ -0,0 +1,285 @@ +"""Scrape Canadian Paediatric Society statements into normalized markdown. + +CPS retains copyright in its position statements and practice points. This +module supports a permission-gated ingestion workflow; obtain written CPS +permission before running a corpus scrape or redistributing its output. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import dataclass +from urllib.parse import unquote, urljoin, urlparse + +import httpx +from lxml import html as lxml_html +from markdownify import MarkdownConverter + +from amfv_datasets.scraping.base import ( + ScrapedDocument, + ScrapeError, + ScrapeRun, + default_client, + scrape_listing_documents, +) +from amfv_datasets.scraping.html import LinkMode, clean_text + +BASE_URL = "https://cps.ca" +STATEMENTS_URL = f"{BASE_URL}/en/documents/statements-by-date" +DOCUMENT_DELAY_SECONDS = 10.0 +LISTING_PAGE_SIZE = 10 + +_POSITION_PATH_RE = re.compile(r"^/(?:en/)?documents/position/(?P[^/]+)/?$", re.IGNORECASE) +_DATE_PATTERNS = { + "posted": re.compile(r"\bPosted:\s*([A-Za-z]+\s+\d{1,2},\s+\d{4})", re.IGNORECASE), + "reaffirmed": re.compile(r"\bReaffirmed:\s*([A-Za-z]+\s+\d{1,2},\s+\d{4})", re.IGNORECASE), + "updated": re.compile(r"\bUpdated:\s*([A-Za-z]+\s+\d{1,2},\s+\d{4})", re.IGNORECASE), +} +_BLANK_LINES_RE = re.compile(r"\n{3,}") +_EMPTY_MARKDOWN_LINK_RE = re.compile(r"(? str: # noqa: ANN001 + classes = element.get("class") or () + href = element.get("href") + if "reference" in classes and href and "#ref" in href: + marker = element.get_text(strip=True).strip("[]") + if "a" in (self.options.get("strip") or ()): + return f"[{marker}]" + return f"[{marker}]({href})" + return super().convert_a(element, text, parent_tags) + + def convert_sup(self, element, text: str, parent_tags: set[str]) -> str: # noqa: ANN001 + if not text.strip(): + return "" + citation = element.find("a", href=lambda href: href and "#ref" in href) + if citation: + marker = element.get_text(strip=True).strip("[]") + if "a" in (self.options.get("strip") or ()): + return f"[{marker}]" + return f"[{marker}]({citation.get('href')})" + return f"{text}" + + +_CPS_MARKDOWN_CONVERTERS = { + LinkMode.KEEP: _CpsMarkdownConverter(bullets="-", heading_style="ATX"), + LinkMode.STRIP: _CpsMarkdownConverter(bullets="-", heading_style="ATX", strip=("a",)), +} + + +class CpsFetchError(ScrapeError): + """Raised when a CPS statement cannot be discovered or parsed.""" + + +@dataclass(frozen=True) +class CpsStatementRef: + """A CPS statement or practice point discovered from the date index.""" + + slug: str + title: str + page_url: str + + +def statement_ref_from_url(url: str, *, title: str | None = None) -> CpsStatementRef: + """Parse a CPS position-statement URL into a canonical reference.""" + parsed = urlparse(url.strip()) + if parsed.scheme not in {"http", "https"} or parsed.netloc.lower() not in {"cps.ca", "www.cps.ca"}: + raise CpsFetchError(f"Enter a CPS statement URL from cps.ca; got {url!r}") + + match = _POSITION_PATH_RE.match(unquote(parsed.path)) + if not match: + raise CpsFetchError(f"Enter a URL like https://cps.ca/en/documents/position/example-statement; got {url!r}") + slug = match.group("slug") + return CpsStatementRef( + slug=slug, + title=title or slug.replace("-", " "), + page_url=f"{BASE_URL}/en/documents/position/{slug}", + ) + + +def listing_page_url(page: int) -> str: + """Return the CPS date-index URL for a one-based page number.""" + if page < 1: + raise ValueError(f"page must be at least 1; got {page}") + if page == 1: + return STATEMENTS_URL + return f"{STATEMENTS_URL}/P{(page - 1) * LISTING_PAGE_SIZE}" + + +def list_statements(client: httpx.Client, page: int) -> list[CpsStatementRef]: + """Return unique statement references from one CPS date-index page.""" + response = client.get(listing_page_url(page)) + response.raise_for_status() + doc = lxml_html.fromstring(response.text) + anchors = doc.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' stmt-title ')]//a[@href]") + + refs: list[CpsStatementRef] = [] + seen_urls: set[str] = set() + for anchor in anchors: + href = anchor.get("href") + title = clean_text(anchor.text_content(), drop_numeric_citations=False) + if not href or not title: + continue + try: + ref = statement_ref_from_url(urljoin(BASE_URL, href), title=title) + except CpsFetchError: + continue + if ref.page_url in seen_urls: + continue + seen_urls.add(ref.page_url) + refs.append(ref) + return refs + + +def _content_root(html_text: str) -> lxml_html.HtmlElement: + doc = lxml_html.fromstring(html_text) + selectors = ( + "//div[contains(concat(' ', normalize-space(@class), ' '), ' statement-wrapper ')][1]", + "//main//article[1]", + "//*[@id='main-content']//article[1]", + "//*[@id='main-content'][1]", + "//main[1]", + ) + for selector in selectors: + matches = doc.xpath(selector) + if matches: + return matches[0] + raise CpsFetchError("No readable statement content found on the CPS page") + + +def _normalize_content(root: lxml_html.HtmlElement, *, base_url: str) -> None: + removable = root.xpath( + ".//script | .//style | .//nav | .//form | .//button | .//noscript | " + ".//*[contains(concat(' ', normalize-space(@class), ' '), ' breadcrumb ')] | " + ".//*[contains(@class, 'share')] | " + ".//*[contains(@class, 'social')] | " + ".//*[contains(concat(' ', normalize-space(@class), ' '), ' related-content ')] | " + ".//*[contains(concat(' ', normalize-space(@class), ' '), ' sidebar ')] | " + ".//*[contains(concat(' ', normalize-space(@class), ' '), ' hide-for-print ')] | " + ".//*[contains(concat(' ', normalize-space(@class), ' '), ' show-for-print ')] | " + ".//*[contains(@class, 'print:tw-hidden')] | " + ".//*[contains(concat(' ', normalize-space(@class), ' '), ' --podcast ')]" + ) + for element in removable: + element.drop_tree() + + for link in root.xpath(".//a[contains(@href, '/en/education/test-your-knowledge')]"): + link.drop_tree() + + for image in root.xpath(".//img[@src]"): + filename = urlparse(image.get("src")).path.rsplit("/", 1)[-1].lower() + if filename in _DECORATIVE_IMAGE_FILENAMES: + image.drop_tree() + + for link in root.xpath(".//a[@href]"): + link.set("href", urljoin(base_url, link.get("href"))) + for image in root.xpath(".//img[@src]"): + image.set("src", urljoin(base_url, image.get("src"))) + + for nested in root.xpath(".//strong//strong | .//em//em"): + nested.drop_tag() + for title in root.xpath(".//h1"): + title.drop_tree() + for heading in root.xpath(".//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]"): + for emphasis in heading.xpath(".//strong | .//em | .//b | .//i"): + emphasis.drop_tag() + if not clean_text(heading.text_content(), drop_numeric_citations=False) and not heading.xpath(".//img"): + heading.drop_tree() + + +def _content_to_markdown(root: lxml_html.HtmlElement, *, link_mode: LinkMode) -> str: + source = lxml_html.tostring(root, encoding="unicode") + markdown = _CPS_MARKDOWN_CONVERTERS[link_mode].convert(source) + markdown = _EMPTY_MARKDOWN_LINK_RE.sub("", markdown) + lines = [line.rstrip() for line in markdown.splitlines()] + return _BLANK_LINES_RE.sub("\n\n", "\n".join(lines)).strip() + + +def build_statement_text( + html_text: str, + *, + link_mode: LinkMode = LinkMode.KEEP, + base_url: str = BASE_URL, +) -> tuple[str, int]: + """Extract one CPS statement as markdown and return its section count.""" + root = _content_root(html_text) + _normalize_content(root, base_url=base_url) + section_count = max(1, len(root.xpath(".//*[self::h2 or self::h3 or self::h4 or self::h5 or self::h6]"))) + content = _content_to_markdown(root, link_mode=link_mode) + if not content: + raise CpsFetchError("No readable statement content found on the CPS page") + return content, section_count + + +def scrape_statement( + client: httpx.Client, + ref: CpsStatementRef, + *, + link_mode: LinkMode = LinkMode.KEEP, +) -> ScrapedDocument: + """Scrape one CPS statement into the shared document schema.""" + response = client.get(ref.page_url) + response.raise_for_status() + content, section_count = build_statement_text(response.text, link_mode=link_mode, base_url=ref.page_url) + raw_root = _content_root(response.text) + headings = [clean_text(value) for value in raw_root.xpath(".//h1[1]//text()")] + title = " ".join(value for value in headings if value) or ref.title + metadata: dict[str, str] = {"slug": ref.slug} + visible_text = clean_text(raw_root.text_content(), drop_numeric_citations=False) + for key, pattern in _DATE_PATTERNS.items(): + if match := pattern.search(visible_text): + metadata[key] = match.group(1).strip() + return ScrapedDocument( + source="cps", + external_id=f"cps-{ref.slug.lower()}", + title=title, + url=ref.page_url, + content=content, + section_count=section_count, + metadata=metadata, + ) + + +def scrape_cps( + *, + documents: int | None, + link_mode: LinkMode = LinkMode.KEEP, + url: str | None = None, +) -> ScrapeRun: + """Configure a CPS scrape from one URL or the current-statements index.""" + if url is not None: + + def scrape_url() -> Iterable[ScrapedDocument]: + with default_client() as client: + yield scrape_statement(client, statement_ref_from_url(url), link_mode=link_mode) + + return ScrapeRun(documents=scrape_url(), total=1) + + return ScrapeRun( + documents=scrape_listing_documents( + documents=documents, + client_factory=default_client, + list_page=list_statements, + scrape_item=lambda client, ref: scrape_statement(client, ref, link_mode=link_mode), + document_delay_seconds=DOCUMENT_DELAY_SECONDS, + ) + ) + + +__all__ = [ + "BASE_URL", + "CpsFetchError", + "CpsStatementRef", + "STATEMENTS_URL", + "build_statement_text", + "list_statements", + "listing_page_url", + "scrape_cps", + "scrape_statement", + "statement_ref_from_url", +] diff --git a/datasets/amfv_datasets/scraping/html.py b/datasets/amfv_datasets/scraping/html.py index 2bd1c968..15805281 100644 --- a/datasets/amfv_datasets/scraping/html.py +++ b/datasets/amfv_datasets/scraping/html.py @@ -119,6 +119,8 @@ def _absolutize_links(html_text: str, *, base_url: str) -> str: root = lxml_html.fragment_fromstring(html_text, create_parent="div") for link in root.xpath(".//a[@href]"): link.set("href", urljoin(base_url, link.get("href"))) + for image in root.xpath(".//img[@src]"): + image.set("src", urljoin(base_url, image.get("src"))) return "".join(lxml_html.tostring(child, encoding="unicode") for child in root) diff --git a/datasets/test/test_scraping_cli.py b/datasets/test/test_scraping_cli.py index 44f714fa..75239b64 100644 --- a/datasets/test/test_scraping_cli.py +++ b/datasets/test/test_scraping_cli.py @@ -11,6 +11,7 @@ from amfv_datasets.scraping.cli import ( ScraperSource, app, + scrape_documents, write_huggingface_dataset, write_jsonl, write_markdown_files, @@ -219,6 +220,50 @@ def fake_scrape_documents( assert json.loads(result.stdout.splitlines()[0])["external_id"] == "nice-ng1" +def test_scrape_documents_dispatches_cps(monkeypatch: pytest.MonkeyPatch) -> None: + """The CPS source delegates to its source-specific scraper.""" + calls = 0 + + def fake_scrape_cps(*, documents: int | None, link_mode: LinkMode, url: str | None) -> ScrapeRun: + nonlocal calls + calls += 1 + assert documents == 2 + assert link_mode is LinkMode.KEEP + assert url == "https://cps.ca/en/documents/position/example" + return ScrapeRun([_document()], total=1) + + monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_cps", fake_scrape_cps) + + run = scrape_documents( + ScraperSource.CPS, + documents=2, + link_mode=LinkMode.KEEP, + url="https://cps.ca/en/documents/position/example", + ) + + assert calls == 1 + assert run.total == 1 + + +def test_scrape_documents_combines_all_sources(monkeypatch: pytest.MonkeyPatch) -> None: + """The all source streams NICE and CPS documents with a combined total.""" + nice = _document() + cps = ScrapedDocument("cps", "cps-example", "CPS example", "https://cps.ca/example", "content") + monkeypatch.setattr( + "amfv_datasets.scraping.cli.scrape_nice", + lambda **_kwargs: ScrapeRun([nice], total=1), + ) + monkeypatch.setattr( + "amfv_datasets.scraping.cli.scrape_cps", + lambda **_kwargs: ScrapeRun([cps], total=1), + ) + + run = scrape_documents(ScraperSource.ALL, documents=1, link_mode=LinkMode.KEEP) + + assert run.total == 2 + assert [document.source for document in run] == ["nice", "cps"] + + def _document() -> ScrapedDocument: return ScrapedDocument( source="nice", diff --git a/datasets/test/test_scraping_cps.py b/datasets/test/test_scraping_cps.py new file mode 100644 index 00000000..26d3bd93 --- /dev/null +++ b/datasets/test/test_scraping_cps.py @@ -0,0 +1,159 @@ +"""Tests for Canadian Paediatric Society statement scraping helpers.""" + +import httpx +import pytest + +from amfv_datasets.scraping.cps import ( + BASE_URL, + STATEMENTS_URL, + CpsFetchError, + CpsStatementRef, + list_statements, + listing_page_url, + scrape_statement, + statement_ref_from_url, +) + + +def test_listing_page_url_uses_cps_offsets() -> None: + """One-based pages map to the CPS P30 offset convention.""" + assert listing_page_url(1) == STATEMENTS_URL + assert listing_page_url(2) == f"{STATEMENTS_URL}/P10" + assert listing_page_url(4) == f"{STATEMENTS_URL}/P30" + + +def test_list_statements_discovers_unique_position_pages() -> None: + """The index yields unique position statements and ignores unrelated links.""" + html_text = """ + + """ + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == STATEMENTS_URL + return httpx.Response(200, text=html_text) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + assert list_statements(client, 1) == [ + CpsStatementRef("acute-asthma", "Acute asthma", f"{BASE_URL}/en/documents/position/acute-asthma"), + CpsStatementRef( + "newborn-glucose", + "Newborn glucose", + f"{BASE_URL}/en/documents/position/newborn-glucose", + ), + ] + + +def test_statement_ref_from_url_normalizes_supported_routes() -> None: + """English and language-neutral CPS routes become canonical HTTPS URLs.""" + assert statement_ref_from_url("http://www.cps.ca/documents/position/Acute-Asthma?print=1#dose") == ( + CpsStatementRef( + "Acute-Asthma", + "Acute Asthma", + "https://cps.ca/en/documents/position/Acute-Asthma", + ) + ) + + +def test_statement_ref_from_url_rejects_non_statement_pages() -> None: + """CPS navigation pages cannot be mistaken for clinical statements.""" + with pytest.raises(CpsFetchError): + statement_ref_from_url("https://cps.ca/en/documents") + + +def test_scrape_statement_preserves_clinical_structure_and_metadata() -> None: + """Statement content is normalized while navigation and sharing chrome are removed.""" + html_text = """ + + Fallback | Canadian Paediatric Society + +

      Site heading

      +
      +
      + +

      Position statement

      +

      Management of well-appearing febrile young infants

      +

      Posted: Oct 27, 2023 | Reaffirmed: Jan 12, 2026 | Updated: May 27, 2026

      +

      Recommendations

      +
      • Assess the infant:
        • Check vital signs.
      +

      Use the risk calculator.

      +

      Current citation[1].

      +

      Legacy citation[2].

      +

      Maternal fever above 38oC and counts of 109/L require attention.

      +

      Full statement PDF + PDF icon

      +

      +
      AgeAction
      0-28 daysInvestigate
      +

      Algorithm

      +

      +

      References

      +
        +
      1. Reference one.
      2. +
      3. Reference two.
      4. +
      + Quiz + + + +
      +
      +
      Contact CPS
      + + + """ + ref = CpsStatementRef( + "febrile-young-infants", + "Listing title", + f"{BASE_URL}/en/documents/position/febrile-young-infants", + ) + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == ref.page_url + return httpx.Response(200, text=html_text) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + document = scrape_statement(client, ref) + + assert document.source == "cps" + assert document.external_id == "cps-febrile-young-infants" + assert document.title == "Management of well-appearing febrile young infants" + assert document.section_count == 3 + assert document.metadata == { + "slug": "febrile-young-infants", + "posted": "Oct 27, 2023", + "reaffirmed": "Jan 12, 2026", + "updated": "May 27, 2026", + } + assert "- Assess the infant:\n - Check vital signs." in document.content + assert "[risk calculator](https://cps.ca/en/tools/risk-calculator)" in document.content + assert ( + "Current citation[1](https://cps.ca/en/documents/position/febrile-young-infants#ref1)." + in document.content + ) + assert ( + "Legacy citation[2](https://cps.ca/en/documents/position/febrile-young-infants#ref2)." + in document.content + ) + assert "38oC" in document.content + assert "109/L" in document.content + assert "# Management of well-appearing" not in document.content + assert "## Algorithm" in document.content + assert "## **Algorithm**" not in document.content + assert "| Age | Action |" in document.content + assert "![](https://cps.ca/uploads/flowcharts/febrile-infant.png)" in document.content + assert "## References" in document.content + assert "1. Reference one." in document.content + assert "2. Reference two." in document.content + assert "[Full statement PDF](https://cps.ca/documents/full-statement.pdf)" in document.content + assert "file-pdf.svg" not in document.content + assert "empty-target" not in document.content + assert "Share this statement" not in document.content + assert "Related news" not in document.content + assert "test-your-knowledge" not in document.content + assert "assets/img/test.png" not in document.content + assert "window.track" not in document.content + assert "Contact CPS" not in document.content diff --git a/datasets/test/test_scraping_html.py b/datasets/test/test_scraping_html.py index d34a8fa3..6e82ae36 100644 --- a/datasets/test/test_scraping_html.py +++ b/datasets/test/test_scraping_html.py @@ -68,3 +68,12 @@ def test_html_to_markdown_can_strip_links() -> None: html_text = '

      Offer treatment.

      ' assert html_to_markdown(html_text, link_mode=LinkMode.STRIP) == "Offer treatment." + + +def test_html_to_markdown_absolutizes_remote_image_references() -> None: + """Relative image references remain usable outside the source website.""" + html_text = '

      Treatment flowchart

      ' + + assert html_to_markdown(html_text, base_url="https://example.org") == ( + "![Treatment flowchart](https://example.org/uploads/flowchart.png)" + ) From 059ab4817babe16e7b440e4c95a4e39c3b5b71b6 Mon Sep 17 00:00:00 2001 From: mkrastev Date: Wed, 15 Jul 2026 13:18:09 +0300 Subject: [PATCH 05/15] add rch guideline scraper --- datasets/amfv_datasets/scraping/__init__.py | 8 + datasets/amfv_datasets/scraping/base.py | 14 +- datasets/amfv_datasets/scraping/cli.py | 105 +++++- datasets/amfv_datasets/scraping/html.py | 2 + datasets/amfv_datasets/scraping/rch.py | 398 ++++++++++++++++++++ datasets/test/test_scraping_base.py | 40 ++ datasets/test/test_scraping_cli.py | 87 ++++- datasets/test/test_scraping_html.py | 9 + datasets/test/test_scraping_rch.py | 252 +++++++++++++ 9 files changed, 894 insertions(+), 21 deletions(-) create mode 100644 datasets/amfv_datasets/scraping/rch.py create mode 100644 datasets/test/test_scraping_rch.py diff --git a/datasets/amfv_datasets/scraping/__init__.py b/datasets/amfv_datasets/scraping/__init__.py index cd7cdb06..6e111130 100644 --- a/datasets/amfv_datasets/scraping/__init__.py +++ b/datasets/amfv_datasets/scraping/__init__.py @@ -27,6 +27,11 @@ scrape_guideline, scrape_nice, ) +from amfv_datasets.scraping.rch import ( + RchFetchError, + RchGuidelineRef, + scrape_rch, +) __all__ = [ "GuidanceRef", @@ -34,6 +39,8 @@ "LinkMode", "NiceFetchError", "OutputFormat", + "RchFetchError", + "RchGuidelineRef", "ScrapeError", "ScrapeRun", "ScrapedDocument", @@ -51,4 +58,5 @@ "scrape_guideline", "scrape_listing_documents", "scrape_nice", + "scrape_rch", ] diff --git a/datasets/amfv_datasets/scraping/base.py b/datasets/amfv_datasets/scraping/base.py index 0708ee87..3651cdb7 100644 --- a/datasets/amfv_datasets/scraping/base.py +++ b/datasets/amfv_datasets/scraping/base.py @@ -74,7 +74,7 @@ def scrape_listing_documents[ListingItemT]( documents: int | None, client_factory: Callable[[], AbstractContextManager[httpx.Client]], list_page: Callable[[httpx.Client, int], Iterable[ListingItemT]], - scrape_item: Callable[[httpx.Client, ListingItemT], ScrapedDocument], + scrape_item: Callable[[httpx.Client, ListingItemT], ScrapedDocument | None], document_delay_seconds: float = 5.0, first_page_items: Iterable[ListingItemT] | None = None, ) -> Iterable[ScrapedDocument]: @@ -85,7 +85,8 @@ def scrape_listing_documents[ListingItemT]( fetched until a page returns no items (default: None). client_factory: Factory returning a context-managed HTTP client. list_page: Function that lists source-specific items for a page. - scrape_item: Function that scrapes one listed item into a document. + scrape_item: Function that scrapes one listed item into a document. It + may return None to skip a discovered item that is not a document. document_delay_seconds: Delay before scraping each document after the first one (default: 5.0). first_page_items: Already-fetched first listing page items. When set, @@ -99,6 +100,7 @@ def scrape_listing_documents[ListingItemT]( with client_factory() as client: page = 1 scraped = 0 + attempted = 0 page_items = list(first_page_items) if first_page_items is not None else None while documents is None or scraped < documents: if page_items is None: @@ -111,9 +113,13 @@ def scrape_listing_documents[ListingItemT]( for item in items: if documents is not None and scraped >= documents: break - if scraped and document_delay_seconds: + if attempted and document_delay_seconds: time.sleep(document_delay_seconds) - yield scrape_item(client, item) + document = scrape_item(client, item) + attempted += 1 + if document is None: + continue + yield document scraped += 1 page += 1 diff --git a/datasets/amfv_datasets/scraping/cli.py b/datasets/amfv_datasets/scraping/cli.py index e8aece34..1660d08a 100644 --- a/datasets/amfv_datasets/scraping/cli.py +++ b/datasets/amfv_datasets/scraping/cli.py @@ -5,7 +5,7 @@ import json import re import sys -from collections.abc import Iterable +from collections.abc import Collection, Iterable from dataclasses import asdict from enum import StrEnum from pathlib import Path @@ -27,6 +27,7 @@ from amfv_datasets.scraping.base import ScrapedDocument, ScrapeRun from amfv_datasets.scraping.html import LinkMode from amfv_datasets.scraping.nice import scrape_nice +from amfv_datasets.scraping.rch import scrape_rch class ScraperSource(StrEnum): @@ -34,6 +35,7 @@ class ScraperSource(StrEnum): ALL = "all" NICE = "nice" + RCH = "rch" class OutputFormat(StrEnum): @@ -53,6 +55,7 @@ def scrape_documents( documents: int | None, link_mode: LinkMode, url: str | None = None, + skip_urls: Collection[str] = (), ) -> ScrapeRun: """Configure a scrape for a source. @@ -64,17 +67,44 @@ def scrape_documents( link_mode: Whether links are kept as markdown links or stripped to their visible text. url: Source URL to scrape as a single document (default: None). + skip_urls: Canonical document URLs already written by a previous run + (default: ()). """ if documents is not None and documents < 1: raise ValueError(f"documents must be at least 1; got {documents}") - for selected_source in _expand_source(source): - match selected_source: - case ScraperSource.NICE: - return scrape_nice(documents=documents, link_mode=link_mode, url=url) - case ScraperSource.ALL: - raise AssertionError("expanded source cannot be all") - raise AssertionError(f"unsupported source: {source}") + selected_sources = _expand_source(source) + if url is not None and len(selected_sources) != 1: + raise ValueError("--url requires one specific scraper source, not 'all'") + runs = tuple( + _scrape_source(selected_source, documents=documents, link_mode=link_mode, url=url, skip_urls=skip_urls) + for selected_source in selected_sources + ) + if len(runs) == 1: + return runs[0] + total = sum(run.total for run in runs) if all(run.total is not None for run in runs) else None + return ScrapeRun(documents=(document for run in runs for document in run), total=total) + + +def _scrape_source( + source: ScraperSource, + *, + documents: int | None, + link_mode: LinkMode, + url: str | None, + skip_urls: Collection[str], +) -> ScrapeRun: + match source: + case ScraperSource.NICE: + if skip_urls: + raise ValueError("resume is not implemented for the NICE scraper") + return scrape_nice(documents=documents, link_mode=link_mode, url=url) + case ScraperSource.RCH: + if skip_urls: + return scrape_rch(documents=documents, link_mode=link_mode, url=url, skip_urls=skip_urls) + return scrape_rch(documents=documents, link_mode=link_mode, url=url) + case ScraperSource.ALL: + raise AssertionError("expanded source cannot be all") def write_jsonl(documents: Iterable[ScrapedDocument], output: TextIO) -> int: @@ -125,7 +155,7 @@ def write_markdown_files(documents: Iterable[ScrapedDocument], output_path: Path def _expand_source(source: ScraperSource) -> tuple[ScraperSource, ...]: if source is ScraperSource.ALL: - return (ScraperSource.NICE,) + return (ScraperSource.NICE, ScraperSource.RCH) return (source,) @@ -138,6 +168,7 @@ def run( output_format: Annotated[OutputFormat, typer.Option("--format", "-f", help="Output format.")] = OutputFormat.JSONL, output_path: Annotated[Path | None, typer.Option("--output", "-o", help="Output JSONL file, markdown directory, or Hugging Face dataset directory. JSONL defaults to stdout.")] = None, # noqa: E501 progress: Annotated[bool, typer.Option("--progress/--no-progress", help="Show a Rich progress bar.")] = True, + resume: Annotated[bool, typer.Option("--resume", help="Append to JSONL and skip URLs already present.")] = False, ) -> None: # fmt: skip """Run a scraper and write the scraped documents. @@ -152,14 +183,25 @@ def run( dataset directory. When unset, JSONL is written to stdout (default: None). progress: Whether to show a Rich progress bar (default: True). + resume: Whether to append to an existing JSONL file and skip its + completed URLs (default: False). """ parsed_documents = _parse_documents(documents) - scrape_run = scrape_documents(source, documents=parsed_documents, link_mode=link_mode, url=url) + completed_urls = _resume_urls(output_format, output_path, resume=resume) + scrape_kwargs = { + "documents": parsed_documents, + "link_mode": link_mode, + "url": url, + } + if completed_urls: + scrape_run = scrape_documents(source, skip_urls=completed_urls, **scrape_kwargs) + else: + scrape_run = scrape_documents(source, **scrape_kwargs) scraped_documents = scrape_run.documents if progress: scraped_documents = _progress_documents(scraped_documents, total=scrape_run.total) if output_format is OutputFormat.JSONL: - count = _write_jsonl_output(scraped_documents, output_path) + count = _write_jsonl_output(scraped_documents, output_path, append=resume) elif output_format is OutputFormat.HUGGINGFACE: if output_path is None: raise typer.BadParameter("--output is required when --format huggingface") @@ -204,13 +246,50 @@ def _progress_documents( yield document -def _write_jsonl_output(documents: Iterable[ScrapedDocument], output_path: Path | None) -> int: +def _write_jsonl_output( + documents: Iterable[ScrapedDocument], + output_path: Path | None, + *, + append: bool = False, +) -> int: if output_path is None: return write_jsonl(documents, sys.stdout) - with output_path.open("w", encoding="utf-8") as output: + with output_path.open("a" if append else "w", encoding="utf-8") as output: return write_jsonl(documents, output) +def _resume_urls( + output_format: OutputFormat, + output_path: Path | None, + *, + resume: bool, +) -> frozenset[str]: + if not resume: + return frozenset() + if output_format is not OutputFormat.JSONL or output_path is None: + raise typer.BadParameter("--resume requires --format jsonl and --output") + if not output_path.exists(): + return frozenset() + + urls: set[str] = set() + with output_path.open(encoding="utf-8") as existing: + for line_number, line in enumerate(existing, start=1): + try: + row = json.loads(line) + url = row["url"] + except (json.JSONDecodeError, KeyError, TypeError) as exc: + raise typer.BadParameter( + f"cannot resume from invalid JSONL row {line_number} in {output_path}" + ) from exc + if not isinstance(url, str) or not url: + raise typer.BadParameter(f"cannot resume from row {line_number} without a valid URL") + urls.add(url) + index_url = row.get("metadata", {}).get("index_url") + if isinstance(index_url, str) and index_url: + urls.add(index_url) + return frozenset(urls) + + def _parse_documents(value: str) -> int | None: normalized = value.strip().lower() if normalized == "all": diff --git a/datasets/amfv_datasets/scraping/html.py b/datasets/amfv_datasets/scraping/html.py index 2bd1c968..15805281 100644 --- a/datasets/amfv_datasets/scraping/html.py +++ b/datasets/amfv_datasets/scraping/html.py @@ -119,6 +119,8 @@ def _absolutize_links(html_text: str, *, base_url: str) -> str: root = lxml_html.fragment_fromstring(html_text, create_parent="div") for link in root.xpath(".//a[@href]"): link.set("href", urljoin(base_url, link.get("href"))) + for image in root.xpath(".//img[@src]"): + image.set("src", urljoin(base_url, image.get("src"))) return "".join(lxml_html.tostring(child, encoding="unicode") for child in root) diff --git a/datasets/amfv_datasets/scraping/rch.py b/datasets/amfv_datasets/scraping/rch.py new file mode 100644 index 00000000..db1e1302 --- /dev/null +++ b/datasets/amfv_datasets/scraping/rch.py @@ -0,0 +1,398 @@ +"""Scrape RCH clinical practice guidelines into normalized markdown documents. + +The Royal Children's Hospital Melbourne (RCH) publishes its clinical practice +guidelines as structured HTML pages. This module discovers guideline pages from +the A-Z index and extracts only the primary guideline and reference widgets. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Collection, Iterable +from dataclasses import dataclass, replace +from urllib.parse import unquote, urljoin, urlparse + +import httpx +from lxml import html as lxml_html + +from amfv_datasets.scraping.base import ( + ScrapedDocument, + ScrapeError, + ScrapeRun, + default_client, + scrape_listing_documents, +) +from amfv_datasets.scraping.html import LinkMode, clean_text, document_title, html_to_markdown + +BASE_URL = "https://www.rch.org.au" +GUIDELINE_INDEX_URL = f"{BASE_URL}/clinicalguide/guideline_index/" +RCH_DATASET_NAME = "rch-webscrape" +RCH_DATASET_DISPLAY_NAME = "RCH Clinical Practice Guidelines Webscrape" +DOCUMENT_DELAY_SECONDS = 10.0 +_LOGGER = logging.getLogger(__name__) + +_NON_GUIDELINE_SLUGS = { + "CPG_Committee_Calendar", + "Fractures", + "Immigrant_health_resources", +} + +_GUIDELINE_PATH_RE = re.compile( + r"^/clinicalguide/guideline_index/(?P.+?)/?$", + re.IGNORECASE, +) +_DIRECT_GUIDELINE_PATHS = { + "/persistent_nasal_discharge_rhinosinusitis/.aspx": ( + "Persistent_nasal_discharge_rhinosinusitis", + "/Persistent_nasal_discharge_rhinosinusitis/.aspx", + ), +} +_SEE_ALIAS_SUFFIX_RE = re.compile(r"\s+\(see\b.*\)$", re.IGNORECASE) +_LAST_UPDATED_RE = re.compile(r"\bLast updated\s+([^\n]+)", re.IGNORECASE) +_EMPTY_MARKDOWN_HEADING_RE = re.compile(r"^#{1,6}\s*$", re.MULTILINE) +_EMPTY_EMPHASIS_RE = re.compile(r"(? RchGuidelineRef: + """Parse an RCH guideline URL into a canonical guideline reference. + + Args: + url: RCH clinical guideline URL to parse. + title: Optional title discovered from the A-Z index (default: None). + """ + parsed = urlparse(url.strip()) + if parsed.scheme not in {"http", "https"} or parsed.netloc.lower() not in { + "www.rch.org.au", + "rch.org.au", + }: + raise RchFetchError(f"Enter an RCH clinical guideline URL from rch.org.au; got {url!r}") + + path = unquote(parsed.path) + match = _GUIDELINE_PATH_RE.match(path) + if not match: + direct_guideline = _DIRECT_GUIDELINE_PATHS.get(path.rstrip("/").casefold()) + if direct_guideline is not None: + slug, canonical_path = direct_guideline + return RchGuidelineRef( + slug=slug, + title=title or slug.replace("_", " "), + page_url=f"{BASE_URL}{canonical_path}", + ) + raise RchFetchError( + f"Enter a URL like https://www.rch.org.au/clinicalguide/guideline_index/Acute_asthma/; got {url!r}" + ) + + slug = match.group("slug") + if slug.lower().endswith((".pdf", ".doc", ".docx")): + raise RchFetchError(f"Enter an RCH HTML clinical guideline URL, not a downloadable file; got {url!r}") + trailing_slash = "" if slug.lower().endswith(".aspx") else "/" + page_url = f"{BASE_URL}/clinicalguide/guideline_index/{slug}{trailing_slash}" + return RchGuidelineRef(slug=slug, title=title or slug.replace("_", " "), page_url=page_url) + + +def list_guidelines(client: httpx.Client) -> list[RchGuidelineRef]: + """Return unique guideline references from the RCH A-Z index. + + Args: + client: HTTP client used to fetch the guideline index. + """ + response = client.get(GUIDELINE_INDEX_URL) + response.raise_for_status() + doc = lxml_html.fromstring(response.text) + anchors = doc.xpath( + "//div[@id='tabnav-letter-blocks' or " + "contains(concat(' ', normalize-space(@class), ' '), ' tabnav-letter-blocks ')]//a[@href]" + ) + + refs_by_url: dict[str, RchGuidelineRef] = {} + for anchor in anchors: + href = anchor.get("href") + title = clean_text(anchor.text_content(), drop_numeric_citations=False) + if not href or not title: + continue + title = _SEE_ALIAS_SUFFIX_RE.sub("", title) + try: + ref = guideline_ref_from_url(urljoin(BASE_URL, href), title=title) + except RchFetchError: + continue + if ref.slug in _NON_GUIDELINE_SLUGS: + continue + url_key = ref.page_url.casefold() + if existing := refs_by_url.get(url_key): + if title != existing.title and title not in existing.aliases: + refs_by_url[url_key] = replace(existing, aliases=(*existing.aliases, title)) + continue + refs_by_url[url_key] = ref + refs = list(refs_by_url.values()) + if not refs: + raise RchFetchError("Could not find clinical guideline links in the RCH A-Z index") + return refs + + +def _content_widgets(html_text: str) -> list[lxml_html.HtmlElement]: + doc = lxml_html.fromstring(html_text) + widgets = doc.xpath( + "//div[@id='rch-primary' or " + "contains(concat(' ', normalize-space(@class), ' '), ' rch-primary ')]" + "//div[contains(concat(' ', normalize-space(@class), ' '), ' widgetBody ')]" + ) + return [widget for widget in widgets if _is_guideline_widget(widget)] + + +def _is_guideline_widget(widget: lxml_html.HtmlElement) -> bool: + if widget.xpath(".//*[self::h2 or self::h3 or self::h4 or self::h5 or self::h6]"): + return True + text_length = len(clean_text(widget.text_content(), drop_numeric_citations=False)) + return text_length >= 500 or bool(widget.xpath(".//table")) and text_length >= 200 + + +def _normalize_widget(widget: lxml_html.HtmlElement) -> None: + for element in widget.xpath(".//script | .//style"): + element.drop_tree() + + for nested in widget.xpath(".//strong//strong | .//em//em"): + nested.drop_tag() + + for image in widget.xpath(".//img[not(@src) or not(normalize-space(@src))]"): + image.drop_tree() + + for element, attribute in ( + *((link, "href") for link in widget.xpath(".//a[@href]")), + *((image, "src") for image in widget.xpath(".//img[@src]")), + ): + value = element.get(attribute) + parsed = urlparse(value) + if parsed.netloc.lower() in {"rch.org.au", "www.rch.org.au", "webedit.rch.org.au"}: + element.set(attribute, parsed._replace(scheme="https", netloc="www.rch.org.au").geturl()) + + for emphasis in widget.xpath(".//strong | .//em | .//b | .//i"): + if not clean_text(emphasis.text_content(), drop_numeric_citations=False) and not emphasis.xpath(".//img"): + emphasis.drop_tree() + + nested_lists = widget.xpath(".//ul/ul | .//ul/ol | .//ol/ul | .//ol/ol") + for nested_list in nested_lists: + previous = nested_list.getprevious() + if previous is not None and previous.tag.lower() == "li": + previous.append(nested_list) + + for heading in widget.xpath(".//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]"): + for emphasis in heading.xpath(".//strong | .//em | .//b | .//i"): + emphasis.drop_tag() + if clean_text(heading.text_content(), drop_numeric_citations=False): + continue + if heading.xpath(".//img"): + heading.drop_tag() + else: + heading.drop_tree() + + +def build_guideline_text( + html_text: str, + *, + link_mode: LinkMode = LinkMode.KEEP, + base_url: str = BASE_URL, +) -> tuple[str, int]: + """Extract an RCH guideline page into markdown and a section count. + + Args: + html_text: RCH guideline page HTML. + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + base_url: Document URL used to resolve relative links and fragments + (default: BASE_URL). + """ + widgets = _content_widgets(html_text) + if not widgets: + raise RchFetchError("No readable clinical guideline content found on the RCH page") + + sections: list[str] = [] + for widget in widgets: + _normalize_widget(widget) + markdown = html_to_markdown( + lxml_html.tostring(widget, encoding="unicode"), + link_mode=link_mode, + base_url=base_url, + ) + markdown = _EMPTY_MARKDOWN_HEADING_RE.sub("", markdown) + markdown = _EMPTY_EMPHASIS_RE.sub("", markdown).strip() + if markdown: + sections.append(markdown) + if not sections: + raise RchFetchError("No readable clinical guideline content found on the RCH page") + return "\n\n".join(sections), len(sections) + + +def scrape_guideline( + client: httpx.Client, + ref: RchGuidelineRef, + *, + link_mode: LinkMode = LinkMode.KEEP, +) -> ScrapedDocument: + """Scrape one RCH guideline into a normalized document. + + Args: + client: HTTP client used to fetch the guideline page. + ref: RCH guideline reference to scrape. + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + """ + response = client.get(ref.page_url) + response.raise_for_status() + content_url = ref.page_url + metadata = {"slug": ref.slug} + if ref.aliases: + metadata["aliases"] = list(ref.aliases) + try: + content, section_count = build_guideline_text( + response.text, + link_mode=link_mode, + base_url=content_url, + ) + except RchFetchError: + content_url = _linked_content_url(response.text, source_url=ref.page_url) + if content_url is None: + raise + response = client.get(content_url) + response.raise_for_status() + content, section_count = build_guideline_text( + response.text, + link_mode=link_mode, + base_url=content_url, + ) + metadata["index_url"] = ref.page_url + title = document_title(response.text, fallback=ref.title) + last_updated_match = _LAST_UPDATED_RE.search(content) + if last_updated_match: + metadata["last_updated"] = last_updated_match.group(1).strip() + return ScrapedDocument( + source="rch", + external_id=f"rch-{_external_id_slug(ref.slug)}", + title=title, + url=content_url, + content=content, + section_count=section_count, + metadata=metadata, + ) + + +def _linked_content_url(html_text: str, *, source_url: str) -> str | None: + doc = lxml_html.fromstring(html_text) + hrefs = doc.xpath( + "//div[@id='rch-primary']//div[" + "contains(concat(' ', normalize-space(@class), ' '), ' widgetBody ')" + "]//a[@href]/@href" + ) + candidates: list[str] = [] + for href in hrefs: + target = urljoin(source_url, href) + parsed = urlparse(target) + if parsed.scheme not in {"http", "https"} or parsed.netloc.lower() not in { + "rch.org.au", + "www.rch.org.au", + }: + continue + normalized = target.split("#", maxsplit=1)[0] + if normalized != source_url and normalized not in candidates: + candidates.append(normalized) + return candidates[0] if len(candidates) == 1 else None + + +def _external_id_slug(slug: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", slug.lower()).strip("-") + + +def scrape_rch( + *, + documents: int | None, + link_mode: LinkMode = LinkMode.KEEP, + url: str | None = None, + skip_urls: Collection[str] = (), +) -> ScrapeRun: + """Scrape RCH guidelines from a URL or the A-Z index. + + Args: + documents: Number of documents to scrape. Ignored when `url` is set. + When unset, every guideline in the A-Z index is scraped (default: + None). + link_mode: Whether links are kept as markdown links or stripped to their + visible text (default: LinkMode.KEEP). + url: RCH guideline URL to scrape as a single document (default: None). + skip_urls: Canonical document URLs already scraped by a previous run + (default: ()). + """ + if url is not None: + + def scrape_url() -> Iterable[ScrapedDocument]: + with default_client() as client: + yield scrape_guideline(client, guideline_ref_from_url(url), link_mode=link_mode) + + return ScrapeRun(documents=scrape_url(), total=1) + + with default_client() as client: + refs = list_guidelines(client) + refs = [ref for ref in refs if ref.page_url not in skip_urls] + total = len(refs) if documents is None else min(documents, len(refs)) + return ScrapeRun( + total=total, + documents=scrape_listing_documents( + documents=documents, + client_factory=default_client, + first_page_items=refs, + list_page=lambda _client, _page: (), + scrape_item=lambda client, ref: _scrape_or_skip_guideline(client, ref, link_mode=link_mode), + document_delay_seconds=DOCUMENT_DELAY_SECONDS, + ), + ) + + +def _scrape_or_skip_guideline( + client: httpx.Client, + ref: RchGuidelineRef, + *, + link_mode: LinkMode, +) -> ScrapedDocument | None: + try: + return scrape_guideline(client, ref, link_mode=link_mode) + except RchFetchError as exc: + _LOGGER.warning("Skipping unsupported RCH index entry %s: %s", ref.page_url, exc) + return None + except httpx.HTTPStatusError as exc: + if exc.response.status_code not in {404, 410}: + raise + _LOGGER.warning( + "Skipping stale RCH index entry %s: HTTP %s", + ref.page_url, + exc.response.status_code, + ) + return None + + +__all__ = [ + "BASE_URL", + "DOCUMENT_DELAY_SECONDS", + "GUIDELINE_INDEX_URL", + "RCH_DATASET_DISPLAY_NAME", + "RCH_DATASET_NAME", + "RchFetchError", + "RchGuidelineRef", + "build_guideline_text", + "guideline_ref_from_url", + "list_guidelines", + "scrape_guideline", + "scrape_rch", +] diff --git a/datasets/test/test_scraping_base.py b/datasets/test/test_scraping_base.py index eed3c838..2201e4de 100644 --- a/datasets/test/test_scraping_base.py +++ b/datasets/test/test_scraping_base.py @@ -149,3 +149,43 @@ def scrape_item(client: httpx.Client, item: str) -> ScrapedDocument: assert [document.external_id for document in documents] == ["item-1", "item-2", "item-3"] assert delays == [5.0, 5.0] + + +def test_scrape_listing_documents_skips_non_documents_without_consuming_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skipped listing entries are delayed but do not count as returned documents.""" + delays: list[float] = [] + + class _FakeClient: + def __enter__(self) -> "_FakeClient": + return self + + def __exit__(self, *args: object) -> None: + return None + + def client_factory(): + return _FakeClient() + + def list_page(client: httpx.Client, page: int) -> list[str]: + return ["skip", "item-1", "item-2"] if page == 1 else [] + + def scrape_item(client: httpx.Client, item: str) -> ScrapedDocument | None: + if item == "skip": + return None + return ScrapedDocument("test", item, item, f"https://example.org/{item}", "content") + + monkeypatch.setattr(base.time, "sleep", delays.append) + + documents = list( + scrape_listing_documents( + documents=2, + client_factory=client_factory, + list_page=list_page, + scrape_item=scrape_item, + document_delay_seconds=5.0, + ) + ) + + assert [document.external_id for document in documents] == ["item-1", "item-2"] + assert delays == [5.0, 5.0] diff --git a/datasets/test/test_scraping_cli.py b/datasets/test/test_scraping_cli.py index 44f714fa..358f12c6 100644 --- a/datasets/test/test_scraping_cli.py +++ b/datasets/test/test_scraping_cli.py @@ -1,7 +1,7 @@ """Tests for the scraping CLI.""" import json -from collections.abc import Iterator +from collections.abc import Collection, Iterator from pathlib import Path import pytest @@ -11,6 +11,7 @@ from amfv_datasets.scraping.cli import ( ScraperSource, app, + scrape_documents, write_huggingface_dataset, write_jsonl, write_markdown_files, @@ -194,6 +195,47 @@ def fake_progress( assert json.loads(result.stdout.splitlines()[0])["external_id"] == "nice-ng1" +def test_cli_run_dispatches_rch_source(monkeypatch: pytest.MonkeyPatch) -> None: + """The CLI dispatches RCH source URLs to the RCH scraper.""" + runner = CliRunner() + url = "https://www.rch.org.au/clinicalguide/guideline_index/Acute_asthma/" + + def fake_scrape_rch( + *, + documents: int | None, + link_mode: LinkMode, + url: str | None, + ) -> ScrapeRun: + assert documents == 1 + assert link_mode is LinkMode.KEEP + assert url == "https://www.rch.org.au/clinicalguide/guideline_index/Acute_asthma/" + return ScrapeRun([_document(source="rch")], total=1) + + monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_rch", fake_scrape_rch) + + result = runner.invoke(app, ["--source", "rch", "--url", url, "--no-progress"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout.splitlines()[0])["source"] == "rch" + + +def test_scrape_documents_combines_all_sources(monkeypatch: pytest.MonkeyPatch) -> None: + """The all source combines documents and totals from every registered scraper.""" + monkeypatch.setattr( + "amfv_datasets.scraping.cli.scrape_nice", + lambda **_kwargs: ScrapeRun([_document()], total=1), + ) + monkeypatch.setattr( + "amfv_datasets.scraping.cli.scrape_rch", + lambda **_kwargs: ScrapeRun([_document(source="rch")], total=1), + ) + + run = scrape_documents(ScraperSource.ALL, documents=1, link_mode=LinkMode.KEEP) + + assert run.total == 2 + assert [document.source for document in run] == ["nice", "rch"] + + def test_cli_run_accepts_all_documents(monkeypatch: pytest.MonkeyPatch) -> None: """The CLI accepts --documents all.""" runner = CliRunner() @@ -219,10 +261,47 @@ def fake_scrape_documents( assert json.loads(result.stdout.splitlines()[0])["external_id"] == "nice-ng1" -def _document() -> ScrapedDocument: +def test_cli_resume_appends_and_skips_existing_urls(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Resume reads completed URLs before scraping and appends new JSONL rows.""" + output_path = tmp_path / "rch.jsonl" + existing = _document() + existing_row = existing.__dict__ | {"metadata": {"index_url": "https://example.org/index-entry"}} + output_path.write_text(json.dumps(existing_row) + "\n", encoding="utf-8") + new_document = ScrapedDocument( + source="rch", + external_id="rch-new", + title="New guideline", + url="https://www.rch.org.au/clinicalguide/guideline_index/New/", + content="new content", + ) + + def fake_scrape_documents( + source: ScraperSource, + *, + documents: int | None, + link_mode: LinkMode, + url: str | None = None, + skip_urls: Collection[str] = (), + ) -> ScrapeRun: + assert source is ScraperSource.RCH + assert set(skip_urls) == {existing.url, "https://example.org/index-entry"} + return ScrapeRun([new_document], total=1) + + monkeypatch.setattr("amfv_datasets.scraping.cli.scrape_documents", fake_scrape_documents) + result = CliRunner().invoke( + app, + ["--source", "rch", "--output", str(output_path), "--resume", "--no-progress"], + ) + + assert result.exit_code == 0 + rows = [json.loads(line) for line in output_path.read_text(encoding="utf-8").splitlines()] + assert [row["external_id"] for row in rows] == [existing.external_id, new_document.external_id] + + +def _document(*, source: str = "nice") -> ScrapedDocument: return ScrapedDocument( - source="nice", - external_id="nice-ng1", + source=source, + external_id=f"{source}-ng1", title="Guideline 1", url="https://www.nice.org.uk/guidance/ng1", content="content", diff --git a/datasets/test/test_scraping_html.py b/datasets/test/test_scraping_html.py index d34a8fa3..b228222d 100644 --- a/datasets/test/test_scraping_html.py +++ b/datasets/test/test_scraping_html.py @@ -68,3 +68,12 @@ def test_html_to_markdown_can_strip_links() -> None: html_text = '

      Offer treatment.

      ' assert html_to_markdown(html_text, link_mode=LinkMode.STRIP) == "Offer treatment." + + +def test_html_to_markdown_absolutizes_images() -> None: + """Relative image sources are preserved as absolute markdown image URLs.""" + html_text = 'Treatment flowchart' + + assert html_to_markdown(html_text, base_url="https://example.org/guideline/") == ( + "![Treatment flowchart](https://example.org/images/flowchart.png)" + ) diff --git a/datasets/test/test_scraping_rch.py b/datasets/test/test_scraping_rch.py new file mode 100644 index 00000000..5b2c4f92 --- /dev/null +++ b/datasets/test/test_scraping_rch.py @@ -0,0 +1,252 @@ +"""Tests for RCH clinical guideline scraping helpers.""" + +import httpx +import pytest + +from amfv_datasets.scraping.html import LinkMode +from amfv_datasets.scraping.rch import ( + BASE_URL, + GUIDELINE_INDEX_URL, + RchGuidelineRef, + _scrape_or_skip_guideline, + guideline_ref_from_url, + list_guidelines, + scrape_guideline, +) + + +def test_list_guidelines_discovers_unique_html_pages() -> None: + """The A-Z index yields unique HTML guideline references and nested paths.""" + html_text = """ + + + + + """ + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == GUIDELINE_INDEX_URL + return httpx.Response(200, text=html_text) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + + assert list_guidelines(client) == [ + RchGuidelineRef( + slug="Acute_asthma", + title="Acute asthma", + page_url=f"{BASE_URL}/clinicalguide/guideline_index/Acute_asthma/", + aliases=("Asthma acute",), + ), + RchGuidelineRef( + slug="fractures/Elbow_Dislocations", + title="Elbow dislocations", + page_url=f"{BASE_URL}/clinicalguide/guideline_index/fractures/Elbow_Dislocations/", + ), + RchGuidelineRef( + slug="Persistent_nasal_discharge_rhinosinusitis", + title="Nasal discharge", + page_url=f"{BASE_URL}/Persistent_nasal_discharge_rhinosinusitis/.aspx", + aliases=("Rhinosinusitis",), + ), + ] + + +def test_guideline_ref_from_url_normalizes_host_and_query() -> None: + """RCH guideline URLs are normalized to canonical HTTPS page URLs.""" + assert guideline_ref_from_url( + "http://rch.org.au/clinicalguide/guideline_index/Acute_asthma/?print=yes#management" + ) == RchGuidelineRef( + slug="Acute_asthma", + title="Acute asthma", + page_url="https://www.rch.org.au/clinicalguide/guideline_index/Acute_asthma/", + ) + + +def test_guideline_ref_from_url_preserves_legacy_aspx_route() -> None: + """Legacy ASPX guideline paths do not receive a breaking trailing slash.""" + assert ( + guideline_ref_from_url("https://www.rch.org.au/clinicalguide/guideline_index/Gastrostomy.aspx/").page_url + == "https://www.rch.org.au/clinicalguide/guideline_index/Gastrostomy.aspx" + ) + + +def test_guideline_ref_from_url_accepts_direct_rhinosinusitis_guideline() -> None: + """The clinical A-Z index's direct rhinosinusitis route remains discoverable.""" + assert guideline_ref_from_url( + "https://rch.org.au/Persistent_nasal_discharge_rhinosinusitis/.aspx?print=yes" + ) == RchGuidelineRef( + slug="Persistent_nasal_discharge_rhinosinusitis", + title="Persistent nasal discharge rhinosinusitis", + page_url=f"{BASE_URL}/Persistent_nasal_discharge_rhinosinusitis/.aspx", + ) + + +def test_scrape_guideline_extracts_primary_widgets_as_markdown() -> None: + """Guideline content, references, metadata, lists, tables, and images are preserved.""" + html_text = """ + +

      Acute asthma

      +
      +
      PIC Endorsed
      +
      +

      Key points

      +

      Treat urgently.

      +
        +
      • Assess severity:
      • +
        • Check breathing.
        • Check activity.
        +
      +

      Management details

      +

      See the anaphylaxis guideline.

      +

      Jump to management.

      +
      SeverityAction
      SevereEscalate
      +


      +

      Asthma flowchart

      +

      Legacy image

      +

      Missing image source

      +

      Last updated July 2025

      + +
      +

      Reference List

      1. Reference one.
      +
      +

      Contact us

      Footer content.

      + + """ + ref = RchGuidelineRef( + slug="Acute_asthma", + title="Acute asthma listing title", + page_url="https://www.rch.org.au/clinicalguide/guideline_index/Acute_asthma/", + aliases=("Asthma acute", "Wheeze"), + ) + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == ref.page_url + return httpx.Response(200, text=html_text) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + + document = scrape_guideline(client, ref) + + assert document.source == "rch" + assert document.external_id == "rch-acute-asthma" + assert document.title == "Acute asthma" + assert document.section_count == 2 + assert document.metadata == { + "slug": "Acute_asthma", + "aliases": ["Asthma acute", "Wheeze"], + "last_updated": "July 2025", + } + assert "- Assess severity:\n - Check breathing.\n - Check activity." in document.content + assert "### Management details" in document.content + assert ( + "[anaphylaxis guideline](https://www.rch.org.au/clinicalguide/guideline_index/Anaphylaxis/)" in document.content + ) + assert ( + "[management](https://www.rch.org.au/clinicalguide/guideline_index/Acute_asthma/#management)" + in document.content + ) + assert "| Severity | Action |" in document.content + assert "![Asthma flowchart](https://www.rch.org.au/uploadedImages/asthma-flowchart.png)" in document.content + assert "![Legacy image](https://www.rch.org.au/uploadedImages/legacy.png)" in document.content + assert "Missing image source" not in document.content + assert "## Reference List" in document.content + assert "****" not in document.content + assert "PIC Endorsed" not in document.content + assert "Footer content" not in document.content + assert "rchWidget" not in document.content + + +def test_scrape_guideline_keeps_substantial_headingless_legacy_content() -> None: + """Legacy clinical tables without section headings remain valid guideline content.""" + clinical_rows = "".join(f"Disease {index}Dose {index}" for index in range(10)) + html_text = f""" +

      Empiric treatment

      +

      Start empiric treatment based on clinical features and local antimicrobial susceptibility patterns.

      + {clinical_rows}
      DiseaseTreatment
      +
      + """ + ref = RchGuidelineRef("Empiric_treatment", "Empiric treatment", f"{BASE_URL}/guideline/empiric") + + with httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(200, text=html_text))) as client: + document = scrape_guideline(client, ref) + + assert "| Disease | Treatment |" in document.content + assert "local antimicrobial susceptibility patterns" in document.content + + +def test_scrape_guideline_resolves_same_site_link_wrapper() -> None: + """Link-only A-Z entries resolve to their substantial same-site content pages.""" + index_url = f"{BASE_URL}/clinicalguide/guideline_index/IV_Immunoglobulin/" + target_url = f"{BASE_URL}/bloodtrans/about_blood_products/Intravenous_Immunoglobulin_Guideline" + wrapper_html = f""" +

      IV Immunoglobulin

      + """ + target_html = """ +

      Intravenous immunoglobulin

      +

      Administration

      Monitor the patient throughout the infusion.

      +
      + """ + ref = RchGuidelineRef("IV_Immunoglobulin", "IV Immunoglobulin", index_url) + + def handler(request: httpx.Request) -> httpx.Response: + pages = {index_url: wrapper_html, target_url: target_html} + return httpx.Response(200, text=pages[str(request.url)]) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + document = scrape_guideline(client, ref) + + assert document.url == target_url + assert document.title == "Intravenous immunoglobulin" + assert document.metadata == {"slug": "IV_Immunoglobulin", "index_url": index_url} + assert "Monitor the patient throughout the infusion." in document.content + + +def test_scrape_or_skip_guideline_logs_non_guideline_index_entry(caplog: pytest.LogCaptureFixture) -> None: + """Resource pages discovered in the guideline index are reported and skipped.""" + ref = RchGuidelineRef( + slug="Immigrant_health_resources", + title="Immigrant health resources", + page_url="https://www.rch.org.au/clinicalguide/guideline_index/Immigrant_health_resources/", + ) + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == ref.page_url + html_text = "
      Resource link
      " + return httpx.Response(200, text=html_text) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + assert _scrape_or_skip_guideline(client, ref, link_mode=LinkMode.KEEP) is None + + assert "Skipping unsupported RCH index entry" in caplog.text + + +@pytest.mark.parametrize("status_code", [404, 410]) +def test_scrape_or_skip_guideline_logs_stale_index_entry( + status_code: int, + caplog: pytest.LogCaptureFixture, +) -> None: + """Permanently missing pages in the live index are reported and skipped.""" + ref = RchGuidelineRef("stale", "Stale", f"{BASE_URL}/clinicalguide/guideline_index/stale/") + + with httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(status_code))) as client: + assert _scrape_or_skip_guideline(client, ref, link_mode=LinkMode.KEEP) is None + + assert f"HTTP {status_code}" in caplog.text + + +def test_scrape_or_skip_guideline_raises_transient_http_error() -> None: + """Server errors still abort so incomplete runs are not silently accepted.""" + ref = RchGuidelineRef("error", "Error", f"{BASE_URL}/clinicalguide/guideline_index/error/") + + with httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(503))) as client: + with pytest.raises(httpx.HTTPStatusError): + _scrape_or_skip_guideline(client, ref, link_mode=LinkMode.KEEP) From b55d196af48bf57905560acc792c774102a6dbff Mon Sep 17 00:00:00 2001 From: Zander Giuffrida Date: Sun, 26 Jul 2026 00:05:17 -0700 Subject: [PATCH 06/15] allow scrape_item to skip a listed item Taken verbatim from #10 so the two merge cleanly in either order. A listing entry is not always a document: PMC holds some open-access records as metadata only, with no full text deposited. Returning None skips the entry without consuming the --documents budget, and the delay gate moves from scraped to attempted so skipped items still rate-limit. Drop this commit if #10 lands first. --- datasets/amfv_datasets/scraping/base.py | 14 ++++++--- datasets/test/test_scraping_base.py | 40 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/datasets/amfv_datasets/scraping/base.py b/datasets/amfv_datasets/scraping/base.py index 0708ee87..3651cdb7 100644 --- a/datasets/amfv_datasets/scraping/base.py +++ b/datasets/amfv_datasets/scraping/base.py @@ -74,7 +74,7 @@ def scrape_listing_documents[ListingItemT]( documents: int | None, client_factory: Callable[[], AbstractContextManager[httpx.Client]], list_page: Callable[[httpx.Client, int], Iterable[ListingItemT]], - scrape_item: Callable[[httpx.Client, ListingItemT], ScrapedDocument], + scrape_item: Callable[[httpx.Client, ListingItemT], ScrapedDocument | None], document_delay_seconds: float = 5.0, first_page_items: Iterable[ListingItemT] | None = None, ) -> Iterable[ScrapedDocument]: @@ -85,7 +85,8 @@ def scrape_listing_documents[ListingItemT]( fetched until a page returns no items (default: None). client_factory: Factory returning a context-managed HTTP client. list_page: Function that lists source-specific items for a page. - scrape_item: Function that scrapes one listed item into a document. + scrape_item: Function that scrapes one listed item into a document. It + may return None to skip a discovered item that is not a document. document_delay_seconds: Delay before scraping each document after the first one (default: 5.0). first_page_items: Already-fetched first listing page items. When set, @@ -99,6 +100,7 @@ def scrape_listing_documents[ListingItemT]( with client_factory() as client: page = 1 scraped = 0 + attempted = 0 page_items = list(first_page_items) if first_page_items is not None else None while documents is None or scraped < documents: if page_items is None: @@ -111,9 +113,13 @@ def scrape_listing_documents[ListingItemT]( for item in items: if documents is not None and scraped >= documents: break - if scraped and document_delay_seconds: + if attempted and document_delay_seconds: time.sleep(document_delay_seconds) - yield scrape_item(client, item) + document = scrape_item(client, item) + attempted += 1 + if document is None: + continue + yield document scraped += 1 page += 1 diff --git a/datasets/test/test_scraping_base.py b/datasets/test/test_scraping_base.py index eed3c838..2201e4de 100644 --- a/datasets/test/test_scraping_base.py +++ b/datasets/test/test_scraping_base.py @@ -149,3 +149,43 @@ def scrape_item(client: httpx.Client, item: str) -> ScrapedDocument: assert [document.external_id for document in documents] == ["item-1", "item-2", "item-3"] assert delays == [5.0, 5.0] + + +def test_scrape_listing_documents_skips_non_documents_without_consuming_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skipped listing entries are delayed but do not count as returned documents.""" + delays: list[float] = [] + + class _FakeClient: + def __enter__(self) -> "_FakeClient": + return self + + def __exit__(self, *args: object) -> None: + return None + + def client_factory(): + return _FakeClient() + + def list_page(client: httpx.Client, page: int) -> list[str]: + return ["skip", "item-1", "item-2"] if page == 1 else [] + + def scrape_item(client: httpx.Client, item: str) -> ScrapedDocument | None: + if item == "skip": + return None + return ScrapedDocument("test", item, item, f"https://example.org/{item}", "content") + + monkeypatch.setattr(base.time, "sleep", delays.append) + + documents = list( + scrape_listing_documents( + documents=2, + client_factory=client_factory, + list_page=list_page, + scrape_item=scrape_item, + document_delay_seconds=5.0, + ) + ) + + assert [document.external_id for document in documents] == ["item-1", "item-2"] + assert delays == [5.0, 5.0] From 65273392ed20682323702dff2c8d78ca4fcf8f00 Mon Sep 17 00:00:00 2001 From: Zander Giuffrida Date: Sun, 26 Jul 2026 14:53:55 -0700 Subject: [PATCH 07/15] add PubMed guideline scraper Scrapes the intersection of PubMed's Guideline publication type with the PMC Open Access subset, restricted to English: about 3,000 guidelines. That is the only slice where the full text is both retrievable and openly licensed; the other 40k Guideline records expose an abstract only, under publisher copyright. Two scoping choices, both measured rather than assumed: `Guideline[pt]` rather than `Practice Guideline[pt]`. The former is a strict superset and the 352 records it adds are clinical, not administrative (the 2025 Korean CPR guidelines and similar), so the narrower tag would drop 11% of the corpus for nothing. `English[la]`, which drops 185 records. Every other source in this package is already English-only as a side effect of its entry URL: CPS is a bilingual site scraped through its /en/ routes, WHO publishes in six languages and is scraped through its English listing. PubMed's API returns every language, so the filter has to be explicit to match. The excluded records are largely French CMAJ translations of guidelines already in the corpus. Discovery and extraction use NCBI's E-utilities rather than the rendered pages. esearch paginates by numeric offset, so the page number maps straight onto retstart and an empty page past the end terminates the run. esummary resolves a whole batch of PMIDs to PMCIDs and citation metadata in one request. efetch returns JATS XML carrying body, section structure and license. JATS is close enough to HTML that renaming tags and reusing html_to_markdown is cheaper and less error-prone than a second serializer: table-wrap already contains genuine XHTML tables, and inline markup maps one to one. Licensing is recorded per document rather than claimed for the source, because the Open Access Subset is not uniformly Creative Commons licensed. Censused over all 2,999 English records present on 2026-07-29: CC BY 44.5% publisher terms, no CC license 11.7% CC BY-NC 22.1% of which: Elsevier COVID grant, no CC BY-NC-ND 18.4% element at all (112), CC BY-NC-SA 2.1% PMC OA "unrestricted re-use" CC0 1.3% By what that permits: 45.8% unrestricted for derivative works, 24.1% non-commercial only, 18.4% asserting NoDerivatives, 11.7% needing a case-by-case reading. Presence in the subset is not itself a grant to redistribute: 112 records carry only a copyright line such as "(c) Springer-Verlag Tokyo 2007", and Elsevier's pandemic-era deposits grant free access while still reserving all rights. The license name is parsed from the Creative Commons URL rather than the license-type attribute, which the corpus spells 18 different ways. The copyright statement is captured separately because it is a sibling of , not a child, and it holds the reservation of rights. Figures and supplementary files are recorded in metadata rather than linked. Unlike the HTML sources in #9 and #10, which absolutize a real , JATS carries only a bare filename; the served URL inserts a CDN shard and content hash that appear nowhere in the API response, so a constructed link 404s. Supplementary blocks are pointers too: across 40 sampled guidelines every one referenced an external .docx or .tif rather than inline content, totalling 0.18% of body text. Recording name, label and caption keeps the evidence tables findable without re-scraping. E-utilities calls retry with backoff on 429 and 5xx. One document makes up to two calls back to back and the rate limit is per source address, so NCBI does answer with 429 in practice; without a retry that propagates past the skip handler and kills the whole run. external_id prefers the PMID from the record itself, so an article reached from a PMC URL gets the same identifier as one reached from the listing. Records PMC holds without a deposited body, 0.6% of the corpus, are logged and skipped rather than aborting the run. --- datasets/amfv_datasets/scraping/__init__.py | 18 + datasets/amfv_datasets/scraping/cli.py | 6 +- datasets/amfv_datasets/scraping/pubmed.py | 723 ++++++++++++++++++++ datasets/test/test_scraping_pubmed.py | 478 +++++++++++++ 4 files changed, 1224 insertions(+), 1 deletion(-) create mode 100644 datasets/amfv_datasets/scraping/pubmed.py create mode 100644 datasets/test/test_scraping_pubmed.py diff --git a/datasets/amfv_datasets/scraping/__init__.py b/datasets/amfv_datasets/scraping/__init__.py index cd7cdb06..d5630df7 100644 --- a/datasets/amfv_datasets/scraping/__init__.py +++ b/datasets/amfv_datasets/scraping/__init__.py @@ -27,6 +27,16 @@ scrape_guideline, scrape_nice, ) +from amfv_datasets.scraping.pubmed import ( + PubMedArticleRef, + PubMedFetchError, + PubMedListingPage, + build_pubmed_article_text, + list_pubmed_guidelines, + pubmed_ref_from_url, + scrape_pubmed, + scrape_pubmed_article, +) __all__ = [ "GuidanceRef", @@ -34,6 +44,9 @@ "LinkMode", "NiceFetchError", "OutputFormat", + "PubMedArticleRef", + "PubMedFetchError", + "PubMedListingPage", "ScrapeError", "ScrapeRun", "ScrapedDocument", @@ -41,6 +54,7 @@ "USER_AGENT", "absolute_unique_urls", "build_guideline_text", + "build_pubmed_article_text", "clean_text", "document_title", "default_client", @@ -48,7 +62,11 @@ "guidance_ref_from_url", "html_to_markdown", "list_published_guidance", + "list_pubmed_guidelines", + "pubmed_ref_from_url", "scrape_guideline", "scrape_listing_documents", "scrape_nice", + "scrape_pubmed", + "scrape_pubmed_article", ] diff --git a/datasets/amfv_datasets/scraping/cli.py b/datasets/amfv_datasets/scraping/cli.py index e8aece34..32cafb16 100644 --- a/datasets/amfv_datasets/scraping/cli.py +++ b/datasets/amfv_datasets/scraping/cli.py @@ -27,6 +27,7 @@ from amfv_datasets.scraping.base import ScrapedDocument, ScrapeRun from amfv_datasets.scraping.html import LinkMode from amfv_datasets.scraping.nice import scrape_nice +from amfv_datasets.scraping.pubmed import scrape_pubmed class ScraperSource(StrEnum): @@ -34,6 +35,7 @@ class ScraperSource(StrEnum): ALL = "all" NICE = "nice" + PUBMED = "pubmed" class OutputFormat(StrEnum): @@ -72,6 +74,8 @@ def scrape_documents( match selected_source: case ScraperSource.NICE: return scrape_nice(documents=documents, link_mode=link_mode, url=url) + case ScraperSource.PUBMED: + return scrape_pubmed(documents=documents, link_mode=link_mode, url=url) case ScraperSource.ALL: raise AssertionError("expanded source cannot be all") raise AssertionError(f"unsupported source: {source}") @@ -125,7 +129,7 @@ def write_markdown_files(documents: Iterable[ScrapedDocument], output_path: Path def _expand_source(source: ScraperSource) -> tuple[ScraperSource, ...]: if source is ScraperSource.ALL: - return (ScraperSource.NICE,) + return (ScraperSource.NICE, ScraperSource.PUBMED) return (source,) diff --git a/datasets/amfv_datasets/scraping/pubmed.py b/datasets/amfv_datasets/scraping/pubmed.py new file mode 100644 index 00000000..5a1f411b --- /dev/null +++ b/datasets/amfv_datasets/scraping/pubmed.py @@ -0,0 +1,723 @@ +"""Scrape PubMed clinical practice guidelines into normalized markdown documents. + +PubMed indexes roughly 43k articles tagged with the `Guideline` publication +type, but most of them expose an abstract only and stay under publisher +copyright. This module deliberately scrapes the intersection with the PMC Open +Access subset instead: + + Guideline[pt] AND pubmed pmc open access[filter] AND English[la] + +That is about 3,000 guidelines, growing by a handful a week, and it is the only +slice where the full text is both retrievable and openly licensed. + +Licensing is recorded per document rather than claimed for the source, because +the Open Access Subset is not uniformly Creative Commons licensed. Censused +over all 2,999 records present on 2026-07-29: CC BY 44.5%, CC BY-NC 22.1%, +CC BY-NC-ND 18.4%, CC BY-NC-SA 2.1%, CC0 1.3%, and 11.7% carrying publisher +terms instead. By what that permits, 45.8% is unrestricted for derivative +works, 24.1% is non-commercial only, 18.4% asserts NoDerivatives, and 11.7% +has to be read case by case. + +Discovery and extraction both go through NCBI's E-utilities rather than the +rendered pubmed.ncbi.nlm.nih.gov pages. `esearch` paginates by numeric offset and +reports the result total, `esummary` resolves a whole batch of PMIDs to PMCIDs +and citation metadata in one request, and `efetch` returns JATS XML carrying the +article body, section structure and license. All three are documented, versioned +contracts that do not break when the website is restyled. + +Attribution: +Meditron's guideline scrapers (epfLLM/meditron, gap-replay/guidelines) cover a +dozen sources but have no PubMed scraper, so this module has no upstream port to +follow. It follows the conventions of `nice.py` in this package instead. +Source license: Apache License 2.0. +""" + +from __future__ import annotations + +import copy +import logging +import os +import re +import time +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +import httpx +from lxml import etree + +from amfv_datasets.scraping.base import ( + ScrapedDocument, + ScrapeError, + ScrapeRun, + default_client, + scrape_listing_documents, +) +from amfv_datasets.scraping.html import LinkMode, clean_text, html_to_markdown + +BASE_URL = "https://pubmed.ncbi.nlm.nih.gov" +EUTILS_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" +PMC_ARTICLE_URL = "https://www.ncbi.nlm.nih.gov/pmc/articles" +# `Guideline` is a strict superset of `Practice Guideline`: searching for either +# returns exactly the `Guideline` count. The 352 extra records are clinical, not +# administrative — the 2025 Korean CPR guidelines and similar — so scoping to +# `Practice Guideline` would drop 352 records (11%) for nothing. +# +# `English[la]` drops 185 records. Most other sources in this package are +# English-only as a side effect of the URL they start from (CPS is a bilingual +# site scraped through its `/en/` routes; WHO publishes in six languages and is +# scraped through its English listing). PubMed's API returns every language, so +# the filter is explicit here to match. The excluded records are largely French +# CMAJ translations of English guidelines already in the corpus. +SEARCH_TERM = "Guideline[pt] AND pubmed pmc open access[filter] AND English[la]" +PUBMED_DATASET_NAME = "pubmed-webscrape" +PUBMED_DATASET_DISPLAY_NAME = "PubMed Webscrape" +NCBI_TOOL = "amfv" +# NCBI documents 3 requests/second without an API key and 10 with one, so unlike +# a scraped website this delay is a published allowance rather than a guess. Do +# not raise it to match the other scrapers "for consistency" — that turns a +# 20-minute run into a 4-hour one for no benefit. +DOCUMENT_DELAY_SECONDS = 0.4 +LISTING_PAGE_SIZE = 200 +# Enough of a non-CC license statement to tell "unrestricted re-use" apart from +# "all rights reserved" without carrying a wall of boilerplate per document. +_LICENSE_STATEMENT_CHARS = 400 +_FIGURE_CAPTION_CHARS = 300 +# NCBI answers a burst with 429 and is transiently unavailable often enough that +# a multi-thousand document run needs to ride both out. +_MAX_RETRIES = 4 +_RETRY_BACKOFF_SECONDS = 1.0 +_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504}) + +logger = logging.getLogger(__name__) + +_PUBMED_PATH_RE = re.compile(r"^/(?P\d+)/?$") +_PMC_PATH_RE = re.compile(r"^/pmc/articles/(?PPMC\d+)/?$", re.IGNORECASE) +_CC_URL_RE = re.compile(r"https?://creativecommons\.org/\S+?(?=[\s\"<)]|$)") +# Unwrapping `xref` and dropping figures leaves the surrounding spacing behind, +# as in "(PRISMA flow diagram in Supplementary Fig. S1) ." — close it back up. +_ORPHAN_SPACE_RE = re.compile(r" +([,.;:)\]])") +_XLINK_HREF = "{http://www.w3.org/1999/xlink}href" +_ALI_LICENSE_REF = "{http://www.niso.org/schemas/ali/1.0/}license_ref" + +# JATS elements whose entire subtree is dropped. Reference lists and footnote +# groups are citation apparatus rather than clinical content. Figure payloads +# cannot be linked from the API response at all (see `_article_figures`), so the +# `` goes but its `