193 lines
6.4 KiB
Python
193 lines
6.4 KiB
Python
"""Fetch and parse ADE PublishDetails pages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
from urllib.parse import urljoin
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
from sfda_parser.sfda_scraper.detail_pdf import parse_affected_device_list
|
|
from sfda_parser.sfda_scraper.http_client import ADE_BASE, create_session, request_with_retry
|
|
|
|
DOWNLOAD_PATTERN = re.compile(
|
|
r'formaction=["\'](?P<path>/BaseService/DownloadDocument\?download=[^"\']+)["\']',
|
|
re.IGNORECASE,
|
|
)
|
|
TOKEN_PATTERN = re.compile(
|
|
r'name=["\']__RequestVerificationToken["\'][^>]*value=["\']([^"\']+)["\']',
|
|
re.IGNORECASE,
|
|
)
|
|
PUBLISH_ID_PATTERN = re.compile(r"/Fsca/PublishDetails/(\d+)", re.IGNORECASE)
|
|
|
|
HTML_FIELD_MAP = {
|
|
"reference_number": "ReferenceNumber",
|
|
"manufacturer": "ManufacturerName",
|
|
"product_trade_name": "ProductTradeName",
|
|
"model": "ModelNbr",
|
|
"authorized_representative": "AuthorizedRepresentative",
|
|
"problem_reason": "ProblemReason",
|
|
"action": "Action",
|
|
}
|
|
|
|
|
|
class NoPdfDownloadLinkError(ValueError):
|
|
"""ADE PublishDetails page has no DownloadDocument link."""
|
|
|
|
|
|
def _field_value(soup: BeautifulSoup, field_name: str) -> str:
|
|
el = soup.find(attrs={"name": field_name})
|
|
if not el:
|
|
return ""
|
|
if el.name == "textarea":
|
|
return el.get_text(strip=True)
|
|
return (el.get("value") or "").strip()
|
|
|
|
|
|
def _extract_download_path(html: str) -> str | None:
|
|
match = DOWNLOAD_PATTERN.search(html)
|
|
if match:
|
|
return match.group("path")
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
button = soup.select_one("button[formaction*='DownloadDocument']")
|
|
if button and button.get("formaction"):
|
|
return button["formaction"]
|
|
return None
|
|
|
|
|
|
def _extract_verification_token(html: str) -> str | None:
|
|
match = TOKEN_PATTERN.search(html)
|
|
if match:
|
|
return match.group(1)
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
inp = soup.find("input", {"name": "__RequestVerificationToken"})
|
|
if inp and inp.get("value"):
|
|
return inp["value"]
|
|
return None
|
|
|
|
|
|
def parse_publish_details_html(html: str, detail_url: str = "") -> dict[str, Any]:
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
publish_id = ""
|
|
if detail_url:
|
|
match = PUBLISH_ID_PATTERN.search(detail_url)
|
|
if match:
|
|
publish_id = match.group(1)
|
|
|
|
download_path = _extract_download_path(html)
|
|
data: dict[str, Any] = {
|
|
"detail_url": detail_url,
|
|
"publish_id": publish_id,
|
|
"pdf_download_url": urljoin(ADE_BASE, download_path) if download_path else "",
|
|
}
|
|
for key, field_name in HTML_FIELD_MAP.items():
|
|
data[key] = _field_value(soup, field_name)
|
|
return data
|
|
|
|
|
|
def fetch_publish_details_html(detail_url: str, session=None) -> dict[str, Any]:
|
|
session = session or create_session()
|
|
response = request_with_retry(session, "GET", detail_url, timeout=60)
|
|
return parse_publish_details_html(response.text, detail_url=detail_url)
|
|
|
|
|
|
def fetch_publish_details(
|
|
detail_url: str,
|
|
session=None,
|
|
include_device_list_from_pdf: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""ADE page fields; optionally parse Material/GTIN/Batch table from attached PDF."""
|
|
session = session or create_session()
|
|
result = fetch_publish_details_html(detail_url, session=session)
|
|
|
|
if include_device_list_from_pdf:
|
|
try:
|
|
pdf_bytes = download_detail_pdf(detail_url, session=session)
|
|
devices = parse_affected_device_list(pdf_bytes)
|
|
result["device_list_from_pdf"] = [d.as_dict() for d in devices]
|
|
result["device_list_from_pdf_count"] = len(devices)
|
|
except Exception as exc:
|
|
result["device_list_from_pdf"] = []
|
|
result["device_list_from_pdf_count"] = 0
|
|
result["pdf_parse_error"] = str(exc)
|
|
else:
|
|
result["device_list_from_pdf"] = []
|
|
result["device_list_from_pdf_count"] = 0
|
|
|
|
return result
|
|
|
|
|
|
def resolve_detail_url_for_ncmdr_ref(ncmdr_ref: str, session=None) -> str | None:
|
|
"""Resolve ADE URL from recent weekly PDFs when not stored on SFDA Entries."""
|
|
from sfda_parser.sfda_scraper.weekly_list import fetch_recent_weekly_alerts
|
|
from sfda_parser.sfda_scraper.weekly_pdf import parse_safety_alerts_from_pdf
|
|
|
|
session = session or create_session()
|
|
target = (ncmdr_ref or "").strip().upper()
|
|
for weekly in fetch_recent_weekly_alerts(session=session):
|
|
pdf_response = session.get(weekly.pdf_url, timeout=120)
|
|
pdf_response.raise_for_status()
|
|
for alert in parse_safety_alerts_from_pdf(pdf_response.content):
|
|
if alert.ncmdr_ref.strip().upper() == target:
|
|
return alert.detail_url
|
|
return None
|
|
|
|
|
|
def _download_detail_pdf_from_html(
|
|
html: str, detail_url: str, session
|
|
) -> bytes:
|
|
download_path = _extract_download_path(html)
|
|
token = _extract_verification_token(html)
|
|
if not download_path:
|
|
raise NoPdfDownloadLinkError(f"No PDF download link on ADE page: {detail_url}")
|
|
|
|
download_url = urljoin(ADE_BASE, download_path)
|
|
headers = {"Referer": detail_url}
|
|
data = {}
|
|
if token:
|
|
data["__RequestVerificationToken"] = token
|
|
|
|
pdf_response = request_with_retry(
|
|
session,
|
|
"POST",
|
|
download_url,
|
|
data=data,
|
|
headers=headers,
|
|
timeout=120,
|
|
)
|
|
|
|
content_type = (pdf_response.headers.get("Content-Type") or "").lower()
|
|
if "pdf" not in content_type and not pdf_response.content.startswith(b"%PDF"):
|
|
raise ValueError(f"ADE download did not return a PDF for {detail_url}")
|
|
|
|
return pdf_response.content
|
|
|
|
|
|
def fetch_ade_detail(
|
|
detail_url: str, session=None
|
|
) -> tuple[bytes | None, dict[str, Any]]:
|
|
"""Fetch ADE detail page; download PDF when available.
|
|
|
|
Returns (pdf_bytes or None, publish_details). PDF is None when the ADE page
|
|
has no DownloadDocument link.
|
|
"""
|
|
session = session or create_session()
|
|
response = request_with_retry(session, "GET", detail_url, timeout=60)
|
|
html = response.text
|
|
publish_details = parse_publish_details_html(html, detail_url=detail_url)
|
|
|
|
try:
|
|
pdf_bytes = _download_detail_pdf_from_html(html, detail_url, session)
|
|
except NoPdfDownloadLinkError:
|
|
return None, publish_details
|
|
|
|
return pdf_bytes, publish_details
|
|
|
|
|
|
def download_detail_pdf(detail_url: str, session=None) -> bytes:
|
|
pdf_bytes, _publish_details = fetch_ade_detail(detail_url, session=session)
|
|
if pdf_bytes is None:
|
|
raise NoPdfDownloadLinkError(f"No PDF download link on ADE page: {detail_url}")
|
|
return pdf_bytes
|