agentdata 0.3.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. agentdata/__init__.py +33 -0
  2. agentdata/__main__.py +4 -0
  3. agentdata/cache.py +115 -0
  4. agentdata/cli.py +173 -0
  5. agentdata/client.py +158 -0
  6. agentdata/company/__init__.py +3 -0
  7. agentdata/company/cf.py +145 -0
  8. agentdata/company/ch_zefix.py +100 -0
  9. agentdata/company/cli.py +243 -0
  10. agentdata/company/co_rues.py +72 -0
  11. agentdata/company/edgar_cik.py +84 -0
  12. agentdata/company/edgar_fts.py +101 -0
  13. agentdata/company/faces.py +746 -0
  14. agentdata/company/fdic_bankfind.py +107 -0
  15. agentdata/company/fi_ytj.py +89 -0
  16. agentdata/company/fr_recherche.py +124 -0
  17. agentdata/company/fts.py +121 -0
  18. agentdata/company/gleif.py +178 -0
  19. agentdata/company/kr_hometax.py +112 -0
  20. agentdata/company/no_brreg.py +124 -0
  21. agentdata/company/ror.py +105 -0
  22. agentdata/company/ted.py +117 -0
  23. agentdata/company/usaspending.py +124 -0
  24. agentdata/company/vies.py +66 -0
  25. agentdata/daterange.py +35 -0
  26. agentdata/economy/__init__.py +10 -0
  27. agentdata/economy/bis.py +132 -0
  28. agentdata/economy/cli.py +199 -0
  29. agentdata/economy/ecb.py +117 -0
  30. agentdata/economy/eurostat.py +163 -0
  31. agentdata/economy/faces.py +573 -0
  32. agentdata/economy/fred.py +92 -0
  33. agentdata/economy/ilo_stat.py +99 -0
  34. agentdata/economy/imf_sdmx.py +133 -0
  35. agentdata/economy/un_comtrade.py +199 -0
  36. agentdata/economy/unsd_sdg.py +154 -0
  37. agentdata/economy/wb.py +208 -0
  38. agentdata/economy/who_xmart.py +177 -0
  39. agentdata/entries.py +30 -0
  40. agentdata/errors.py +38 -0
  41. agentdata/esg/__init__.py +5 -0
  42. agentdata/esg/cli.py +180 -0
  43. agentdata/esg/climatetrace.py +260 -0
  44. agentdata/esg/eiti.py +187 -0
  45. agentdata/esg/epa_frs.py +138 -0
  46. agentdata/esg/euasl.py +75 -0
  47. agentdata/esg/eutl.py +66 -0
  48. agentdata/esg/faces.py +498 -0
  49. agentdata/esg/paris_mou.py +240 -0
  50. agentdata/esg/thetis_mrv.py +67 -0
  51. agentdata/healthcare/__init__.py +7 -0
  52. agentdata/healthcare/au_tga.py +142 -0
  53. agentdata/healthcare/ca_dpd.py +123 -0
  54. agentdata/healthcare/ca_mdall.py +155 -0
  55. agentdata/healthcare/cli.py +375 -0
  56. agentdata/healthcare/ctgov.py +101 -0
  57. agentdata/healthcare/dailymed.py +102 -0
  58. agentdata/healthcare/epmc.py +108 -0
  59. agentdata/healthcare/eudamed.py +124 -0
  60. agentdata/healthcare/faces.py +811 -0
  61. agentdata/healthcare/fda_crl.py +86 -0
  62. agentdata/healthcare/fda_devices.py +199 -0
  63. agentdata/healthcare/fda_drugs.py +156 -0
  64. agentdata/healthcare/fda_recalls.py +137 -0
  65. agentdata/healthcare/gb_mhra.py +137 -0
  66. agentdata/healthcare/gudid.py +84 -0
  67. agentdata/healthcare/mesh.py +89 -0
  68. agentdata/healthcare/rasff.py +164 -0
  69. agentdata/healthcare/rxnav.py +67 -0
  70. agentdata/healthcare/safety_gate.py +175 -0
  71. agentdata/healthcare/shortages.py +107 -0
  72. agentdata/legal/__init__.py +4 -0
  73. agentdata/legal/afdb.py +93 -0
  74. agentdata/legal/ca_sema.py +143 -0
  75. agentdata/legal/cli.py +312 -0
  76. agentdata/legal/co_sic.py +70 -0
  77. agentdata/legal/courtlistener.py +77 -0
  78. agentdata/legal/csl.py +139 -0
  79. agentdata/legal/dgcomp.py +125 -0
  80. agentdata/legal/ecfr.py +129 -0
  81. agentdata/legal/faces.py +700 -0
  82. agentdata/legal/federalregister.py +188 -0
  83. agentdata/legal/gb_cma.py +117 -0
  84. agentdata/legal/gb_tna_caselaw.py +245 -0
  85. agentdata/legal/idb.py +85 -0
  86. agentdata/legal/lda.py +147 -0
  87. agentdata/legal/sipri.py +178 -0
  88. agentdata/legal/ukec.py +148 -0
  89. agentdata/legal/wb.py +92 -0
  90. agentdata/procurement/__init__.py +5 -0
  91. agentdata/procurement/br_pncp.py +126 -0
  92. agentdata/procurement/cli.py +155 -0
  93. agentdata/procurement/co_secop.py +88 -0
  94. agentdata/procurement/faces.py +239 -0
  95. agentdata/procurement/ny_open_data.py +82 -0
  96. agentdata/procurement/ungm.py +137 -0
  97. agentdata/procurement/wb_proc.py +84 -0
  98. agentdata/research/__init__.py +4 -0
  99. agentdata/research/biorxiv.py +66 -0
  100. agentdata/research/cli.py +129 -0
  101. agentdata/research/crossref.py +121 -0
  102. agentdata/research/faces.py +349 -0
  103. agentdata/research/gb_gtr.py +58 -0
  104. agentdata/research/icite.py +52 -0
  105. agentdata/research/nih_reporter.py +143 -0
  106. agentdata/research/nsf.py +68 -0
  107. agentdata/research/openalex.py +145 -0
  108. agentdata/research/opencitations.py +50 -0
  109. agentdata/security/__init__.py +3 -0
  110. agentdata/security/cisa_kev.py +42 -0
  111. agentdata/security/cli.py +37 -0
  112. agentdata/security/faces.py +91 -0
  113. agentdata/security/osv.py +72 -0
  114. agentdata/transport.py +541 -0
  115. agentdata-0.3.26.dist-info/METADATA +266 -0
  116. agentdata-0.3.26.dist-info/RECORD +120 -0
  117. agentdata-0.3.26.dist-info/WHEEL +5 -0
  118. agentdata-0.3.26.dist-info/entry_points.txt +10 -0
  119. agentdata-0.3.26.dist-info/licenses/LICENSE +201 -0
  120. agentdata-0.3.26.dist-info/top_level.txt +1 -0
@@ -0,0 +1,82 @@
1
+ """ny_open_data — New York State open data Socrata three-face provider (data.ny.gov; keyless).
2
+
3
+ procurement register (0.3.6 twin faces + 0.3.12 OGS third face; shapes verified by first-hand live probing). Probed live 2026-10-29/30:
4
+ GET /resource/ehig-g5x3.json?$limit=2 → [rows…25 cols] (ABO state-authority contracts **275,763 rows**)
5
+ GET /resource/twsw-2mqa.json?$limit=2 → [rows…27 cols] (MTA procurements **107,503 rows**)
6
+ GET /resource/rb9h-9fit.json?$limit=1 → [rows…7 cols] (OGS design & construction payments **69,221 rows**)
7
+ —— Socrata emits a bare array with no total echo (same contract as co_secop: page via $offset, probe the
8
+ full corpus separately with $select=count).
9
+ ABO row (Authorities Budget Office state-authority universe): authority_name (buyer authority) × vendor_name+
10
+ vendor_address_1/city/state/postal_code/country (**name+full address — no UEI/DUNS, name-walk identification**) +
11
+ contract_amount/amount_expended_for_fiscal_year/amount_expended_to_date/
12
+ current_or_outstanding_balance four value columns + award_date/begin_date/renewal_date/
13
+ fiscal_year_end_date date family + status/type_of_procurement/award_process/
14
+ number_of_bids_or_proposals_received + solicited_mwbe/vendor_is_a_mwbe/
15
+ nys_or_foreign_business_enterprise procurement-preference flag family.
16
+ MTA row (Metropolitan Transportation Authority): same skeleton + end_date/transaction_number/number_of_mwbe_proposals/
17
+ vendor_is_nys_or_fbe (no authority_name — buyer is always the MTA).
18
+ OGS row (Office of General Services design & construction payments, probed live 2026-10-30): contractnumber/
19
+ vendor/typeofservice/paymentamount/fiscalyear (a "2014-2015"-form string)/quarter/county — seven columns,
20
+ buyer always OGS, **no address columns** (name-walk single key; contractnumber+vendor+quarter pin a payment row).
21
+ Socrata $-param verbatim channel shares the co_secop contract: $where raw SoQL verbatim pass-through/$limit/$offset/$order;
22
+ $order does not take NULLS syntax. Socrata float-coercion pitfall on record: without a $select projection
23
+ transaction_number comes back in scientific-notation form — the row face returns it as upstream does; when exact
24
+ numeric keys are needed, narrow with --order/--where and probe separately.
25
+ TTL: contract/procurement rows drift slowly with status — 24h; payment rows are historical fact — 7d;
26
+ polite tier 1 rps (conservative pick for anonymous Socrata).
27
+ """
28
+ from ..errors import SdkError
29
+
30
+ provider = {
31
+ "id": "ny_open_data",
32
+ "baseUrl": "https://data.ny.gov",
33
+ "rate": {"rps": 1, "burst": 2},
34
+ "ops": {
35
+ "abo_contracts": {"path": "/resource/ehig-g5x3.json"}, # Socrata $limit/$offset/$where/$order
36
+ "mta_procurements": {"path": "/resource/twsw-2mqa.json"},
37
+ "ogs_dc_payments": {"path": "/resource/rb9h-9fit.json"},
38
+ },
39
+ "ttl": {"abo_contracts": 24 * 3600, "mta_procurements": 24 * 3600,
40
+ "ogs_dc_payments": 7 * 24 * 3600},
41
+ }
42
+
43
+
44
+ def _socrata_raw(op, where=None, *, limit=20, offset=0, order=None, cache=None, no_cache=False):
45
+ """Socrata $-channel, same contract (co_secop-shaped): where=$where raw SoQL/limit 1-500/offset≥0/order=column [ASC|DESC]."""
46
+ from ..transport import call_provider
47
+
48
+ size = int(limit) if limit is not None else 20 # test None explicitly — 0 swallowed by `or` into the default is the same-shaped 0.2.10 pitfall
49
+ if not 1 <= size <= 500:
50
+ raise SdkError("usage_error", provider="ny_open_data",
51
+ detail=f"limit must be 1-500 (polite tier), got '{limit}'")
52
+ off = int(offset) if offset is not None else 0
53
+ if off < 0:
54
+ raise SdkError("usage_error", provider="ny_open_data", detail=f"offset must be ≥0, got '{offset}'")
55
+ params = {"$limit": size, "$offset": off}
56
+ w = str(where or "").strip() or None
57
+ if w:
58
+ params["$where"] = w
59
+ o = str(order or "").strip() or None
60
+ if o:
61
+ params["$order"] = o
62
+ r = call_provider(provider, op, params, cache=cache, no_cache=no_cache)
63
+ data = r["data"] or []
64
+ return {"rows": data, "count": len(data), "limit_applied": size, "params": params, "_meta": r}
65
+
66
+
67
+ def abo_contracts_raw(where=None, *, limit=20, offset=0, order=None, cache=None, no_cache=False):
68
+ """→ {rows[], count, limit_applied, params, _meta} (Socrata sends no total echo)."""
69
+ return _socrata_raw("abo_contracts", where, limit=limit, offset=offset, order=order,
70
+ cache=cache, no_cache=no_cache)
71
+
72
+
73
+ def mta_procurements_raw(where=None, *, limit=20, offset=0, order=None, cache=None, no_cache=False):
74
+ """→ same as above (buyer is always the MTA — rows carrying no authority_name column is the upstream shape, not missing data)."""
75
+ return _socrata_raw("mta_procurements", where, limit=limit, offset=offset, order=order,
76
+ cache=cache, no_cache=no_cache)
77
+
78
+
79
+ def ogs_dc_payments_raw(where=None, *, limit=20, offset=0, order=None, cache=None, no_cache=False):
80
+ """→ same as above (OGS design & construction payments, 69,221 rows — buyer is always OGS; rows carry no address columns, name-walk identity)."""
81
+ return _socrata_raw("ogs_dc_payments", where, limit=limit, offset=offset, order=order,
82
+ cache=cache, no_cache=no_cache)
@@ -0,0 +1,137 @@
1
+ """ungm — UN Global Marketplace contract-award search provider (www.ungm.org/Public/ContractAward; keyless).
2
+
3
+ procurement register (admitted in 0.3.12; shapes verified by first-hand live probing). Probed live 2026-10-30
4
+ (the XHR contract reverse-engineered from bundles/ungmcommon; the two-step pairing walked end to end):
5
+ GET /Public/ContractAward (**a browser UA is mandatory** — the default agentdata UA is refused with a 403
6
+ (a 134-byte 403 stub; same UA-fingerprint gate family as FAA; any full browser UA passes)) → two Set-Cookie
7
+ pieces (the __RequestVerificationToken session cookie + GCLB) + the same-named token in a hidden field on
8
+ the page (the pairing key).
9
+ POST /Public/ContractAward/PublicSearch (JSON body = the browser buildOptions() **full 15-field shape**;
10
+ carries the Cookie + RequestVerificationToken header pair) → HTML row family back (each row keyed by
11
+ data-contractawardid + six slots: title/vendor/award_date/agency/reference/supplier_country) + a trailing
12
+ script `#searchTotal").html(N)` total echo. A bare POST without the pairing = 400; **a null SortField = 500
13
+ server crash** (pinned live 2026-10-30 — the request body must be the full shape and SortField never empty).
14
+ Full corpus ~50,035 awards (Supplier/AwardFrom/AwardTo/Countries/Agencies/UNSPSCs filters;
15
+ PageSize 1-100 with the browser at 15/page, PageIndex 1-based paging).
16
+ The anti-forgery pairing (cookie × token) is cached 15 minutes and reused; 400 = the pairing expired —
17
+ re-collected once automatically.
18
+ TTL: published awards are immutable — 7d; polite tier 0.5 rps (conservative pick; the UN portal publishes no quota).
19
+ """
20
+ import html as _html
21
+ import re
22
+
23
+ from ..errors import SdkError
24
+
25
+ provider = {
26
+ "id": "ungm",
27
+ "baseUrl": "https://www.ungm.org",
28
+ "rate": {"rps": 0.5, "burst": 1},
29
+ "ops": {
30
+ "search_page": {"path": "/Public/ContractAward", "accept": "text"},
31
+ "search": {"path": "/Public/ContractAward/PublicSearch", "method": "POST",
32
+ "idempotent": True, "accept": "text"},
33
+ },
34
+ "ttl": {"search": 7 * 24 * 3600},
35
+ }
36
+
37
+ # UA fingerprint gate (pinned live 2026-10-30): curl/python default UAs get 403; full browser UAs get 200 — per-request header override (ext 8)
38
+ _BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
39
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
40
+
41
+ _SESSION_TTL = 15 * 60 # anti-forgery pairing reuse window (the same pairing is reusable within the ASP.NET session lifetime)
42
+
43
+ _TOKEN_FIELD = re.compile(r'name="__RequestVerificationToken"[^>]*value="([^"]+)"')
44
+ _ROW_SPLIT = re.compile(r'<div role="row"[^>]*data-contractawardid="(\d+)"')
45
+ _SPAN = re.compile(r"<span[^>]*>(.*?)</span>", re.S)
46
+ _TAGWS = re.compile(r"<[^>]+>|\s+")
47
+ _TOTAL = re.compile(r'#searchTotal"\)\.html\((\d+)\)')
48
+
49
+
50
+ def _cell_text(chunk):
51
+ """cell block -> the first span's text (tags stripped + entities unescaped + whitespace collapsed; an empty slot = None — e.g. rows carrying no reference)."""
52
+ m = _SPAN.search(chunk)
53
+ if not m:
54
+ return None
55
+ return _html.unescape(_TAGWS.sub(" ", m.group(1))).strip() or None
56
+
57
+
58
+ def awards_from_upstream(text):
59
+ """search-page HTML -> {rows[], total, count} (a pure function, testable offline).
60
+
61
+ The row's six slots (stable role=cell order): title/vendor/award_date/agency/reference/supplier_country."""
62
+ rows = []
63
+ parts = _ROW_SPLIT.split(text or "")
64
+ for rid, block in zip(parts[1::2], parts[2::2]):
65
+ cells = [_cell_text(c) for c in block.split('<div role="cell"')[1:]]
66
+ c = (cells + [None] * 6)[:6]
67
+ rows.append({"id": rid, "title": c[0], "vendor": c[1], "award_date": c[2],
68
+ "agency": c[3], "reference": c[4], "supplier_country": c[5]})
69
+ m = _TOTAL.search(text or "")
70
+ return {"rows": rows, "total": int(m.group(1)) if m else None, "count": len(rows)}
71
+
72
+
73
+ def _session(cache, no_cache=False):
74
+ """-> (cookie_hdr, token): the anti-forgery pairing — cached 15 minutes and reused; on miss, collected by GETting the search page."""
75
+ if not no_cache and cache is not None:
76
+ hit = cache.get("ungm", "antiforgery_session", _SESSION_TTL)
77
+ if hit:
78
+ return hit["payload"]["cookie"], hit["payload"]["token"]
79
+ from ..transport import call_provider
80
+ r = call_provider(provider, "search_page", {}, cache=cache, no_cache=True,
81
+ extra_headers={"User-Agent": _BROWSER_UA,
82
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"})
83
+ m = _TOKEN_FIELD.search(r["data"] or "")
84
+ if not m or not r.get("set_cookie"):
85
+ raise SdkError("upstream_unavailable", provider="ungm",
86
+ detail="bootstrap did not obtain the anti-forgery pairing (page token/session cookie missing) — the upstream page shape may have changed")
87
+ pair = {"cookie": "; ".join(sc.split(";", 1)[0].strip() for sc in r["set_cookie"]),
88
+ "token": m.group(1)}
89
+ if cache is not None:
90
+ cache.set("ungm", "antiforgery_session", pair, _SESSION_TTL)
91
+ return pair["cookie"], pair["token"]
92
+
93
+
94
+ def awards_raw(supplier=None, *, title=None, reference=None, award_from=None, award_to=None,
95
+ countries=None, agencies=None, unspscs=None, page_index=1, page_size=15,
96
+ sort_field="AwardDate", sort_ascending=False, cache=None, no_cache=False):
97
+ """-> {rows[], total, count, params, _meta} (params = the JSON body actually sent — replayable).
98
+
99
+ Filter params mirror the upstream names (Supplier/Title/Reference/AwardFrom/AwardTo/Countries/Agencies/UNSPSCs);
100
+ page_index is 1-based / page_size 1-100 (the browser uses 15/page); sort_field defaults to AwardDate
101
+ (null = upstream 500, never empty)."""
102
+ from ..transport import call_provider
103
+
104
+ idx = int(page_index) if page_index is not None else 1
105
+ if idx < 1:
106
+ raise SdkError("usage_error", provider="ungm",
107
+ detail=f"page_index must be >=1 (1-based), got {page_index!r}")
108
+ size = int(page_size) if page_size is not None else 15
109
+ if not 1 <= size <= 100:
110
+ raise SdkError("usage_error", provider="ungm",
111
+ detail=f"page_size must be 1-100 (politeness cap; the browser uses 15/page), got {page_size!r}")
112
+ # request body = the browser buildOptions() full shape: missing keys/empty values can each trigger a
113
+ # server-side exception (pinned: a null SortField = 500)
114
+ params = {
115
+ "PageIndex": idx, "PageSize": size,
116
+ "Title": title or "", "Description": "", "Reference": reference or "",
117
+ "Supplier": supplier or "", "UngmNumber": "",
118
+ "AwardFrom": award_from or "", "AwardTo": award_to or "",
119
+ "Countries": list(countries or []), "SupplierCountries": [],
120
+ "Agencies": list(agencies or []), "UNSPSCs": list(unspscs or []),
121
+ "SortField": sort_field or "AwardDate", "SortAscending": bool(sort_ascending),
122
+ }
123
+ cookie, token = _session(cache, no_cache)
124
+ hdrs = {"User-Agent": _BROWSER_UA, "Cookie": cookie, "RequestVerificationToken": token}
125
+ try:
126
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache,
127
+ extra_headers=hdrs)
128
+ except SdkError as e:
129
+ # 400 = the anti-forgery pairing expired (session cookie invalidated) — drop the cached pairing,
130
+ # re-collect once and resend; still 400 → raise as-is
131
+ if e.status != 400 or no_cache:
132
+ raise
133
+ cookie, token = _session(None) # force re-collection (bypassing the cached pairing)
134
+ r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache,
135
+ extra_headers={"User-Agent": _BROWSER_UA, "Cookie": cookie,
136
+ "RequestVerificationToken": token})
137
+ return {**awards_from_upstream(r["data"]), "params": params, "_meta": r}
@@ -0,0 +1,84 @@
1
+ """wb_proc — World Bank procurement notice stream face provider (search.worldbank.org /api/v2/procnotices; keyless).
2
+
3
+ procurement register (admitted in 0.3.6; shapes verified by first-hand live probing). Probed live 2026-10-29:
4
+ GET /api/v2/procnotices?format=json&notice_type=Contract+Award&rows=2&os=0
5
+ → {rows (echoed), os (echoed), page (echoed), total ("313071" — a string, not a number), procnotices [rows…15 keys]}
6
+ —— **313,071 award notices in full** (MDB buyer universe: IBRD/IDA development projects in 100+ countries).
7
+ Row keys: id (OP004xxxxx stable key)/notice_type/noticedate ("28-Oct-2026" form)/notice_status/
8
+ project_id (P five-digit key)/project_name/project_ctry_name/bid_reference_no/bid_description/
9
+ procurement_group/procurement_method_code/_name/submission_date/notice_text (full HTML text).
10
+ Supplier keys hide in the embedded notice_text section: `Awarded Firm(s): NAME (SUPP_ID) address…` — regex-extracted into
11
+ {name, supp_id} (**firm name + WB supplier id, both emitted**, probed live 2026-10-29: EASYMANAGEMENT (1137478) etc.);
12
+ notice_text itself is not returned on the row face (embedded contact addresses/phones stay out of the face — face values = name + key + project axis).
13
+ Params mirrored verbatim: notice_type (default "Contract Award" — the value domain of other notice types is upstream's own, passed through with no local wall)/
14
+ rows (page size)/os (offset); filter params in quoted form (%22…%22) give a fake zero-hit — upstream quirk on record, not compensated.
15
+ TTL: immutable once notice_status=Published — 7 days; polite tier 1 rps (no published quota, conservative pick).
16
+ """
17
+ import re
18
+
19
+ from ..errors import SdkError
20
+
21
+ provider = {
22
+ "id": "wb_proc",
23
+ "baseUrl": "https://search.worldbank.org",
24
+ "rate": {"rps": 1, "burst": 2},
25
+ "ops": {
26
+ "procnotices": {"path": "/api/v2/procnotices"},
27
+ },
28
+ "ttl": {"procnotices": 7 * 24 * 3600},
29
+ }
30
+
31
+ # Row narrowing (15 keys → skeleton minus notice_text/notice_lang_name; upstream key names verbatim)
32
+ _FIELDS = ("id", "notice_type", "noticedate", "notice_status", "project_ctry_name",
33
+ "project_id", "project_name", "bid_reference_no", "bid_description",
34
+ "procurement_group", "procurement_method_code", "procurement_method_name",
35
+ "submission_date")
36
+
37
+ # Embedded supplier section of notice_text (after tag stripping): starts at `Awarded Firm(s):`, stops at the Scores/Minimum Qualifying section markers
38
+ _AWARD_SEG = re.compile(r"Awarded Firm\(s\)\s*:?\s*(.*?)(?=\s{2,}(?:Scores|Minimum Qualifying|Date Notification)|$)",
39
+ re.S)
40
+ # Pair-by-pair `NAME (SUPP_ID)` inside the section — supplier id is 4-10 digits
41
+ _FIRM_PAIR = re.compile(r"([^\(\n]{2,120}?)\s*\((\d{4,10})\)")
42
+ _TAG = re.compile(r"<[^>]+>")
43
+
44
+
45
+ def _awarded_firms(notice_text):
46
+ """notice_text HTML → [{name, supp_id}…] (pure function, offline-testable; address/phone sections are not paired in)."""
47
+ seg = _AWARD_SEG.search(_TAG.sub(" ", notice_text or ""))
48
+ if not seg:
49
+ return []
50
+ return [{"name": m.group(1).strip(" ,;:"), "supp_id": m.group(2)}
51
+ for m in _FIRM_PAIR.finditer(seg.group(1))]
52
+
53
+
54
+ def procnotices_from_upstream(data):
55
+ """Upstream response → {rows[], total, count} (pure function, offline-testable)."""
56
+ rows = []
57
+ for r in (data or {}).get("procnotices") or []:
58
+ row = {k: r.get(k) for k in _FIELDS}
59
+ row["awarded_firms"] = _awarded_firms(r.get("notice_text"))
60
+ rows.append(row)
61
+ try:
62
+ total = int(data.get("total")) if data and data.get("total") is not None else None
63
+ except (TypeError, ValueError):
64
+ total = None # total is echoed as a string — a non-numeric shape is not passed off as a number
65
+ return {"rows": rows, "total": total, "count": len(rows)}
66
+
67
+
68
+ def procnotices_raw(notice_type="Contract Award", *, rows=10, os_=0, cache=None, no_cache=False):
69
+ """→ {rows[], total, count, params, _meta} (os = offset under its upstream name — takes os_ to avoid shadowing the Python built-in).
70
+
71
+ notice_type passed through under its upstream name (default Contract Award); rows 1-100 polite tier; os_ ≥0."""
72
+ from ..transport import call_provider
73
+
74
+ size = int(rows) if rows is not None else 10
75
+ if not 1 <= size <= 100:
76
+ raise SdkError("usage_error", provider="wb_proc",
77
+ detail=f"rows must be 1-100 (polite tier), got '{rows}'")
78
+ off = int(os_ if os_ is not None else 0)
79
+ if off < 0:
80
+ raise SdkError("usage_error", provider="wb_proc", detail=f"os must be ≥0, got '{os_}'")
81
+ params = {"format": "json", "notice_type": notice_type or "Contract Award",
82
+ "rows": size, "os": off}
83
+ r = call_provider(provider, "procnotices", params, cache=cache, no_cache=no_cache)
84
+ return {**procnotices_from_upstream(r["data"]), "params": params, "_meta": r}
@@ -0,0 +1,4 @@
1
+ """research domain package — flat provider adapters (one file per source) + faces/cli (0.2.6 domain reorg).
2
+ """
3
+ from . import (biorxiv, crossref, gb_gtr, icite, nih_reporter, nsf, # noqa: F401
4
+ openalex, opencitations)
@@ -0,0 +1,66 @@
1
+ """biorxiv — bioRxiv/medRxiv details API (preprint metadata stream; keyless).
2
+
3
+ No key needed (api.biorxiv.org; no stated quota — self-imposed polite 1 rps tier; for heavy
4
+ loads use the upstream S3 bulk files). details face, interval stream (probed 2026-10-26:
5
+ 2026-10-01→20 total 4,619): GET /details/biorxiv/{from}/{to}/{cursor}; response
6
+ {messages:[{status,count,total,…}], collection:[…]}.
7
+ Three traps: count/total inside messages are strings ('30'/'4619') and must be normalized
8
+ to int; the per-row authors field is a semicolon-joined string ("Balch, J. K.; McIntosh,
9
+ T. L.; …"), not an array; jatsxml is a URL to the full-text XML (not inline) — full-text
10
+ bulk belongs on the jatsxml links, this face is metadata only.
11
+ """
12
+ provider = {
13
+ "id": "biorxiv",
14
+ "baseUrl": "https://api.biorxiv.org",
15
+ "rate": {"rps": 1, "burst": 5},
16
+ "ops": {
17
+ "details": {"path": "/details/biorxiv/{from_date}/{to_date}/{cursor}", "method": "GET", "idempotent": True},
18
+ },
19
+ "ttl": {"details": 24 * 3600},
20
+ }
21
+
22
+
23
+ def _int_or_none(v):
24
+ try:
25
+ return int(str(v))
26
+ except (TypeError, ValueError):
27
+ return None
28
+
29
+
30
+ def _detail_row(c):
31
+ return {
32
+ "doi": c.get("doi"), "title": c.get("title"), "category": c.get("category"),
33
+ "date": c.get("date"), "version": c.get("version"),
34
+ "authors": c.get("authors"), # semicolon-joined string (upstream verbatim)
35
+ "author_corresponding": c.get("author_corresponding"),
36
+ "author_corresponding_institution": c.get("author_corresponding_institution"),
37
+ "jatsxml_url": c.get("jatsxml"), # full-text XML link (URL string, not inline)
38
+ }
39
+
40
+
41
+ def details_raw(from_date, to_date, *, cursor=0, cache=None, no_cache=False):
42
+ """→ {total, count, rows, has_more, params, _meta}; /details/biorxiv/{from}/{to}/{cursor} direct.
43
+ from_date/to_date required (YYYY-MM-DD interval); cursor=page index (0-based, upstream also
44
+ accepts 1 in practice); total=total papers in the interval (string normalized to int); upstream
45
+ does not echo the page size — has_more judges the boundary conservatively (a non-empty page may
46
+ not be the end; an empty page certainly is)."""
47
+ from ..transport import call_provider
48
+ from ..errors import SdkError
49
+
50
+ fd = str(from_date or "").strip()
51
+ td = str(to_date or "").strip()
52
+ if not fd or not td:
53
+ raise SdkError("usage_error", provider="biorxiv",
54
+ detail="biorxiv_details requires from_date and to_date (YYYY-MM-DD interval)")
55
+ cur = int(cursor)
56
+ if cur < 0:
57
+ raise SdkError("usage_error", provider="biorxiv", detail="cursor must be ≥0 (page index)")
58
+ params = {"from_date": fd, "to_date": td, "cursor": cur}
59
+ r = call_provider(provider, "details", params, cache=cache, no_cache=no_cache)
60
+ data = r["data"] or {}
61
+ m0 = (data.get("messages") or [{}])[0] or {}
62
+ rows = [_detail_row(c) for c in data.get("collection") or []]
63
+ total = _int_or_none(m0.get("total"))
64
+ count = _int_or_none(m0.get("count"))
65
+ return {"total": total, "count": count if count is not None else len(rows),
66
+ "rows": rows, "has_more": bool(rows), "params": params, "_meta": r}
@@ -0,0 +1,129 @@
1
+ """research cli — domain command-face registration (split per domain in 0.2.6, migrated from the root cli.py).
2
+
3
+ register(v, g): registers verb-compat faces + flat commands onto the domain subparser (g = root _leaf_globals
4
+ factory, passed in as a parameter to avoid a cli <-> domain-cli cycle); FLAT = flat dispatch table, merged by
5
+ the root cli for main to use.
6
+ """
7
+ import argparse
8
+
9
+ def register(v, g):
10
+ f1 = v.add_parser("biorxiv_details", parents=[g()],
11
+ description="bioRxiv preprint metadata window stream (realtime, local execution; /details/biorxiv/{from}/{to}/{cursor}; authors arrive as a semicolon-joined string and jatsxml is a full-text XML URL, not inline — full-text bulk belongs to the jatsxml_url links; message counts are strings upstream, normalized here)")
12
+ f1.add_argument("--from-date", dest="from_date", required=True, help="Window start YYYY-MM-DD")
13
+ f1.add_argument("--to-date", dest="to_date", required=True, help="Window end YYYY-MM-DD")
14
+ f1.add_argument("--cursor", type=int, default=0, help="Page index (0-based)")
15
+
16
+ f2 = v.add_parser("crossref_works", parents=[g()],
17
+ description="Crossref works search with funder bridge (realtime, local execution; query full-text × funder DOI (filter=funder:) × award_number (filter=award.number:, requires --funder) × from/to publication window combine AND; award numbers live only inside rows' funders[].award — select cannot fetch them (probe: select-not-available); offset+rows ≤ 10,000 documented upstream wall — deep paging switches to --cursor, the official deep-pager channel (0.2.9 audit)")
18
+ f2.add_argument("--query", default=None, help="Full-text query")
19
+ f2.add_argument("--funder", default=None, help="Funder DOI (e.g. 10.13039/100000001 = NSF)")
20
+ f2.add_argument("--award-number", dest="award_number", default=None,
21
+ help="Award/grant number (requires --funder)")
22
+ f2.add_argument("--from-pub-date", dest="from_pub_date", default=None,
23
+ help="Publication window start YYYY-MM-DD (upstream filter: from-pub-date; 0.2.9 audit)")
24
+ f2.add_argument("--to-pub-date", dest="to_pub_date", default=None,
25
+ help="Publication window end YYYY-MM-DD (upstream filter: until-pub-date; 0.2.9 audit)")
26
+ f2.add_argument("--rows", type=int, default=25, help="Rows (upstream param: rows; 1-100 politeness, upstream doc cap 1000)")
27
+ f2.add_argument("--offset", type=int, default=0, help="Result offset (offset+rows ≤ 10,000; mutually exclusive with --cursor)")
28
+ f2.add_argument("--cursor", default=None,
29
+ help="Deep-pager cursor ('*' for first page, then feed next_cursor; bypasses the 10,000 offset wall; 0.2.9 audit)")
30
+
31
+ f3 = v.add_parser("gb_gtr_projects", parents=[g()],
32
+ description="UK UKRI Gateway to Research projects (realtime, local execution; /projects?q=&s=&p=; s ≥ 10 is an upstream 400 wall; upstream sends no total — full page signals more via has_more; organisation edges live one hop deeper in detail links.link[] rel=LEAD_ORG, this face is the list only)")
33
+ f3.add_argument("--q", required=True, help="Search term")
34
+ f3.add_argument("--s", type=int, default=20, help="Page size (upstream param: s; ≥10)")
35
+ f3.add_argument("--p", type=int, default=1, help="Page number (upstream param: p)")
36
+
37
+ f4 = v.add_parser("icite_pubs", parents=[g()],
38
+ description="NIH iCite citation-impact card for a PubMed paper (realtime, local execution; /api/pubs?pmids= single record; RCR/FCR/citation counts — the impact hop after nih_reporter_publications hands you a pmid)")
39
+ f4.add_argument("--pmid", required=True, help="PubMed id (digits only, e.g. 26017442)")
40
+
41
+ f5 = v.add_parser("nih_reporter_projects", parents=[g()],
42
+ description="NIH RePORTER project search (realtime, local execution; POST /v2/projects/search; criteria=snake_case and include_fields=camelCase are separately hard-bound upstream — wrong shapes are silently ignored, so bare passthrough is not exposed; org match is exact, not fuzzy; offset+limit cap 15,000)")
43
+ f5.add_argument("--org-name", dest="org_name", default=None,
44
+ help="Receiving organization name (exact match upstream; scalar auto-wrapped into org_names array)")
45
+ f5.add_argument("--fiscal-year", dest="fiscal_year", type=int, default=None,
46
+ help="Fiscal year (goes into fiscal_years array)")
47
+ f5.add_argument("--include-fields", dest="include_fields", default=None,
48
+ help="Comma list of camelCase fields (upstream include_fields; wrong names are silently ignored upstream)")
49
+ f5.add_argument("--limit", type=int, default=25, help="Page size (upstream param: limit; 1-500)")
50
+ f5.add_argument("--offset", type=int, default=0, help="Result offset (offset+limit ≤ 15,000)")
51
+
52
+ f6 = v.add_parser("nih_reporter_publications", parents=[g()],
53
+ description="NIH funding↔publication bridge (realtime, local execution; POST /v2/publications/search; criteria recognizes ONLY pmids/appl_ids — every other key is silently ignored upstream and dumps the whole 8.3M table, so the local gate requires one of the two and no other criteria is passed through; rows are a 3-key bridge {coreproject, pmid, applid}; zero rows = no linkage on record, sparseness is a real answer)")
54
+ f6.add_argument("--pmids", dest="pmids", default=None,
55
+ help="Comma list of PubMed ids (criteria.pmids)")
56
+ f6.add_argument("--appl-ids", dest="appl_ids", default=None,
57
+ help="Comma list of NIH application ids from nih_reporter_projects (criteria.appl_ids)")
58
+ f6.add_argument("--limit", type=int, default=25, help="Page size (upstream param: limit; 1-500)")
59
+ f6.add_argument("--offset", type=int, default=0, help="Result offset")
60
+
61
+ f7 = v.add_parser("nsf_awards", parents=[g()],
62
+ description="US NSF awards search (realtime, local execution; keyword full-text XOR id point lookup; rpp 1-200 per upstream doc; upstream sends no total — full page signals more via has_more)")
63
+ f7.add_argument("--keyword", default=None, help="Full-text search keyword (XOR --id)")
64
+ f7.add_argument("--id", default=None, help="NSF award id, point lookup (XOR --keyword)")
65
+ f7.add_argument("--rpp", type=int, default=25, help="Results per page (upstream param: rpp; 1-200)")
66
+ f7.add_argument("--offset", type=int, default=0, help="Result offset for pagination")
67
+
68
+ f8 = v.add_parser("openalex_institutions", parents=[g()],
69
+ description="Research-institution candidate search (realtime, local execution; /institutions?search=; same-name multi-country clones coexist — the candidate list is NOT auto-picked: choose the ROR explicitly, then query output via openalex_works --ror)")
70
+ f8.add_argument("--search", required=True, help="Institution name (e.g. pfizer)")
71
+ f8.add_argument("--per-page", dest="per_page", type=int, default=25,
72
+ help="Results per page (upstream param: per-page; 1-200)")
73
+
74
+ f9 = v.add_parser("openalex_works", parents=[g()],
75
+ description="Global scholarly works search (realtime, local execution; api.openalex.org/works; search full-text × ror institution edge × doi × title_search combine AND into filter=; per_page 1-200 (explicit upstream 400 wall); cursor pagination via meta.next_cursor — the cursor encodes paging only, so re-send the same constraints when continuing; select is force-merged with core fields so slimming cannot strip id/doi/display_name/authorships/open_access)")
76
+ f9.add_argument("--search", default=None, help="Full-text search term")
77
+ f9.add_argument("--ror", default=None, help="ROR id (bare 01xdqrp08 or full https://ror.org/… → filter=institutions.ror:)")
78
+ f9.add_argument("--doi", default=None, help="DOI exact match (filter=doi:)")
79
+ f9.add_argument("--title-search", dest="title_search", default=None,
80
+ help="Title substring match (filter=title.search:)")
81
+ f9.add_argument("--per-page", dest="per_page", type=int, default=25,
82
+ help="Results per page (upstream param: per-page; 1-200)")
83
+ f9.add_argument("--cursor", default=None, help="Pagination cursor from next_cursor (constraints must be re-sent)")
84
+ f9.add_argument("--select", default=None,
85
+ help="Comma list of extra fields (core fields are force-merged, never stripped)")
86
+
87
+ f10 = v.add_parser("opencitations_citations", parents=[g()],
88
+ description="OpenCitations COCI citing-DOI list (realtime, local execution; /index/v1/citations/{doi}; doi: prefix and doi.org URLs are normalized away — upstream 400s otherwise; upstream limit param is silently ignored (probe: ?limit=3 still returns all 217) so no fake pagination, the full list comes back with a count)")
89
+ f10.add_argument("--doi", required=True, help="Cited DOI (doi: prefix auto-stripped)")
90
+
91
+ FLAT = {
92
+ # research (grants axis stood up in 0.2.2, renamed research with 8 more faces in 0.2.4) / security 0.2.2 flat panel
93
+ # (upstream param names verbatim + our defaults; lambdas only shuttle names across namespaces)
94
+ "biorxiv_details": lambda a: {"from_date": a.from_date, "to_date": a.to_date, "cursor": a.cursor},
95
+ "crossref_works": lambda a: {"query": a.query, "funder": a.funder, "award_number": a.award_number,
96
+ "rows": a.rows, "offset": a.offset, "cursor": a.cursor,
97
+ "from_pub_date": a.from_pub_date, "to_pub_date": a.to_pub_date},
98
+ "gb_gtr_projects": lambda a: {"q": a.q, "s": a.s, "p": a.p},
99
+ "icite_pubs": lambda a: {"pmid": a.pmid},
100
+ "nih_reporter_projects": lambda a: {"org_name": a.org_name, "fiscal_year": a.fiscal_year,
101
+ "include_fields": [x for x in (a.include_fields or "").split(",") if x.strip()] or None,
102
+ "limit": a.limit, "offset": a.offset},
103
+ "nih_reporter_publications": lambda a: {"pmids": [int(x) for x in (a.pmids or "").split(",") if x.strip()] or None,
104
+ "appl_ids": [int(x) for x in (a.appl_ids or "").split(",") if x.strip()] or None,
105
+ "limit": a.limit, "offset": a.offset},
106
+ "nsf_awards": lambda a: {"keyword": a.keyword, "id": a.id, "rpp": a.rpp, "offset": a.offset},
107
+ "openalex_institutions": lambda a: {"search": a.search, "per_page": a.per_page},
108
+ "openalex_works": lambda a: {"search": a.search, "ror": a.ror, "doi": a.doi,
109
+ "title_search": a.title_search, "per_page": a.per_page,
110
+ "cursor": a.cursor, "select": a.select},
111
+ "opencitations_citations": lambda a: {"doi": a.doi},
112
+ }
113
+
114
+
115
+ # 0.3.10 jurisdiction chapter: face → jurisdiction (default = issuing authority's jurisdiction; institutions
116
+ # covering global phenomena = GLOBAL; vocabulary ISO-3166 alpha-2 + EU + GLOBAL) —— merged by the root cli.py
117
+ # into _JURISDICTION, the success envelope carries a jurisdiction chapter
118
+ COUNTRY = {
119
+ "biorxiv_details": "GLOBAL",
120
+ "crossref_works": "GLOBAL",
121
+ "gb_gtr_projects": "GB",
122
+ "icite_pubs": "US",
123
+ "nih_reporter_projects": "US",
124
+ "nih_reporter_publications": "US",
125
+ "nsf_awards": "US",
126
+ "openalex_institutions": "GLOBAL",
127
+ "openalex_works": "GLOBAL",
128
+ "opencitations_citations": "GLOBAL",
129
+ }
@@ -0,0 +1,121 @@
1
+ """crossref — Crossref REST API (DOI registry paper search; keyless).
2
+
3
+ No key needed (the polite pool officially recommends a mailto in the User-Agent; self-imposed
4
+ 5 rps = 1/10 of the documented cap of 50, same high-volume tier as edgar/openalex). works face
5
+ (probed 2026-10-26): filter=funder:<funder DOI> collects funder output (NSF 10.13039/100000001
6
+ → 462,059 works, rows carry the award number in funder[].award[]); award.number:<number> combined
7
+ with funder narrows exactly (OAC-1730655 → 10 works); query= with filter= is legal
8
+ (funder×query=graphene → 2,962).
9
+ Three traps: select=award reports select-not-available (awards live only inside rows, they
10
+ cannot be selected); the deep-paging offset has a documented upstream wall at 10,000 (not probed
11
+ point by point — documented figure) — **the proper way around it is cursor** (deep-pager, probed
12
+ 2026-10-28: cursor=*&filter=from-pub-date:2026-06-01 → 5 rows + next-cursor, all good).
13
+ """
14
+ provider = {
15
+ "id": "crossref",
16
+ "baseUrl": "https://api.crossref.org",
17
+ "rate": {"rps": 5, "burst": 10}, # high-volume tier (edgar also 5rps; official cap 50 rps, quota semantics 10+5=15 ≤ 50, comfortable)
18
+ "ops": {
19
+ "works": {"path": "/works", "method": "GET", "idempotent": True},
20
+ },
21
+ "ttl": {"works": 24 * 3600},
22
+ }
23
+
24
+ _ROWS_CAP = 100 # default page-size cap (upstream doc allows up to 1000, politeness tier tightens it)
25
+ _OFFSET_WALL = 10000 # upstream documented deep-paging wall (not probed point by point, documented figure; the cursor channel is not subject to it)
26
+
27
+
28
+ def _work_row(it):
29
+ issued = ((it.get("issued") or {}).get("date-parts") or [[None]])[0]
30
+ return {
31
+ "doi": it.get("DOI"),
32
+ "title": (it.get("title") or [None])[0],
33
+ "container_title": (it.get("container-title") or [None])[0],
34
+ "type": it.get("type"),
35
+ "publisher": it.get("publisher"),
36
+ "year": issued[0] if issued else None,
37
+ "authors": [{"given": a.get("given"), "family": a.get("family"),
38
+ "affiliation": [x.get("name") for x in a.get("affiliation") or []]}
39
+ for a in it.get("author") or []],
40
+ "funders": [{"doi": f.get("DOI"), "name": f.get("name"), "award": f.get("award") or []}
41
+ for f in it.get("funder") or []],
42
+ "is_referenced_by_count": it.get("is-referenced-by-count"),
43
+ "url": it.get("URL"),
44
+ }
45
+
46
+
47
+ def works_raw(query=None, *, funder=None, award_number=None, rows=25, offset=0,
48
+ cursor=None, from_pub_date=None, to_pub_date=None,
49
+ cache=None, no_cache=False):
50
+ """→ {total, count, rows, next_cursor, params, _meta}; /works direct, verbatim name.
51
+ query full-text × funder (funder DOI→filter=funder:) × award_number (→filter=award.number:,
52
+ only meaningful when funder is given too) × from_pub_date/to_pub_date (→filter=from/until-pub-date:
53
+ bare date; from=2026-10-28 probed, until is the same family per the docs) can be combined;
54
+ **constraint = at least one of query/funder/award_number** (local wall: a bare whole-corpus
55
+ scan is served by the bulk pipeline — a date pair only narrows, it does not count as a
56
+ constraint, from_pub_date alone still scans the whole corpus and is blocked all the same);
57
+ rows 1-100 (politeness tightening, upstream doc cap 1000); offset+rows ≤10,000 (upstream doc wall).
58
+ **cursor (adopted in the 0.2.9 audit)**: deep-pager is the proper deep-paging channel — send
59
+ `cursor="*"` for the first page, feed back `next_cursor` for the next; mutually exclusive with
60
+ offset (the cursor channel is not subject to the 10,000 wall); next_cursor is echoed back
61
+ (null on the last page).
62
+ Award numbers live only inside funder[].award[] on the row, the select face cannot fetch
63
+ them (probe 2026-10-26: select-not-available)."""
64
+ from ..transport import call_provider
65
+ from ..errors import SdkError
66
+ import re
67
+
68
+ q = str(query or "").strip() or None
69
+ f_v = str(funder or "").strip() or None
70
+ a_v = str(award_number or "").strip() or None
71
+ cur = str(cursor or "").strip() or None
72
+ if not (q or f_v or a_v):
73
+ raise SdkError("usage_error", provider="crossref",
74
+ detail="crossref_works requires at least one of query/funder/award_number"
75
+ " (local safety wall: a bare whole-corpus scan is served by the bulk "
76
+ "pipeline; from/to_pub_date is a narrowing pair, not a constraint)")
77
+ if a_v and not f_v:
78
+ raise SdkError("usage_error", provider="crossref",
79
+ detail="award_number requires funder (the upstream award.number filter only "
80
+ "makes sense combined with funder) — pass --funder as well")
81
+ n = int(rows)
82
+ if not 1 <= n <= _ROWS_CAP:
83
+ raise SdkError("usage_error", provider="crossref",
84
+ detail=f"rows must be 1-{_ROWS_CAP} (politeness tightening; upstream doc cap 1000)")
85
+ params = {"rows": n}
86
+ if cur is not None:
87
+ params["cursor"] = cur
88
+ else:
89
+ off = int(offset)
90
+ if off < 0:
91
+ raise SdkError("usage_error", provider="crossref", detail="offset must be ≥0")
92
+ if off + n > _OFFSET_WALL:
93
+ raise SdkError("usage_error", provider="crossref",
94
+ detail=f"offset+rows must be ≤{_OFFSET_WALL:,} (upstream documented "
95
+ "deep-paging wall) — deep paging goes through the cursor channel "
96
+ "(cursor=\"*\" first, then next_cursor)")
97
+ params["offset"] = off
98
+ if q:
99
+ params["query"] = q
100
+ filters = []
101
+ if f_v:
102
+ filters.append(f"funder:{f_v}")
103
+ if a_v:
104
+ filters.append(f"award.number:{a_v}")
105
+ for key, label, v in (("from_pub_date", "from-pub-date", from_pub_date),
106
+ ("to_pub_date", "until-pub-date", to_pub_date)):
107
+ if v in (None, ""):
108
+ continue
109
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(v)):
110
+ raise SdkError("usage_error", provider="crossref",
111
+ detail=f"{key} must be a bare date YYYY-MM-DD (e.g. 2026-06-01)")
112
+ filters.append(f"{label}:{v}")
113
+ if filters:
114
+ params["filter"] = ",".join(filters)
115
+ r = call_provider(provider, "works", params, cache=cache, no_cache=no_cache)
116
+ msg = (r["data"] or {}).get("message") or {}
117
+ items = msg.get("items") or []
118
+ rows_out = [_work_row(it) for it in items]
119
+ return {"total": msg.get("total-results"), "count": len(rows_out), "rows": rows_out,
120
+ "next_cursor": msg.get("next-cursor") if cur is not None else None,
121
+ "params": params, "_meta": r}