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.
- agentdata/__init__.py +33 -0
- agentdata/__main__.py +4 -0
- agentdata/cache.py +115 -0
- agentdata/cli.py +173 -0
- agentdata/client.py +158 -0
- agentdata/company/__init__.py +3 -0
- agentdata/company/cf.py +145 -0
- agentdata/company/ch_zefix.py +100 -0
- agentdata/company/cli.py +243 -0
- agentdata/company/co_rues.py +72 -0
- agentdata/company/edgar_cik.py +84 -0
- agentdata/company/edgar_fts.py +101 -0
- agentdata/company/faces.py +746 -0
- agentdata/company/fdic_bankfind.py +107 -0
- agentdata/company/fi_ytj.py +89 -0
- agentdata/company/fr_recherche.py +124 -0
- agentdata/company/fts.py +121 -0
- agentdata/company/gleif.py +178 -0
- agentdata/company/kr_hometax.py +112 -0
- agentdata/company/no_brreg.py +124 -0
- agentdata/company/ror.py +105 -0
- agentdata/company/ted.py +117 -0
- agentdata/company/usaspending.py +124 -0
- agentdata/company/vies.py +66 -0
- agentdata/daterange.py +35 -0
- agentdata/economy/__init__.py +10 -0
- agentdata/economy/bis.py +132 -0
- agentdata/economy/cli.py +199 -0
- agentdata/economy/ecb.py +117 -0
- agentdata/economy/eurostat.py +163 -0
- agentdata/economy/faces.py +573 -0
- agentdata/economy/fred.py +92 -0
- agentdata/economy/ilo_stat.py +99 -0
- agentdata/economy/imf_sdmx.py +133 -0
- agentdata/economy/un_comtrade.py +199 -0
- agentdata/economy/unsd_sdg.py +154 -0
- agentdata/economy/wb.py +208 -0
- agentdata/economy/who_xmart.py +177 -0
- agentdata/entries.py +30 -0
- agentdata/errors.py +38 -0
- agentdata/esg/__init__.py +5 -0
- agentdata/esg/cli.py +180 -0
- agentdata/esg/climatetrace.py +260 -0
- agentdata/esg/eiti.py +187 -0
- agentdata/esg/epa_frs.py +138 -0
- agentdata/esg/euasl.py +75 -0
- agentdata/esg/eutl.py +66 -0
- agentdata/esg/faces.py +498 -0
- agentdata/esg/paris_mou.py +240 -0
- agentdata/esg/thetis_mrv.py +67 -0
- agentdata/healthcare/__init__.py +7 -0
- agentdata/healthcare/au_tga.py +142 -0
- agentdata/healthcare/ca_dpd.py +123 -0
- agentdata/healthcare/ca_mdall.py +155 -0
- agentdata/healthcare/cli.py +375 -0
- agentdata/healthcare/ctgov.py +101 -0
- agentdata/healthcare/dailymed.py +102 -0
- agentdata/healthcare/epmc.py +108 -0
- agentdata/healthcare/eudamed.py +124 -0
- agentdata/healthcare/faces.py +811 -0
- agentdata/healthcare/fda_crl.py +86 -0
- agentdata/healthcare/fda_devices.py +199 -0
- agentdata/healthcare/fda_drugs.py +156 -0
- agentdata/healthcare/fda_recalls.py +137 -0
- agentdata/healthcare/gb_mhra.py +137 -0
- agentdata/healthcare/gudid.py +84 -0
- agentdata/healthcare/mesh.py +89 -0
- agentdata/healthcare/rasff.py +164 -0
- agentdata/healthcare/rxnav.py +67 -0
- agentdata/healthcare/safety_gate.py +175 -0
- agentdata/healthcare/shortages.py +107 -0
- agentdata/legal/__init__.py +4 -0
- agentdata/legal/afdb.py +93 -0
- agentdata/legal/ca_sema.py +143 -0
- agentdata/legal/cli.py +312 -0
- agentdata/legal/co_sic.py +70 -0
- agentdata/legal/courtlistener.py +77 -0
- agentdata/legal/csl.py +139 -0
- agentdata/legal/dgcomp.py +125 -0
- agentdata/legal/ecfr.py +129 -0
- agentdata/legal/faces.py +700 -0
- agentdata/legal/federalregister.py +188 -0
- agentdata/legal/gb_cma.py +117 -0
- agentdata/legal/gb_tna_caselaw.py +245 -0
- agentdata/legal/idb.py +85 -0
- agentdata/legal/lda.py +147 -0
- agentdata/legal/sipri.py +178 -0
- agentdata/legal/ukec.py +148 -0
- agentdata/legal/wb.py +92 -0
- agentdata/procurement/__init__.py +5 -0
- agentdata/procurement/br_pncp.py +126 -0
- agentdata/procurement/cli.py +155 -0
- agentdata/procurement/co_secop.py +88 -0
- agentdata/procurement/faces.py +239 -0
- agentdata/procurement/ny_open_data.py +82 -0
- agentdata/procurement/ungm.py +137 -0
- agentdata/procurement/wb_proc.py +84 -0
- agentdata/research/__init__.py +4 -0
- agentdata/research/biorxiv.py +66 -0
- agentdata/research/cli.py +129 -0
- agentdata/research/crossref.py +121 -0
- agentdata/research/faces.py +349 -0
- agentdata/research/gb_gtr.py +58 -0
- agentdata/research/icite.py +52 -0
- agentdata/research/nih_reporter.py +143 -0
- agentdata/research/nsf.py +68 -0
- agentdata/research/openalex.py +145 -0
- agentdata/research/opencitations.py +50 -0
- agentdata/security/__init__.py +3 -0
- agentdata/security/cisa_kev.py +42 -0
- agentdata/security/cli.py +37 -0
- agentdata/security/faces.py +91 -0
- agentdata/security/osv.py +72 -0
- agentdata/transport.py +541 -0
- agentdata-0.3.26.dist-info/METADATA +266 -0
- agentdata-0.3.26.dist-info/RECORD +120 -0
- agentdata-0.3.26.dist-info/WHEEL +5 -0
- agentdata-0.3.26.dist-info/entry_points.txt +10 -0
- agentdata-0.3.26.dist-info/licenses/LICENSE +201 -0
- agentdata-0.3.26.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""kr_hometax — Korea NTA hometax business-registration status (휴폐업/tax type) provider (keyless anonymous single-shot).
|
|
2
|
+
|
|
3
|
+
company roster (admitted in 0.3.11, probe tmp/probe-co-kr/). Shape verified 2026-10-30:
|
|
4
|
+
POST to the bare domain hometax.go.kr/wqAction.do?actionId=ATTABZAA001R08&screenId=UTEABAAA13&
|
|
5
|
+
popupYn=false&realScreenId= (the actionId family lives in the query string) + a seven-field flat JSON body
|
|
6
|
+
(dongCode = middle-2 digits of the 10-digit number, txprDscmNo = the 10-digit business number,
|
|
7
|
+
the other four constants) →
|
|
8
|
+
{resultMsg{result S/F}, trtCntn full status sentence, smpcBmanTrtCntn concise status + 폐업일자,
|
|
9
|
+
smpcBmanEnglTrtCntn English version, txprNm always null when anonymous (no owner-name leak),
|
|
10
|
+
trtEndCd, nrgtTxprYn}.
|
|
11
|
+
|
|
12
|
+
**Harvest-recipe deltas (two SDK-layer reversals, 2026-10-30 elimination probe)**: (1) the EverSafe
|
|
13
|
+
trailing token (42-char hash appended to the JSON body) is **NOT required** — a bare POST without it
|
|
14
|
+
still returns 200 result=S with the correct state (the old recipe was defensive redundancy; the
|
|
15
|
+
hometaxbot k4 confusion is a smokescreen); (2) a browser UA is **NOT required** — urllib's default
|
|
16
|
+
UA passes (not in the FAA/JFTC header-fingerprint gate family). (3) The response is **UTF-8 JSON**
|
|
17
|
+
(Content-Type self-evident; the old cp949 observation belonged to a different action — the SDK face
|
|
18
|
+
decodes UTF-8 via http_json and is correct).
|
|
19
|
+
|
|
20
|
+
**Four-state semantics (every result=S shape is a legal answer, not an error)**: operating =
|
|
21
|
+
「부가가치세 일반과세자 입니다.」 (VAT general taxpayer) or the 간이/과세특례 variants; closed =
|
|
22
|
+
「폐업자 (…, 폐업일자:YYYY-MM-DD)」 (ISO date regex-extracted); unregistered =
|
|
23
|
+
「국세청에 등록되지 않은…」 (registered=False); 휴업 (suspended) same sentence family.
|
|
24
|
+
Positioning = status-column enrichment over lists (the batch-100-건 actionId has drifted, unexplored).
|
|
25
|
+
TTL: status changes slowly — 7 days; politeness 0.5 rps (conservative tier for a government
|
|
26
|
+
real-name-grade query gate).
|
|
27
|
+
"""
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
from ..errors import SdkError
|
|
31
|
+
|
|
32
|
+
provider = {
|
|
33
|
+
"id": "kr_hometax",
|
|
34
|
+
"baseUrl": "https://hometax.go.kr",
|
|
35
|
+
"rate": {"rps": 0.5, "burst": 2},
|
|
36
|
+
"ops": {
|
|
37
|
+
# actionId family inline in the path query string (POST read semantics: the seven fields are not path
|
|
38
|
+
# params, they go in the JSON body; idempotent participates in retries)
|
|
39
|
+
"biz_status": {"path": "/wqAction.do?actionId=ATTABZAA001R08&screenId=UTEABAAA13"
|
|
40
|
+
"&popupYn=false&realScreenId=",
|
|
41
|
+
"method": "POST", "idempotent": True, "timeoutMs": 30000},
|
|
42
|
+
},
|
|
43
|
+
"ttl": {"biz_status": 7 * 86400},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_BIZNO_RE = re.compile(r"^\d{10}$")
|
|
47
|
+
_CLOSED_DATE_RE = re.compile(r"폐업일자[::]?\s*(\d{4}-\d{2}-\d{2})")
|
|
48
|
+
_UNREGISTERED_MARK = "국세청에 등록되지 않은"
|
|
49
|
+
_CLOSED_MARK = "폐업"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _bizno(v):
|
|
53
|
+
"""'124-81-00998'/'1248100998' → 10-digit string (Korean business number 3-2-5 hyphens optional)."""
|
|
54
|
+
norm = re.sub(r"[\s-]", "", str(v or ""))
|
|
55
|
+
if not _BIZNO_RE.match(norm):
|
|
56
|
+
raise SdkError("usage_error", provider="kr_hometax",
|
|
57
|
+
detail=f"business registration number must be 10 digits (3-2-5 hyphens optional, e.g. 124-81-00998), got {v!r}")
|
|
58
|
+
return norm
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_status(data, biz_no):
|
|
62
|
+
"""Upstream JSON → four-state normalization (operating/closed/unregistered/other; registered bit + closed ISO date extracted).
|
|
63
|
+
|
|
64
|
+
Every result=S shape is a legal answer (unregistered is S too, not an error — same semantics as VIES INVALID);
|
|
65
|
+
result≠S or a malformed shape → state='error', verbatim passthrough, never faked.
|
|
66
|
+
"""
|
|
67
|
+
d = data or {}
|
|
68
|
+
result = ((d.get("resultMsg") or {}).get("result")) or None
|
|
69
|
+
trt = (d.get("trtCntn") or "").strip() or None
|
|
70
|
+
simple = (d.get("smpcBmanTrtCntn") or "").strip() or None
|
|
71
|
+
english = (d.get("smpcBmanEnglTrtCntn") or "").strip() or None
|
|
72
|
+
blob = " ".join(x for x in (trt, simple) if x)
|
|
73
|
+
m = _CLOSED_DATE_RE.search(blob)
|
|
74
|
+
closed_date = m.group(1) if m else None
|
|
75
|
+
if result == "S" and blob:
|
|
76
|
+
if _UNREGISTERED_MARK in blob:
|
|
77
|
+
state, registered = "unregistered", False
|
|
78
|
+
elif _CLOSED_MARK in blob or closed_date:
|
|
79
|
+
state, registered = "closed", True
|
|
80
|
+
else:
|
|
81
|
+
state, registered = "active", True
|
|
82
|
+
else:
|
|
83
|
+
state, registered = "error", None
|
|
84
|
+
return {
|
|
85
|
+
"biz_no": biz_no,
|
|
86
|
+
"result": result,
|
|
87
|
+
"state": state, # active | closed | unregistered | error
|
|
88
|
+
"registered": registered, # None = malformed shape, never self-answered
|
|
89
|
+
"tax_type": trt, # full status sentence (tax type: 일반/간이/과세특례…)
|
|
90
|
+
"simple_status": simple, # concise status + 폐업일자
|
|
91
|
+
"english_status": english, # English status sentence
|
|
92
|
+
"closed_date": closed_date, # 폐업일자 ISO (always present on closed rows; operating/unregistered = None)
|
|
93
|
+
"trt_end_cd": d.get("trtEndCd"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def biz_status_raw(biz_no, *, cache=None, no_cache=False):
|
|
98
|
+
"""→ {status, _meta} (four-state normalized; txprNm stays null when anonymous and is not carried in upstream response values)."""
|
|
99
|
+
from ..transport import call_provider
|
|
100
|
+
|
|
101
|
+
norm = _bizno(biz_no)
|
|
102
|
+
params = {
|
|
103
|
+
"dongCode": norm[3:5], # middle-2-digit segment of the 10-digit number (district code)
|
|
104
|
+
"txprDscmNo": norm, # the 10-digit business number
|
|
105
|
+
"inqrTrgtClCd": "1",
|
|
106
|
+
"mobYn": "N",
|
|
107
|
+
"psbSearch": "Y",
|
|
108
|
+
"pubcUserNo": "",
|
|
109
|
+
"userReqInfoVO": {},
|
|
110
|
+
}
|
|
111
|
+
r = call_provider(provider, "biz_status", params, cache=cache, no_cache=no_cache)
|
|
112
|
+
return {"status": parse_status(r["data"], norm), "_meta": r}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""no_brreg — Norwegian Enhetsregisteret (Brønnøysund registry master) provider (HAL JSON front door; keyless).
|
|
2
|
+
|
|
3
|
+
2026-10-29 probed shape: the real param is **?navn=** (?name= gives 400 valideringsfeil — the parameter takes
|
|
4
|
+
the Norwegian spelling); pagination ?size=&page= (0-based) → HAL page{number,size,totalElements,totalPages};
|
|
5
|
+
single record GET /enheter/{orgnr} (404 = no such number); roles face GET /enheter/{orgnr}/roller →
|
|
6
|
+
rollegrupper[] (DAGL CEO / STYR board / REVI auditor groups), rows = person{navn{fornavn,etternavn},
|
|
7
|
+
fodselsdato, erDoed} or enhet{organisasjonsnummer} (auditors are often companies) + the avregistrert retirement flag.
|
|
8
|
+
PII line: the public face exposes only birth dates, no national id numbers (fnr is not on this face) — name + birth
|
|
9
|
+
date are collectible.
|
|
10
|
+
TTL: enheter/enhet 7 days (current-value queries) / roller 30 days (role sequences change slowly); politeness tier 0.5 rps.
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from ..errors import SdkError
|
|
15
|
+
|
|
16
|
+
provider = {
|
|
17
|
+
"id": "no_brreg",
|
|
18
|
+
"baseUrl": "https://data.brreg.no/enhetsregisteret/api",
|
|
19
|
+
"rate": {"rps": 0.5, "burst": 2},
|
|
20
|
+
"ops": {
|
|
21
|
+
"enheter": {"path": "/enheter"}, # params: navn/size/page (query)
|
|
22
|
+
"enhet": {"path": "/enheter/{organisasjonsnummer}", "notFound": [404]}, # params: organisasjonsnummer (path)
|
|
23
|
+
"roller": {"path": "/enheter/{organisasjonsnummer}/roller", "notFound": [404]}, # same (roles face)
|
|
24
|
+
},
|
|
25
|
+
"ttl": {"enheter": 7 * 86400, "enhet": 7 * 86400, "roller": 30 * 86400},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_ORGNR_RE = re.compile(r"^\d{9}$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _orgnr(v):
|
|
32
|
+
norm = str(v or "").replace(" ", "").strip()
|
|
33
|
+
if not _ORGNR_RE.match(norm):
|
|
34
|
+
raise SdkError("usage_error", provider="no_brreg",
|
|
35
|
+
detail=f"organisasjonsnummer must be 9 digits (Norwegian organization number), got \"{v}\"")
|
|
36
|
+
return norm
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _norm_enhet(e):
|
|
40
|
+
"""Full 40-key record → due-diligence core projection (industry/flags/address/headcount); historical names passed through."""
|
|
41
|
+
addr = e.get("forretningsadresse") or {}
|
|
42
|
+
return {
|
|
43
|
+
"organisasjonsnummer": e.get("organisasjonsnummer"),
|
|
44
|
+
"navn": e.get("navn"),
|
|
45
|
+
"organisasjonsform": {"kode": (e.get("organisasjonsform") or {}).get("kode"),
|
|
46
|
+
"beskrivelse": (e.get("organisasjonsform") or {}).get("beskrivelse")},
|
|
47
|
+
"naeringskode1": {"kode": (e.get("naeringskode1") or {}).get("kode"),
|
|
48
|
+
"beskrivelse": (e.get("naeringskode1") or {}).get("beskrivelse")}, # NACE main industry
|
|
49
|
+
"antall_ansatte": e.get("antallAnsatte"),
|
|
50
|
+
"registreringsdato_enhetsregisteret": e.get("registreringsdatoEnhetsregisteret"),
|
|
51
|
+
"stiftelsesdato": e.get("stiftelsesdato"),
|
|
52
|
+
"hjemmeside": e.get("hjemmeside"),
|
|
53
|
+
"konkurs": e.get("konkurs"),
|
|
54
|
+
"under_avvikling": e.get("underAvvikling"),
|
|
55
|
+
"under_tvangsavvikling": e.get("underTvangsavviklingEllerTvangsopplosning"),
|
|
56
|
+
"registrert_i_mva_registeret": e.get("registrertIMvaregisteret"), # VAT register flag
|
|
57
|
+
"registrert_i_foretaksregisteret": e.get("registrertIForetaksregisteret"),
|
|
58
|
+
"overordnet_enhet": e.get("overordnetEnhet"), # parent org number (subsidiary edge)
|
|
59
|
+
"forretningsadresse": {"adresse": (addr.get("adresse") or [None])[0],
|
|
60
|
+
"poststed": addr.get("poststed"), "kommune": addr.get("kommune"),
|
|
61
|
+
"landkode": addr.get("landkode")},
|
|
62
|
+
"historiske_navn": e.get("historiskeNavn"), # former names passed through (upstream shape)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def search_enheter(navn, *, page=0, size=20, cache=None, no_cache=False):
|
|
67
|
+
"""→ {enheter[], page{}, params, _meta}; name search (navn verbatim channel; size/page 0-based HAL pagination)."""
|
|
68
|
+
from ..transport import call_provider
|
|
69
|
+
|
|
70
|
+
q = str(navn or "").strip()
|
|
71
|
+
if not q:
|
|
72
|
+
raise SdkError("usage_error", provider="no_brreg", detail="search requires navn (name search, the ?navn= verbatim channel)")
|
|
73
|
+
p, s = max(0, int(page or 0)), min(max(1, int(size or 20)), 100) # 1-100 = our politeness cap
|
|
74
|
+
r = call_provider(provider, "enheter", {"navn": q, "page": p, "size": s},
|
|
75
|
+
cache=cache, no_cache=no_cache)
|
|
76
|
+
d = r["data"] or {}
|
|
77
|
+
rows = [_norm_enhet(e) for e in (d.get("_embedded") or {}).get("enheter") or []]
|
|
78
|
+
pg = d.get("page") or {}
|
|
79
|
+
return {"enheter": rows,
|
|
80
|
+
"page": {"number": pg.get("number"), "size": pg.get("size"),
|
|
81
|
+
"total_elements": pg.get("totalElements"), "total_pages": pg.get("totalPages")},
|
|
82
|
+
"params": {"navn": q, "page": p, "size": s}, "_meta": r}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_enhet(organisasjonsnummer, *, cache=None, no_cache=False):
|
|
86
|
+
"""→ {enhet, _meta}; single record by 9-digit org number (404 = no such number, honestly reported)."""
|
|
87
|
+
from ..transport import call_provider
|
|
88
|
+
|
|
89
|
+
v = _orgnr(organisasjonsnummer)
|
|
90
|
+
r = call_provider(provider, "enhet", {"organisasjonsnummer": v}, cache=cache, no_cache=no_cache)
|
|
91
|
+
return {"enhet": _norm_enhet(r["data"] or {}), "_meta": r}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_roller(organisasjonsnummer, *, cache=None, no_cache=False):
|
|
95
|
+
"""→ {organisasjonsnummer, rollegrupper[], _meta}; roles face (DAGL/STYR/REVI groups).
|
|
96
|
+
Person rows = name + birth date (no national id number on the public face); corporate rows (auditors, typically) =
|
|
97
|
+
org number. avregistrert=true = retired."""
|
|
98
|
+
from ..transport import call_provider
|
|
99
|
+
|
|
100
|
+
v = _orgnr(organisasjonsnummer)
|
|
101
|
+
r = call_provider(provider, "roller", {"organisasjonsnummer": v}, cache=cache, no_cache=no_cache)
|
|
102
|
+
d = r["data"] or {}
|
|
103
|
+
groups = [{
|
|
104
|
+
"type_kode": (g.get("type") or {}).get("kode"),
|
|
105
|
+
"type_beskrivelse": (g.get("type") or {}).get("beskrivelse"),
|
|
106
|
+
"sist_endret": g.get("sistEndret"),
|
|
107
|
+
"roller": [{
|
|
108
|
+
"kode": (m.get("type") or {}).get("kode"),
|
|
109
|
+
"beskrivelse": (m.get("type") or {}).get("beskrivelse"),
|
|
110
|
+
"person": _person(m.get("person")),
|
|
111
|
+
"enhet_organisasjonsnummer": ((m.get("enhet") or {}).get("organisasjonsnummer")),
|
|
112
|
+
"avregistrert": m.get("avregistrert"),
|
|
113
|
+
"rekkefolge": m.get("rekkefolge"), # order within the group
|
|
114
|
+
} for m in g.get("roller") or []],
|
|
115
|
+
} for g in d.get("rollegrupper") or []]
|
|
116
|
+
return {"organisasjonsnummer": v, "rollegrupper": groups, "_meta": r}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _person(p):
|
|
120
|
+
if not p:
|
|
121
|
+
return None
|
|
122
|
+
navn = p.get("navn") or {}
|
|
123
|
+
full = " ".join(x for x in (navn.get("fornavn"), navn.get("etternavn")) if x) or None
|
|
124
|
+
return {"navn": full, "fodselsdato": p.get("fodselsdato"), "er_doed": p.get("erDoed")}
|
agentdata/company/ror.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""ror — ROR (Research Organization Registry) provider (v2 schema; keyless).
|
|
2
|
+
|
|
3
|
+
api.ror.org. 2026-10-23 probed shape:
|
|
4
|
+
GET /organizations?query=<q> → {number_of_results, time_taken, items:[{id,names[],types[],status,
|
|
5
|
+
established,locations[],external_ids[],relationships[],domains[],links[],admin}]};
|
|
6
|
+
GET /organizations/{id} (id = the full ror.org URL, path-segment encoded) → **a bare single record** (no items
|
|
7
|
+
wrapper, 200 verified).
|
|
8
|
+
v2 migration notes: **the top-level name/country keys are gone** — the display name lives in names[] (the entry whose
|
|
9
|
+
types includes ror_display has the value), the country in locations[0].geonames_details.country_name; external_ids is
|
|
10
|
+
an array ({type,preferred,all[]}) not the v1 dict; relationships[] {label,type:parent|child|related,id(full URL)} is
|
|
11
|
+
the parent/child/related edge face.
|
|
12
|
+
The search face is fixed at 20 rows per page (upstream has no per_page param, only page) — one page is enough for the
|
|
13
|
+
candidate face, pagination is not wired up.
|
|
14
|
+
TTL: search 7 days / record 30 days (ROR ships quarterly); politeness tier 1 rps (official no-key guidance is ≤2).
|
|
15
|
+
"""
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from ..errors import SdkError
|
|
19
|
+
|
|
20
|
+
provider = {
|
|
21
|
+
"id": "ror",
|
|
22
|
+
"baseUrl": "https://api.ror.org",
|
|
23
|
+
"rate": {"rps": 1, "burst": 2},
|
|
24
|
+
"ops": {
|
|
25
|
+
"search": {"path": "/organizations"}, # params: query, page, all_status (added in 0.2.9)
|
|
26
|
+
"record": {"path": "/organizations/{id}"}, # id takes the full https://ror.org/<ui> (pre-encoded at build time)
|
|
27
|
+
},
|
|
28
|
+
"ttl": {"search": 7 * 86400, "record": 30 * 86400},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_ROR_UI = re.compile(r"^(?:https?://ror\.org/)?(0[a-z0-9]{8})$", re.IGNORECASE) # ROR id: leading 0 + 8 chars
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def display_name(names):
|
|
35
|
+
"""v2 display name = the names[] entry whose types includes ror_display (falls back to label / first entry)."""
|
|
36
|
+
if not isinstance(names, list) or not names:
|
|
37
|
+
return None
|
|
38
|
+
for want in ("ror_display", "label"):
|
|
39
|
+
for n in names:
|
|
40
|
+
if want in (n.get("types") or []):
|
|
41
|
+
return n.get("value")
|
|
42
|
+
return names[0].get("value")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def orgs_from_upstream(data):
|
|
46
|
+
"""Upstream payload → org candidates (pure function, offline-testable). A bare single record must first be wrapped as {items:[rec]}."""
|
|
47
|
+
items = data if isinstance(data, list) else (data or {}).get("items") or []
|
|
48
|
+
candidates = []
|
|
49
|
+
for o in items:
|
|
50
|
+
loc = ((o.get("locations") or [{}])[0].get("geonames_details") or {})
|
|
51
|
+
parent = next((r for r in (o.get("relationships") or []) if r.get("type") == "parent"), None)
|
|
52
|
+
candidates.append({
|
|
53
|
+
"ror_id": o.get("id", "").replace("https://ror.org/", "").replace("http://ror.org/", "") or None,
|
|
54
|
+
"name": display_name(o.get("names")), # v2: the top-level name key is gone
|
|
55
|
+
"aliases": [n.get("value") for n in (o.get("names") or [])
|
|
56
|
+
if "ror_display" not in (n.get("types") or []) and n.get("value")],
|
|
57
|
+
"type": (o.get("types") or [None])[0], # company|education|healthcare|facility|government|nonprofit|archive|other
|
|
58
|
+
"country": loc.get("country_name"), # v2: country comes from locations (first address)
|
|
59
|
+
"country_code": loc.get("country_code"),
|
|
60
|
+
"status": o.get("status"), # active|inactive|withdrawn
|
|
61
|
+
"established": o.get("established"),
|
|
62
|
+
"website": next((l.get("value") for l in (o.get("links") or []) if l.get("type") == "website"), None),
|
|
63
|
+
"domains": o.get("domains") or [],
|
|
64
|
+
"parent": ({"name": parent.get("label"),
|
|
65
|
+
"ror_id": (parent.get("id") or "").replace("https://ror.org/", "") or None}
|
|
66
|
+
if parent else None), # parent-org edge (the research-world counterpart of the GLEIF parent)
|
|
67
|
+
"external_ids": {e.get("type"): (e.get("preferred") or (e.get("all") or [None])[0])
|
|
68
|
+
for e in (o.get("external_ids") or [])},
|
|
69
|
+
})
|
|
70
|
+
return {
|
|
71
|
+
"candidates": candidates,
|
|
72
|
+
"total": data.get("number_of_results") if isinstance(data, dict) and isinstance(data.get("number_of_results"), int) else (1 if isinstance(data, list) else None),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def search_orgs(q, *, page=None, all_status=None, cache=None, no_cache=False):
|
|
77
|
+
"""→ {candidates[], total, _meta}; org-name search candidates (query matches all fields; fixed 20 rows/page, page passes straight through).
|
|
78
|
+
all_status (added in the 0.2.9 audit): an upstream boolean, default false = **only active orgs are returned** —
|
|
79
|
+
inactive/withdrawn are hidden wholesale by default (2026-10-28 probe, AstraZeneca: default 22 rows all active vs
|
|
80
|
+
all_status=true 23 rows including 1 withdrawn); None = param not sent, upstream default applies; True/False passed
|
|
81
|
+
through explicitly. Due-diligence use cases usually want withdrawn rows included explicitly."""
|
|
82
|
+
from ..transport import call_provider
|
|
83
|
+
|
|
84
|
+
query = str(q or "").strip()
|
|
85
|
+
if not query:
|
|
86
|
+
raise SdkError("usage_error", provider="ror", detail="search_orgs requires a value (org name or ROR id)")
|
|
87
|
+
params = {"query": query}
|
|
88
|
+
if page is not None:
|
|
89
|
+
params["page"] = int(page)
|
|
90
|
+
if all_status is not None:
|
|
91
|
+
params["all_status"] = "true" if all_status else "false"
|
|
92
|
+
r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
|
|
93
|
+
return {**orgs_from_upstream(r["data"]), "_meta": r}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def lookup_ror(id_, *, cache=None, no_cache=False):
|
|
97
|
+
"""→ {candidates[1], total, _meta}; ROR id single-record identity card (accepts either the bare UI or the full https://ror.org/… form)."""
|
|
98
|
+
from ..transport import call_provider
|
|
99
|
+
|
|
100
|
+
m = _ROR_UI.match(str(id_ or "").strip())
|
|
101
|
+
if not m:
|
|
102
|
+
raise SdkError("usage_error", provider="ror", detail=f"not a ROR id shape (0 + 8 chars): {id_}")
|
|
103
|
+
r = call_provider(provider, "record", {"id": f"https://ror.org/{m.group(1).lower()}"}, cache=cache, no_cache=no_cache)
|
|
104
|
+
data = r["data"]
|
|
105
|
+
return {**orgs_from_upstream(data if isinstance(data, list) else {"items": [data]}), "_meta": r}
|
agentdata/company/ted.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""ted — EU TED (Tenders Electronic Daily) v3 provider (EU procurement notices; keyless).
|
|
2
|
+
|
|
3
|
+
The old face (search.api.ted.europa.eu REST/SOAP) was 429-blocked three times in 2026-10 — overturned on
|
|
4
|
+
2026-10-23: api.ted.europa.eu/v3 POST expert search works from this egress (the old block only applied to the old
|
|
5
|
+
face; do not extrapolate across faces).
|
|
6
|
+
Contract pitfalls (self-evidenced by the 400 error body): 1. GET→405 (POST-only); 2. body {query, fields[], limit, page};
|
|
7
|
+
3. fields must come from the eForms field enumeration (a wrong name 400s and dumps the full 1,833-field list — the error
|
|
8
|
+
body is the documentation).
|
|
9
|
+
Wildcard pitfall: values must match ^[^*?] — leading wildcards are forbidden (*Siemens* 400), a trailing wildcard is
|
|
10
|
+
legal (Siemens* = prefix match).
|
|
11
|
+
Return-shape pitfall: text fields are language dicts ({'deu':['…']}) — loc() flattens preferring eng; publication-date
|
|
12
|
+
carries a 'Z' suffix.
|
|
13
|
+
TTL: search 24h (notice feeds update daily). Politeness tier 0.5 rps (conservative given the old face's 429 record).
|
|
14
|
+
"""
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from ..errors import SdkError
|
|
18
|
+
|
|
19
|
+
provider = {
|
|
20
|
+
"id": "ted",
|
|
21
|
+
"baseUrl": "https://api.ted.europa.eu/v3",
|
|
22
|
+
"rate": {"rps": 0.5, "burst": 2},
|
|
23
|
+
"ops": {
|
|
24
|
+
# POST with read semantics: expert search is idempotent read-only; all params go in the JSON body
|
|
25
|
+
"search": {"path": "/notices/search", "method": "POST", "idempotent": True, "timeoutMs": 30000},
|
|
26
|
+
},
|
|
27
|
+
"ttl": {"search": 24 * 3600},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_FIELDS = ["publication-number", "publication-date", "notice-title", "notice-type",
|
|
31
|
+
"organisation-name-buyer", "organisation-country-buyer", "total-value"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def loc(v):
|
|
35
|
+
"""Language dict/array flattening: {'eng':['X'],'deu':['Y']} → prefer eng, take the first value."""
|
|
36
|
+
if isinstance(v, list):
|
|
37
|
+
return next((x for x in v if x), None)
|
|
38
|
+
if isinstance(v, dict):
|
|
39
|
+
k = "eng" if "eng" in v else next(iter(v), None)
|
|
40
|
+
return loc(v.get(k)) if k is not None else None
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ted_from_upstream(data, limit=10):
|
|
45
|
+
"""Upstream payload → {notices[], has_more} (pure function, offline-testable); no total — inferred from a full page."""
|
|
46
|
+
ns = (data or {}).get("notices") or []
|
|
47
|
+
return {
|
|
48
|
+
"has_more": len(ns) >= limit,
|
|
49
|
+
"notices": [{
|
|
50
|
+
"publication_number": n.get("publication-number"), # stable key (e.g. 671086-2023)
|
|
51
|
+
"publication_date": (n.get("publication-date") or "").removesuffix("Z") or None,
|
|
52
|
+
"title": loc(n.get("notice-title")),
|
|
53
|
+
"notice_type": n.get("notice-type"), # eForms type (cn-standard = contract notice, standard…)
|
|
54
|
+
"buyer": loc(n.get("organisation-name-buyer")),
|
|
55
|
+
"buyer_country": n.get("organisation-country-buyer"),
|
|
56
|
+
"total_value": n.get("total-value"),
|
|
57
|
+
"xml_url": (n.get("links") or {}).get("xml"), # eForms XML original anchor (the corpus line pulls full text from it)
|
|
58
|
+
} for n in ns],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def company_eu_tenders(name=None, *, country=None, cpv=None, limit=10, cache=None, no_cache=False):
|
|
63
|
+
"""→ {notices[], has_more, query, _meta}; EU procurement notice rows (buyer prefix match).
|
|
64
|
+
Join discipline (same as fed_awards): match on name against the buyer face, parent/subsidiary groups are not merged,
|
|
65
|
+
narrow by VAT/LEI before persisting."""
|
|
66
|
+
from ..transport import call_provider
|
|
67
|
+
|
|
68
|
+
parts = []
|
|
69
|
+
q = str(name or "").strip()
|
|
70
|
+
if q:
|
|
71
|
+
if q[:1] in "*?":
|
|
72
|
+
raise SdkError("usage_error", provider="ted",
|
|
73
|
+
detail="upstream forbids leading wildcards (^[^*?]) — use prefix matching (e.g. Siemens)")
|
|
74
|
+
parts.append(f"organisation-name-buyer={re.sub(r'[*?]', '', q)}*") # trailing wildcard = prefix match
|
|
75
|
+
c = str(country or "").strip().upper()
|
|
76
|
+
if c:
|
|
77
|
+
if not re.fullmatch(r"[A-Z]{3}", c):
|
|
78
|
+
raise SdkError("usage_error", provider="ted", detail="country must be ISO-3 (e.g. DEU)")
|
|
79
|
+
parts.append(f"organisation-country-buyer={c}")
|
|
80
|
+
cp = str(cpv or "").strip()
|
|
81
|
+
if cp:
|
|
82
|
+
if not re.fullmatch(r"\d{8}", cp):
|
|
83
|
+
raise SdkError("usage_error", provider="ted", detail="cpv must be an 8-digit CPV code (e.g. 33100000 medical consumables)")
|
|
84
|
+
parts.append(f"classification-cpv={cp}")
|
|
85
|
+
if not parts:
|
|
86
|
+
raise SdkError("usage_error", provider="ted", detail="give at least one of name / country / cpv")
|
|
87
|
+
lim = min(max(1, int(limit) or 10), 20)
|
|
88
|
+
query = " AND ".join(parts)
|
|
89
|
+
r = call_provider(provider, "search", {"query": query, "fields": _FIELDS, "limit": lim, "page": 1},
|
|
90
|
+
cache=cache, no_cache=no_cache)
|
|
91
|
+
return {**ted_from_upstream(r["data"], lim), "query": query, "_meta": r}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def search_raw(query, *, fields=None, limit=10, page=1, cache=None, no_cache=False):
|
|
95
|
+
"""→ {notices[], has_more, query, params, _meta}; expert search verbatim (query goes into the POST body by its upstream name).
|
|
96
|
+
limit wall 1-250: measured 2026-10-23, 251→400 SEARCH_EXCEEDS_MAX_LIMIT
|
|
97
|
+
("Value (251) of parameter 'limit' exceeds maximum allowed value (250)"); 250 served in full.
|
|
98
|
+
fields defaults to the seven _FIELDS columns and can be overridden with a comma-separated string — a wrong name 400s and
|
|
99
|
+
dumps the full 1,833-field list (the error body is the documentation).
|
|
100
|
+
The old company_eu_tenders 20 clamp is this repo's politeness tier, not an upstream limit — the flat face follows the
|
|
101
|
+
measured wall."""
|
|
102
|
+
from ..transport import call_provider
|
|
103
|
+
from ..errors import SdkError
|
|
104
|
+
|
|
105
|
+
q = str(query or "").strip()
|
|
106
|
+
if not q:
|
|
107
|
+
raise SdkError("usage_error", provider="ted",
|
|
108
|
+
detail="ted_notices_search requires query (expert syntax, e.g. organisation-name-buyer=Siemens*)")
|
|
109
|
+
lim = int(limit)
|
|
110
|
+
if not 1 <= lim <= 250:
|
|
111
|
+
raise SdkError("usage_error", provider="ted",
|
|
112
|
+
detail="limit must be 1-250 (measured 2026-10-23: 251→400 SEARCH_EXCEEDS_MAX_LIMIT, "
|
|
113
|
+
"upstream text \"exceeds maximum allowed value (250)\")")
|
|
114
|
+
flds = [f.strip() for f in str(fields).split(",") if f.strip()] if fields else list(_FIELDS)
|
|
115
|
+
params = {"query": q, "fields": flds, "limit": lim, "page": int(page)}
|
|
116
|
+
r = call_provider(provider, "search", params, cache=cache, no_cache=no_cache)
|
|
117
|
+
return {**ted_from_upstream(r["data"], lim), "query": q, "params": params, "_meta": r}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""usaspending — USAspending API (US federal contracts/grants by recipient; keyless).
|
|
2
|
+
|
|
3
|
+
Keyless at ~1,000 req/hr/IP plus 30 req/5s bursts — we set 0.25 rps/burst 5 (≈900/hr, comfortably inside) so no
|
|
4
|
+
daily-quota ledger is needed.
|
|
5
|
+
First use of POST read semantics: spending_by_award is a POST JSON body, ops declare idempotent=True to join the
|
|
6
|
+
retry path. Live-shape check: the whole tree sits under the /api/v2/ prefix (/v2/ is now 404);
|
|
7
|
+
autocomplete returns {count, results[{recipient_name, uei, duns}]};
|
|
8
|
+
spending_by_award's recipient_search_text must be an **array** (422 measured).
|
|
9
|
+
"""
|
|
10
|
+
provider = {
|
|
11
|
+
"id": "usaspending",
|
|
12
|
+
"baseUrl": "https://api.usaspending.gov",
|
|
13
|
+
"rate": {"rps": 0.25, "burst": 5},
|
|
14
|
+
"ops": {
|
|
15
|
+
"recipientAutocomplete": {"path": "/api/v2/autocomplete/recipient/", "method": "POST", "idempotent": True},
|
|
16
|
+
"spendingByAward": {"path": "/api/v2/search/spending_by_award/", "method": "POST", "idempotent": True},
|
|
17
|
+
},
|
|
18
|
+
"ttl": {"recipientAutocomplete": 7 * 86400, "spendingByAward": 24 * 3600},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_recipient(name, *, limit=5, cache=None, no_cache=False):
|
|
23
|
+
"""→ {candidates[{recipient_name,uei,duns}], _meta}; uei is often null — the authoritative UEI anchor is the SAM.gov card (watch list); this serves only as a candidate hint."""
|
|
24
|
+
from ..transport import call_provider
|
|
25
|
+
from ..errors import SdkError
|
|
26
|
+
|
|
27
|
+
q = str(name or "").strip()
|
|
28
|
+
if not q:
|
|
29
|
+
raise SdkError("usage_error", provider="usaspending", detail="--name is required")
|
|
30
|
+
r = call_provider(provider, "recipientAutocomplete",
|
|
31
|
+
{"search_text": q, "limit": min(max(1, int(limit) or 5), 20)},
|
|
32
|
+
cache=cache, no_cache=no_cache)
|
|
33
|
+
rows = [{"recipient_name": e.get("recipient_name"), "uei": e.get("uei"), "duns": e.get("duns")}
|
|
34
|
+
for e in (r["data"] or {}).get("results") or []] # duns = legacy key, trending permanently null as of 2026
|
|
35
|
+
return {"candidates": rows, "_meta": r}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _awards_from_upstream(data):
|
|
39
|
+
"""Upstream payload → awards rows (pure function, offline-testable; shared by company_fed_awards and awards_raw)."""
|
|
40
|
+
return [{
|
|
41
|
+
"award_id": a.get("Award ID"),
|
|
42
|
+
"recipient_name": a.get("Recipient Name"),
|
|
43
|
+
"award_amount": a.get("Award Amount"),
|
|
44
|
+
"start_date": a.get("Start Date"),
|
|
45
|
+
"end_date": a.get("End Date"),
|
|
46
|
+
"awarding_agency": a.get("Awarding Agency"),
|
|
47
|
+
"generated_internal_id": a.get("generated_internal_id"), # official detail key (CONT_AWD_…), composes the USAspending human page
|
|
48
|
+
} for a in (data or {}).get("results") or []]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def company_fed_awards(name, *, limit=10, startdt=None, enddt=None, cache=None, no_cache=False):
|
|
52
|
+
"""→ {page_metadata, has_more, awards[], date_range, _meta}; the four contract types A/B/C/D (grant types come in a later wave).
|
|
53
|
+
page_metadata has no total (only hasNext/last_record_* pagination cursors — the endpoint gives no counts) — the old name
|
|
54
|
+
total_estimate is fully removed; the window semantics = the award period intersects the window (long-running contracts that
|
|
55
|
+
span it are included), not "started inside the window".
|
|
56
|
+
Join discipline: no blind name concatenation — the caller narrows by CIK/LEI first, then this face yields the numbers."""
|
|
57
|
+
from ..transport import call_provider
|
|
58
|
+
from ..errors import SdkError
|
|
59
|
+
|
|
60
|
+
q = str(name or "").strip()
|
|
61
|
+
if not q:
|
|
62
|
+
raise SdkError("usage_error", provider="usaspending", detail="related kind=fed_awards requires --name")
|
|
63
|
+
filters = {
|
|
64
|
+
"award_type_codes": ["A", "B", "C", "D"],
|
|
65
|
+
"recipient_search_text": [q], # measured 2026-10-19: must be an array, a string gets 422
|
|
66
|
+
}
|
|
67
|
+
date_range = None
|
|
68
|
+
if startdt or enddt: # either end given → fill the other into a complete window; date_range echoes the values actually in effect (guards against "sent but silently swallowed" look-alikes)
|
|
69
|
+
from ..daterange import resolve as _range
|
|
70
|
+
_start, _end, date_range = _range(startdt, enddt, default_start="2007-10-01",
|
|
71
|
+
provider=provider["id"], label="recipient award period")
|
|
72
|
+
filters["time_period"] = [{"start_date": _start, "end_date": _end}]
|
|
73
|
+
r = call_provider(provider, "spendingByAward", {
|
|
74
|
+
"filters": filters,
|
|
75
|
+
"fields": ["Award ID", "Recipient Name", "Award Amount", "Start Date", "End Date", "Awarding Agency"],
|
|
76
|
+
"limit": min(max(1, int(limit) or 10), 100),
|
|
77
|
+
"page": 1,
|
|
78
|
+
"subawards": False,
|
|
79
|
+
}, cache=cache, no_cache=no_cache)
|
|
80
|
+
rows = _awards_from_upstream(r["data"])
|
|
81
|
+
# page_metadata has no total — truncation visibility rides on has_more (hasNext)
|
|
82
|
+
pm = (r["data"] or {}).get("page_metadata")
|
|
83
|
+
return {"page_metadata": pm, "has_more": bool(pm and pm.get("hasNext")), "awards": rows,
|
|
84
|
+
"date_range": date_range, "_meta": r}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def awards_raw(recipient_search_text, *, award_type_codes=None, start_date=None, end_date=None,
|
|
88
|
+
fields=None, limit=10, page=1, subawards=False, cache=None, no_cache=False):
|
|
89
|
+
"""→ {page_metadata, has_more, awards[], date_range, params, _meta}; spending_by_award params pass through verbatim.
|
|
90
|
+
recipient_search_text must be an array upstream (a string 422s, measured 2026-10-19) — a scalar is auto-wrapped;
|
|
91
|
+
award_type_codes comma string → array (default A,B,C,D the four contract types; grants 02/03 etc. can override);
|
|
92
|
+
time_period auto-fills the missing end (upstream silently swallows single-ended windows — same EDGAR guard), date_range
|
|
93
|
+
echoes the window actually in effect; fields comma string → array (default six columns); limit 1-100 (upstream API docs);
|
|
94
|
+
subawards passes straight through upstream."""
|
|
95
|
+
from ..transport import call_provider
|
|
96
|
+
from ..errors import SdkError
|
|
97
|
+
|
|
98
|
+
q = str(recipient_search_text or "").strip()
|
|
99
|
+
if not q:
|
|
100
|
+
raise SdkError("usage_error", provider="usaspending",
|
|
101
|
+
detail="usaspending_spending_by_award requires recipient_search_text (recipient name, upstream array semantics — a scalar is auto-wrapped)")
|
|
102
|
+
lim = int(limit)
|
|
103
|
+
if not 1 <= lim <= 100:
|
|
104
|
+
raise SdkError("usage_error", provider="usaspending",
|
|
105
|
+
detail="limit must be 1-100 (upstream API docs spending_by_award page-size cap)")
|
|
106
|
+
types = ([t.strip() for t in str(award_type_codes).split(",") if t.strip()]
|
|
107
|
+
if award_type_codes else ["A", "B", "C", "D"])
|
|
108
|
+
filters = {"award_type_codes": types, "recipient_search_text": [q]}
|
|
109
|
+
date_range = None
|
|
110
|
+
if start_date or end_date:
|
|
111
|
+
from ..daterange import resolve as _range
|
|
112
|
+
_start, _end, date_range = _range(start_date, end_date, default_start="2007-10-01",
|
|
113
|
+
provider=provider["id"], label="recipient award period")
|
|
114
|
+
filters["time_period"] = [{"start_date": _start, "end_date": _end}]
|
|
115
|
+
flds = ([f.strip() for f in str(fields).split(",") if f.strip()]
|
|
116
|
+
if fields else ["Award ID", "Recipient Name", "Award Amount",
|
|
117
|
+
"Start Date", "End Date", "Awarding Agency"])
|
|
118
|
+
params = {"filters": filters, "fields": flds, "limit": lim,
|
|
119
|
+
"page": int(page), "subawards": bool(subawards)}
|
|
120
|
+
r = call_provider(provider, "spendingByAward", params, cache=cache, no_cache=no_cache)
|
|
121
|
+
pm = (r["data"] or {}).get("page_metadata")
|
|
122
|
+
return {"page_metadata": pm, "has_more": bool(pm and pm.get("hasNext")),
|
|
123
|
+
"awards": _awards_from_upstream(r["data"]), "date_range": date_range,
|
|
124
|
+
"params": params, "_meta": r}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""vies — EU VIES provider (EU VAT number online validation; keyless).
|
|
2
|
+
|
|
3
|
+
GET /ms/{cc}/vat/{number} → {isValid, userError, name, address, requestDate, requestIdentifier,
|
|
4
|
+
vatNumber, originalVatNumber, viesApproximate{matchName, matchStreet…}}.
|
|
5
|
+
Three-way semantics: isValid=true → valid (name/address may be masked as '---' by a member state — DE does,
|
|
6
|
+
record null honestly); isValid=false + userError=INVALID → the legal answer "number invalid" (ok=True, not an
|
|
7
|
+
error); userError=anything else (MS_UNAVAILABLE/TIME_OUT/*MAX_CONCURRENT_REQ…) → upstream_unavailable (the
|
|
8
|
+
member-state system is offline/rate-limited).
|
|
9
|
+
Input accepts the whole VAT string ('DE811907980', first two chars = member-state code, Greece is EL not GR —
|
|
10
|
+
passed through as-is, not normalized).
|
|
11
|
+
TTL: 7 days (the validation verdict; requestIdentifier is the official audit-trace key).
|
|
12
|
+
"""
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from ..errors import SdkError
|
|
16
|
+
|
|
17
|
+
provider = {
|
|
18
|
+
"id": "vies",
|
|
19
|
+
"baseUrl": "https://ec.europa.eu/taxation_customs/vies/rest-api",
|
|
20
|
+
"rate": {"rps": 0.5, "burst": 2},
|
|
21
|
+
"ops": {
|
|
22
|
+
"check": {"path": "/ms/{cc}/vat/{number}", "ttl": 7 * 86400, "timeoutMs": 30000},
|
|
23
|
+
},
|
|
24
|
+
"ttl": {"check": 7 * 86400},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_VAT = re.compile(r"^([A-Z]{2})([A-Z0-9+*]{2,13})$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _soft(v):
|
|
31
|
+
return None if v == "---" else v
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_vat(value):
|
|
35
|
+
"""'DE811907980' → ('DE','811907980'); no country prefix → usage_error (VIES routes by member state)."""
|
|
36
|
+
s = re.sub(r"[\s.]", "", str(value or "")).upper()
|
|
37
|
+
m = _VAT.match(s)
|
|
38
|
+
if not m:
|
|
39
|
+
raise SdkError("usage_error", provider="vies",
|
|
40
|
+
detail="VAT number must carry a member-state prefix (e.g. DE811907980 / EL040127129); add the country code to a bare number first")
|
|
41
|
+
return m.group(1), m.group(2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_vat(value, *, cache=None, no_cache=False):
|
|
45
|
+
"""→ {valid, user_error, vat_number, name, address, request_identifier, approximate, _meta}."""
|
|
46
|
+
from ..transport import call_provider
|
|
47
|
+
|
|
48
|
+
cc, number = parse_vat(value)
|
|
49
|
+
r = call_provider(provider, "check", {"cc": cc, "number": number}, cache=cache, no_cache=no_cache)
|
|
50
|
+
d = r["data"] or {}
|
|
51
|
+
ue = d.get("userError")
|
|
52
|
+
if ue and ue not in ("VALID", "INVALID"):
|
|
53
|
+
raise SdkError("upstream_unavailable", provider="vies",
|
|
54
|
+
detail=f"VIES member-state system returned no verdict (userError={ue}) — retry later, not a validation failure")
|
|
55
|
+
return {
|
|
56
|
+
"valid": d.get("isValid") is True,
|
|
57
|
+
"user_error": ue,
|
|
58
|
+
"country_code": cc,
|
|
59
|
+
"vat_number": f"{cc}{d.get('originalVatNumber') or d.get('vatNumber') or number}", # display sense, prefix restored
|
|
60
|
+
"name": _soft(d.get("name")),
|
|
61
|
+
"address": _soft(d.get("address")),
|
|
62
|
+
"request_date": d.get("requestDate"),
|
|
63
|
+
"request_identifier": d.get("requestIdentifier"), # official audit-trace key (for audit backtracking)
|
|
64
|
+
"approximate": d.get("viesApproximate"), # approximate match scores (corroborating evidence for member-state data confidence)
|
|
65
|
+
"_meta": r,
|
|
66
|
+
}
|