fd-open-data-protocol 0.3.1__tar.gz → 0.3.2__tar.gz
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.
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/PKG-INFO +1 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/__init__.py +1 -1
- fd_open_data_protocol-0.3.2/fd_open_data_protocol/openalex_provider.py +213 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/concepts.yaml +62 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/crosswalks/datacommons-worldbank.yaml +1 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/entity_types.yaml +78 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/event_types.yaml +1 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/qualifiers.yaml +1 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/relation_types.yaml +46 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/units.yaml +8 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/PKG-INFO +1 -1
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/SOURCES.txt +2 -0
- fd_open_data_protocol-0.3.2/tests/test_openalex_provider.py +170 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/README.md +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/constants.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/loader.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/provider.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry_cli.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry_store.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/schema.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/dependency_links.txt +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/entry_points.txt +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/requires.txt +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/top_level.txt +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/pyproject.toml +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/setup.cfg +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_loader.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_registry_pipeline.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_registry_schema.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_schema_vocabulary.py +0 -0
- {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_vocabulary.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fd-open-data-protocol
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: The open-data datasource protocol: a manifest contract a datasource exposes to be ingested by fd-open-data-mcp (or any consumer).
|
|
5
5
|
Author: FindDataOfficial
|
|
6
6
|
License: MIT
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""OpenAlex 学术图谱数据源 provider(CC0,免费)。
|
|
2
|
+
|
|
3
|
+
契约要点(对应 academic-graph-connector spec):
|
|
4
|
+
- 礼貌调用:请求带含 mail 的 User-Agent(免费档 1000 credits/次级)
|
|
5
|
+
- 失败显式:配额/限流/不可达分别报错,**禁止返回空结果冒充无数据**
|
|
6
|
+
- 收录偏差:未收录如实标注,不断言「不存在引用」
|
|
7
|
+
- 引用图是增益,不替代既有 papers 检索
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .provider import BaseDataProvider
|
|
19
|
+
|
|
20
|
+
API = "https://api.openalex.org"
|
|
21
|
+
DEFAULT_MAILTO = os.environ.get("OPENALEX_MAILTO", "ops@finddata.dev")
|
|
22
|
+
TIMEOUT = float(os.environ.get("OPENALEX_TIMEOUT", "25"))
|
|
23
|
+
|
|
24
|
+
# 礼貌调用:OpenAlex 要求可识别的 User-Agent(含 mail),否则可能被限流
|
|
25
|
+
UA = f"finddata-academic-connector/1.0 (mailto:{DEFAULT_MAILTO})"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenAlexUnavailable(RuntimeError):
|
|
29
|
+
"""上游不可用/不可达——显式失败,不静默返回空。"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OpenAlexQuotaExceeded(RuntimeError):
|
|
33
|
+
"""配额/限流——显式失败,不冒充「无数据」。"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpenAlexProvider(BaseDataProvider):
|
|
37
|
+
name = "academic-openalex"
|
|
38
|
+
|
|
39
|
+
def registry(self) -> dict:
|
|
40
|
+
return {"manifest": "academic_openalex.yaml"}
|
|
41
|
+
|
|
42
|
+
def run(self, command: str, params: dict) -> Any:
|
|
43
|
+
handlers = {
|
|
44
|
+
"get_work": self.get_work,
|
|
45
|
+
"search_works": self.search_works,
|
|
46
|
+
"get_references": self.get_references,
|
|
47
|
+
"get_citations": self.get_citations,
|
|
48
|
+
}
|
|
49
|
+
if command not in handlers:
|
|
50
|
+
raise ValueError(f"unknown command {command!r}")
|
|
51
|
+
return handlers[command](params or {})
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------ HTTP
|
|
54
|
+
|
|
55
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> tuple[dict, dict]:
|
|
56
|
+
"""GET 并返回 (payload, quota_headers)。失败显式抛错。"""
|
|
57
|
+
url = f"{API}/{path}"
|
|
58
|
+
if params:
|
|
59
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
60
|
+
req = urllib.request.Request(
|
|
61
|
+
url, headers={"User-Agent": UA, "Accept": "application/json"}
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
65
|
+
raw = json.loads(resp.read().decode("utf-8", "replace"))
|
|
66
|
+
headers = dict(resp.headers.items())
|
|
67
|
+
except urllib.error.HTTPError as exc:
|
|
68
|
+
# 429 或 5xx 都要区分清楚:前者是配额,后者是上游故障
|
|
69
|
+
if exc.code == 429:
|
|
70
|
+
raise OpenAlexQuotaExceeded(
|
|
71
|
+
f"OpenAlex rate limit / quota exceeded (HTTP 429): {exc.read()[:200]!r}"
|
|
72
|
+
) from exc
|
|
73
|
+
if 500 <= exc.code < 600:
|
|
74
|
+
raise OpenAlexUnavailable(
|
|
75
|
+
f"OpenAlex upstream unavailable (HTTP {exc.code})"
|
|
76
|
+
) from exc
|
|
77
|
+
raise OpenAlexUnavailable(
|
|
78
|
+
f"OpenAlex request failed (HTTP {exc.code}): {exc.read()[:200]!r}"
|
|
79
|
+
) from exc
|
|
80
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
81
|
+
raise OpenAlexUnavailable(f"OpenAlex unreachable: {exc}") from exc
|
|
82
|
+
quota = {
|
|
83
|
+
k.lower(): v
|
|
84
|
+
for k, v in headers.items()
|
|
85
|
+
if k.lower().startswith("x-ratelimit") or k.lower() == "retry-after"
|
|
86
|
+
}
|
|
87
|
+
return raw, quota
|
|
88
|
+
|
|
89
|
+
def _resolve_work_id(self, params: dict) -> str:
|
|
90
|
+
"""按 work_id 或 doi 定位作品。两者皆缺则报错(不猜)。"""
|
|
91
|
+
work_id = (params.get("work_id") or "").strip()
|
|
92
|
+
doi = (params.get("doi") or "").strip()
|
|
93
|
+
if not work_id and not doi:
|
|
94
|
+
raise ValueError("get_references/get_citations requires `work_id` or `doi`")
|
|
95
|
+
if work_id:
|
|
96
|
+
# 允许传完整 URL,统一取末段
|
|
97
|
+
return work_id.rstrip("/").split("/")[-1]
|
|
98
|
+
payload, _ = self._get(
|
|
99
|
+
"works",
|
|
100
|
+
{"filter": f"doi:{doi}", "select": "id", "per-page": "1"},
|
|
101
|
+
)
|
|
102
|
+
results = payload.get("results") or []
|
|
103
|
+
if not results:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"work not found in OpenAlex for doi={doi!r} "
|
|
106
|
+
f"(未收录,不等于该作品不存在)"
|
|
107
|
+
)
|
|
108
|
+
return str(results[0]["id"]).rstrip("/").split("/")[-1]
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------- commands
|
|
111
|
+
|
|
112
|
+
def get_work(self, params: dict) -> dict:
|
|
113
|
+
doi = (params.get("doi") or "").strip()
|
|
114
|
+
if not doi:
|
|
115
|
+
raise ValueError("get_work requires `doi`")
|
|
116
|
+
payload, quota = self._get(
|
|
117
|
+
"works", {"filter": f"doi:{doi}", "per-page": "1"}
|
|
118
|
+
)
|
|
119
|
+
results = payload.get("results") or []
|
|
120
|
+
if not results:
|
|
121
|
+
# 未收录 ≠ 不存在:如实说明
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"work not found in OpenAlex for doi={doi!r} (未收录,不等于该作品不存在)"
|
|
124
|
+
)
|
|
125
|
+
w = results[0]
|
|
126
|
+
return {
|
|
127
|
+
"work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
|
|
128
|
+
"title": w.get("title"),
|
|
129
|
+
"year": w.get("publication_year"),
|
|
130
|
+
"venue": (w.get("primary_location") or {}).get("source", {}).get("display_name")
|
|
131
|
+
if isinstance(w.get("primary_location"), dict)
|
|
132
|
+
else None,
|
|
133
|
+
"cited_by_count": w.get("cited_by_count"),
|
|
134
|
+
"doi": w.get("doi"),
|
|
135
|
+
"source": "openalex",
|
|
136
|
+
"license": "CC0",
|
|
137
|
+
"quota": quota,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def search_works(self, params: dict) -> dict:
|
|
141
|
+
query = (params.get("query") or "").strip()
|
|
142
|
+
if not query:
|
|
143
|
+
raise ValueError("search_works requires `query`")
|
|
144
|
+
limit = int(params.get("limit") or 10)
|
|
145
|
+
payload, quota = self._get(
|
|
146
|
+
"works",
|
|
147
|
+
{
|
|
148
|
+
"search": query,
|
|
149
|
+
"per-page": str(min(limit, 200)),
|
|
150
|
+
"select": "id,title,publication_year,cited_by_count",
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
rows = []
|
|
154
|
+
for w in payload.get("results") or []:
|
|
155
|
+
rows.append(
|
|
156
|
+
{
|
|
157
|
+
"work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
|
|
158
|
+
"title": w.get("title"),
|
|
159
|
+
"year": w.get("publication_year"),
|
|
160
|
+
"cited_by_count": w.get("cited_by_count"),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
return {
|
|
164
|
+
"total": (payload.get("meta") or {}).get("count"),
|
|
165
|
+
"results": rows,
|
|
166
|
+
"source": "openalex",
|
|
167
|
+
"license": "CC0",
|
|
168
|
+
"quota": quota,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def get_references(self, params: dict) -> dict:
|
|
172
|
+
work_id = self._resolve_work_id(params)
|
|
173
|
+
payload, quota = self._get(
|
|
174
|
+
f"works/{work_id}", {"select": "id,referenced_works_count,referenced_works"}
|
|
175
|
+
)
|
|
176
|
+
refs = payload.get("referenced_works") or []
|
|
177
|
+
return {
|
|
178
|
+
"work_id": work_id,
|
|
179
|
+
"reference_count": payload.get("referenced_works_count"),
|
|
180
|
+
"reference_ids": [str(r).rstrip("/").split("/")[-1] for r in refs],
|
|
181
|
+
"source": "openalex",
|
|
182
|
+
"license": "CC0",
|
|
183
|
+
"quota": quota,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def get_citations(self, params: dict) -> dict:
|
|
187
|
+
work_id = self._resolve_work_id(params)
|
|
188
|
+
limit = int(params.get("limit") or 10)
|
|
189
|
+
payload, quota = self._get(
|
|
190
|
+
"works",
|
|
191
|
+
{
|
|
192
|
+
"filter": f"cites:{work_id}",
|
|
193
|
+
"per-page": str(min(limit, 200)),
|
|
194
|
+
"select": "id,title,publication_year",
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
rows = []
|
|
198
|
+
for w in payload.get("results") or []:
|
|
199
|
+
rows.append(
|
|
200
|
+
{
|
|
201
|
+
"citing_work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
|
|
202
|
+
"title": w.get("title"),
|
|
203
|
+
"year": w.get("publication_year"),
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return {
|
|
207
|
+
"work_id": work_id,
|
|
208
|
+
"total_count": (payload.get("meta") or {}).get("count"),
|
|
209
|
+
"results": rows,
|
|
210
|
+
"source": "openalex",
|
|
211
|
+
"license": "CC0",
|
|
212
|
+
"quota": quota,
|
|
213
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# `variables:` block are declarative only (derivation links to them by code).
|
|
13
13
|
#
|
|
14
14
|
# Entries are never deleted — retirement is expressed with `superseded_by`.
|
|
15
|
-
version: "2026.
|
|
15
|
+
version: "2026.11"
|
|
16
16
|
kind: concept
|
|
17
17
|
entries:
|
|
18
18
|
- id: GDP
|
|
@@ -228,3 +228,64 @@ entries:
|
|
|
228
228
|
unit_type: none
|
|
229
229
|
dimensions: []
|
|
230
230
|
variables: []
|
|
231
|
+
|
|
232
|
+
- id: TradeFlow
|
|
233
|
+
label_en: Trade flow value
|
|
234
|
+
label_zh: 贸易流值
|
|
235
|
+
description: >-
|
|
236
|
+
Annual trade flow value (FOB, USD) for a China export commodity at
|
|
237
|
+
HS6 level, split by partner economy. The partner axis is expressed
|
|
238
|
+
encoded in the concept code (trade.export_flow.world/us/de/jp/uk,
|
|
239
|
+
one series per code per the dashboard plane contract); the import
|
|
240
|
+
side joins when an import batch lands. Source: UN Comtrade annual HS flows.
|
|
241
|
+
value_type: currency
|
|
242
|
+
unit_type: currency
|
|
243
|
+
dimensions: [world, us, de, jp, uk]
|
|
244
|
+
variables:
|
|
245
|
+
# 看板取数契约(data_plane.series 只按 code+source+entity 过滤):
|
|
246
|
+
# 伙伴国轴编进 concept code,measure 统一 export——一序列一码。
|
|
247
|
+
- {code: trade.export_flow.world, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
|
|
248
|
+
- {code: trade.export_flow.us, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
|
|
249
|
+
- {code: trade.export_flow.de, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
|
|
250
|
+
- {code: trade.export_flow.jp, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
|
|
251
|
+
- {code: trade.export_flow.uk, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
|
|
252
|
+
|
|
253
|
+
- id: FxRate
|
|
254
|
+
label_en: FX reference rate
|
|
255
|
+
label_zh: 汇率参考价
|
|
256
|
+
description: >-
|
|
257
|
+
Reference/middle exchange rate for a currency pair, quoted in the
|
|
258
|
+
quote currency per one unit of base (EURCNY = CNY per 1 EUR).
|
|
259
|
+
Sources: ECB EXR monthly reference; PBOC middle rate when it lands.
|
|
260
|
+
value_type: number
|
|
261
|
+
unit_type: currency
|
|
262
|
+
dimensions: [reference]
|
|
263
|
+
variables:
|
|
264
|
+
- {code: fx.rate, entity_type: currency_pair, measure: reference, unit: cny, frequency: monthly}
|
|
265
|
+
|
|
266
|
+
- id: PolicyNotice
|
|
267
|
+
label_en: Policy notice count
|
|
268
|
+
label_zh: 政策通报数
|
|
269
|
+
description: >-
|
|
270
|
+
WTO TBT/SPS notifications raised per notifying member per month
|
|
271
|
+
(WTO ePing feed, deduplicated by member+title+date). For
|
|
272
|
+
cross-border exporters the heat answers "which markets are
|
|
273
|
+
raising barriers this month".
|
|
274
|
+
value_type: number
|
|
275
|
+
unit_type: count
|
|
276
|
+
dimensions: [tbt_sps]
|
|
277
|
+
variables:
|
|
278
|
+
- {code: policy.notice_count, entity_type: country, measure: tbt_sps, unit: count, frequency: monthly}
|
|
279
|
+
|
|
280
|
+
- id: PortCalls
|
|
281
|
+
label_en: Port calls
|
|
282
|
+
label_zh: 港口停靠数
|
|
283
|
+
description: >-
|
|
284
|
+
Daily number of vessel calls at a port (IMF PortWatch). Keyed by
|
|
285
|
+
the portwatch port id; the cross-border port batch registers the
|
|
286
|
+
Chinese hub ports only (never offshore-terminal feeds).
|
|
287
|
+
value_type: number
|
|
288
|
+
unit_type: count
|
|
289
|
+
dimensions: [daily_calls]
|
|
290
|
+
variables:
|
|
291
|
+
- {code: port.calls, entity_type: port, measure: daily_calls, unit: count, frequency: daily}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
#
|
|
11
11
|
# URIs for the mapped Variables themselves are Phase 4; this release carries
|
|
12
12
|
# only family URIs.
|
|
13
|
-
version: "2026.
|
|
13
|
+
version: "2026.11"
|
|
14
14
|
kind: crosswalk
|
|
15
15
|
mappings:
|
|
16
16
|
# --- Population -------------------------------------------------------------
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# URIs: https://schema.finddata.tech/entity-type/<id>
|
|
8
8
|
#
|
|
9
9
|
# Entries are never deleted — retirement is expressed with `superseded_by`.
|
|
10
|
-
version: "2026.
|
|
10
|
+
version: "2026.11"
|
|
11
11
|
kind: entity-type
|
|
12
12
|
entries:
|
|
13
13
|
- id: country
|
|
@@ -97,3 +97,80 @@ entries:
|
|
|
97
97
|
description: >-
|
|
98
98
|
Human entities — fund managers, officers (logical-only; no taxonomy
|
|
99
99
|
table). Aligns with schema.org Person and the Wikidata QID.
|
|
100
|
+
- id: paper
|
|
101
|
+
label_en: Paper
|
|
102
|
+
label_zh: 论文
|
|
103
|
+
description: >-
|
|
104
|
+
Scholarly works — journal articles, conference papers, preprints.
|
|
105
|
+
Keyed by DOI (10.1038/nature14539) or OpenAlex work id (W2919115771).
|
|
106
|
+
Aligns with schema.org ScholarlyArticle.
|
|
107
|
+
- id: author
|
|
108
|
+
label_en: Author
|
|
109
|
+
label_zh: 作者
|
|
110
|
+
description: >-
|
|
111
|
+
Persons credited as authors of scholarly works. Keyed by OpenAlex
|
|
112
|
+
author id; distinct from `person` (which carries no scholarly role).
|
|
113
|
+
Aligns with schema.org Person.
|
|
114
|
+
- id: institution
|
|
115
|
+
label_en: Institution
|
|
116
|
+
label_zh: 机构
|
|
117
|
+
description: >-
|
|
118
|
+
Research and academic institutions (universities, research institutes,
|
|
119
|
+
hospitals) as affiliation targets. Keyed by OpenAlex institution id;
|
|
120
|
+
aligns with schema.org EducationalOrganization. `organization` covers
|
|
121
|
+
generic bodies; this type is scoped to scholarly affiliation.
|
|
122
|
+
- id: venue
|
|
123
|
+
label_en: Venue
|
|
124
|
+
label_zh: 发表载体
|
|
125
|
+
description: >-
|
|
126
|
+
Publication venues — journals, conference proceedings, repositories.
|
|
127
|
+
Keyed by ISSN or OpenAlex source id. Aligns with schema.org
|
|
128
|
+
Periodical; distinct from `organization` (a venue is not a body).
|
|
129
|
+
- id: topic
|
|
130
|
+
label_en: Topic
|
|
131
|
+
label_zh: 主题
|
|
132
|
+
description: >-
|
|
133
|
+
Scholarly topics / fields of study (OpenAlex topics, concepts).
|
|
134
|
+
Keyed by OpenAlex topic id; describes what a work is about.
|
|
135
|
+
- id: customs_regime
|
|
136
|
+
label_en: Customs regime code
|
|
137
|
+
label_zh: 海关监管代码
|
|
138
|
+
description: >-
|
|
139
|
+
China customs supervision regime codes for cross-border e-commerce
|
|
140
|
+
(9610 retail export, 1210 bonded, 9710 B2B direct export, 9810
|
|
141
|
+
overseas-warehouse export). Keyed by the 4-digit regime code.
|
|
142
|
+
- id: platform
|
|
143
|
+
label_en: E-commerce platform
|
|
144
|
+
label_zh: 电商平台
|
|
145
|
+
description: >-
|
|
146
|
+
Cross-border e-commerce marketplace platforms (amazon, tiktok_shop,
|
|
147
|
+
temu, shein). Keyed by FindData shorthand; marketplace/store detail
|
|
148
|
+
lives in metadata. Merchant-authorized data is keyed here.
|
|
149
|
+
- id: hs_code
|
|
150
|
+
label_en: HS commodity code
|
|
151
|
+
label_zh: HS 商品编码
|
|
152
|
+
description: >-
|
|
153
|
+
Harmonized System commodity classifications keyed by 2/4/6-digit HS
|
|
154
|
+
code (854142). WCO nomenclature; China tariff lines extend to 8-10
|
|
155
|
+
digits on top of the HS6 base.
|
|
156
|
+
- id: currency_pair
|
|
157
|
+
label_en: Currency pair
|
|
158
|
+
label_zh: 货币对
|
|
159
|
+
description: >-
|
|
160
|
+
FX pairs keyed by base+quote ISO codes concatenated (EURCNY). Rate
|
|
161
|
+
concepts carry reference/middle rates per source; value unit is the
|
|
162
|
+
quote currency.
|
|
163
|
+
- id: port
|
|
164
|
+
label_en: Port
|
|
165
|
+
label_zh: 口岸
|
|
166
|
+
description: >-
|
|
167
|
+
Customs ports and logistics gateways keyed by UN/LOCODE or customs
|
|
168
|
+
port code. Reserved for the cross-border port batch (authoritative
|
|
169
|
+
port-code list pending; do not seed from offshore-terminal feeds).
|
|
170
|
+
- id: policy
|
|
171
|
+
label_en: Policy entry
|
|
172
|
+
label_zh: 政策条目
|
|
173
|
+
description: >-
|
|
174
|
+
Individual policy/instrument entries (customs announcements, tax
|
|
175
|
+
notices) keyed by issuing-body document id. Registered incrementally
|
|
176
|
+
as policy patrol output lands.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
# URIs: https://schema.finddata.tech/relation-type/<id>
|
|
10
10
|
#
|
|
11
11
|
# Entries are never deleted — retirement is expressed with `superseded_by`.
|
|
12
|
-
version: "2026.
|
|
12
|
+
version: "2026.11"
|
|
13
13
|
kind: relation-type
|
|
14
14
|
entries:
|
|
15
15
|
- id: belongs_to
|
|
@@ -71,3 +71,48 @@ entries:
|
|
|
71
71
|
The instrument was issued by the entity (stock/bond/fund -> company).
|
|
72
72
|
source_types: [stock, bond, fund]
|
|
73
73
|
target_types: [company, organization]
|
|
74
|
+
- id: cites
|
|
75
|
+
label_en: Cites
|
|
76
|
+
label_zh: 引用
|
|
77
|
+
description: >-
|
|
78
|
+
The source work cites the target work in its bibliography
|
|
79
|
+
(paper -> paper). Directional: source references target.
|
|
80
|
+
source_types: [paper]
|
|
81
|
+
target_types: [paper]
|
|
82
|
+
- id: cited_by
|
|
83
|
+
label_en: Cited By
|
|
84
|
+
label_zh: 被引
|
|
85
|
+
description: >-
|
|
86
|
+
Inverse of `cites` — the source work is cited by the target work
|
|
87
|
+
(paper -> paper). Retained explicitly because external scholarly
|
|
88
|
+
APIs expose both directions as distinct queries.
|
|
89
|
+
source_types: [paper]
|
|
90
|
+
target_types: [paper]
|
|
91
|
+
- id: authored_by
|
|
92
|
+
label_en: Authored By
|
|
93
|
+
label_zh: 作者为
|
|
94
|
+
description: >-
|
|
95
|
+
The work was authored by the person (paper -> author).
|
|
96
|
+
source_types: [paper]
|
|
97
|
+
target_types: [author]
|
|
98
|
+
- id: affiliated_with
|
|
99
|
+
label_en: Affiliated With
|
|
100
|
+
label_zh: 隶属于
|
|
101
|
+
description: >-
|
|
102
|
+
The author is affiliated with the institution (author -> institution).
|
|
103
|
+
source_types: [author]
|
|
104
|
+
target_types: [institution]
|
|
105
|
+
- id: published_in
|
|
106
|
+
label_en: Published In
|
|
107
|
+
label_zh: 发表于
|
|
108
|
+
description: >-
|
|
109
|
+
The work was published in the venue (paper -> venue).
|
|
110
|
+
source_types: [paper]
|
|
111
|
+
target_types: [venue]
|
|
112
|
+
- id: has_topic
|
|
113
|
+
label_en: Has Topic
|
|
114
|
+
label_zh: 主题为
|
|
115
|
+
description: >-
|
|
116
|
+
The work is classified under the topic (paper -> topic).
|
|
117
|
+
source_types: [paper]
|
|
118
|
+
target_types: [topic]
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# URIs: https://schema.finddata.tech/unit/<id>
|
|
9
9
|
#
|
|
10
10
|
# Entries are never deleted — retirement is expressed with `superseded_by`.
|
|
11
|
-
version: "2026.
|
|
11
|
+
version: "2026.11"
|
|
12
12
|
kind: unit
|
|
13
13
|
entries:
|
|
14
14
|
# --- Monetary ---------------------------------------------------------------
|
|
@@ -92,3 +92,10 @@ entries:
|
|
|
92
92
|
label_zh: 年
|
|
93
93
|
description: A duration measured in calendar years.
|
|
94
94
|
un_cefact_code: ANN
|
|
95
|
+
|
|
96
|
+
- id: count
|
|
97
|
+
label_en: Count
|
|
98
|
+
label_zh: 计数
|
|
99
|
+
description: >-
|
|
100
|
+
Dimensionless tally of discrete events or items (notifications, port
|
|
101
|
+
calls). Used where the value is a number of things, not a measurement.
|
{fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fd-open-data-protocol
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: The open-data datasource protocol: a manifest contract a datasource exposes to be ingested by fd-open-data-mcp (or any consumer).
|
|
5
5
|
Author: FindDataOfficial
|
|
6
6
|
License: MIT
|
|
@@ -3,6 +3,7 @@ pyproject.toml
|
|
|
3
3
|
fd_open_data_protocol/__init__.py
|
|
4
4
|
fd_open_data_protocol/constants.py
|
|
5
5
|
fd_open_data_protocol/loader.py
|
|
6
|
+
fd_open_data_protocol/openalex_provider.py
|
|
6
7
|
fd_open_data_protocol/provider.py
|
|
7
8
|
fd_open_data_protocol/registry.py
|
|
8
9
|
fd_open_data_protocol/registry_cli.py
|
|
@@ -23,6 +24,7 @@ fd_open_data_protocol/vocabulary/relation_types.yaml
|
|
|
23
24
|
fd_open_data_protocol/vocabulary/units.yaml
|
|
24
25
|
fd_open_data_protocol/vocabulary/crosswalks/datacommons-worldbank.yaml
|
|
25
26
|
tests/test_loader.py
|
|
27
|
+
tests/test_openalex_provider.py
|
|
26
28
|
tests/test_registry_pipeline.py
|
|
27
29
|
tests/test_registry_schema.py
|
|
28
30
|
tests/test_schema_vocabulary.py
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""OpenAlex provider:离线测试(不访问网络)。
|
|
2
|
+
|
|
3
|
+
覆盖契约要点:礼貌调用、四项命令、失败显式(配额/限流/不可达)、
|
|
4
|
+
收录偏差如实标注、CC0 归属。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import urllib.error
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from fd_open_data_protocol import openalex_provider as oa
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeResp:
|
|
16
|
+
def __init__(self, headers=None):
|
|
17
|
+
self.headers = headers or {}
|
|
18
|
+
|
|
19
|
+
def read(self):
|
|
20
|
+
return b"boom"
|
|
21
|
+
|
|
22
|
+
def __enter__(self):
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def __exit__(self, *a):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
def close(self):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def items(self):
|
|
32
|
+
return list(self.headers.items())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_polite_user_agent_carries_mailto():
|
|
36
|
+
assert "mailto:" in oa.UA
|
|
37
|
+
assert "@" in oa.UA.split("mailto:")[-1].strip(")")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_quota_headers_recorded(monkeypatch):
|
|
41
|
+
payload = {
|
|
42
|
+
"results": [
|
|
43
|
+
{
|
|
44
|
+
"id": "https://openalex.org/W1",
|
|
45
|
+
"title": "T",
|
|
46
|
+
"publication_year": 2020,
|
|
47
|
+
"cited_by_count": 5,
|
|
48
|
+
"doi": "10.1/x",
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def fake_urlopen(req, timeout=None):
|
|
54
|
+
assert req.headers.get("User-agent") == oa.UA or req.get_header("User-agent") == oa.UA
|
|
55
|
+
return _json_resp(payload, {"x-ratelimit-limit": "1000"})
|
|
56
|
+
|
|
57
|
+
monkeypatch.setattr(oa.urllib.request, "urlopen", fake_urlopen)
|
|
58
|
+
out = oa.OpenAlexProvider().run("get_work", {"doi": "10.1/x"})
|
|
59
|
+
assert out["quota"]["x-ratelimit-limit"] == "1000"
|
|
60
|
+
assert out["source"] == "openalex" and out["license"] == "CC0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _json_resp(payload, headers=None):
|
|
64
|
+
class R:
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self.headers = _H(headers or {})
|
|
67
|
+
|
|
68
|
+
def read(self):
|
|
69
|
+
return __import__("json").dumps(payload).encode()
|
|
70
|
+
|
|
71
|
+
def __enter__(self):
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *a):
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
class _H(dict):
|
|
78
|
+
def items(self):
|
|
79
|
+
return list(dict.items(self))
|
|
80
|
+
|
|
81
|
+
return R()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_search_works_returns_total_and_rows(monkeypatch):
|
|
85
|
+
payload = {
|
|
86
|
+
"meta": {"count": 42},
|
|
87
|
+
"results": [
|
|
88
|
+
{"id": "https://openalex.org/Wa", "title": "A", "publication_year": 2017,
|
|
89
|
+
"cited_by_count": 7},
|
|
90
|
+
{"id": "https://openalex.org/Wb", "title": "B", "publication_year": 2019,
|
|
91
|
+
"cited_by_count": 9},
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
monkeypatch.setattr(oa.urllib.request, "urlopen",
|
|
95
|
+
lambda req, timeout=None: _json_resp(payload))
|
|
96
|
+
out = oa.OpenAlexProvider().run("search_works", {"query": "q", "limit": 2})
|
|
97
|
+
assert out["total"] == 42 and len(out["results"]) == 2
|
|
98
|
+
assert out["results"][0]["work_id"] == "Wa"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_references_and_citations(monkeypatch):
|
|
102
|
+
p = oa.OpenAlexProvider()
|
|
103
|
+
monkeypatch.setattr(oa.urllib.request, "urlopen", lambda req, timeout=None: _json_resp(
|
|
104
|
+
{"id": "https://openalex.org/W1", "referenced_works_count": 2,
|
|
105
|
+
"referenced_works": ["https://openalex.org/Wx", "https://openalex.org/Wy"]}))
|
|
106
|
+
r = p.run("get_references", {"work_id": "W1"})
|
|
107
|
+
assert r["reference_count"] == 2 and r["reference_ids"] == ["Wx", "Wy"]
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr(oa.urllib.request, "urlopen", lambda req, timeout=None: _json_resp(
|
|
110
|
+
{"meta": {"count": 100},
|
|
111
|
+
"results": [{"id": "https://openalex.org/Wc", "title": "C", "publication_year": 2021}]}))
|
|
112
|
+
c = p.run("get_citations", {"work_id": "W1", "limit": 1})
|
|
113
|
+
assert c["total_count"] == 100 and c["results"][0]["citing_work_id"] == "Wc"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_quota_error_is_explicit(monkeypatch):
|
|
117
|
+
def raise_429(req, timeout=None):
|
|
118
|
+
raise urllib.error.HTTPError("u", 429, "Too Many Requests", {}, FakeResp())
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(oa.urllib.request, "urlopen", raise_429)
|
|
121
|
+
with pytest.raises(oa.OpenAlexQuotaExceeded):
|
|
122
|
+
oa.OpenAlexProvider().run("get_work", {"doi": "10.1/x"})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_upstream_5xx_is_explicit(monkeypatch):
|
|
126
|
+
def raise_503(req, timeout=None):
|
|
127
|
+
raise urllib.error.HTTPError("u", 503, "Unavailable", {}, FakeResp())
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr(oa.urllib.request, "urlopen", raise_503)
|
|
130
|
+
with pytest.raises(oa.OpenAlexUnavailable):
|
|
131
|
+
oa.OpenAlexProvider().run("get_work", {"doi": "10.1/x"})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_unreachable_is_explicit(monkeypatch):
|
|
135
|
+
def raise_urlerr(req, timeout=None):
|
|
136
|
+
raise urllib.error.URLError("dns fail")
|
|
137
|
+
|
|
138
|
+
monkeypatch.setattr(oa.urllib.request, "urlopen", raise_urlerr)
|
|
139
|
+
with pytest.raises(oa.OpenAlexUnavailable):
|
|
140
|
+
oa.OpenAlexProvider().run("get_work", {"doi": "10.1/x"})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_not_found_states_not_indexed_not_nonexistent(monkeypatch):
|
|
144
|
+
"""未收录必须如实说明——不断言「不存在」。"""
|
|
145
|
+
monkeypatch.setattr(oa.urllib.request, "urlopen",
|
|
146
|
+
lambda req, timeout=None: _json_resp({"results": []}))
|
|
147
|
+
with pytest.raises(ValueError, match="未收录"):
|
|
148
|
+
oa.OpenAlexProvider().run("get_work", {"doi": "10.1/nope"})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_missing_identifier_rejected():
|
|
152
|
+
p = oa.OpenAlexProvider()
|
|
153
|
+
with pytest.raises(ValueError):
|
|
154
|
+
p.run("get_references", {})
|
|
155
|
+
with pytest.raises(ValueError):
|
|
156
|
+
p.run("get_work", {})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_manifest_validates_and_declares_relationships():
|
|
160
|
+
from fd_open_data_protocol.loader import load_catalog
|
|
161
|
+
|
|
162
|
+
m = load_catalog("adapters/academic_openalex.yaml")
|
|
163
|
+
cmds = {f.command for f in m.functions}
|
|
164
|
+
assert {"get_work", "search_works", "get_references", "get_citations"} <= cmds
|
|
165
|
+
rels = {(r.relation_type, r.source_entity_type, r.target_entity_type)
|
|
166
|
+
for r in (m.relationships or [])}
|
|
167
|
+
assert ("cites", "paper", "paper") in rels
|
|
168
|
+
assert ("cited_by", "paper", "paper") in rels
|
|
169
|
+
ents = {e.entity_type for e in (m.entities or [])}
|
|
170
|
+
assert {"paper", "author", "venue", "topic", "institution"} <= ents
|
|
File without changes
|
{fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
{fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/provider.py
RENAMED
|
File without changes
|
{fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry.py
RENAMED
|
File without changes
|
{fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry_cli.py
RENAMED
|
File without changes
|
{fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry_store.py
RENAMED
|
File without changes
|
|
File without changes
|
{fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|