Initial commit: sfda_parser Frappe app

This commit is contained in:
Duradundi 2026-08-19 11:36:01 +00:00
commit 7113bf1867
69 changed files with 19596 additions and 0 deletions

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
# Root editor config file
root = true
# Common settings
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
# python, js indentation settings
[{*.py,*.js,*.vue,*.css,*.scss,*.html}]
indent_style = tab
indent_size = 4
max_line_length = 99
# JSON files - mostly doctype schema files
[{*.json}]
insert_final_newline = false
indent_style = space
indent_size = 2

124
.eslintrc Normal file
View File

@ -0,0 +1,124 @@
{
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parserOptions": {
"sourceType": "module"
},
"extends": "eslint:recommended",
"rules": {
"indent": "off",
"brace-style": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"space-unary-ops": ["error", { "words": true }],
"linebreak-style": "off",
"quotes": ["off"],
"semi": "off",
"camelcase": "off",
"no-unused-vars": "off",
"no-console": ["warn"],
"no-extra-boolean-cast": ["off"],
"no-control-regex": ["off"],
},
"root": true,
"globals": {
"frappe": true,
"Vue": true,
"SetVueGlobals": true,
"__": true,
"repl": true,
"Class": true,
"locals": true,
"cint": true,
"cstr": true,
"cur_frm": true,
"cur_dialog": true,
"cur_page": true,
"cur_list": true,
"cur_tree": true,
"msg_dialog": true,
"is_null": true,
"in_list": true,
"has_common": true,
"posthog": true,
"has_words": true,
"validate_email": true,
"open_web_template_values_editor": true,
"validate_name": true,
"validate_phone": true,
"validate_url": true,
"get_number_format": true,
"format_number": true,
"format_currency": true,
"comment_when": true,
"open_url_post": true,
"toTitle": true,
"lstrip": true,
"rstrip": true,
"strip": true,
"strip_html": true,
"replace_all": true,
"flt": true,
"precision": true,
"CREATE": true,
"AMEND": true,
"CANCEL": true,
"copy_dict": true,
"get_number_format_info": true,
"strip_number_groups": true,
"print_table": true,
"Layout": true,
"web_form_settings": true,
"$c": true,
"$a": true,
"$i": true,
"$bg": true,
"$y": true,
"$c_obj": true,
"refresh_many": true,
"refresh_field": true,
"toggle_field": true,
"get_field_obj": true,
"get_query_params": true,
"unhide_field": true,
"hide_field": true,
"set_field_options": true,
"getCookie": true,
"getCookies": true,
"get_url_arg": true,
"md5": true,
"$": true,
"jQuery": true,
"moment": true,
"hljs": true,
"Awesomplete": true,
"Sortable": true,
"Showdown": true,
"Taggle": true,
"Gantt": true,
"Slick": true,
"Webcam": true,
"PhotoSwipe": true,
"PhotoSwipeUI_Default": true,
"io": true,
"JsBarcode": true,
"L": true,
"Chart": true,
"DataTable": true,
"Cypress": true,
"cy": true,
"it": true,
"describe": true,
"expect": true,
"context": true,
"before": true,
"beforeEach": true,
"after": true,
"qz": true,
"localforage": true,
"extend_cscript": true
}
}

69
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,69 @@
exclude: 'node_modules|.git'
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
files: "sfda_parser.*"
exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
- id: check-merge-conflict
- id: check-ast
- id: check-json
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
hooks:
- id: ruff
name: "Run ruff import sorter"
args: ["--select=I", "--fix"]
- id: ruff
name: "Run ruff linter"
- id: ruff-format
name: "Run ruff formatter"
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
types_or: [javascript, vue, scss]
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
sfda_parser/public/dist/.*|
.*node_modules.*|
.*boilerplate.*|
sfda_parser/templates/includes/.*|
sfda_parser/public/js/lib/.*
)$
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.44.0
hooks:
- id: eslint
types_or: [javascript]
args: ['--quiet']
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
sfda_parser/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
sfda_parser/templates/includes/.*|
sfda_parser/public/js/lib/.*
)$
ci:
autoupdate_schedule: weekly
skip: []
submodules: false

33
README.md Normal file
View File

@ -0,0 +1,33 @@
### SFDA Parser
SFDA weekly alert scraper
### Installation
You can install this app using the [bench](https://github.com/frappe/bench) CLI:
```bash
cd $PATH_TO_YOUR_BENCH
bench get-app $URL_OF_THIS_REPO --branch develop
bench install-app sfda_parser
```
### Contributing
This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
```bash
cd apps/sfda_parser
pre-commit install
```
Pre-commit is configured to use the following tools for checking and formatting your code:
- ruff
- eslint
- prettier
- pyupgrade
### License
mit

21
license.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

59
pyproject.toml Normal file
View File

@ -0,0 +1,59 @@
[project]
name = "sfda_parser"
authors = [
{ name = "seyfert", email = "support@seeraarabia.com"}
]
description = "SFDA weekly alert scraper"
requires-python = ">=3.10"
readme = "README.md"
dynamic = ["version"]
dependencies = [
# "frappe~=15.0.0" # Installed and managed by bench.
"beautifulsoup4>=4.12.0",
"pdfplumber>=0.11.0",
"requests>=2.31.0",
]
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
# These dependencies are only installed when developer mode is enabled
[tool.bench.dev-dependencies]
# package_name = "~=1.1.0"
[tool.ruff]
line-length = 110
target-version = "py310"
[tool.ruff.lint]
select = [
"F",
"E",
"W",
"I",
"UP",
"B",
"RUF",
]
ignore = [
"B017", # assertRaises(Exception) - should be more specific
"B018", # useless expression, not assigned to anything
"B023", # function doesn't bind loop variable - will have last iteration's value
"B904", # raise inside except without from
"E101", # indentation contains mixed spaces and tabs
"E402", # module level import not at top of file
"E501", # line too long
"E741", # ambiguous variable name
"F401", # "unused" imports
"F403", # can't detect undefined names from * import
"F405", # can't detect undefined names from * import
"F722", # syntax error in forward type annotation
"W191", # indentation contains tabs
]
typing-modules = ["frappe.types.DF"]
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
docstring-code-format = true

1
sfda_parser/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = "0.0.1"

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

View File

@ -0,0 +1,87 @@
"""Whitelisted API for SFDA scraper and ADE PublishDetails preview."""
from __future__ import annotations
import frappe
from sfda_parser.sfda_scraper.detail_page import (
fetch_publish_details,
resolve_detail_url_for_ncmdr_ref,
)
from sfda_parser.sfda_scraper.importer import import_latest_weekly_alerts
from sfda_parser.sfda_scraper.reparse import reparse_all_stored_pdf_device_lists
@frappe.whitelist()
def run_sfda_scraper_now():
frappe.only_for("System Manager")
return import_latest_weekly_alerts()
@frappe.whitelist()
def reparse_device_list_from_stored_pdfs(sfda_entries_name: str | None = None):
"""Rebuild Device List child rows from stored detail_pdf using current PDF parser."""
frappe.only_for("System Manager")
return reparse_all_stored_pdf_device_lists(sfda_entries_name=sfda_entries_name)
@frappe.whitelist()
def get_ade_publish_details(
detail_url: str | None = None,
sfda_entries_name: str | None = None,
sfda_device_entry_name: str | None = None,
ncmdr_ref: str | None = None,
include_device_list_from_pdf: int | str = 0,
):
"""
Load ADE PublishDetails via the ERP server (works when your PC browser is blocked).
Pass detail_url and/or sfda_entries_name with a child row reference.
"""
include_pdf = str(include_device_list_from_pdf).lower() in ("1", "true", "yes")
url = (detail_url or "").strip()
if sfda_entries_name and not url:
doc = frappe.get_doc("SFDA Entries", sfda_entries_name)
child_row = None
if sfda_device_entry_name:
child_row = next(
(row for row in doc.device_list if row.name == sfda_device_entry_name),
None,
)
elif ncmdr_ref:
target = (ncmdr_ref or "").strip().upper()
child_row = next(
(
row
for row in doc.device_list
if (row.ncmdr_ref or "").strip().upper() == target
),
None,
)
if child_row:
url = (child_row.get("ade_detail_url") or "").strip()
if not url and child_row.ncmdr_ref:
url = resolve_detail_url_for_ncmdr_ref(child_row.ncmdr_ref) or ""
if url:
child_row.ade_detail_url = url
doc.save(ignore_permissions=True)
if not url:
frappe.throw(
"No ADE URL available. Pass detail_url, select a device row with an ADE URL, "
"or re-run the weekly import."
)
if not url.startswith("http"):
url = f"https://ade.sfda.gov.sa/Fsca/PublishDetails/{url}"
try:
return fetch_publish_details(url, include_device_list_from_pdf=include_pdf)
except Exception as exc:
frappe.log_error(title="SFDA: ADE fetch failed", message=f"URL: {url}\n{exc}")
frappe.throw(
f"Could not load ADE from the server: {exc}. "
"The site may be blocked on your PC but reachable from ERP."
)

View File

View File

@ -0,0 +1 @@
[]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
[
{
"allow_in_quick_entry": 0,
"allow_on_submit": 1,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Asset",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "custom_recalled",
"fieldtype": "Select",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "custom_category",
"is_system_generated": 0,
"is_virtual": 0,
"label": "Recalled",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2026-06-05 12:26:39.091744",
"module": "SFDA Parser",
"name": "Asset-custom_recalled",
"no_copy": 0,
"non_negative": 0,
"options": "\nYes\nNo",
"permlevel": 0,
"placeholder": null,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
}
]

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
[]

File diff suppressed because one or more lines are too long

262
sfda_parser/hooks.py Normal file
View File

@ -0,0 +1,262 @@
app_name = "sfda_parser"
app_title = "SFDA Parser"
app_publisher = "seyfert"
app_description = "SFDA weekly alert scraper"
app_email = "support@seeraarabia.com"
app_license = "mit"
# Apps
# ------------------
# required_apps = []
# Each item in the list will be shown as an app in the apps page
# add_to_apps_screen = [
# {
# "name": "sfda_parser",
# "logo": "/assets/sfda_parser/logo.png",
# "title": "SFDA Parser",
# "route": "/sfda_parser",
# "has_permission": "sfda_parser.api.permission.has_app_permission"
# }
# ]
# Includes in <head>
# ------------------
# include js, css files in header of desk.html
# app_include_css = "/assets/sfda_parser/css/sfda_parser.css"
# app_include_js = "/assets/sfda_parser/js/sfda_parser.js"
# include js, css files in header of web template
# web_include_css = "/assets/sfda_parser/css/sfda_parser.css"
# web_include_js = "/assets/sfda_parser/js/sfda_parser.js"
# include custom scss in every website theme (without file extension ".scss")
# website_theme_scss = "sfda_parser/public/scss/website"
# include js, css files in header of web form
# webform_include_js = {"doctype": "public/js/doctype.js"}
# webform_include_css = {"doctype": "public/css/doctype.css"}
# include js in page
# page_js = {"page" : "public/js/file.js"}
# include js in doctype views
# doctype_js = {"doctype" : "public/js/doctype.js"}
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
# Svg Icons
# ------------------
# include app icons in desk
# app_include_icons = "sfda_parser/public/icons.svg"
# Home Pages
# ----------
# application home page (will override Website Settings)
# home_page = "login"
# website user home page (by Role)
# role_home_page = {
# "Role": "home_page"
# }
# Generators
# ----------
# automatically create page for each record of this doctype
# website_generators = ["Web Page"]
# Jinja
# ----------
# add methods and filters to jinja environment
# jinja = {
# "methods": "sfda_parser.utils.jinja_methods",
# "filters": "sfda_parser.utils.jinja_filters"
# }
# Installation
# ------------
# before_install = "sfda_parser.install.before_install"
# after_install = "sfda_parser.install.after_install"
# Uninstallation
# ------------
# before_uninstall = "sfda_parser.uninstall.before_uninstall"
# after_uninstall = "sfda_parser.uninstall.after_uninstall"
# Integration Setup
# ------------------
# To set up dependencies/integrations with other apps
# Name of the app being installed is passed as an argument
# before_app_install = "sfda_parser.utils.before_app_install"
# after_app_install = "sfda_parser.utils.after_app_install"
# Integration Cleanup
# -------------------
# To clean up dependencies/integrations with other apps
# Name of the app being uninstalled is passed as an argument
# before_app_uninstall = "sfda_parser.utils.before_app_uninstall"
# after_app_uninstall = "sfda_parser.utils.after_app_uninstall"
# Desk Notifications
# ------------------
# See frappe.core.notifications.get_notification_config
# notification_config = "sfda_parser.notifications.get_notification_config"
# Permissions
# -----------
# Permissions evaluated in scripted ways
# permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
#
# has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission",
# }
# DocType Class
# ---------------
# Override standard doctype classes
# override_doctype_class = {
# "ToDo": "custom_app.overrides.CustomToDo"
# }
# Document Events
# ---------------
# Hook on document methods and events
# doc_events = {
# "*": {
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }
# }
# Scheduled Tasks
# ---------------
# SFDA recall scraper: every Monday 07:00 (server timezone).
# Always imports from the newest row on https://www.sfda.gov.sa/en/weekly-alert only.
scheduler_events = {
"cron": {
"0 7 * * 1": [
"sfda_parser.sfda_scraper.jobs.run_weekly_sfda_import_scheduled",
],
},
}
# Testing
# -------
# before_tests = "sfda_parser.install.before_tests"
# Overriding Methods
# ------------------------------
#
# override_whitelisted_methods = {
# "frappe.desk.doctype.event.event.get_events": "sfda_parser.event.get_events"
# }
#
# each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps
# override_doctype_dashboards = {
# "Task": "sfda_parser.task.get_dashboard_data"
# }
# exempt linked doctypes from being automatically cancelled
#
# auto_cancel_exempted_doctypes = ["Auto Repeat"]
# Ignore links to specified DocTypes when deleting documents
# -----------------------------------------------------------
# ignore_links_on_delete = ["Communication", "ToDo"]
# Request Events
# ----------------
# before_request = ["sfda_parser.utils.before_request"]
# after_request = ["sfda_parser.utils.after_request"]
# Job Events
# ----------
# before_job = ["sfda_parser.utils.before_job"]
# after_job = ["sfda_parser.utils.after_job"]
# User Data Protection
# --------------------
# user_data_fields = [
# {
# "doctype": "{doctype_1}",
# "filter_by": "{filter_by}",
# "redact_fields": ["{field_1}", "{field_2}"],
# "partial": 1,
# },
# {
# "doctype": "{doctype_2}",
# "filter_by": "{filter_by}",
# "partial": 1,
# },
# {
# "doctype": "{doctype_3}",
# "strict": False,
# },
# {
# "doctype": "{doctype_4}"
# }
# ]
# Authentication and authorization
# --------------------------------
# auth_hooks = [
# "sfda_parser.auth.validate"
# ]
# Automatically update python controller files with type annotations for this app.
# export_python_type_annotations = True
# default_log_clearing_doctypes = {
# "Logging DocType Name": 30 # days to retain logs
# }
# Translation
# ------------
# List of apps whose translatable strings should be excluded from this app's translations.
# ignore_translatable_strings_from = []
fixtures = [
"Custom DocPerm",
{"doctype": "Custom Field", "filters": [
[
"module", "=", "SFDA Parser"
]
]},
{"doctype": "Property Setter", "filters": [
[
"module", "=", "SFDA Parser"
]
]},
{"dt": "Print Format", "filters": {"custom_format": 1}},
{"doctype": "Client Script", "filters": [["module","=","SFDA Parser"]]},
{"doctype": "Server Script", "filters": [["module","=","SFDA Parser"]]},
{"doctype": "Report", "filters": [["module","=","SFDA Parser"]]}
]

1
sfda_parser/modules.txt Normal file
View File

@ -0,0 +1 @@
SFDA Parser

7
sfda_parser/patches.txt Normal file
View File

@ -0,0 +1,7 @@
[pre_model_sync]
# Patches added in this section will be executed before doctypes are migrated
# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
sfda_parser.patches.reparse_sfda_device_lists_from_pdfs

View File

View File

@ -0,0 +1,20 @@
"""One-time post-migrate: refresh device_list from stored detail PDFs after header alias updates."""
import frappe
from sfda_parser.sfda_scraper.reparse import reparse_all_stored_pdf_device_lists
def execute():
if not frappe.db.exists("DocType", "SFDA Entries"):
return
summary = reparse_all_stored_pdf_device_lists()
frappe.logger("sfda_parser").info(
"Re-parsed SFDA device lists from PDFs: %s", summary
)
if summary.get("errors"):
frappe.log_error(
title="SFDA Parser: PDF reparse completed with errors",
message=frappe.as_json(summary, indent=2),
)

View File

View File

View File

@ -0,0 +1,120 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2026-06-01 00:00:00",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"ncmdr_ref",
"manufacturer",
"detail_pdf",
"ade_detail_url",
"column_break_alert",
"material",
"material_description",
"catalog_number",
"udi",
"serial_no",
"gstn",
"batch",
"serial_no_matching"
],
"fields": [
{
"fieldname": "ncmdr_ref",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "NCMDR Ref"
},
{
"fieldname": "manufacturer",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Manufacturer",
"read_only": 1
},
{
"fieldname": "detail_pdf",
"fieldtype": "Attach",
"in_list_view": 1,
"label": "ADE Detail PDF",
"read_only": 1
},
{
"fieldname": "ade_detail_url",
"fieldtype": "Data",
"hidden": 1,
"label": "ADE Detail URL",
"read_only": 1
},
{
"fieldname": "column_break_alert",
"fieldtype": "Column Break"
},
{
"fieldname": "material",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Material"
},
{
"fieldname": "material_description",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Material Description"
},
{
"fieldname": "catalog_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Catalog Number"
},
{
"fieldname": "udi",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "UDI"
},
{
"fieldname": "serial_no",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Serial No"
},
{
"fieldname": "gstn",
"fieldtype": "Data",
"label": "GTIN"
},
{
"fieldname": "batch",
"fieldtype": "Small Text",
"label": "Batch"
},
{
"fieldname": "serial_no_matching",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Matching",
"options": "\nYes\nNo"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-09 13:37:08.969913",
"modified_by": "support@seeraarabia.com",
"module": "SFDA Parser",
"name": "SFDA Device Entries",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,8 @@
# Copyright (c) 2026, seyfert and contributors
# For license information, please see license.txt
from frappe.model.document import Document
class SFDADeviceEntries(Document):
pass

View File

@ -0,0 +1,199 @@
// Copyright (c) 2026, seyfert and contributors
const ADE_FIELD_LABELS = {
reference_number: "NCMDR / Reference",
manufacturer: "Manufacturer",
product_trade_name: "Product / Trade Name",
model: "Model",
authorized_representative: "Authorized Representative",
problem_reason: "Problem / Reason",
action: "Corrective Action",
};
function format_ade_value(value) {
if (!value) {
return "—";
}
return frappe.utils.escape_html(String(value)).replace(/\n/g, "<br>");
}
function build_ade_dialog_html(frm, data, child_row) {
let rows = "";
for (const [key, label] of Object.entries(ADE_FIELD_LABELS)) {
rows += `<tr>
<td class="text-muted" style="width:35%;vertical-align:top;"><b>${label}</b></td>
<td>${format_ade_value(data[key])}</td>
</tr>`;
}
const erp_count = (frm.doc.device_list || []).filter(
(row) => row.ncmdr_ref === child_row.ncmdr_ref
).length;
const pdf_count = data.device_list_from_pdf_count || 0;
let device_section = `<hr><p><b>Device rows for ${format_ade_value(
child_row.ncmdr_ref
)} in ERP:</b> ${erp_count} row(s)</p>`;
device_section += `<p><b>Device rows parsed from PDF (live):</b> ${pdf_count}</p>`;
if (data.pdf_parse_error) {
device_section += `<p class="text-danger"><b>PDF note:</b> ${format_ade_value(
data.pdf_parse_error
)}</p>`;
}
if (pdf_count > 0 && data.device_list_from_pdf) {
device_section += `<table class="table table-bordered table-sm"><thead><tr>
<th>Material</th><th>Description</th><th>Catalog No</th><th>UDI</th><th>Serial No</th><th>GTIN</th><th>Batch</th>
</tr></thead><tbody>`;
data.device_list_from_pdf.forEach((row) => {
device_section += `<tr>
<td>${format_ade_value(row.material)}</td>
<td>${format_ade_value(row.material_description)}</td>
<td>${format_ade_value(row.catalog_number)}</td>
<td>${format_ade_value(row.udi)}</td>
<td>${format_ade_value(row.serial_no)}</td>
<td>${format_ade_value(row.gstn)}</td>
<td>${format_ade_value(row.batch)}</td>
</tr>`;
});
device_section += "</tbody></table>";
}
const url_line = data.detail_url
? `<p class="text-muted small">Source: ${format_ade_value(data.detail_url)}</p>`
: "";
const pdf_link = child_row.detail_pdf
? `<p><a href="${frappe.utils.escape_html(child_row.detail_pdf)}" target="_blank" rel="noopener">
${__("Download original PDF")}</a></p>`
: "";
return `<div style="max-height:65vh;overflow:auto;">
<p class="text-muted small">Loaded via ERP server ADE may be blocked in your local browser.</p>
${pdf_link}
${url_line}
<table class="table table-bordered">${rows}</table>
${device_section}
</div>`;
}
const MATCHING_YES_ROW_COLOR = "#ffedd5";
function highlight_matching_device_rows(frm) {
const grid = frm.fields_dict.device_list?.grid;
if (!grid?.grid_rows) {
return;
}
grid.grid_rows.forEach((grid_row) => {
const is_match =
String(grid_row.doc.serial_no_matching || "").trim() === "Yes";
$(grid_row.row).css(
"background-color",
is_match ? MATCHING_YES_ROW_COLOR : ""
);
});
}
function get_selected_device_row(frm) {
const grid = frm.fields_dict.device_list?.grid;
if (!grid) {
return null;
}
const selected = grid.get_selected_children();
if (selected?.length === 1) {
return selected[0];
}
if (grid.grid_rows?.length === 1) {
return grid.grid_rows[0].doc;
}
return null;
}
frappe.ui.form.on("SFDA Entries", {
refresh(frm) {
highlight_matching_device_rows(frm);
frm.add_custom_button(
__("View ADE details"),
() => {
const child_row = get_selected_device_row(frm);
if (!child_row) {
frappe.msgprint(
__("Please select exactly one row in Device List to view ADE details.")
);
return;
}
frappe.call({
method: "sfda_parser.api.sfda_scraper.get_ade_publish_details",
args: {
sfda_entries_name: frm.doc.name,
sfda_device_entry_name: child_row.name,
include_device_list_from_pdf: 1,
},
freeze: true,
freeze_message: __("Loading from ADE (server)..."),
callback(r) {
if (!r.message) {
return;
}
const d = new frappe.ui.Dialog({
title: __("ADE Publish Details"),
size: "large",
fields: [{ fieldtype: "HTML", fieldname: "body" }],
primary_action_label: __("Close"),
primary_action() {
d.hide();
},
});
d.fields_dict.body.$wrapper.html(
build_ade_dialog_html(frm, r.message, child_row)
);
d.show();
const needs_save =
(r.message.detail_url && !child_row.ade_detail_url) ||
(r.message.manufacturer && !child_row.manufacturer);
if (r.message.detail_url && !child_row.ade_detail_url) {
frappe.model.set_value(
child_row.doctype,
child_row.name,
"ade_detail_url",
r.message.detail_url
);
}
if (r.message.manufacturer && !child_row.manufacturer) {
frappe.model.set_value(
child_row.doctype,
child_row.name,
"manufacturer",
r.message.manufacturer
);
}
if (needs_save) {
frm.save();
}
},
});
},
__("Actions")
);
},
device_list_on_form_rendered(frm) {
highlight_matching_device_rows(frm);
},
});
frappe.ui.form.on("SFDA Device Entries", {
serial_no_matching(frm, cdt, cdn) {
const grid_row = frm.fields_dict.device_list?.grid?.grid_rows_by_docname?.[cdn];
if (!grid_row) {
return;
}
const is_match = String(frappe.model.get_value(cdt, cdn, "serial_no_matching") || "").trim() === "Yes";
$(grid_row.row).css("background-color", is_match ? MATCHING_YES_ROW_COLOR : "");
},
});

View File

@ -0,0 +1,98 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:title",
"creation": "2026-06-01 16:16:53.409449",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"column_break_dqny",
"date",
"section_break_qcgk",
"device_list",
"section_break_ihhu",
"passed_date",
"column_break_whlv",
"passed"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_global_search": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Title",
"unique": 1
},
{
"fieldname": "column_break_dqny",
"fieldtype": "Column Break"
},
{
"fieldname": "date",
"fieldtype": "Date",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Date"
},
{
"fieldname": "section_break_qcgk",
"fieldtype": "Section Break"
},
{
"fieldname": "device_list",
"fieldtype": "Table",
"label": "Device List",
"options": "SFDA Device Entries"
},
{
"fieldname": "section_break_ihhu",
"fieldtype": "Section Break"
},
{
"fieldname": "passed_date",
"fieldtype": "Datetime",
"label": "Passed Date"
},
{
"fieldname": "column_break_whlv",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "passed",
"fieldtype": "Check",
"label": "Passed"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-06-09 13:18:06.533455",
"modified_by": "support@seeraarabia.com",
"module": "SFDA Parser",
"name": "SFDA Entries",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,8 @@
# Copyright (c) 2026, seyfert and contributors
# For license information, please see license.txt
from frappe.model.document import Document
class SFDAEntries(Document):
pass

View File

@ -0,0 +1,8 @@
# Copyright (c) 2026, seyfert and Contributors
# See license.txt
from frappe.tests import IntegrationTestCase
class TestSFDAEntries(IntegrationTestCase):
pass

View File

@ -0,0 +1 @@
# SFDA weekly recall scraper for sfda_parser

View File

@ -0,0 +1,192 @@
"""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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,74 @@
"""Shared HTTP session for SFDA / ADE scraping."""
from __future__ import annotations
import time
from typing import Any
import requests
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
SFDA_BASE = "https://www.sfda.gov.sa"
ADE_BASE = "https://ade.sfda.gov.sa"
# Rate-limit protection for ADE (DownloadDocument throttles after ~10 rapid calls).
ADE_REQUEST_DELAY_SECONDS = 3
ADE_MAX_RETRIES = 3
ADE_RETRY_BACKOFF_SECONDS = 30
ADE_RETRY_STATUS_CODES = {429, 503}
def create_session() -> requests.Session:
session = requests.Session()
session.headers.update(DEFAULT_HEADERS)
return session
def _retry_wait_seconds(response: requests.Response, attempt: int) -> float:
retry_after = response.headers.get("Retry-After")
if retry_after:
try:
return max(float(retry_after), 1.0)
except ValueError:
pass
return ADE_RETRY_BACKOFF_SECONDS * (2**attempt)
def request_with_retry(
session: requests.Session,
method: str,
url: str,
*,
max_retries: int = ADE_MAX_RETRIES,
retry_status_codes: set[int] | None = None,
**kwargs: Any,
) -> requests.Response:
"""Perform an HTTP request with exponential backoff on rate-limit responses."""
retry_status_codes = retry_status_codes or ADE_RETRY_STATUS_CODES
last_response: requests.Response | None = None
for attempt in range(max_retries + 1):
response = session.request(method, url, **kwargs)
last_response = response
if response.status_code not in retry_status_codes:
response.raise_for_status()
return response
if attempt >= max_retries:
response.raise_for_status()
wait_seconds = _retry_wait_seconds(response, attempt)
time.sleep(wait_seconds)
if last_response is not None:
last_response.raise_for_status()
raise requests.HTTPError(f"Request failed for {url}")

View File

@ -0,0 +1,407 @@
"""Import scraped SFDA alerts into SFDA Entries documents."""
from __future__ import annotations
import re
import time
from datetime import date
from typing import Any
import frappe
from frappe.utils import now_datetime
from frappe.utils.file_manager import save_file
from sfda_parser.sfda_scraper.detail_page import fetch_ade_detail
from sfda_parser.sfda_scraper.detail_pdf import parse_affected_device_list
from sfda_parser.sfda_scraper.http_client import ADE_REQUEST_DELAY_SECONDS, create_session
from sfda_parser.sfda_scraper.weekly_list import (
WEEKLY_BULLETINS_TO_IMPORT,
WeeklyAlertRow,
fetch_recent_weekly_alerts,
)
from sfda_parser.sfda_scraper.weekly_pdf import SafetyAlertRow, parse_safety_alerts_from_pdf
# Tracks which weekly PDF was last processed (persists across scheduled runs).
LAST_WEEKLY_PDF_KEY = "sfda_parser_last_processed_weekly_pdf_url"
FAILED_REFS_CACHE_KEY = "sfda_parser_failed_import_refs"
def _normalize_ncmdr_ref(ncmdr_ref: str) -> str:
return (ncmdr_ref or "").strip().upper()
def _attach_detail_pdf(doc_name: str, ncmdr_ref: str, pdf_bytes: bytes) -> str:
safe_ref = re.sub(r"[^\w\-]", "_", ncmdr_ref)
file_doc = save_file(
f"{safe_ref}-ade-detail.pdf",
pdf_bytes,
"SFDA Entries",
doc_name,
decode=False,
is_private=0,
)
return file_doc.file_url
def _set_detail_pdf_on_child_rows(doc_name: str, ncmdr_ref: str, file_url: str) -> None:
target = _normalize_ncmdr_ref(ncmdr_ref)
for row in frappe.get_all(
"SFDA Device Entries",
filters={"parent": doc_name, "parenttype": "SFDA Entries"},
fields=["name", "ncmdr_ref"],
):
if _normalize_ncmdr_ref(row.get("ncmdr_ref") or "") == target:
frappe.db.set_value("SFDA Device Entries", row.name, "detail_pdf", file_url)
def _get_failed_refs() -> list[dict[str, Any]]:
return frappe.cache().get_value(FAILED_REFS_CACHE_KEY) or []
def _save_failed_refs(refs: list[dict[str, Any]]) -> None:
frappe.cache().set_value(FAILED_REFS_CACHE_KEY, refs)
def _add_failed_ref(
ncmdr_ref: str,
detail_url: str,
weekly_title: str,
weekly_date: date | None,
) -> None:
refs = _get_failed_refs()
existing = {_normalize_ncmdr_ref(item.get("ncmdr_ref") or "") for item in refs}
if _normalize_ncmdr_ref(ncmdr_ref) in existing:
return
refs.append(
{
"ncmdr_ref": ncmdr_ref,
"detail_url": detail_url,
"weekly_title": weekly_title,
"weekly_date": str(weekly_date) if weekly_date else None,
}
)
_save_failed_refs(refs)
def _remove_failed_ref(ncmdr_ref: str) -> None:
target = _normalize_ncmdr_ref(ncmdr_ref)
refs = [
item
for item in _get_failed_refs()
if _normalize_ncmdr_ref(item.get("ncmdr_ref") or "") != target
]
_save_failed_refs(refs)
def _find_weekly_entry_name(weekly_title: str, weekly_date: date | None) -> str | None:
filters: dict[str, Any] = {"title": weekly_title}
if weekly_date:
filters["date"] = weekly_date
return frappe.db.get_value("SFDA Entries", filters)
def _get_existing_ncmdr_refs(doc_name: str) -> set[str]:
rows = frappe.get_all(
"SFDA Device Entries",
filters={"parent": doc_name, "parenttype": "SFDA Entries"},
pluck="ncmdr_ref",
)
return {_normalize_ncmdr_ref(ref) for ref in rows if ref}
def _build_device_rows_for_alert(
alert: SafetyAlertRow,
*,
manufacturer: str,
devices: list,
) -> list[dict[str, Any]]:
ncmdr_ref = (alert.ncmdr_ref or "").strip()
base = {
"doctype": "SFDA Device Entries",
"ncmdr_ref": ncmdr_ref,
"manufacturer": manufacturer,
"ade_detail_url": alert.detail_url,
}
if devices:
return [{**base, **device.as_dict()} for device in devices]
return [base]
def _import_single_alert_into_weekly_entry(
alert: SafetyAlertRow,
*,
weekly_title: str,
weekly_date: date | None,
session,
summary: dict,
doc,
existing_ncmdr_refs: set[str],
pending_pdfs: list[tuple[str, bytes]],
) -> bool:
"""Fetch one alert and append child rows to the weekly SFDA Entries doc."""
ncmdr_ref = (alert.ncmdr_ref or "").strip()
if not ncmdr_ref:
return False
normalized_ref = _normalize_ncmdr_ref(ncmdr_ref)
if normalized_ref in existing_ncmdr_refs:
summary["skipped_existing"] += 1
_remove_failed_ref(ncmdr_ref)
return False
try:
detail_pdf, publish_details = fetch_ade_detail(alert.detail_url, session=session)
manufacturer = (publish_details.get("manufacturer") or "").strip()
devices = parse_affected_device_list(detail_pdf) if detail_pdf else []
if not detail_pdf:
warning_msg = (
f"{ncmdr_ref} ({alert.detail_url}): No PDF on ADE page; "
"created child row from HTML fields only"
)
summary["warnings"].append(warning_msg)
frappe.logger("sfda_scraper").warning(warning_msg)
for row in _build_device_rows_for_alert(
alert,
manufacturer=manufacturer,
devices=devices,
):
doc.append("device_list", row)
if detail_pdf:
pending_pdfs.append((ncmdr_ref, detail_pdf))
summary["pdfs_saved"] += 1
existing_ncmdr_refs.add(normalized_ref)
summary["alerts_imported"] += 1
_remove_failed_ref(ncmdr_ref)
return True
except Exception as exc:
error_msg = f"{ncmdr_ref} ({alert.detail_url}): {exc}"
summary["errors"].append(error_msg)
_add_failed_ref(ncmdr_ref, alert.detail_url, weekly_title, weekly_date)
frappe.log_error(
title="SFDA Scraper: Alert import failed",
message=error_msg,
)
return False
def _get_or_create_weekly_doc(weekly_title: str, weekly_date: date | None):
existing_name = _find_weekly_entry_name(weekly_title, weekly_date)
if existing_name:
return frappe.get_doc("SFDA Entries", existing_name), _get_existing_ncmdr_refs(
existing_name
), True
doc = frappe.get_doc(
{
"doctype": "SFDA Entries",
"title": weekly_title,
"date": weekly_date,
}
)
return doc, set(), False
def _save_weekly_doc(doc, is_existing: bool) -> None:
if is_existing:
doc.save(ignore_permissions=True)
else:
doc.insert(ignore_permissions=True)
frappe.db.commit()
def _attach_pending_pdfs(doc_name: str, pending_pdfs: list[tuple[str, bytes]]) -> None:
for ncmdr_ref, pdf_bytes in pending_pdfs:
file_url = _attach_detail_pdf(doc_name, ncmdr_ref, pdf_bytes)
_set_detail_pdf_on_child_rows(doc_name, ncmdr_ref, file_url)
if pending_pdfs:
frappe.db.commit()
def _import_weekly_bulletin(
weekly: WeeklyAlertRow,
alerts: list[SafetyAlertRow],
*,
session,
summary: dict,
) -> None:
doc, existing_ncmdr_refs, is_existing = _get_or_create_weekly_doc(
weekly.title,
weekly.alert_date,
)
pending_pdfs: list[tuple[str, bytes]] = []
imported_any = False
for alert in alerts:
if _import_single_alert_into_weekly_entry(
alert,
weekly_title=weekly.title,
weekly_date=weekly.alert_date,
session=session,
summary=summary,
doc=doc,
existing_ncmdr_refs=existing_ncmdr_refs,
pending_pdfs=pending_pdfs,
):
imported_any = True
time.sleep(ADE_REQUEST_DELAY_SECONDS)
if not imported_any:
return
_save_weekly_doc(doc, is_existing)
_attach_pending_pdfs(doc.name, pending_pdfs)
if not is_existing:
summary["created"] += 1
else:
summary["updated"] += 1
def _retry_failed_refs(session, summary: dict) -> None:
"""Retry alerts that failed on a previous run (e.g. ADE 429 rate limits)."""
failed_refs = _get_failed_refs()
if not failed_refs:
return
summary["failed_refs_queued"] = len(failed_refs)
frappe.logger("sfda_scraper").info(
"SFDA scraper retrying %s previously failed alert(s)",
len(failed_refs),
)
weekly_groups: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
for item in failed_refs:
weekly_date = item.get("weekly_date")
weekly_groups.setdefault(
(item.get("weekly_title") or "", weekly_date),
[],
).append(item)
for (weekly_title, weekly_date_str), items in weekly_groups.items():
weekly_date = None
if weekly_date_str:
try:
weekly_date = date.fromisoformat(weekly_date_str)
except ValueError:
weekly_date = None
weekly = WeeklyAlertRow(
title=weekly_title,
alert_date=weekly_date,
pdf_url="",
)
alerts = [
SafetyAlertRow(
ncmdr_ref=item.get("ncmdr_ref") or "",
detail_url=item.get("detail_url") or "",
)
for item in items
]
_import_weekly_bulletin(weekly, alerts, session=session, summary=summary)
def import_latest_weekly_alerts() -> dict:
"""
Recurring job: reads the most recent weekly bulletin(s) from SFDA and imports
alerts into one SFDA Entries document per weekly bulletin.
Each scheduled run:
- Retries previously failed refs from the cache queue
- Fetches https://www.sfda.gov.sa/en/weekly-alert
- Uses the last WEEKLY_BULLETINS_TO_IMPORT weekly PDFs
- Creates or updates one SFDA Entries doc per week with child rows per alert
- Skips NCMDR refs already present in that week's device_list
"""
session = create_session()
summary = {
"weekly_bulletins_to_import": WEEKLY_BULLETINS_TO_IMPORT,
"weekly_title": "",
"weekly_date": None,
"weekly_pdf_url": "",
"weekly_titles": [],
"alerts_found": 0,
"alerts_imported": 0,
"created": 0,
"updated": 0,
"pdfs_saved": 0,
"skipped_existing": 0,
"weekly_already_processed": False,
"failed_refs_queued": 0,
"warnings": [],
"errors": [],
}
_retry_failed_refs(session, summary)
weeklies = fetch_recent_weekly_alerts(session=session)
if not weeklies:
frappe.log_error(
title="SFDA Scraper: No weekly bulletins found",
message="https://www.sfda.gov.sa/en/weekly-alert",
)
_log_run_summary(summary)
return summary
weekly_newest = weeklies[0]
summary["weekly_title"] = weekly_newest.title
summary["weekly_date"] = (
str(weekly_newest.alert_date) if weekly_newest.alert_date else None
)
summary["weekly_pdf_url"] = weekly_newest.pdf_url
summary["weekly_titles"] = [w.title for w in weeklies]
last_pdf_url = frappe.db.get_default(LAST_WEEKLY_PDF_KEY)
# Oldest week first so backfill order matches bulletin dates.
for weekly in reversed(weeklies):
pdf_response = session.get(weekly.pdf_url, timeout=120)
pdf_response.raise_for_status()
alerts = parse_safety_alerts_from_pdf(pdf_response.content)
if not alerts:
frappe.log_error(
title="SFDA Scraper: No alerts in weekly PDF",
message=f"URL: {weekly.pdf_url}",
)
continue
summary["alerts_found"] += len(alerts)
_import_weekly_bulletin(weekly, alerts, session=session, summary=summary)
# Same newest weekly PDF as last run and every alert already in ERP
if (
last_pdf_url == weekly_newest.pdf_url
and summary["created"] == 0
and summary["updated"] == 0
and summary["skipped_existing"] == summary["alerts_found"]
and summary["alerts_found"] > 0
):
summary["weekly_already_processed"] = True
# Track newest bulletin URL (detect when SFDA publishes a new top-row PDF)
frappe.db.set_default(LAST_WEEKLY_PDF_KEY, weekly_newest.pdf_url)
_log_run_summary(summary)
return summary
def _log_run_summary(summary: dict) -> None:
frappe.logger("sfda_scraper").info(
"SFDA scheduled import at %s — latest weekly bulletin '%s' (%s): %s",
now_datetime(),
summary.get("weekly_title"),
summary.get("weekly_date"),
summary,
)
if summary.get("errors"):
frappe.log_error(
title="SFDA Scraper: Run completed with errors",
message=frappe.as_json(summary, indent=2),
)

View File

@ -0,0 +1,25 @@
"""Scheduled and manual entry points for the SFDA scraper."""
from __future__ import annotations
import frappe
from sfda_parser.sfda_scraper.importer import import_latest_weekly_alerts
def run_weekly_sfda_import_scheduled():
"""
Runs automatically every Monday (see hooks.py cron).
Each execution:
1. Opens SFDA weekly-alert page
2. Picks the **latest** bulletin (top / newest date)
3. Imports new recalls into SFDA Entries (skips existing NCMDR refs)
When SFDA publishes a new weekly PDF, the table top row changes;
the next run imports only alerts that are not yet in ERP.
"""
frappe.logger("sfda_scraper").info("SFDA weekly scheduled job started")
summary = import_latest_weekly_alerts()
frappe.logger("sfda_scraper").info("SFDA weekly scheduled job finished: %s", summary)
return summary

View File

@ -0,0 +1,140 @@
"""Re-parse device_list child rows from stored ADE detail PDF attachments."""
from __future__ import annotations
import os
import frappe
from frappe.utils.file_manager import get_file_path
from sfda_parser.sfda_scraper.detail_pdf import parse_affected_device_list
def _read_detail_pdf_bytes(file_url: str) -> bytes:
file_name = file_url.split("/files/")[-1]
path = get_file_path(file_name)
if not path or not os.path.isfile(path):
site_path = frappe.get_site_path("public", "files", file_name)
if os.path.isfile(site_path):
path = site_path
else:
raise FileNotFoundError(f"PDF not found for {file_url}")
with open(path, "rb") as f:
return f.read()
def _normalize_ncmdr_ref(ncmdr_ref: str) -> str:
return (ncmdr_ref or "").strip().upper()
def _group_child_rows_by_ncmdr_ref(doc) -> dict[str, dict[str, str]]:
groups: dict[str, dict[str, str]] = {}
for row in doc.device_list or []:
ref = (row.ncmdr_ref or "").strip()
if not ref:
continue
key = _normalize_ncmdr_ref(ref)
if key not in groups:
groups[key] = {
"ncmdr_ref": ref,
"manufacturer": row.manufacturer or "",
"detail_pdf": row.detail_pdf or "",
"ade_detail_url": row.ade_detail_url or "",
}
return groups
def reparse_sfda_entry_device_list(doc_name: str) -> dict:
"""Rebuild device_list child rows from stored detail_pdf on each NCMDR group."""
doc = frappe.get_doc("SFDA Entries", doc_name)
groups = _group_child_rows_by_ncmdr_ref(doc)
if not groups:
return {"name": doc_name, "device_rows": 0, "skipped": "no_child_rows"}
rebuilt_rows: list[dict] = []
total_device_rows = 0
for group in groups.values():
detail_pdf = group.get("detail_pdf") or ""
if not detail_pdf:
rebuilt_rows.append(
{
"doctype": "SFDA Device Entries",
"ncmdr_ref": group["ncmdr_ref"],
"manufacturer": group["manufacturer"],
"ade_detail_url": group["ade_detail_url"],
}
)
continue
pdf_bytes = _read_detail_pdf_bytes(detail_pdf)
devices = parse_affected_device_list(pdf_bytes)
if devices:
for device in devices:
rebuilt_rows.append(
{
"doctype": "SFDA Device Entries",
"ncmdr_ref": group["ncmdr_ref"],
"manufacturer": group["manufacturer"],
"detail_pdf": detail_pdf,
"ade_detail_url": group["ade_detail_url"],
**device.as_dict(),
}
)
total_device_rows += len(devices)
else:
rebuilt_rows.append(
{
"doctype": "SFDA Device Entries",
"ncmdr_ref": group["ncmdr_ref"],
"manufacturer": group["manufacturer"],
"detail_pdf": detail_pdf,
"ade_detail_url": group["ade_detail_url"],
}
)
doc.set("device_list", [])
for row in rebuilt_rows:
doc.append("device_list", row)
doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"name": doc_name,
"ncmdr_groups": len(groups),
"device_rows": total_device_rows or len(rebuilt_rows),
}
def reparse_all_stored_pdf_device_lists(sfda_entries_name: str | None = None) -> dict:
"""Re-parse device_list from child-row detail_pdf for one or all SFDA Entries."""
if sfda_entries_name:
names = [sfda_entries_name]
else:
names = frappe.db.sql_list(
"""
select distinct parent
from `tabSFDA Device Entries`
where ifnull(detail_pdf, '') != ''
"""
)
summary = {
"processed": 0,
"updated": 0,
"total_device_rows": 0,
"errors": [],
}
for name in names:
summary["processed"] += 1
try:
result = reparse_sfda_entry_device_list(name)
summary["updated"] += 1
summary["total_device_rows"] += result.get("device_rows", 0)
except Exception as exc:
frappe.db.rollback()
summary["errors"].append(f"{name}: {exc}")
return summary

View File

@ -0,0 +1,158 @@
"""Tests for splitting comma-separated device identifiers into separate rows."""
from __future__ import annotations
import os
import unittest
from sfda_parser.sfda_scraper.detail_pdf import (
DeviceRow,
_expand_device_row,
_expand_device_rows,
_split_multi_values,
parse_affected_device_list,
)
class TestSplitMultiValues(unittest.TestCase):
def test_splits_comma_separated_values(self):
self.assertEqual(
_split_multi_values("24B0729, 25B0770, 25H0171, 26B0542"),
["24B0729", "25B0770", "25H0171", "26B0542"],
)
def test_single_value_unchanged(self):
self.assertEqual(_split_multi_values("24B0729"), ["24B0729"])
def test_splits_semicolon_separated_values(self):
self.assertEqual(_split_multi_values("A1; A2; A3"), ["A1", "A2", "A3"])
def test_splits_space_separated_numeric_ids(self):
value = (
"0231547054 0231591827 0231758880 0231758898 0231758932 "
"0231792414 0231792421 0231912903 0232670901 0232709219 "
"0232709254 0232744928 0232784925 0232784989 0232805458 0232837110"
)
parts = _split_multi_values(value)
self.assertEqual(len(parts), 16)
self.assertEqual(parts[0], "0231547054")
self.assertEqual(parts[-1], "0232837110")
def test_does_not_split_prose_with_spaces(self):
self.assertEqual(
_split_multi_values("Surgical Instrument Kit"),
["Surgical Instrument Kit"],
)
class TestExpandDeviceRow(unittest.TestCase):
def _row(self, **kwargs) -> DeviceRow:
base = {
"material": "Test Device",
"material_description": "Description",
"catalog_number": "",
"udi": "",
"serial_no": "",
"gstn": "",
"batch": "",
}
base.update(kwargs)
return DeviceRow(**base)
def test_expands_serial_numbers_into_separate_rows(self):
rows = _expand_device_row(
self._row(serial_no="24B0729, 25B0770, 25H0171, 26B0542")
)
self.assertEqual(len(rows), 4)
self.assertEqual([row.serial_no for row in rows], [
"24B0729",
"25B0770",
"25H0171",
"26B0542",
])
for row in rows:
self.assertEqual(row.material, "Test Device")
self.assertEqual(row.material_description, "Description")
def test_expands_catalog_numbers_into_separate_rows(self):
rows = _expand_device_row(self._row(catalog_number="CAT-1, CAT-2, CAT-3"))
self.assertEqual(len(rows), 3)
self.assertEqual([row.catalog_number for row in rows], ["CAT-1", "CAT-2", "CAT-3"])
def test_expands_batch_values_into_separate_rows(self):
rows = _expand_device_row(self._row(batch="LOT1, LOT2"))
self.assertEqual(len(rows), 2)
self.assertEqual([row.batch for row in rows], ["LOT1", "LOT2"])
def test_zips_serial_and_catalog_when_counts_match(self):
rows = _expand_device_row(
self._row(serial_no="S1, S2", catalog_number="C1, C2")
)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0].serial_no, "S1")
self.assertEqual(rows[0].catalog_number, "C1")
self.assertEqual(rows[1].serial_no, "S2")
self.assertEqual(rows[1].catalog_number, "C2")
def test_single_value_row_unchanged(self):
row = self._row(serial_no="ONLY-ONE", catalog_number="CAT-9")
self.assertEqual(_expand_device_row(row), [row])
def test_expand_device_rows_flattens_list(self):
devices = [
self._row(serial_no="A, B"),
self._row(catalog_number="X, Y, Z"),
]
expanded = _expand_device_rows(devices)
self.assertEqual(len(expanded), 5)
def test_expands_space_separated_udi_values(self):
udi_value = "0231547054 0231591827 0231758880"
rows = _expand_device_row(self._row(udi=udi_value, material="Monitor"))
self.assertEqual(len(rows), 3)
self.assertEqual(rows[1].udi, "0231591827")
self.assertEqual(rows[2].material, "Monitor")
class TestParseAffectedDeviceListExpansion(unittest.TestCase):
def test_parse_expands_comma_separated_serials_from_table_like_input(self):
# Minimal synthetic check via direct expansion path used by parser output.
devices = _expand_device_rows([
DeviceRow(
material="Pump",
material_description="",
catalog_number="REF-100",
udi="",
serial_no="24B0729, 25B0770, 25H0171, 26B0542",
gstn="",
batch="",
)
])
self.assertEqual(len(devices), 4)
self.assertEqual(devices[0].catalog_number, "REF-100")
self.assertEqual(devices[-1].serial_no, "26B0542")
class TestSparseTablePdfExpansion(unittest.TestCase):
def test_medtronic_pdf_expands_sparse_table_lot_lists(self):
pdf_path = os.path.abspath(
os.path.join(
os.path.dirname(__file__),
"../../../../sites/erp.seeraunified.com/public/files/"
"SA-28-05-26-1408-ade-detailad786ead786e.pdf",
)
)
if not os.path.isfile(pdf_path):
self.skipTest(f"fixture PDF not found: {pdf_path}")
with open(pdf_path, "rb") as pdf_file:
devices = parse_affected_device_list(pdf_file.read())
self.assertGreaterEqual(len(devices), 40)
serial_numbers = [device.serial_no for device in devices if device.serial_no]
self.assertIn("0012508285", serial_numbers)
self.assertIn("0013272796", serial_numbers)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,176 @@
"""Tests for material/serial sanitization in ADE detail PDF parsing."""
from __future__ import annotations
import unittest
from sfda_parser.sfda_scraper.detail_pdf import (
DeviceRow,
_clean_material_value,
_clean_serial_value,
_is_likely_date_value,
_is_likely_prose,
_paired_rows_from_summary,
_parse_summary_kv,
_sanitize_device_row,
)
class TestMaterialCleaning(unittest.TestCase):
def test_strips_product_concerned_prefix(self):
self.assertEqual(
_clean_material_value("of the product concerned: TU2000 TRYTABLE"),
"TU2000 TRYTABLE",
)
def test_strips_trade_name_product_concerned_prefix(self):
self.assertEqual(
_clean_material_value("Trade name of the product concerned: TU2000 TRYTABLE"),
"TU2000 TRYTABLE",
)
def test_parse_summary_trade_name_line(self):
lines = ["Trade name of the product concerned: TU2000 TRYTABLE"]
summary = _parse_summary_kv(lines)
self.assertEqual(summary.get("material"), "TU2000 TRYTABLE")
class TestSerialDateRejection(unittest.TestCase):
def test_detects_day_month_year_dates(self):
self.assertTrue(_is_likely_date_value("21-APR-2025"))
self.assertTrue(_is_likely_date_value("21/APR/2025"))
def test_allows_real_serial_numbers(self):
self.assertFalse(_is_likely_date_value("24B0729"))
self.assertFalse(_is_likely_date_value("TSMICS1"))
def test_clean_serial_rejects_dates(self):
self.assertEqual(_clean_serial_value("21-APR-2025"), "")
self.assertEqual(_clean_serial_value("ABC12345"), "ABC12345")
def test_sanitize_device_row_clears_date_serial(self):
row = DeviceRow(
material="Pump",
material_description="",
catalog_number="",
udi="",
serial_no="21-APR-2025",
gstn="",
batch="",
)
cleaned = _sanitize_device_row(row)
self.assertEqual(cleaned.serial_no, "")
class TestPairedUdiSn(unittest.TestCase):
def test_pairs_udi_and_sn_by_position(self):
udi_values = (
"04020012, 04020014, 04020017, 04020018, 04020026, 04020021, "
"04020009, 04020010, 04020011, 04020001, 04020025, 04020013, "
"04020003, 04020005, 04020016, 04020023, 04020028, 04020008, "
"04020020, 04020019, 04020015, 04020024, 04020022, 04020006, "
"04020027, 04020029, 04020030, 04020031"
)
sn_values = (
"32090012, 32090014, 32090017, 32090018, 32090026, 32090021, "
"32090009, 32090010, 32090011, TR05617, 32090025, 32090013, "
"32090003, 302090005, 32090016, 32090023, 32090028, 32090008, "
"32090020, 32090019, 32090015, 32090024, 32090022, 302090006, "
"32090027, 32090029, 32090030, 32090031"
)
summary = {
"material": "TU2000 TRYTABLE",
"udi": udi_values,
"serial_no": sn_values,
}
rows = _paired_rows_from_summary(summary)
self.assertEqual(len(rows), 28)
self.assertEqual(rows[0].udi, "04020012")
self.assertEqual(rows[0].serial_no, "32090012")
self.assertEqual(rows[1].udi, "04020014")
self.assertEqual(rows[1].serial_no, "32090014")
self.assertEqual(rows[9].udi, "04020001")
self.assertEqual(rows[9].serial_no, "TR05617")
self.assertEqual(rows[27].udi, "04020031")
self.assertEqual(rows[27].serial_no, "32090031")
self.assertEqual(rows[0].material, "TU2000 TRYTABLE")
def test_parse_summary_kv_stops_before_narrative(self):
lines = [
"Trade name of the product concerned: TU2000 TRYTABLE",
"UDI-DI: 04020012, 04020014",
"SN: 32090012, 32090014",
"Dear Customer",
"The manufacturer has identified an error in the user manual.",
]
summary = _parse_summary_kv(lines)
self.assertEqual(summary.get("udi"), "04020012, 04020014")
self.assertEqual(summary.get("serial_no"), "32090012, 32090014")
self.assertNotIn("Dear Customer", summary.get("serial_no", ""))
def test_parse_summary_kv_multiline_udi_sn_lists(self):
lines = [
"Trade name of the product concerned: TU2000 TRYTABLE",
"UDI-DI: 04020012, 04020014, 04020017, 04020018, 04020026, 04020021, 04020009,",
"04020010, 04020011, 04020001, 04020025, 04020013, 04020003, 04020005, 04020016,",
"04020023, 04020028, 04020008, 04020020, 04020019, 04020015, 04020024, 04020022,",
"04020006, 04020027, 04020029, 04020030, 04020031",
"SN: 32090012, 32090014, 32090017, 32090018, 32090026, 32090021, 32090009, 32090010,",
"32090011, TR05617, 32090025, 32090013, 32090003, 302090005, 32090016, 32090023,",
"32090028, 32090008, 32090020, 32090019, 32090015, 32090024, 32090022, 302090006,",
"32090027, 32090029, 32090030, 32090031",
"Manufactured: 18/07/2024 17/03/2026",
"Dear Customer,",
]
summary = _parse_summary_kv(lines)
rows = _paired_rows_from_summary(summary)
self.assertEqual(len(rows), 28)
self.assertEqual(rows[9].udi, "04020001")
self.assertEqual(rows[9].serial_no, "TR05617")
class TestProseRejection(unittest.TestCase):
def test_detects_narrative_fragments(self):
self.assertTrue(_is_likely_prose("Dear Customer"))
self.assertTrue(
_is_likely_prose(
"The manufacturer has identified an error in the user manual."
)
)
self.assertTrue(_is_likely_prose("Manufactured: 18/07/2024 17/03/2026"))
def test_clean_serial_rejects_prose(self):
self.assertEqual(_clean_serial_value("Dear Customer"), "")
self.assertEqual(_clean_serial_value("32090012"), "32090012")
def test_allows_long_comma_separated_identifier_lists(self):
line = (
"0012508285, 0012517045, 0012526102, 0012526103, 0012614786, 0012614787, "
"0012623740, 0012623741, 0012642959, 0012659419"
)
self.assertFalse(_is_likely_prose(line))
def test_clean_serial_keeps_comma_separated_lot_list(self):
value = "24B0729, 25B0770, 25H0171, 26B0542"
self.assertEqual(_clean_serial_value(value), value)
class TestSerialLabelParsing(unittest.TestCase):
def test_parse_sn_label_from_text(self):
lines = [
"UDI-DI: 01234567890123",
"SN: ABC12345",
"Date: 21-APR-2025",
]
summary = _parse_summary_kv(lines)
self.assertEqual(summary.get("udi"), "01234567890123")
self.assertEqual(summary.get("serial_no"), "ABC12345")
def test_sn_label_ignores_date_values(self):
lines = ["SN: 21-APR-2025"]
summary = _parse_summary_kv(lines)
self.assertNotIn("serial_no", summary)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,103 @@
"""Fetch the latest (newest) NCMDR weekly alert row from the SFDA website."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from sfda_parser.sfda_scraper.http_client import SFDA_BASE, create_session
WEEKLY_ALERT_URL = f"{SFDA_BASE}/en/weekly-alert"
# TEMPORARY: how many recent weekly bulletins to import/scan. Set to 1 for latest-only.
WEEKLY_BULLETINS_TO_IMPORT = 1
@dataclass
class WeeklyAlertRow:
title: str
alert_date: date | None
pdf_url: str
def _parse_date(value: str) -> date | None:
value = (value or "").strip()
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _parse_table_row(tr) -> WeeklyAlertRow | None:
title_el = tr.select_one(".views-field-title")
date_el = tr.select_one(".views-field-field-date")
file_el = tr.select_one(".views-field-field-file a[href]")
if not title_el or not file_el:
return None
title = title_el.get_text(strip=True)
date_str = date_el.get_text(strip=True) if date_el else ""
href = file_el.get("href")
if not title or not href:
return None
return WeeklyAlertRow(
title=title,
alert_date=_parse_date(date_str),
pdf_url=urljoin(SFDA_BASE, href),
)
def fetch_all_weekly_alerts(session=None) -> list[WeeklyAlertRow]:
"""Return every weekly bulletin row from the SFDA table (newest usually listed first)."""
session = session or create_session()
response = session.get(WEEKLY_ALERT_URL, timeout=60)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
rows: list[WeeklyAlertRow] = []
for tr in soup.select("tr"):
parsed = _parse_table_row(tr)
if parsed:
rows.append(parsed)
if not rows:
raise ValueError("Could not find any weekly alert rows on SFDA page")
return rows
def _sort_weekly_alerts_newest_first(rows: list[WeeklyAlertRow]) -> list[WeeklyAlertRow]:
"""Newest bulletin first; top-of-page wins when dates tie."""
indexed = list(enumerate(rows))
indexed.sort(
key=lambda item: (item[1].alert_date or date.min, -item[0]),
reverse=True,
)
return [item[1] for item in indexed]
def fetch_recent_weekly_alerts(
session=None, count: int | None = None
) -> list[WeeklyAlertRow]:
"""
Return the N newest weekly bulletins (default: WEEKLY_BULLETINS_TO_IMPORT).
Order: newest first. Use reversed() when importing oldest-to-newest.
"""
if count is None:
count = WEEKLY_BULLETINS_TO_IMPORT
rows = _sort_weekly_alerts_newest_first(fetch_all_weekly_alerts(session=session))
return rows[: max(1, count)]
def fetch_latest_weekly_alert(session=None) -> WeeklyAlertRow:
"""Return the single newest weekly bulletin."""
return fetch_recent_weekly_alerts(session=session, count=1)[0]

View File

@ -0,0 +1,74 @@
"""Parse Safety Alerts table from the NCMDR weekly update PDF."""
from __future__ import annotations
import io
import re
from dataclasses import dataclass
import pdfplumber
NCMDR_REF_PATTERN = re.compile(r"^SA-\d", re.IGNORECASE)
@dataclass
class SafetyAlertRow:
ncmdr_ref: str
detail_url: str
def _collect_refs_from_page(page) -> list[str]:
refs: list[str] = []
for table in page.extract_tables() or []:
for row in table or []:
if not row:
continue
for cell in row:
if not cell:
continue
text = str(cell).strip()
if NCMDR_REF_PATTERN.match(text):
refs.append(text)
return refs
def _collect_links_from_page(page) -> list[str]:
hyperlinks = [
h
for h in (page.hyperlinks or [])
if h.get("uri") and "PublishDetails" in h["uri"]
]
hyperlinks.sort(key=lambda h: h.get("top", 0))
return [h["uri"] for h in hyperlinks]
def parse_safety_alerts_from_pdf(pdf_bytes: bytes) -> list[SafetyAlertRow]:
alerts: list[SafetyAlertRow] = []
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
for page in pdf.pages:
refs = _collect_refs_from_page(page)
links = _collect_links_from_page(page)
if not refs:
continue
if len(refs) != len(links):
# Fallback: zip min length and log mismatch in importer
pair_count = min(len(refs), len(links))
refs = refs[:pair_count]
links = links[:pair_count]
for ncmdr_ref, detail_url in zip(refs, links):
alerts.append(SafetyAlertRow(ncmdr_ref=ncmdr_ref, detail_url=detail_url))
# Deduplicate by NCMDR ref while preserving order
seen: set[str] = set()
unique: list[SafetyAlertRow] = []
for alert in alerts:
key = alert.ncmdr_ref.strip().upper()
if key in seen:
continue
seen.add(key)
unique.append(alert)
return unique

View File

View File