Skill: changye/cde-monitor (version 1.0.4)

--- name: cde-monitor description: Query CDE China drug review public data for breakthrough therapy announcements, priority review announcements, included lists by company or drug, in-review registration lookups by company or drug, and acceptance-number review status lookups. homepage: https://www.cde.org.cn metadata: {"openclaw":{"emoji":"💊","homepage":"https://www.cde.org.cn","requires":{"anyBins":["python","python3","py"]},"os":["win32","linux"]}} --- # CDE Monitor Use this skill when the user needs live information from the China Center for Drug Evaluation site and the answer depends on current CDE public listings rather than static knowledge. This skill should also activate when the user uses a clear trigger phrase such as `CDE 药物信息查询`, `查询 CDE 药物信息`, `cde drugs query`, or `CDE drug query`, even if the user has not yet provided a concrete company name, drug name, or query type. When the user only provides a trigger phrase or a vague request, do not run a query immediately. First introduce the skill's capabilities in a short overview, then guide the user to choose one of the supported query types and provide the exact company name or drug name when needed. ## Quick Start ```bash # List current breakthrough therapy announcements python {baseDir}/scripts/cde_query.py breakthrough-announcements --pretty # Find breakthrough included records for a company python {baseDir}/scripts/cde_query.py breakthrough-included-by-company --company "示例公司" --pretty # Find priority review included records for a drug python {baseDir}/scripts/cde_query.py priority-included-by-drug --drug "示例药品" --pretty # Query in-review catalog records across 2016 to 2026 python {baseDir}/scripts/cde_query.py in-review-by-company --company "示例公司" --years 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 --pretty # Query review task status by acceptance number python {baseDir}/scripts/cde_query.py review-status-by-acceptance-no --acceptance-no "CYSB2600096" --pretty ``` Use `--show-browser` when the CDE page flow needs visible debugging. Omit `--pretty` when you want raw JSON output for downstream processing. ## Conversation Opening If this skill is activated by a generic trigger such as `CDE 药物信息查询` or `cde drugs query`, start with a short capability summary before asking for input. Match the conversation language to the user's language. If the user asks in Chinese, reply in Chinese. If the user asks in English, reply in English. Keep the language choice consistent across the introduction, clarification questions, and final results unless the user explicitly switches languages. Do not reuse the Chinese opening when the user starts in English. If the trigger phrase or the user's request is in English, the first capability summary and the follow-up question must also be in English. Recommended opening pattern for Chinese: ```md 我可以帮助你查询 CDE 公开药品审评信息，当前支持： - 拟突破性治疗品种公示 - 纳入突破性治疗品种名单，按企业或药品查询 - 拟优先审评品种公示 - 纳入优先审评品种名单，按企业或药品查询 - 在审品种目录浏览，按企业或药品查询，支持年份 2016 到 2026 - 按受理号查询申请基础信息和评审状态 请告诉我你要查哪一类信息。 如果是按企业或药品查询，请尽量提供准确的企业全称或药品名称。 如果是按受理号查询，请提供完整受理号。 ``` Recommended opening pattern for English: ```md I can help you query public CDE drug review information. I currently support: - Breakthrough therapy announcements - Included breakthrough therapy lists, searchable by company or drug - Priority review announcements - Included priority review lists, searchable by company or drug - In-review registration catalog lookups by company or drug for years 2016 through 2026 - Acceptance-number lookups for application basic info and review task status Tell me which type of CDE information you want to check. If you want a company-specific or drug-specific lookup, please provide the exact company name or drug name whenever possible. If you want an acceptance-number lookup, please provide the full acceptance number. ``` If the user then chooses a company-specific or drug-specific query, confirm the exact company or drug name when the provided string could be ambiguous, abbreviated, or misspelled. If the user chooses an acceptance-number lookup, confirm the acceptance number only when it appears incomplete, malformed, or ambiguous. ## Data Sources | Source | URL | Data used by this skill | | --- | --- | --- | | CDE 信息公开入口 | https://www.cde.org.cn/main/xxgk/listpage/9f9c74c73e0f8f56a8bfbc646055026d | Entry page for all supported public query flows | | 突破性治疗公示 | https://www.cde.org.cn/main/xxgk/listpage/9f9c74c73e0f8f56a8bfbc646055026d | 拟突破性治疗品种 and 纳入突破性治疗品种名单 | | 优先审评公示 | https://www.cde.org.cn/main/xxgk/listpage/9f9c74c73e0f8f56a8bfbc646055026d | 拟优先审评品种公示 and 纳入优先审评品种名单 | | 受理品种信息 | https://www.cde.org.cn/main/xxgk/listpage/9f9c74c73e0f8f56a8bfbc646055026d | 在审品种目录浏览 across the requested years | | 审评任务公示 | https://www.cde.org.cn/main/xxgk/listpage/369ac7cfeb67c6000c33f85e6f374044 | 新报任务公示 review task lookup used by acceptance-number status queries | This skill uses Selenium to drive the public CDE site and read the rendered listing data after the page JavaScript finishes loading. It does not rely on static local datasets. Results reflect the public CDE pages available at query time. ## Environment Requirements Required: - Python 3.10 or newer. - Google Chrome or Chromium installed locally. - Network access to https://www.cde.org.cn. - Python packages from `scripts/requirements.txt`. Install on Windows: ```powershell py -m venv .venv .\.venv\Scripts\Activate.ps1 python -m pip install --upgrade pip python -m pip install -r .\scripts\requirements.txt ``` Install on Linux: ```bash python3 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip python -m pip install -r ./scripts/requirements.txt ``` OpenClaw only needs a working Python launcher on the machine. Chrome itself does not need to be on `PATH`, but Selenium must be able to launch a local Chrome or Chromium browser. For `in-review-by-company` and `in-review-by-drug`, the supported year range is limited to 2016 through 2026. If the user passes any year outside that range, the CLI should return a clear validation error. ## Use Cases - Breakthrough therapy: list drugs currently in 拟突破性治疗品种 announcements. - Breakthrough therapy: find which drugs from a company are already in 纳入突破性治疗品种名单. - Breakthrough therapy: find whether a drug appears in 纳入突破性治疗品种名单. - Priority review: list drugs currently in 拟优先审评品种公示 announcements. - Priority review: find which drugs from a company are already in 纳入优先审评品种名单. - Priority review: find whether a drug appears in 纳入优先审评品种名单. - In-review catalog: find CDE registration items for a company across years 2016 to 2026. - In-review catalog: find CDE registration items for a drug across years 2016 to 2026. - Acceptance number lookup: find the application basic info first, then locate the review task status and professional-stage lamp states. ## Name Confirmation Rule For company-specific or drug-specific lookups, first confirm the exact company or drug name if the user input could be ambiguous, abbreviated, or misspelled. CDE matching is strict. If the user already provided a precise name and there is no ambiguity, proceed directly. ## Command Surface Run the Python CLI from this skill directory. ```bash python {baseDir}/scripts/cde_query.py breakthrough-announcements python {baseDir}/scripts/cde_query.py breakthrough-included-by-company --company "<公司名>" python {baseDir}/scripts/cde_query.py breakthrough-included-by-drug --drug "<药品名>" python {baseDir}/scripts/cde_query.py priority-announcements python {baseDir}/scripts/cde_query.py priority-included-by-company --company "<公司名>" python {baseDir}/scripts/cde_query.py priority-included-by-drug --drug "<药品名>" python {baseDir}/scripts/cde_query.py in-review-by-company --company "<公司名>" --years 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 python {baseDir}/scripts/cde_query.py in-review-by-drug --drug "<药品名>" --years 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 python {baseDir}/scripts/cde_query.py review-status-by-acceptance-no --acceptance-no "<受理号>" ``` Add `--show-browser` when you need visible browser debugging. JSON is the default output. Use `--pretty` only when you want a human-readable terminal summary during debugging. Use `--max-pages <n>` only for debugging or validation; omit it for the full production query. ## Response Handling - Read `records` for the merged deduplicated results. - Use each record's `normalized` object for answering. It exposes stable keys such as `drug_name`, `company_name`, `acceptance_no`, `publication_title`, `year`, `source_menu`, and `source_tab`. - Use `metadata.total_records`, `metadata.pages_visited`, `metadata.years_queried`, and `metadata.applied_filters` to summarize scope. - The final user-facing answer should be a Markdown table whenever records are returned. - Prefer a compact table with columns such as `药品名称`, `企业名称`, `受理号`, and `年份`. Add a short summary line above the table for `total_records`, `pages_visited`, and the applied filters when helpful. - If the command returns no records, tell the user that no matching public CDE entries were found for the confirmed query. - For `review-status-by-acceptance-no`, read `basic_info_found`, `review_status_found`, `basic_info`, `review_status`, and `attempts` instead of `records`. - For `review-status-by-acceptance-no`, the acceptance number's inferred year must currently fall within the CDE page's supported year range of 2016 through 2026. - For `review-status-by-acceptance-no`, summarize the first-step basic info using fields such as `drug_name`, `drug_type`, `application_type`, and `acceptance_no`. - For `review-status-by-acceptance-no`, when `review_status_found` is true, summarize `review_state`, `entered_center_at`, and each professional-stage lamp status from `review_status.stages`. - For `review-status-by-acceptance-no`, when `basic_info_found` is true but `review_status_found` is false, tell the user that the application basic info was found but no matching public review-task status is currently shown on the CDE page. ## Failure Handling - If Python or Selenium is missing, load [setup](./references/setup.md). - If `review-status-by-acceptance-no` infers a year outside 2016 through 2026, report that the acceptance number's inferred year is outside the current CDE page query range. - If the site structure changes or the query fails, report that the live CDE page could not be parsed reliably and include the command error summary. - Do not invent results when the command fails. ## References - [Setup](./references/setup.md) - [Queries](./references/queries.md) - [Development guide](./references/development-guide.md) - [Publish checklist](./references/publish-checklist.md) ## Troubleshooting - If Python or Selenium is missing, follow the install commands in [Setup](./references/setup.md). - If Chrome or Chromium does not launch, verify that the browser is installed locally and can be opened manually on the same machine. - If a query returns no expected records, first confirm the exact company name or drug name. CDE matching is strict. - If the visible page flow or results change unexpectedly, rerun with `--show-browser` to inspect the live CDE page behavior. - If the CDE page structure has changed, update the selector and parsing logic in `scripts/cde_client.py` before relying on the result.

path: references/development-guide.md
content: # Development Guide

## Project snapshot

This repository queries live public drug-review data from the CDE website through Selenium-driven browser automation. It is intentionally split into a thin CLI layer, a single runtime client that knows the site behavior, and small normalization/result-model helpers.

Current supported command families:

- `breakthrough-announcements`
- `breakthrough-included-by-company`
- `breakthrough-included-by-drug`
- `priority-announcements`
- `priority-included-by-company`
- `priority-included-by-drug`
- `in-review-by-company`
- `in-review-by-drug`
- `review-status-by-acceptance-no`

Current public constraints:

- The implementation depends on the live CDE page structure and current network payload shape.
- `in-review-*` year selection is limited to `2016` through `2026`.
- `review-status-by-acceptance-no` also depends on the inferred year falling within `2016` through `2026`.
- Company and drug matching on the CDE page is strict; fuzzy matching is not implemented.

## Repository layout

### `scripts/cde_query.py`

CLI entrypoint.

Responsibilities:

- Defines the command surface with `argparse`.
- Performs lightweight input validation such as year bounds.
- Dispatches each command into `CDEClient`.
- Prints either raw JSON or the compact `--pretty` output.

When adding a new feature, this file is usually the first place to update if the feature needs a new CLI subcommand or arguments.

### `scripts/cde_client.py`

Core runtime implementation.

Responsibilities:

- Starts the Chrome driver.
- Navigates CDE menus and tabs.
- Applies text and select filters.
- Submits searches and captures JSON payloads from performance logs.
- Scrapes DOM tables when a page does not expose a stable JSON result path.
- Normalizes records and returns structured result objects.

This is the main extension point for new CDE capabilities.

### `scripts/models.py`

Result model definitions.

Current result types:

- `QueryTarget`: static definition of a generic page flow.
- `PageCapture`: wrapper around one captured JSON payload.
- `QueryRunResult`: standard list-result shape for most commands.
- `AcceptanceReviewResult`: specialized result shape for the two-step acceptance-number lookup.

If a new feature returns a materially different payload shape, add or extend a dedicated result model here.

### `scripts/normalizers.py`

Field normalization and deduplication.

Responsibilities:

- Maps raw CDE keys into a stable `normalized` object.
- Infers the year from the record date when needed.
- Generates dedupe fingerprints across merged pages and years.

Any new raw field that should be exposed consistently to the skill or CLI should be added here first.

### `tests/`

Unit tests use the standard library `unittest` framework.

Current testing split:

- `tests/test_cli.py`: parser and argument validation.
- `tests/test_cde_client.py`: query orchestration, pagination, acceptance-number lookup behavior.
- `tests/test_normalizers.py`: normalization and dedupe behavior.

### `SKILL.md`

Operator-facing skill contract.

Keep it aligned with the actual command surface, supported flows, failure handling, and response interpretation. When a feature is added or behavior changes, this file should be updated in the same change.

## Two implementation patterns

The codebase currently has two stable patterns for feature work.

### Pattern 1: Generic list-style query

Used by:

- breakthrough announcements and included lists
- priority announcements and included lists
- in-review lookups by company or drug

Flow:

1. Define a `QueryTarget` constant in `scripts/cde_client.py`.
2. Add a public `query_*` method on `CDEClient`.
3. Route the method through `_query_target()` or `_query_in_review()`.
4. Add a CLI subcommand in `scripts/cde_query.py`.
5. Extend tests and docs.

`_query_target()` already handles most of the repeated mechanics:

- open page
- click left and right tabs
- apply text and select filters
- submit search
- read JSON payloads from the browser network log
- paginate until exhausted or repeated
- normalize and dedupe records

If the new CDE page behaves like a list page with a stable payload, prefer this pattern.

### Pattern 2: Specialized orchestrated flow

Used by:

- `review-status-by-acceptance-no`

Flow:

1. Resolve prerequisite information first.
2. Build one or more search plans.
3. Run page-specific logic that may mix filters, DOM scraping, and pagination.
4. Return a dedicated result model instead of forcing the data into `records`.

This pattern is appropriate when the CDE workflow is multi-step or the result page is not served through the same network-payload pattern as the other list pages.

## Current interface inventory

### Public `CDEClient` methods

- `query_breakthrough_announcements()`
- `query_breakthrough_included_by_company(company)`
- `query_breakthrough_included_by_drug(drug)`
- `query_priority_announcements()`
- `query_priority_included_by_company(company)`
- `query_priority_included_by_drug(drug)`
- `query_in_review_by_company(company, years)`
- `query_in_review_by_drug(drug, years)`
- `query_review_status_by_acceptance_no(acceptance_no)`

These methods are the runtime API that the CLI consumes. If another integration layer is added later, it should usually call these methods rather than reimplementing page logic.

### Internal reusable helpers in `CDEClient`

Generic helpers:

- `_query_target()`
- `_build_driver()`
- `_open_listing_page()`
- `_click_left_tab()`
- `_click_right_tab()`
- `_fill_text_filter()`
- `_select_filter()`
- `_submit_search()`
- `_wait_for_payload()`
- `_extract_capture_from_log()`
- `_detect_total_pages()`
- `_go_to_page()`

Acceptance-number helpers:

- `infer_acceptance_year()`
- `_query_acceptance_basic_info()`
- `_query_review_status_for_basic_info()`
- `_build_review_search_plans()`
- `_run_review_status_attempt()`
- `_scrape_review_task_page()`
- `_normalize_review_task_row()`

When implementing a new feature, reuse these helpers before adding new site-specific code.

## How the current commands are implemented

### Breakthrough and priority commands

These are the simplest commands. They differ mainly by:

- left tab
- right tab
- whether a company or drug filter is applied

They all return `QueryRunResult` and use normalized list records.

### In-review by company or drug

These are still list-style queries, but they are special because they merge results across multiple years. The public methods delegate into `_query_in_review()`, which calls `_query_target()` once per year, then deduplicates the merged records.

This pattern is a good reference if you need to add another feature that must sweep multiple parameter values and merge the output.

### Review status by acceptance number

This is the current most complex feature.

Implementation summary:

1. Normalize the acceptance number.
2. Infer the year from digits 5 and 6.
3. Validate that the inferred year is currently queryable on the CDE page.
4. Query `受理品种信息 -> 在审品种目录浏览` to fetch the basic application record.
5. Derive review-task filters from `drug_type`, `application_type`, and the acceptance-number prefix.
6. Query `审评任务公示 -> 新报任务公示`.
7. If the page returns a warning for the strict acceptance-number filter, retry without that filter and scan the paginated table for the exact row.
8. Convert lamp icons into stable stage labels.

This flow is the reference implementation for any future feature that requires multiple dependent CDE pages.

## Site-specific assumptions worth preserving

- Left navigation is most reliable through `.etcd_nav_ul li`.
- Generic text and select filters are located through label text plus a set of preferred element ids.
- Generic list pages are consumed primarily through captured network payloads.
- The review-task page is consumed through DOM scraping because the UI behavior is different enough that direct payload reuse is less reliable.
- Review-task lamp icon mapping is part of the business output and should be treated as a maintained contract.

If a future feature breaks, verify whether the underlying issue is:

- a changed selector
- a changed network payload shape
- a changed tab layout
- a changed filter id or label

## Recommended workflow for adding a new feature

1. Confirm whether the target CDE page fits the generic list-query pattern or needs a specialized flow.
2. Inspect the live page with `--show-browser` and verify tabs, filter labels, ids, and result behavior.
3. Implement the runtime logic in `scripts/cde_client.py` first.
4. Add or update normalized fields in `scripts/normalizers.py` if new data needs to be exposed.
5. Add or update result models in `scripts/models.py` if the output shape is new.
6. Expose the command in `scripts/cde_query.py`.
7. Add unit tests in `tests/`.
8. Update `SKILL.md` and any relevant files in `references/`.
9. Run unit tests and at least one live smoke test for the new command.

## Testing and release notes

Primary verification command:

```bash
python -m unittest discover -s tests -p "test_*.py" -v
```

Typical release checks:

- `python scripts/cde_query.py --help`
- unit test suite
- one live query per supported query family
- rebuild `dist/package` and `dist/runtime-package`

Preferred packaging command:

```bash
python ./scripts/build_dist.py
```

This script clears Python cache artifacts before copying files into `dist` and excludes those artifacts from the generated zip files.

Current packaging convention:

- full package includes `SKILL.md`, `.clawhubignore`, `scripts`, `references`, and `tests`
- runtime package includes `SKILL.md`, `scripts`, and `references`

## Good starting points for the next feature

Choose the starting point by feature type:

- New list page with standard filters: copy the breakthrough or priority pattern.
- New multi-year list page: copy the `in-review` pattern.
- New dependent multi-step lookup: copy the acceptance-number review-status pattern.
- New output field on existing commands: start in `scripts/normalizers.py`, then adjust the caller and tests.
- New user-facing command only: start in `scripts/cde_query.py`, but only after the runtime method exists.

If the next feature is still inside the CDE public site, `scripts/cde_client.py` should remain the single place that knows the browser and page details.

path: references/publish-checklist.md
content: # Publish Checklist

## Skill format

- `SKILL.md` exists at the root of the skill folder.
- Frontmatter keys are single-line.
- `metadata` is a single-line JSON object.
- The skill name is `cde-monitor`.
- The skill folder should also be named `cde-monitor` before packaging or publishing.

## Bundle safety

- No passwords, API keys, cookies, or tokens are checked in.
- All included files are text-based.
- Ignore local caches and virtual environments through `.clawhubignore`.
- Clean `__pycache__`, `.pytest_cache`, `.pyc`, and `.pyo` artifacts before packaging.
- Do not include `.clawhubignore` in the runtime zip that will be uploaded to ClawHub.
- Do not include browser traces, screenshots, downloads, or temporary artifacts.

## Runtime clarity

- `scripts/requirements.txt` lists only the Python dependencies needed to run the skill.
- `references/setup.md` explains Windows and Linux setup.
- `references/queries.md` documents the supported command surface.

## Validation before publish

Run these checks locally:

```bash
python ./scripts/cde_query.py --help
python -m unittest discover -s tests -p "test_*.py"
```

For real release validation, also run at least one live query from each of the six supported query families.

## Build dist artifacts

Use the packaging script from the repository root:

```bash
python ./scripts/build_dist.py
```

This script:

- removes local Python cache artifacts from `scripts/` and `tests/`
- rebuilds `dist/package` and `dist/runtime-package`
- excludes `__pycache__`, `.pyc`, `.pyo`, and `.pytest_cache` from copied contents and zip files

## ClawHub notes

- ClawHub publishing requires MIT-0 acceptance for published skills.
- You can publish from the skill root with a semver version, for example:

```bash
clawhub skill publish . --slug cde-monitor --name "CDE Monitor" --version 1.0.0 --tags latest
```


path: references/queries.md
content: # Queries

## Supported commands

### breakthrough-announcements

Returns all pages from:

- 信息公开
- 突破性治疗公示
- 拟突破性治疗品种

### breakthrough-included-by-company

Returns filtered results from:

- 信息公开
- 突破性治疗公示
- 纳入突破性治疗品种名单
- Filter: 注册申请人

Command:

```bash
python ./scripts/cde_query.py breakthrough-included-by-company --company "示例公司"
```

### breakthrough-included-by-drug

Returns filtered results from:

- 信息公开
- 突破性治疗公示
- 纳入突破性治疗品种名单
- Filter: 药品名称

Command:

```bash
python ./scripts/cde_query.py breakthrough-included-by-drug --drug "示例药物"
```

### priority-announcements

Returns all pages from:

- 信息公开
- 优先审评公示
- 拟优先审评品种公示

### priority-included-by-company

Returns filtered results from:

- 信息公开
- 优先审评公示
- 纳入优先审评品种名单
- Filter: 注册申请人

### priority-included-by-drug

Returns filtered results from:

- 信息公开
- 优先审评公示
- 纳入优先审评品种名单
- Filter: 药品名称

Command:

```bash
python ./scripts/cde_query.py priority-included-by-drug --drug "示例药物"
```

### in-review-by-company

Returns merged results across the requested years from:

- 信息公开
- 受理品种信息
- 在审品种目录浏览
- Filters: 年度, 企业名称

### in-review-by-drug

Returns merged results across the requested years from:

- 信息公开
- 受理品种信息
- 在审品种目录浏览
- Filters: 年度, 药品名称

## Output shape

The Python CLI still returns structured JSON by default, but the skill should present final user-facing results as Markdown tables.

Recommended answer pattern:

```md
查询条件：`示例公司`
查询范围：`信息公开 > 受理品种信息 > 在审品种目录浏览`
结果汇总：`共 42 条，3 页，年份 2016 至 2026`

| 序号 | 药品名称 | 企业名称 | 受理号 | 年份 |
|---|---|---|---|---|
| 1 | 示例药物A | 示例公司 | CXHS250001 | 2025 |
| 2 | 示例药物B | 示例公司 | CXHS260002 | 2026 |
```

Underlying data fields available to the skill:

- `records[].normalized.drug_name`
- `records[].normalized.company_name`
- `records[].normalized.acceptance_no`
- `records[].normalized.publication_title`
- `records[].normalized.year`
- `records[].normalized.source_menu`
- `records[].normalized.source_tab`
- `records[].normalized.page`
- `metadata.applied_filters`
- `metadata.pages_visited`
- `metadata.years_queried`
- `metadata.total_records`

## Notes for agents

- Use `--max-pages` only for debugging or validation. The default behavior is to keep paginating until the available result set is exhausted or a repeated page fingerprint is observed.
- Use `normalized` fields when composing the answer.
- Keep `raw` only as a fallback when a page uses an unexpected field name.
- Prefer Markdown tables in the final answer whenever records are returned.
- If the user asks for a company-specific or drug-specific query and the name could be ambiguous, confirm the exact string before running the command.


path: references/setup.md
content: # Setup

## Runtime requirements

- Python 3.10 or newer.
- Google Chrome or Chromium installed locally.
- Network access to https://www.cde.org.cn.

## Install on Windows

```powershell
py -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -r .\scripts\requirements.txt
```

If `python` is not on `PATH`, OpenClaw can usually use `py` instead. The skill metadata only gates on a Python launcher being available. Chrome itself does not need to be on `PATH`, but Selenium must be able to launch it on the local machine.

## Install on Linux

```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r ./scripts/requirements.txt
```

Install Chrome or Chromium through the system package manager if it is not already present.

## Smoke test

```bash
python ./scripts/cde_query.py --help
python ./scripts/cde_query.py breakthrough-announcements --pretty
```

## Troubleshooting

- If Selenium cannot start Chrome, verify that Chrome or Chromium is installed and can be opened manually on the same machine.
- If the first query hangs or times out, retry once with `--show-browser` so you can see where the CDE page flow breaks.
- If a company or drug query returns no results, confirm the exact company or drug name first. CDE matching is strict.
- If the CDE page layout changes, inspect the visible browser flow first, then update the selector helpers in `scripts/cde_client.py`.


path: scripts/cde_client.py
content: from __future__ import annotations

import json
import math
import re
import time
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple

from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.support.ui import WebDriverWait

from models import AcceptanceReviewResult, PageCapture, QueryRunResult, QueryTarget
from normalizers import dedupe_records, normalize_record


INFO_DISCLOSURE_URL = "https://www.cde.org.cn/main/xxgk/listpage/9f9c74c73e0f8f56a8bfbc646055026d"

BREAKTHROUGH_ANNOUNCEMENTS = QueryTarget(
    command="breakthrough-announcements",
    left_tab="突破性治疗公示",
    right_tab="拟突破性治疗品种",
    description="List all drugs currently shown in 拟突破性治疗品种.",
    scope_selector="#breakthroughTherapyTab",
)

BREAKTHROUGH_INCLUDED = QueryTarget(
    command="breakthrough-included-by-company",
    left_tab="突破性治疗公示",
    right_tab="纳入突破性治疗品种名单",
    description="Find company-specific drugs in 纳入突破性治疗品种名单.",
    scope_selector="#breakthroughTherapyTab",
)

BREAKTHROUGH_INCLUDED_BY_DRUG = QueryTarget(
    command="breakthrough-included-by-drug",
    left_tab="突破性治疗公示",
    right_tab="纳入突破性治疗品种名单",
    description="Find drug-specific records in 纳入突破性治疗品种名单.",
    scope_selector="#breakthroughTherapyTab",
)

PRIORITY_ANNOUNCEMENTS = QueryTarget(
    command="priority-announcements",
    left_tab="优先审评公示",
    right_tab="拟优先审评品种公示",
    description="List all drugs currently shown in 拟优先审评品种公示.",
    scope_selector="#priorityReviewTab",
)

PRIORITY_INCLUDED = QueryTarget(
    command="priority-included-by-company",
    left_tab="优先审评公示",
    right_tab="纳入优先审评品种名单",
    description="Find company-specific drugs in 纳入优先审评品种名单.",
    scope_selector="#priorityReviewTab",
)

PRIORITY_INCLUDED_BY_DRUG = QueryTarget(
    command="priority-included-by-drug",
    left_tab="优先审评公示",
    right_tab="纳入优先审评品种名单",
    description="Find drug-specific records in 纳入优先审评品种名单.",
    scope_selector="#priorityReviewTab",
)

IN_REVIEW = QueryTarget(
    command="in-review",
    left_tab="受理品种信息",
    right_tab="在审品种目录浏览",
    description="Query the in-review registration catalog.",
    scope_selector="#content_9f9c74c73e0f8f56a8bfbc646055026d",
)

REVIEW_TASKS = QueryTarget(
    command="review-status-by-acceptance-no",
    left_tab="审评任务公示",
    right_tab="新报任务公示",
    description="Query review task status by acceptance number.",
    scope_selector="#content_369ac7cfeb67c6000c33f85e6f374044",
)


class CDEQueryError(RuntimeError):
    pass


TEXT_FILTER_HINTS = {
    "受理号": (
        "acceptid",
        "acceptid2",
        "acceptid3",
        "acceptid4",
        "acceptidInclude",
        "acceptidPlan",
        "acceptidBreakInclude",
        "acceptidBreakPlan",
    ),
    "药品名称": ("drugname", "drugnameInclude", "drugnamePlan", "drugnameBreakInclude", "drugnameBreakPlan"),
    "企业名称": ("company",),
    "注册申请人": (
        "companyInclude",
        "companyPlan",
        "companyBreakInclude",
        "companyBreakPlan",
        "company",
    ),
}

SELECT_FILTER_HINTS = {
    "年度": ("year",),
    "药品类型": ("drugtype", "drugtypeNewReport"),
    "申请类型": ("applytype", "applytypecdeNewReport", "applytypecdeNewReportZy", "applytypecdeNewReportSw"),
    "公示类型": ("splxtype",),
    "审评任务分类": ("applytypecdeNewReport", "applytypecdeNewReportZy", "applytypecdeNewReportSw"),
}

REVIEW_STAGE_COLUMNS = (
    "药理毒理",
    "临床",
    "药学",
    "统计",
    "临床药理",
    "合规",
)

REVIEW_STAGE_ICON_MAP = {
    "/main/img/lamp_shut.gif": {"code": 1, "label": "本专业已完成审评"},
    "/main/img/lamp_y.jpg": {"code": 2, "label": "本专业排队待审评"},
    "/main/img/lamp.gif": {"code": 3, "label": "本专业正在审评"},
}

MIN_ACCEPTANCE_LOOKUP_YEAR = 2016
MAX_ACCEPTANCE_LOOKUP_YEAR = 2026


class CDEClient:
    def __init__(self, headless: bool = True, timeout: int = 25, max_pages: Optional[int] = None) -> None:
        self.headless = headless
        self.timeout = timeout
        self.max_pages = max_pages

    def query_breakthrough_announcements(self) -> Dict[str, Any]:
        return self._query_target(BREAKTHROUGH_ANNOUNCEMENTS).to_dict()

    def query_breakthrough_included_by_company(self, company: str) -> Dict[str, Any]:
        self._validate_exact_name(company, "company")
        return self._query_target(
            BREAKTHROUGH_INCLUDED,
            text_filters=(("注册申请人", company),),
            applied_filters={"company": company},
        ).to_dict()

    def query_breakthrough_included_by_drug(self, drug: str) -> Dict[str, Any]:
        self._validate_exact_name(drug, "drug")
        return self._query_target(
            BREAKTHROUGH_INCLUDED_BY_DRUG,
            text_filters=(("药品名称", drug),),
            applied_filters={"drug": drug},
        ).to_dict()

    def query_priority_announcements(self) -> Dict[str, Any]:
        return self._query_target(PRIORITY_ANNOUNCEMENTS).to_dict()

    def query_priority_included_by_company(self, company: str) -> Dict[str, Any]:
        self._validate_exact_name(company, "company")
        return self._query_target(
            PRIORITY_INCLUDED,
            text_filters=(("注册申请人", company),),
            applied_filters={"company": company},
        ).to_dict()

    def query_priority_included_by_drug(self, drug: str) -> Dict[str, Any]:
        self._validate_exact_name(drug, "drug")
        return self._query_target(
            PRIORITY_INCLUDED_BY_DRUG,
            text_filters=(("药品名称", drug),),
            applied_filters={"drug": drug},
        ).to_dict()

    def query_in_review_by_company(self, company: str, years: Sequence[int]) -> Dict[str, Any]:
        self._validate_exact_name(company, "company")
        return self._query_in_review(
            text_filter_label="企业名称",
            text_filter_value=company,
            filter_key="company",
            years=years,
        ).to_dict()

    def query_in_review_by_drug(self, drug: str, years: Sequence[int]) -> Dict[str, Any]:
        self._validate_exact_name(drug, "drug")
        return self._query_in_review(
            text_filter_label="药品名称",
            text_filter_value=drug,
            filter_key="drug",
            years=years,
        ).to_dict()

    def query_review_status_by_acceptance_no(self, acceptance_no: str) -> Dict[str, Any]:
        normalized_acceptance_no = self._normalize_acceptance_no(acceptance_no)
        inferred_year = self.infer_acceptance_year(normalized_acceptance_no)
        self._validate_acceptance_lookup_year(inferred_year, normalized_acceptance_no)
        basic_record = self._query_acceptance_basic_info(normalized_acceptance_no)
        if basic_record is None:
            return AcceptanceReviewResult(
                acceptance_no=normalized_acceptance_no,
                inferred_year=inferred_year,
            ).to_dict()

        review_lookup = self._query_review_status_for_basic_info(normalized_acceptance_no, basic_record)
        return AcceptanceReviewResult(
            acceptance_no=normalized_acceptance_no,
            inferred_year=inferred_year,
            basic_info=basic_record.get("normalized"),
            review_status=review_lookup.get("review_status"),
            attempts=review_lookup.get("attempts", []),
            pages_visited=review_lookup.get("pages_visited", 0),
        ).to_dict()

    @staticmethod
    def infer_acceptance_year(acceptance_no: str) -> int:
        normalized = re.sub(r"\s+", "", (acceptance_no or "").upper())
        if not re.fullmatch(r"[A-Z]{4}\d{7,}", normalized):
            raise CDEQueryError(f"Invalid acceptance number: {acceptance_no}")
        return 2000 + int(normalized[4:6])

    def _query_in_review(
        self,
        *,
        text_filter_label: str,
        text_filter_value: str,
        filter_key: str,
        years: Sequence[int],
    ) -> QueryRunResult:
        merged_records: List[Dict[str, Any]] = []
        total_pages = 0
        queried_years: List[int] = []
        for year in years:
            partial = self._query_target(
                IN_REVIEW,
                text_filters=((text_filter_label, text_filter_value),),
                select_filters=(("年度", str(year)),),
                applied_filters={filter_key: text_filter_value, "year": year},
                year=year,
            )
            merged_records.extend(partial.records)
            total_pages += partial.pages_visited
            queried_years.append(year)
        deduped = dedupe_records(merged_records)
        return QueryRunResult(
            command=f"in-review-by-{filter_key}",
            description=IN_REVIEW.description,
            records=deduped,
            applied_filters={filter_key: text_filter_value},
            years_queried=queried_years,
            pages_visited=total_pages,
        )

    def _normalize_acceptance_no(self, acceptance_no: str) -> str:
        normalized = re.sub(r"\s+", "", (acceptance_no or "").upper())
        if not normalized:
            raise CDEQueryError("A non-empty acceptance number is required")
        return normalized

    def _validate_acceptance_lookup_year(self, year: int, acceptance_no: str) -> None:
        if MIN_ACCEPTANCE_LOOKUP_YEAR <= year <= MAX_ACCEPTANCE_LOOKUP_YEAR:
            return
        raise CDEQueryError(
            "该受理号推断年份超出当前 CDE 页面可查询范围: "
            f"{acceptance_no} -> {year}，当前支持年份为 {MIN_ACCEPTANCE_LOOKUP_YEAR} 到 {MAX_ACCEPTANCE_LOOKUP_YEAR}"
        )

    def _query_acceptance_basic_info(self, acceptance_no: str) -> Optional[Dict[str, Any]]:
        inferred_year = self.infer_acceptance_year(acceptance_no)
        result = self._query_target(
            IN_REVIEW,
            text_filters=(("受理号", acceptance_no),),
            select_filters=(("年度", str(inferred_year)),),
            applied_filters={"acceptance_no": acceptance_no, "year": inferred_year},
            year=inferred_year,
        )
        for record in result.records:
            normalized = record.get("normalized", {})
            if self._normalize_acceptance_no(normalized.get("acceptance_no", "")) == acceptance_no:
                return record
        return None

    def _query_review_status_for_basic_info(
        self,
        acceptance_no: str,
        basic_record: Dict[str, Any],
    ) -> Dict[str, Any]:
        plans = self._build_review_search_plans(basic_record)
        attempts: List[Dict[str, Any]] = []
        pages_visited = 0
        driver = self._build_driver()
        try:
            self._open_listing_page(driver)
            self._clear_logs(driver)
            self._click_left_tab(driver, REVIEW_TASKS.left_tab)
            self._clear_logs(driver)
            self._click_right_tab(driver, REVIEW_TASKS.right_tab, scope_selector=REVIEW_TASKS.scope_selector)
            for plan in plans:
                filtered_attempt = self._run_review_status_attempt(
                    driver,
                    acceptance_no,
                    plan,
                    use_acceptance_filter=True,
                )
                attempts.append(filtered_attempt["attempt"])
                pages_visited += filtered_attempt["attempt"].get("pages_scanned", 0)
                if filtered_attempt.get("review_status") is not None:
                    return {
                        "review_status": filtered_attempt["review_status"],
                        "attempts": attempts,
                        "pages_visited": pages_visited,
                    }

                if not filtered_attempt["attempt"].get("warning"):
                    continue

                broadened_attempt = self._run_review_status_attempt(
                    driver,
                    acceptance_no,
                    plan,
                    use_acceptance_filter=False,
                )
                attempts.append(broadened_attempt["attempt"])
                pages_visited += broadened_attempt["attempt"].get("pages_scanned", 0)
                if broadened_attempt.get("review_status") is not None:
                    return {
                        "review_status": broadened_attempt["review_status"],
                        "attempts": attempts,
                        "pages_visited": pages_visited,
                    }

            return {"review_status": None, "attempts": attempts, "pages_visited": pages_visited}
        finally:
            driver.quit()

    def _build_review_search_plans(self, basic_record: Dict[str, Any]) -> List[Dict[str, Any]]:
        normalized = basic_record.get("normalized", {})
        drug_type = normalized.get("drug_type") or ""
        application_type = normalized.get("application_type") or ""
        acceptance_no = normalized.get("acceptance_no") or ""

        public_type = self._map_public_notice_type(drug_type, acceptance_no)
        task_category = self._map_review_task_category(public_type, application_type, acceptance_no)
        biologics_subtypes = self._map_biologics_subtypes(public_type, drug_type)

        plans: List[Dict[str, Any]] = []
        for biologics_subtype in biologics_subtypes:
            plans.append(
                {
                    "public_type": public_type,
                    "task_category": task_category,
                    "biologics_subtype": biologics_subtype,
                }
            )
        return plans

    def _map_public_notice_type(self, drug_type: str, acceptance_no: str) -> str:
        if "中药" in drug_type:
            return "中药审评序列公示"
        if "生物" in drug_type or "预防用" in drug_type or "治疗用" in drug_type:
            return "生物制品审评序列公示"

        normalized = self._normalize_acceptance_no(acceptance_no)
        third_letter = normalized[2]
        if third_letter == "Z":
            return "中药审评序列公示"
        if third_letter == "S":
            return "生物制品审评序列公示"
        return "化药审评序列公示"

    def _map_biologics_subtypes(self, public_type: str, drug_type: str) -> List[Optional[str]]:
        if public_type != "生物制品审评序列公示":
            return [None]
        if "治疗用" in drug_type:
            return ["治疗用生物制品"]
        if "预防用" in drug_type:
            return ["预防用生物制品"]
        return ["治疗用生物制品", "预防用生物制品"]

    def _map_review_task_category(self, public_type: str, application_type: str, acceptance_no: str) -> str:
        normalized = self._normalize_acceptance_no(acceptance_no)
        second_letter = normalized[1]
        fourth_letter = normalized[3]

        if public_type == "生物制品审评序列公示":
            if "补充" in application_type or second_letter == "B" or fourth_letter == "B":
                return "补充申请"
            if "再注册" in application_type or fourth_letter == "Z":
                return "再注册"
            if "临床" in application_type or fourth_letter == "L":
                return "临床试验申请"
            return "上市申请"

        if "补充" in application_type or second_letter == "B" or fourth_letter == "B":
            return "补充申请"
        if "进口再注册" in application_type:
            return "进口再注册"
        if "复审" in application_type or fourth_letter == "R":
            return "复审"
        if "验证性临床" in application_type:
            return "验证性临床"
        if "仿制" in application_type or second_letter == "Y":
            return "ANDA"
        if "临床" in application_type or fourth_letter == "L":
            return "IND"
        return "NDA"

    def _run_review_status_attempt(
        self,
        driver: webdriver.Chrome,
        acceptance_no: str,
        plan: Dict[str, Any],
        *,
        use_acceptance_filter: bool,
    ) -> Dict[str, Any]:
        scope_selector = REVIEW_TASKS.scope_selector
        self._apply_review_status_filters(
            driver,
            acceptance_no,
            plan,
            use_acceptance_filter=use_acceptance_filter,
            scope_selector=scope_selector,
        )
        self._submit_review_task_search(driver, scope_selector=scope_selector)
        time.sleep(1.5)

        page_payload = self._scrape_review_task_page(driver, scope_selector=scope_selector)
        pages_scanned = 1
        review_status = self._find_review_task_row(page_payload["rows"], acceptance_no)

        if review_status is None and not page_payload["warning"]:
            total_pages = page_payload.get("total_pages", 1)
            for page in range(2, total_pages + 1):
                if not self._go_to_page(driver, page, scope_selector=scope_selector):
                    break
                time.sleep(1.0)
                page_payload = self._scrape_review_task_page(driver, scope_selector=scope_selector)
                pages_scanned += 1
                review_status = self._find_review_task_row(page_payload["rows"], acceptance_no)
                if review_status is not None:
                    break

        attempt = {
            "public_type": plan["public_type"],
            "task_category": plan["task_category"],
            "biologics_subtype": plan.get("biologics_subtype"),
            "used_acceptance_filter": use_acceptance_filter,
            "warning": page_payload.get("warning"),
            "pages_scanned": pages_scanned,
            "found": review_status is not None,
        }
        return {"attempt": attempt, "review_status": review_status}

    def _apply_review_status_filters(
        self,
        driver: webdriver.Chrome,
        acceptance_no: str,
        plan: Dict[str, Any],
        *,
        use_acceptance_filter: bool,
        scope_selector: str,
    ) -> None:
        self._select_filter(driver, "公示类型", plan["public_type"], scope_selector=scope_selector)
        if plan.get("biologics_subtype"):
            self._select_filter(driver, "药品类型", plan["biologics_subtype"], scope_selector=scope_selector)
        self._select_filter(driver, "审评任务分类", plan["task_category"], scope_selector=scope_selector)
        self._fill_text_filter(
            driver,
            "受理号",
            acceptance_no if use_acceptance_filter else "",
            scope_selector=scope_selector,
        )

    def _submit_review_task_search(self, driver: webdriver.Chrome, *, scope_selector: str) -> None:
        script = """
        const scopeSelector = arguments[0];
        const scopeRoot = scopeSelector ? document.querySelector(scopeSelector) : document;
        const scope = scopeRoot && scopeRoot.querySelector('.layui-tab-content .layui-show')
          ? scopeRoot.querySelector('.layui-tab-content .layui-show')
          : (scopeRoot || document);
                const button = Array.from(scope.querySelectorAll('button.searchBtn')).find((candidate) =>
                    (candidate.getAttribute('onclick') || '').includes("getNewReportList('xb')")
                );
        if (!button) {
          return false;
        }
        button.click();
        return true;
        """
        if not driver.execute_script(script, scope_selector):
            raise CDEQueryError("Could not find the new-review search button on the CDE page")

    def _scrape_review_task_page(self, driver: webdriver.Chrome, *, scope_selector: str) -> Dict[str, Any]:
        script = """
        const scopeRoot = document.querySelector(arguments[0]);
        const scope = scopeRoot && scopeRoot.querySelector('.layui-tab-content .layui-show')
          ? scopeRoot.querySelector('.layui-tab-content .layui-show')
          : (scopeRoot || document);
        const warnNode = scope.querySelector('#hightLightWarn_xb');
        const countNode = scope.querySelector('#newReportPage .layui-laypage-count');
        const currentNode = scope.querySelector('#newReportPage .layui-laypage-curr em:last-child');
        const limitNode = scope.querySelector('#newReportPage .layui-laypage-limits option:checked');
        const countMatch = countNode && countNode.textContent ? countNode.textContent.match(/共\s*(\d+)\s*条/) : null;
        const totalRecords = countMatch ? parseInt(countMatch[1], 10) : 0;
        const pageSize = limitNode ? parseInt(limitNode.value || limitNode.textContent, 10) : 10;
        const totalPages = totalRecords && pageSize ? Math.max(1, Math.ceil(totalRecords / pageSize)) : 1;
        const currentPage = currentNode ? parseInt(currentNode.textContent, 10) : 1;
        const rows = Array.from(scope.querySelectorAll('#newReportTbody tr')).map((row) => {
          const cells = Array.from(row.querySelectorAll('td'));
          const stageCells = cells.slice(5, 11).map((cell) => {
            const img = cell.querySelector('img');
            return img ? img.getAttribute('src') || '' : '';
          });
          const acceptanceCell = cells[1] || null;
          return {
            sequence: (cells[0]?.innerText || '').trim(),
            acceptance_no: (acceptanceCell?.innerText || '').trim(),
            drug_name: (cells[2]?.innerText || '').trim(),
            entered_center_at: (cells[3]?.innerText || '').trim(),
            review_state: (cells[4]?.innerText || '').trim(),
            remark: (cells[11]?.innerText || '').trim(),
            stage_icons: stageCells,
            is_highlighted: Boolean(acceptanceCell && acceptanceCell.querySelector('font')),
          };
        }).filter((row) => row.acceptance_no);
        return {
          warning: warnNode ? (warnNode.innerText || '').trim() : '',
          current_page: currentPage,
          total_pages: totalPages,
          total_records: totalRecords,
          rows,
        };
        """
        page_payload = driver.execute_script(script, scope_selector)
        rows = [self._normalize_review_task_row(row) for row in page_payload.get("rows", [])]
        return {
            "warning": page_payload.get("warning") or "",
            "current_page": page_payload.get("current_page", 1),
            "total_pages": page_payload.get("total_pages", 1),
            "total_records": page_payload.get("total_records", 0),
            "rows": rows,
        }

    def _normalize_review_task_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
        stages: Dict[str, Dict[str, Any]] = {}
        for column, icon in zip(REVIEW_STAGE_COLUMNS, row.get("stage_icons", [])):
            stage_info = REVIEW_STAGE_ICON_MAP.get(icon, {"code": 0, "label": "本专业未启动"})
            stages[column] = {
                "code": stage_info["code"],
                "label": stage_info["label"],
                "icon": icon,
            }
        return {
            "sequence": row.get("sequence"),
            "acceptance_no": row.get("acceptance_no"),
            "drug_name": row.get("drug_name"),
            "entered_center_at": row.get("entered_center_at"),
            "review_state": row.get("review_state"),
            "remark": row.get("remark"),
            "is_highlighted": bool(row.get("is_highlighted")),
            "stages": stages,
        }

    def _find_review_task_row(self, rows: Sequence[Dict[str, Any]], acceptance_no: str) -> Optional[Dict[str, Any]]:
        for row in rows:
            if self._normalize_acceptance_no(row.get("acceptance_no", "")) == acceptance_no:
                return row
        return None

    def _query_target(
        self,
        target: QueryTarget,
        *,
        text_filters: Iterable[Tuple[str, str]] = (),
        select_filters: Iterable[Tuple[str, str]] = (),
        applied_filters: Optional[Dict[str, Any]] = None,
        year: Optional[int] = None,
    ) -> QueryRunResult:
        driver = self._build_driver()
        try:
            self._open_listing_page(driver)
            self._clear_logs(driver)
            self._click_left_tab(driver, target.left_tab)
            if target.right_tab:
                self._clear_logs(driver)
                self._click_right_tab(driver, target.right_tab, scope_selector=target.scope_selector)

            if text_filters or select_filters:
                self._clear_logs(driver)
                for label, value in select_filters:
                    self._select_filter(driver, label, value, scope_selector=target.scope_selector)
                for label, value in text_filters:
                    self._fill_text_filter(driver, label, value, scope_selector=target.scope_selector)
                self._submit_search(driver, scope_selector=target.scope_selector)

            first_page = self._wait_for_payload(driver, page=1)
            total_pages = self._detect_total_pages(driver, first_page, scope_selector=target.scope_selector)
            if self.max_pages is not None:
                total_pages = min(total_pages, max(1, self.max_pages))

            records = [
                normalize_record(
                    raw_record,
                    source_menu=target.left_tab,
                    source_tab=target.right_tab or target.left_tab,
                    page=1,
                    year=year,
                )
                for raw_record in first_page.records
            ]
            pages_visited = 1
            seen_page_keys = {self._page_key(first_page.records)}

            for page in range(2, total_pages + 1):
                self._clear_logs(driver)
                if not self._go_to_page(driver, page, scope_selector=target.scope_selector):
                    break
                capture = self._wait_for_payload(driver, page=page)
                page_key = self._page_key(capture.records)
                if not capture.records or page_key in seen_page_keys:
                    break
                seen_page_keys.add(page_key)
                pages_visited += 1
                records.extend(
                    normalize_record(
                        raw_record,
                        source_menu=target.left_tab,
                        source_tab=target.right_tab or target.left_tab,
                        page=page,
                        year=year,
                    )
                    for raw_record in capture.records
                )

            return QueryRunResult(
                command=target.command,
                description=target.description,
                records=dedupe_records(records),
                applied_filters=applied_filters or {},
                years_queried=[year] if year is not None else [],
                pages_visited=pages_visited,
            )
        finally:
            driver.quit()

    def _build_driver(self) -> webdriver.Chrome:
        options = webdriver.ChromeOptions()
        if self.headless:
            options.add_argument("--headless=new")
        options.add_argument("--window-size=1440,1200")
        options.add_argument("--disable-dev-shm-usage")
        options.add_argument("--disable-blink-features=AutomationControlled")
        options.add_argument("--no-sandbox")
        options.add_argument("--lang=zh-CN")
        options.add_argument(
            "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
        )
        options.set_capability("goog:loggingPrefs", {"performance": "ALL"})
        try:
            driver = webdriver.Chrome(options=options)
        except WebDriverException as exc:
            raise CDEQueryError(
                "Could not start Chrome via Selenium. Check Chrome installation and local driver support."
            ) from exc
        driver.execute_cdp_cmd(
            "Page.addScriptToEvaluateOnNewDocument",
            {
                "source": """
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh', 'en-US', 'en']});
Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4]});
                """
            },
        )
        return driver

    def _open_listing_page(self, driver: webdriver.Chrome) -> None:
        driver.get(INFO_DISCLOSURE_URL)
        WebDriverWait(driver, self.timeout).until(
            lambda current_driver: current_driver.execute_script("return document.readyState") == "complete"
        )
        time.sleep(1.5)

    def _click_left_tab(self, driver: webdriver.Chrome, text: str) -> None:
        script = """
        const targetText = arguments[0];
        const normalize = (value) => (value || '').replace(/\s+/g, '').replace(/[：:]/g, '').trim();
        const target = normalize(targetText);
        const items = Array.from(document.querySelectorAll('.etcd_nav_ul li'));
        for (const item of items) {
            if (normalize(item.textContent || '') !== target) continue;
            item.click();
            return true;
        }
        return false;
        """
        if not driver.execute_script(script, text) and not self._click_element_by_exact_text(driver, text, exclude_left=False):
            raise CDEQueryError(f"Could not find left navigation tab: {text}")
        time.sleep(1.5)

    def _click_right_tab(self, driver: webdriver.Chrome, text: str, *, scope_selector: Optional[str] = None) -> None:
        if not self._click_element_by_exact_text(driver, text, exclude_left=True, scope_selector=scope_selector):
            raise CDEQueryError(f"Could not find right-side tab: {text}")
        time.sleep(1.5)

    def _click_element_by_exact_text(
        self,
        driver: webdriver.Chrome,
        text: str,
        *,
        exclude_left: bool,
        scope_selector: Optional[str] = None,
    ) -> bool:
        script = """
        const targetText = arguments[0];
        const excludeLeft = arguments[1];
        const scopeSelector = arguments[2];
        const scope = scopeSelector ? document.querySelector(scopeSelector) : document;
        const elements = (scope || document).querySelectorAll('a, span, button, li, div');
        const leftSelectors = '.left-nav, .sidebar, .menu-list, .el-menu, [class*="left"]';
        const normalize = (value) => (value || '').replace(/\s+/g, '').replace(/[：:]/g, '').trim();
        const isVisible = (element) => {
            const style = window.getComputedStyle(element);
            const rect = element.getBoundingClientRect();
            return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
        };
        const normalizedTarget = normalize(targetText);
        let containsMatch = null;
        for (const element of elements) {
            if (!isVisible(element)) continue;
            const content = normalize(element.textContent || '');
            if (!content) continue;
            if (excludeLeft && element.closest(leftSelectors)) continue;
            if (content === normalizedTarget) {
                element.click();
                return true;
            }
            if (content.includes(normalizedTarget) || normalizedTarget.includes(content)) {
                if (!containsMatch || content.length < containsMatch.content.length) {
                    containsMatch = { element, content };
                }
            }
        }
        if (containsMatch) {
            containsMatch.element.click();
            return true;
        }
        return false;
        """
        return bool(driver.execute_script(script, text, exclude_left, scope_selector))

    def _fill_text_filter(
        self,
        driver: webdriver.Chrome,
        label: str,
        value: str,
        *,
        scope_selector: Optional[str] = None,
    ) -> None:
        script = """
        const labelText = arguments[0];
        const targetValue = arguments[1];
        const scopeSelector = arguments[2];
                const preferredIds = arguments[3] || [];
        const scopeRoot = scopeSelector ? document.querySelector(scopeSelector) : document;
        const scope = scopeRoot && scopeRoot.querySelector('.layui-tab-content .layui-show')
          ? scopeRoot.querySelector('.layui-tab-content .layui-show')
          : (scopeRoot || document);
                const setValue = (input, currentValue) => {
                    const descriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
                    if (descriptor && descriptor.set) {
                        descriptor.set.call(input, currentValue);
                    } else {
                        input.value = currentValue;
                    }
                    input.dispatchEvent(new Event('input', { bubbles: true }));
                    input.dispatchEvent(new Event('change', { bubbles: true }));
                };
                for (const preferredId of preferredIds) {
                    const escapedId = window.CSS && CSS.escape ? CSS.escape(preferredId) : preferredId;
                    const direct = scope.querySelector(`#${escapedId}`) || scope.querySelector(`input[name="${preferredId}"], textarea[name="${preferredId}"]`);
                    if (direct) {
                        setValue(direct, targetValue);
                        return true;
                    }
                }
        const candidates = scope.querySelectorAll('label, span, div, td, th');
        const matchLabel = (text) => text && text.replace(/[:：]/g, '').trim().includes(labelText);
        for (const candidate of candidates) {
          const text = (candidate.textContent || '').trim();
          if (!text || text.length > 40) continue;
          if (!matchLabel(text)) continue;
          let node = candidate;
          for (let depth = 0; depth < 4 && node; depth += 1) {
            const input = node.querySelector('input, textarea');
            if (input) {
              setValue(input, targetValue);
              return true;
            }
            node = node.parentElement;
          }
        }
        return false;
        """
        preferred_ids = list(TEXT_FILTER_HINTS.get(label, ()))
        if not driver.execute_script(script, label, value, scope_selector, preferred_ids):
            raise CDEQueryError(f"Could not find text filter labeled {label}")
        time.sleep(0.4)

    def _select_filter(
        self,
        driver: webdriver.Chrome,
        label: str,
        value: str,
        *,
        scope_selector: Optional[str] = None,
    ) -> None:
        script = """
        const labelText = arguments[0];
        const targetValue = arguments[1];
        const scopeSelector = arguments[2];
            const preferredIds = arguments[3] || [];
        const scopeRoot = scopeSelector ? document.querySelector(scopeSelector) : document;
        const scope = scopeRoot && scopeRoot.querySelector('.layui-tab-content .layui-show')
          ? scopeRoot.querySelector('.layui-tab-content .layui-show')
          : (scopeRoot || document);
                const setSelectValue = (select) => {
                    for (const option of select.options) {
                        const optionText = (option.textContent || '').trim();
                        if (optionText === targetValue || option.value === targetValue) {
                            select.value = option.value;
                            select.dispatchEvent(new Event('change', { bubbles: true }));
                            return true;
                        }
                    }
                    return false;
                };
                let preferredMatch = false;
                for (const preferredId of preferredIds) {
                    const escapedId = window.CSS && CSS.escape ? CSS.escape(preferredId) : preferredId;
                    const direct = scope.querySelector(`#${escapedId}`) || scope.querySelector(`select[name="${preferredId}"]`);
                    if (direct && setSelectValue(direct)) {
                        preferredMatch = true;
                    }
                }
                if (preferredMatch) {
                    return true;
                }
        const candidates = scope.querySelectorAll('label, span, div, td, th');
        const matchLabel = (text) => text && text.replace(/[:：]/g, '').trim().includes(labelText);
        for (const candidate of candidates) {
            const text = (candidate.textContent || '').trim();
            if (!text || text.length > 40) continue;
            if (!matchLabel(text)) continue;
            let node = candidate;
            for (let depth = 0; depth < 4 && node; depth += 1) {
                const select = node.querySelector('select');
                if (select && setSelectValue(select)) {
                    return true;
                }
                node = node.parentElement;
            }
        }
        return false;
        """
        preferred_ids = list(SELECT_FILTER_HINTS.get(label, ()))
        if not driver.execute_script(script, label, value, scope_selector, preferred_ids):
            raise CDEQueryError(f"Could not find select filter labeled {label}")
        time.sleep(0.4)

    def _submit_search(self, driver: webdriver.Chrome, *, scope_selector: Optional[str] = None) -> None:
        script = """
        const scopeSelector = arguments[0];
        const scopeRoot = scopeSelector ? document.querySelector(scopeSelector) : document;
        const scope = scopeRoot && scopeRoot.querySelector('.layui-tab-content .layui-show')
          ? scopeRoot.querySelector('.layui-tab-content .layui-show')
          : (scopeRoot || document);
                const candidates = Array.from(scope.querySelectorAll('button, a, span, div'));
        const labels = ['查询', '查找', '搜索'];
                const score = (candidate) => {
                    let current = 0;
                    if (candidate.tagName === 'BUTTON') current += 100;
                    if (candidate.tagName === 'A') current += 80;
                    if (candidate.getAttribute('onclick')) current += 40;
                    if ((candidate.className || '').includes('searchBtn')) current += 20;
                    return current;
                };
                candidates.sort((left, right) => score(right) - score(left));
                for (const candidate of candidates) {
          const text = (candidate.textContent || '').trim();
          if (!labels.includes(text)) continue;
          if (candidate.closest('.layui-laypage')) continue;
          candidate.click();
          return true;
        }
        return false;
        """
        if not driver.execute_script(script, scope_selector):
            raise CDEQueryError("Could not find the search button on the CDE page")
        time.sleep(1.5)

    def _wait_for_payload(self, driver: webdriver.Chrome, *, page: int) -> PageCapture:
        deadline = time.time() + self.timeout
        best_capture: Optional[PageCapture] = None
        best_size = -1
        while time.time() < deadline:
            logs = driver.get_log("performance")
            for entry in logs:
                capture = self._extract_capture_from_log(driver, entry, page)
                if capture is None:
                    continue
                size = len(capture.records)
                if size > best_size:
                    best_capture = capture
                    best_size = size
            if best_capture is not None:
                return best_capture
            time.sleep(0.3)
        raise CDEQueryError("Timed out while waiting for a CDE data response")

    def _extract_capture_from_log(
        self,
        driver: webdriver.Chrome,
        entry: Dict[str, Any],
        page: int,
    ) -> Optional[PageCapture]:
        try:
            message = json.loads(entry["message"]).get("message", {})
        except (KeyError, TypeError, json.JSONDecodeError):
            return None
        if message.get("method") != "Network.responseReceived":
            return None
        params = message.get("params", {})
        response = params.get("response", {})
        request_id = params.get("requestId")
        url = response.get("url", "")
        mime_type = response.get("mimeType", "")
        if not request_id or "cde.org.cn" not in url:
            return None
        if "json" not in mime_type.lower() and "api" not in url.lower() and "list" not in url.lower():
            return None
        try:
            body = driver.execute_cdp_cmd("Network.getResponseBody", {"requestId": request_id})
        except WebDriverException:
            return None
        raw_body = body.get("body", "")
        try:
            payload = json.loads(raw_body)
        except json.JSONDecodeError:
            return None
        capture = PageCapture(page=page, request_url=url, payload=payload)
        if self._is_result_payload(payload):
            return capture
        return None

    def _is_result_payload(self, payload: Any) -> bool:
        if not isinstance(payload, dict):
            return False
        data = payload.get("data")
        if isinstance(data, dict):
            records = data.get("records")
            if isinstance(records, list):
                return True
            for key in ("pages", "totalPage", "pageCount", "total", "size"):
                if key in data:
                    return True
        records = payload.get("records")
        return isinstance(records, list)

    def _detect_total_pages(
        self,
        driver: webdriver.Chrome,
        first_page: PageCapture,
        *,
        scope_selector: Optional[str] = None,
    ) -> int:
        payload = first_page.payload.get("data") if isinstance(first_page.payload, dict) else None
        if isinstance(payload, dict):
            for key in ("pages", "totalPage", "pageCount"):
                value = payload.get(key)
                if isinstance(value, int) and value > 0:
                    return value
            total = payload.get("total")
            if isinstance(total, int) and total > 0 and first_page.records:
                page_size = payload.get("size") or len(first_page.records)
                if isinstance(page_size, int) and page_size > 0:
                    return max(1, math.ceil(total / page_size))

        script = """
        const scopeSelector = arguments[0];
        const scopeRoot = scopeSelector ? document.querySelector(scopeSelector) : document;
        const scope = scopeRoot && scopeRoot.querySelector('.layui-tab-content .layui-show')
          ? scopeRoot.querySelector('.layui-tab-content .layui-show')
          : (scopeRoot || document);
        const countNode = scope.querySelector('.layui-laypage-count');
        if (countNode) {
          const match = countNode.textContent.match(/共\s*(\d+)\s*条/);
          if (match) {
            const limits = Array.from(scope.querySelectorAll('.layui-laypage-limits option')).map((option) => ({
              selected: option.selected,
              value: parseInt(option.value || option.textContent, 10),
            }));
            const selectedLimit = limits.find((item) => !Number.isNaN(item.value) && item.selected);
            return { total: parseInt(match[1], 10), pageSize: selectedLimit ? selectedLimit.value : 10 };
          }
        }
        const buttons = scope.querySelectorAll('.layui-laypage a');
        let maxPage = 1;
        for (const button of buttons) {
          const value = parseInt((button.textContent || '').trim(), 10);
          if (!Number.isNaN(value) && value > maxPage) {
            maxPage = value;
          }
        }
        return { maxPage };
        """
        info = driver.execute_script(script, scope_selector)
        if isinstance(info, dict):
            total = info.get("total")
            page_size = info.get("pageSize") or 10
            if isinstance(total, int) and total > 0:
                return max(1, math.ceil(total / page_size))
            max_page = info.get("maxPage")
            if isinstance(max_page, int) and max_page > 0:
                return max_page
        return 1

    def _go_to_page(self, driver: webdriver.Chrome, page: int, *, scope_selector: Optional[str] = None) -> bool:
        script = """
        const pageNumber = String(arguments[0]);
        const scopeSelector = arguments[1];
        const scopeRoot = scopeSelector ? document.querySelector(scopeSelector) : document;
        const scope = scopeRoot && scopeRoot.querySelector('.layui-tab-content .layui-show')
          ? scopeRoot.querySelector('.layui-tab-content .layui-show')
          : (scopeRoot || document);
        let input = null;
        const skipDiv = scope.querySelector('.layui-laypage-skip');
        if (skipDiv) {
          input = skipDiv.querySelector('input.layui-input');
        }
        if (!input) {
          const inputs = scope.querySelectorAll('input.layui-input');
          for (const candidate of inputs) {
            const parentText = candidate.parentElement ? candidate.parentElement.textContent : '';
            if (parentText && parentText.includes('到第') && parentText.includes('页')) {
              input = candidate;
              break;
            }
          }
        }
        if (!input) {
          return false;
        }
        const descriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
        if (descriptor && descriptor.set) {
          descriptor.set.call(input, pageNumber);
        } else {
          input.value = pageNumber;
        }
        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.dispatchEvent(new Event('change', { bubbles: true }));
        const button = scope.querySelector('.layui-laypage-btn');
        if (!button) {
          return false;
        }
        button.click();
        return true;
        """
        success = bool(driver.execute_script(script, page, scope_selector))
        if success:
            time.sleep(1.5)
        return success

    def _clear_logs(self, driver: webdriver.Chrome) -> None:
        try:
            driver.get_log("performance")
        except WebDriverException:
            return

    def _page_key(self, records: List[Dict[str, Any]]) -> str:
        return json.dumps(records, ensure_ascii=False, sort_keys=True)

    def _validate_exact_name(self, value: str, label: str) -> None:
        if not value or not value.strip():
            raise CDEQueryError(f"A non-empty {label} name is required")


path: scripts/cde_query.py
content: from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, Sequence

CURRENT_DIR = Path(__file__).resolve().parent
if str(CURRENT_DIR) not in sys.path:
    sys.path.insert(0, str(CURRENT_DIR))

from cde_client import CDEClient, CDEQueryError


MIN_IN_REVIEW_YEAR = 2016
MAX_IN_REVIEW_YEAR = 2026
DEFAULT_IN_REVIEW_YEARS = list(range(2016, 2027))


def _parse_in_review_year(value: str) -> int:
    try:
        year = int(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(f"invalid year: {value}") from exc
    if year < MIN_IN_REVIEW_YEAR or year > MAX_IN_REVIEW_YEAR:
        raise argparse.ArgumentTypeError(
            f"year must be between {MIN_IN_REVIEW_YEAR} and {MAX_IN_REVIEW_YEAR}: {value}"
        )
    return year


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Query live public data from the CDE website")
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--show-browser", action="store_true", help="Run with a visible Chrome window")
    common.add_argument("--timeout", type=int, default=25, help="Selenium wait timeout in seconds")
    common.add_argument("--pretty", action="store_true", help="Print a compact human-readable summary")
    common.add_argument("--max-pages", type=int, default=None, help="Optional pagination cap for debugging")

    for action in common._actions:
        if action.dest == "help":
            continue
        parser._add_action(action)

    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser("breakthrough-announcements", parents=[common], add_help=False)

    bt_company = subparsers.add_parser("breakthrough-included-by-company", parents=[common], add_help=False)
    bt_company.add_argument("--company", required=True)

    bt_drug = subparsers.add_parser("breakthrough-included-by-drug", parents=[common], add_help=False)
    bt_drug.add_argument("--drug", required=True)

    subparsers.add_parser("priority-announcements", parents=[common], add_help=False)

    pr_company = subparsers.add_parser("priority-included-by-company", parents=[common], add_help=False)
    pr_company.add_argument("--company", required=True)

    pr_drug = subparsers.add_parser("priority-included-by-drug", parents=[common], add_help=False)
    pr_drug.add_argument("--drug", required=True)

    in_review_company = subparsers.add_parser("in-review-by-company", parents=[common], add_help=False)
    in_review_company.add_argument("--company", required=True)
    in_review_company.add_argument("--years", nargs="+", type=_parse_in_review_year, default=DEFAULT_IN_REVIEW_YEARS)

    in_review_drug = subparsers.add_parser("in-review-by-drug", parents=[common], add_help=False)
    in_review_drug.add_argument("--drug", required=True)
    in_review_drug.add_argument("--years", nargs="+", type=_parse_in_review_year, default=DEFAULT_IN_REVIEW_YEARS)

    review_status = subparsers.add_parser("review-status-by-acceptance-no", parents=[common], add_help=False)
    review_status.add_argument("--acceptance-no", required=True)

    return parser


def _pretty_print(payload: Dict[str, Any]) -> None:
    if payload.get("command") == "review-status-by-acceptance-no":
        print(f"command: {payload.get('command')}")
        print(f"acceptance_no: {payload.get('acceptance_no')}")
        print(f"inferred_year: {payload.get('inferred_year')}")
        print(f"basic_info_found: {payload.get('basic_info_found')}")
        print(f"review_status_found: {payload.get('review_status_found')}")
        basic_info = payload.get("basic_info") or {}
        if basic_info:
            print(
                f"basic_info: 药品={basic_info.get('drug_name') or 'N/A'} | 药品类型={basic_info.get('drug_type') or 'N/A'} | 申请类型={basic_info.get('application_type') or 'N/A'}"
            )
        review_status = payload.get("review_status") or {}
        if review_status:
            print(
                f"review_status: 状态={review_status.get('review_state') or 'N/A'} | 进入中心时间={review_status.get('entered_center_at') or 'N/A'}"
            )
            for stage, details in (review_status.get("stages") or {}).items():
                print(f"  - {stage}: {details.get('label')}")
        return

    metadata = payload.get("metadata", {})
    print(f"command: {payload.get('command')}")
    print(f"total_records: {metadata.get('total_records', 0)}")
    print(f"pages_visited: {metadata.get('pages_visited', 0)}")
    years = metadata.get("years_queried") or []
    if years:
        print(f"years_queried: {', '.join(str(year) for year in years)}")
    filters = metadata.get("applied_filters") or {}
    if filters:
        print(f"applied_filters: {json.dumps(filters, ensure_ascii=False)}")
    for index, record in enumerate(payload.get("records", [])[:10], start=1):
        normalized = record.get("normalized", {})
        print(
            f"{index}. 药品={normalized.get('drug_name') or 'N/A'} | 企业={normalized.get('company_name') or 'N/A'} | 受理号={normalized.get('acceptance_no') or 'N/A'}"
        )


def run(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    client = CDEClient(headless=not args.show_browser, timeout=args.timeout, max_pages=args.max_pages)
    try:
        if args.command == "breakthrough-announcements":
            payload = client.query_breakthrough_announcements()
        elif args.command == "breakthrough-included-by-company":
            payload = client.query_breakthrough_included_by_company(args.company)
        elif args.command == "breakthrough-included-by-drug":
            payload = client.query_breakthrough_included_by_drug(args.drug)
        elif args.command == "priority-announcements":
            payload = client.query_priority_announcements()
        elif args.command == "priority-included-by-company":
            payload = client.query_priority_included_by_company(args.company)
        elif args.command == "priority-included-by-drug":
            payload = client.query_priority_included_by_drug(args.drug)
        elif args.command == "in-review-by-company":
            payload = client.query_in_review_by_company(args.company, args.years)
        elif args.command == "in-review-by-drug":
            payload = client.query_in_review_by_drug(args.drug, args.years)
        elif args.command == "review-status-by-acceptance-no":
            payload = client.query_review_status_by_acceptance_no(args.acceptance_no)
        else:
            parser.error(f"Unsupported command: {args.command}")
            return 2
    except CDEQueryError as exc:
        print(
            json.dumps(
                {"ok": False, "error": str(exc), "command": getattr(args, "command", None)},
                ensure_ascii=False,
                indent=2,
            ),
            file=sys.stderr,
        )
        return 1

    if args.pretty:
        _pretty_print(payload)
    else:
        print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(run())

path: scripts/models.py
content: from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional


@dataclass(frozen=True)
class QueryTarget:
    command: str
    left_tab: str
    right_tab: Optional[str]
    description: str
    scope_selector: Optional[str] = None


@dataclass
class PageCapture:
    page: int
    request_url: Optional[str]
    payload: Dict[str, Any]

    @property
    def records(self) -> List[Dict[str, Any]]:
        data = self.payload.get("data")
        if isinstance(data, dict):
            records = data.get("records")
            if isinstance(records, list):
                return [item for item in records if isinstance(item, dict)]
        records = self.payload.get("records")
        if isinstance(records, list):
            return [item for item in records if isinstance(item, dict)]
        return []


@dataclass
class QueryRunResult:
    command: str
    description: str
    records: List[Dict[str, Any]] = field(default_factory=list)
    applied_filters: Dict[str, Any] = field(default_factory=dict)
    years_queried: List[int] = field(default_factory=list)
    pages_visited: int = 0
    generated_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds")
    )

    def to_dict(self) -> Dict[str, Any]:
        return {
            "ok": True,
            "command": self.command,
            "description": self.description,
            "records": self.records,
            "metadata": {
                "generated_at": self.generated_at,
                "applied_filters": self.applied_filters,
                "pages_visited": self.pages_visited,
                "years_queried": self.years_queried,
                "total_records": len(self.records),
            },
        }


@dataclass
class AcceptanceReviewResult:
    acceptance_no: str
    inferred_year: int
    basic_info: Optional[Dict[str, Any]] = None
    review_status: Optional[Dict[str, Any]] = None
    attempts: List[Dict[str, Any]] = field(default_factory=list)
    pages_visited: int = 0
    generated_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds")
    )

    def to_dict(self) -> Dict[str, Any]:
        return {
            "ok": True,
            "command": "review-status-by-acceptance-no",
            "acceptance_no": self.acceptance_no,
            "inferred_year": self.inferred_year,
            "basic_info_found": self.basic_info is not None,
            "review_status_found": self.review_status is not None,
            "basic_info": self.basic_info,
            "review_status": self.review_status,
            "attempts": self.attempts,
            "metadata": {
                "generated_at": self.generated_at,
                "pages_visited": self.pages_visited,
                "total_attempts": len(self.attempts),
            },
        }


path: scripts/normalizers.py
content: from __future__ import annotations

import json
import re
from typing import Any, Dict, Iterable, List, Optional


DRUG_FIELDS = (
    "drgnamecn",
    "drug_name",
    "drugName",
    "drugname",
    "varietyName",
    "ypmc",
    "name",
    "title",
)

COMPANY_FIELDS = (
    "company",
    "companyName",
    "companys",
    "applicant",
    "applyPerson",
    "registerApplicant",
    "enterpriseName",
    "entname",
    "sqr",
)

ACCEPTANCE_FIELDS = (
    "acceptid",
    "acceptance_no",
    "acceptNo",
    "slh",
    "subcode",
)

TITLE_FIELDS = (
    "title",
    "noticeTitle",
    "pubTitle",
    "announcementTitle",
    "drug_name",
    "drgnamecn",
)

DATE_FIELDS = (
    "publicDate",
    "pubDate",
    "acceptDate",
    "createdate",
    "createTime",
    "date",
    "sqrq",
)

DRUG_TYPE_FIELDS = (
    "drugtype",
    "drugType",
)

APPLICATION_TYPE_FIELDS = (
    "applytype",
    "applyType",
)

REGISTRATION_CATEGORY_FIELDS = (
    "registerkind",
    "registerKind",
)

ID_FIELDS = (
    "paidCODE",
    "pridCODE",
    "bcaidCODE",
    "bcnidCODE",
    "pdidCODE",
    "bcdidCODE",
    "acceptidCODE",
    "idCODE",
)


def _first_value(record: Dict[str, Any], candidates: Iterable[str]) -> Optional[str]:
    for key in candidates:
        value = record.get(key)
        if value is None:
            continue
        text = str(value).strip()
        if text:
            return text
    return None


def _infer_year(value: Optional[str], fallback_year: Optional[int]) -> Optional[int]:
    if fallback_year is not None:
        return fallback_year
    if not value:
        return None
    match = re.search(r"(20\d{2})", value)
    if match:
        return int(match.group(1))
    return None


def normalize_record(
    raw_record: Dict[str, Any],
    *,
    source_menu: str,
    source_tab: str,
    page: int,
    year: Optional[int] = None,
) -> Dict[str, Any]:
    date_value = _first_value(raw_record, DATE_FIELDS)
    normalized = {
        "record_id": _first_value(raw_record, ID_FIELDS),
        "drug_name": _first_value(raw_record, DRUG_FIELDS),
        "company_name": _first_value(raw_record, COMPANY_FIELDS),
        "acceptance_no": _first_value(raw_record, ACCEPTANCE_FIELDS),
        "drug_type": _first_value(raw_record, DRUG_TYPE_FIELDS),
        "application_type": _first_value(raw_record, APPLICATION_TYPE_FIELDS),
        "registration_category": _first_value(raw_record, REGISTRATION_CATEGORY_FIELDS),
        "publication_title": _first_value(raw_record, TITLE_FIELDS),
        "publication_date": date_value,
        "stage": source_tab,
        "year": _infer_year(date_value, year),
        "source_menu": source_menu,
        "source_tab": source_tab,
        "page": page,
    }
    return {"normalized": normalized, "raw": raw_record}


def _fingerprint(record: Dict[str, Any]) -> str:
    normalized = record.get("normalized", {})
    values = [
        normalized.get("source_menu"),
        normalized.get("source_tab"),
        normalized.get("record_id"),
        normalized.get("acceptance_no"),
        normalized.get("drug_name"),
        normalized.get("company_name"),
        normalized.get("publication_title"),
        normalized.get("year"),
    ]
    compact = [str(value).strip().lower() for value in values if value not in (None, "")]
    if compact:
        return "|".join(compact)
    return json.dumps(record.get("raw", record), ensure_ascii=False, sort_keys=True)


def dedupe_records(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    unique: Dict[str, Dict[str, Any]] = {}
    for record in records:
        unique.setdefault(_fingerprint(record), record)
    return list(unique.values())


path: scripts/requirements.txt
content: selenium>=4.20.0


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)