birdrecord-cli 0.1.0__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.
- birdrecord_cli-0.1.0.dist-info/METADATA +102 -0
- birdrecord_cli-0.1.0.dist-info/RECORD +8 -0
- birdrecord_cli-0.1.0.dist-info/WHEEL +5 -0
- birdrecord_cli-0.1.0.dist-info/entry_points.txt +2 -0
- birdrecord_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- birdrecord_cli-0.1.0.dist-info/top_level.txt +2 -0
- birdrecord_client.py +81 -0
- main.py +2675 -0
main.py
ADDED
|
@@ -0,0 +1,2675 @@
|
|
|
1
|
+
#! /usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.12"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "click>=8.1.0",
|
|
6
|
+
# "httpx>=0.28.1",
|
|
7
|
+
# "pycryptodome>=3.21.0",
|
|
8
|
+
# "pydantic>=2.10.0",
|
|
9
|
+
# "pypinyin>=0.53.0",
|
|
10
|
+
# ]
|
|
11
|
+
# ///
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
Birdrecord / 观鸟记录 — portal https://www.birdreport.cn/ ; API default host ``weixin.birdrecord.cn``. Typed HTTP client + ``birdrecord-cli`` CLI.
|
|
15
|
+
|
|
16
|
+
Business JSON often lives under envelope ``data`` / ``result`` and may be AES-CBC (see ``parse_encrypted_envelope``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import functools
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import (
|
|
30
|
+
Any,
|
|
31
|
+
Callable,
|
|
32
|
+
Generic,
|
|
33
|
+
Literal,
|
|
34
|
+
Mapping,
|
|
35
|
+
Optional,
|
|
36
|
+
Type,
|
|
37
|
+
TypeAlias,
|
|
38
|
+
TypeVar,
|
|
39
|
+
Union,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
import click
|
|
43
|
+
import httpx
|
|
44
|
+
from Crypto.Cipher import AES
|
|
45
|
+
from Crypto.Util.Padding import unpad
|
|
46
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
47
|
+
from pypinyin import Style, lazy_pinyin
|
|
48
|
+
|
|
49
|
+
AES_KEY = b"3583ec0257e2f4c8195eec7410ff1619"
|
|
50
|
+
AES_IV = b"d93c0d5ec6352f20"
|
|
51
|
+
|
|
52
|
+
BASE_URL = "https://weixin.birdrecord.cn"
|
|
53
|
+
|
|
54
|
+
# Default taxon/search version from captured traffic.
|
|
55
|
+
DEFAULT_TAXON_VERSION = "Z4-67FA07177A544FBD96006A7CC7489D25"
|
|
56
|
+
|
|
57
|
+
DEFAULT_USER_AGENT = (
|
|
58
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
59
|
+
"(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 "
|
|
60
|
+
"MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac "
|
|
61
|
+
"MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf264181d) XWEB/19024"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
DEFAULT_REFERER = "https://servicewechat.com/wx9ebf8f0d26aa0240/91/page-frame.html"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cli_use_zh_cn() -> bool:
|
|
68
|
+
"""True when BIRDRECORD_CLI_CN is set to a truthy value (0/false/no/off disable)."""
|
|
69
|
+
raw = os.environ.get("BIRDRECORD_CLI_CN")
|
|
70
|
+
if raw is None:
|
|
71
|
+
return False
|
|
72
|
+
s = raw.strip().lower()
|
|
73
|
+
if s in ("0", "false", "no", "off"):
|
|
74
|
+
return False
|
|
75
|
+
return bool(s)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cli_txt(en: str, cn: str) -> str:
|
|
79
|
+
return cn if _cli_use_zh_cn() else en
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Bilingual schema text only for models re-exported in ``birdrecord_client`` or emitted
|
|
83
|
+
# via CLI ``--schema`` (see ``json_schema_text`` / ``json_schema_text_object``).
|
|
84
|
+
_schema_txt = _cli_txt
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Request models
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AdcodeProvinceRequest(BaseModel):
|
|
93
|
+
"""POST adcode/province — body ``{}``."""
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(
|
|
96
|
+
extra="forbid",
|
|
97
|
+
json_schema_extra={
|
|
98
|
+
"description": _schema_txt("Empty JSON object.", "空 JSON 对象。"),
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AdcodeCityRequest(BaseModel):
|
|
104
|
+
"""POST adcode/city — cities under one province."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(
|
|
107
|
+
json_schema_extra={
|
|
108
|
+
"description": _schema_txt(
|
|
109
|
+
"Province adcode for city list.",
|
|
110
|
+
"省级行政区划代码,用于获取下属城市列表。",
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
province_code: str = Field(
|
|
116
|
+
...,
|
|
117
|
+
description=_schema_txt(
|
|
118
|
+
"6-digit province adcode (e.g. 110000).",
|
|
119
|
+
"6 位省级行政区划代码(如 110000)。",
|
|
120
|
+
),
|
|
121
|
+
examples=["110000", "130000"],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TaxonSearchRequest(BaseModel):
|
|
126
|
+
"""POST taxon/search — full checklist for a build version."""
|
|
127
|
+
|
|
128
|
+
model_config = ConfigDict(
|
|
129
|
+
json_schema_extra={
|
|
130
|
+
"description": _schema_txt(
|
|
131
|
+
"Checklist build token (must match server).",
|
|
132
|
+
"清单构建令牌(须与服务器一致)。",
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
version: str = Field(
|
|
138
|
+
default=DEFAULT_TAXON_VERSION,
|
|
139
|
+
description=_schema_txt("Checklist version string.", "清单版本字符串。"),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RegionChartQueryBody(BaseModel):
|
|
144
|
+
"""Chart filters + common/get chart calls; client sets ``sqlid`` per request (input ``sqlid`` ignored)."""
|
|
145
|
+
|
|
146
|
+
model_config = ConfigDict(
|
|
147
|
+
extra="allow",
|
|
148
|
+
json_schema_extra={
|
|
149
|
+
"description": _schema_txt(
|
|
150
|
+
"Chart/share filters; extra keys allowed.",
|
|
151
|
+
"图表/分享筛选条件;允许额外字段。",
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
taxonname: str = Field(
|
|
157
|
+
default="",
|
|
158
|
+
description=_schema_txt(
|
|
159
|
+
"Species name; empty = no filter.",
|
|
160
|
+
"鸟种名称;空表示不过滤。",
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
startTime: str = Field(
|
|
164
|
+
default="",
|
|
165
|
+
description=_schema_txt(
|
|
166
|
+
"Range start YYYY-MM-DD; empty = omit.",
|
|
167
|
+
"范围起始日期 YYYY-MM-DD;空表示不传。",
|
|
168
|
+
),
|
|
169
|
+
examples=["2026-02-24"],
|
|
170
|
+
)
|
|
171
|
+
endTime: str = Field(
|
|
172
|
+
default="",
|
|
173
|
+
description=_schema_txt(
|
|
174
|
+
"Range end YYYY-MM-DD; empty = omit.",
|
|
175
|
+
"范围结束日期 YYYY-MM-DD;空表示不传。",
|
|
176
|
+
),
|
|
177
|
+
examples=["2026-03-24"],
|
|
178
|
+
)
|
|
179
|
+
province: str = Field(
|
|
180
|
+
default="",
|
|
181
|
+
description=_schema_txt(
|
|
182
|
+
"Province label (Chinese); empty = omit.",
|
|
183
|
+
"省份名称(中文);空表示不传。",
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
city: str = Field(
|
|
187
|
+
default="",
|
|
188
|
+
description=_schema_txt(
|
|
189
|
+
"City label (Chinese); empty = omit.",
|
|
190
|
+
"城市名称(中文);空表示不传。",
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
district: str = Field(
|
|
194
|
+
default="",
|
|
195
|
+
description=_schema_txt(
|
|
196
|
+
"District label (Chinese); empty = omit.",
|
|
197
|
+
"区县名称(中文);空表示不传。",
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
pointname: str = Field(
|
|
201
|
+
default="",
|
|
202
|
+
description=_schema_txt(
|
|
203
|
+
"Point / hotspot name substring.",
|
|
204
|
+
"观鸟点/热点名称子串。",
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
username: str = Field(
|
|
208
|
+
default="",
|
|
209
|
+
description=_schema_txt("User context; often empty.", "用户上下文;常为空。"),
|
|
210
|
+
)
|
|
211
|
+
serial_id: str = Field(
|
|
212
|
+
default="",
|
|
213
|
+
description=_schema_txt("Share / session id.", "分享/会话 ID。"),
|
|
214
|
+
)
|
|
215
|
+
ctime: str = Field(
|
|
216
|
+
default="",
|
|
217
|
+
description=_schema_txt(
|
|
218
|
+
"Context date YYYY-MM-DD; empty = omit.",
|
|
219
|
+
"上下文日期 YYYY-MM-DD;空表示不传。",
|
|
220
|
+
),
|
|
221
|
+
examples=["2026-03-24"],
|
|
222
|
+
)
|
|
223
|
+
taxonid: int = Field(
|
|
224
|
+
default=0,
|
|
225
|
+
description=_schema_txt("Species id; 0 = unset.", "鸟种 ID;0 表示未设置。"),
|
|
226
|
+
examples=[4148],
|
|
227
|
+
)
|
|
228
|
+
version: str = Field(
|
|
229
|
+
default="CH4",
|
|
230
|
+
description=_schema_txt(
|
|
231
|
+
"Client protocol tag (e.g. CH4).",
|
|
232
|
+
"客户端协议标记(如 CH4)。",
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# POST /api/weixin/common/get — sqlid is the only differing field for chart summary vs query-report.
|
|
238
|
+
COMMON_GET_SQLID_CHART_RECORD_SUMMARY = "selectChartRecordSummary"
|
|
239
|
+
COMMON_GET_SQLID_CHART_QUERY_REPORT = "selectchartQueryReport"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def coerce_region_chart_body(
|
|
243
|
+
body: RegionChartQueryBody | Mapping[str, Any] | None,
|
|
244
|
+
) -> RegionChartQueryBody:
|
|
245
|
+
"""Coerce to ``RegionChartQueryBody``."""
|
|
246
|
+
if body is None:
|
|
247
|
+
return RegionChartQueryBody()
|
|
248
|
+
if isinstance(body, RegionChartQueryBody):
|
|
249
|
+
return body
|
|
250
|
+
return RegionChartQueryBody.model_validate(body)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def build_common_get_chart_payload(
|
|
254
|
+
filters: RegionChartQueryBody, *, sqlid: str
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
"""common/get body: filters + ``sqlid`` (replaces any existing ``sqlid``)."""
|
|
257
|
+
payload = dict(filters.model_dump())
|
|
258
|
+
payload.pop("sqlid", None)
|
|
259
|
+
payload["sqlid"] = sqlid
|
|
260
|
+
return payload
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class ChartStatisticsReportsRequest(RegionChartQueryBody):
|
|
264
|
+
"""POST chart/record/statistics/reports — monthly report counts."""
|
|
265
|
+
|
|
266
|
+
sqlid: Literal["selectchartQueryReport"] = Field(
|
|
267
|
+
default="selectchartQueryReport",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class ChartStatisticsTaxonRequest(ChartStatisticsReportsRequest):
|
|
272
|
+
"""POST chart/record/statistics/taxon — same body shape as reports in traffic."""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class CommonActivityQueryBody(BaseModel):
|
|
276
|
+
"""Filters for common/list and common/page (activity chart)."""
|
|
277
|
+
|
|
278
|
+
model_config = ConfigDict(
|
|
279
|
+
extra="allow",
|
|
280
|
+
json_schema_extra={
|
|
281
|
+
"description": _schema_txt(
|
|
282
|
+
"Activity chart drill-down; empty strings when unset.",
|
|
283
|
+
"活动图表下钻筛选;未设置时用空字符串。",
|
|
284
|
+
),
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
taxonname: str = Field(
|
|
289
|
+
default="",
|
|
290
|
+
description=_schema_txt("Species name; empty = none.", "鸟种名称;空表示无。"),
|
|
291
|
+
)
|
|
292
|
+
startTime: str = Field(
|
|
293
|
+
default="",
|
|
294
|
+
description=_schema_txt(
|
|
295
|
+
"Start YYYY-MM-DD; empty = omit.",
|
|
296
|
+
"起始日期 YYYY-MM-DD;空表示不传。",
|
|
297
|
+
),
|
|
298
|
+
examples=["2026-03-22"],
|
|
299
|
+
)
|
|
300
|
+
endTime: str = Field(
|
|
301
|
+
default="",
|
|
302
|
+
description=_schema_txt(
|
|
303
|
+
"End YYYY-MM-DD; empty = omit.",
|
|
304
|
+
"结束日期 YYYY-MM-DD;空表示不传。",
|
|
305
|
+
),
|
|
306
|
+
examples=["2026-03-24"],
|
|
307
|
+
)
|
|
308
|
+
province: str = Field(
|
|
309
|
+
default="",
|
|
310
|
+
description=_schema_txt(
|
|
311
|
+
"Province (Chinese); empty = omit.",
|
|
312
|
+
"省份(中文);空表示不传。",
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
city: str = Field(
|
|
316
|
+
default="",
|
|
317
|
+
description=_schema_txt("City; empty = omit.", "城市;空表示不传。"),
|
|
318
|
+
)
|
|
319
|
+
district: str = Field(
|
|
320
|
+
default="",
|
|
321
|
+
description=_schema_txt("District; empty = omit.", "区县;空表示不传。"),
|
|
322
|
+
)
|
|
323
|
+
pointname: str = Field(
|
|
324
|
+
default="",
|
|
325
|
+
description=_schema_txt("Point name substring.", "观鸟点名称子串。"),
|
|
326
|
+
)
|
|
327
|
+
username: str = Field(
|
|
328
|
+
default="",
|
|
329
|
+
description=_schema_txt("User filter; often empty.", "用户筛选;常为空。"),
|
|
330
|
+
)
|
|
331
|
+
serial_id: str = Field(
|
|
332
|
+
default="",
|
|
333
|
+
description=_schema_txt("Share / serial filter.", "分享/流水号筛选。"),
|
|
334
|
+
)
|
|
335
|
+
ctime: str = Field(
|
|
336
|
+
default="",
|
|
337
|
+
description=_schema_txt(
|
|
338
|
+
"Reference time string; often empty.",
|
|
339
|
+
"参考时间字符串;常为空。",
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
taxonid: str = Field(
|
|
343
|
+
default="",
|
|
344
|
+
description=_schema_txt(
|
|
345
|
+
"Taxon id string; empty = none.",
|
|
346
|
+
"鸟种 ID 字符串;空表示无。",
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
version: str = Field(
|
|
350
|
+
default="CH4",
|
|
351
|
+
description=_schema_txt("Protocol tag.", "协议标记。"),
|
|
352
|
+
)
|
|
353
|
+
taxon_month: str = Field(
|
|
354
|
+
default="",
|
|
355
|
+
description=_schema_txt(
|
|
356
|
+
"Month bucket (e.g. 03); empty = omit.",
|
|
357
|
+
"月份桶(如 03);空表示不传。",
|
|
358
|
+
),
|
|
359
|
+
examples=["03"],
|
|
360
|
+
)
|
|
361
|
+
outside_type: int = Field(
|
|
362
|
+
default=0,
|
|
363
|
+
description=_schema_txt(
|
|
364
|
+
"Series discriminator; 0 = default in traffic.",
|
|
365
|
+
"系列区分字段;抓包中 0 为默认。",
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class CommonListActivityTaxonRequest(CommonActivityQueryBody):
|
|
371
|
+
"""POST common/list — taxon ranking for a chart month."""
|
|
372
|
+
|
|
373
|
+
model_config = ConfigDict(
|
|
374
|
+
json_schema_extra={
|
|
375
|
+
"description": _schema_txt(
|
|
376
|
+
"Taxa + counts; envelope may include page/total/size.",
|
|
377
|
+
"鸟种及数量;信封中可能含 page/total/size。",
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
sqlid: Literal["searchChartActivityTaxon"] = Field(
|
|
383
|
+
default="searchChartActivityTaxon",
|
|
384
|
+
description=_schema_txt(
|
|
385
|
+
"sqlid for taxon activity list.",
|
|
386
|
+
"活动鸟种列表的 sqlid。",
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class CommonPageActivityRequest(CommonActivityQueryBody):
|
|
392
|
+
"""POST common/page — paged activity reports."""
|
|
393
|
+
|
|
394
|
+
model_config = ConfigDict(
|
|
395
|
+
json_schema_extra={
|
|
396
|
+
"description": _schema_txt(
|
|
397
|
+
"Paged report cards; align report_month with taxon_month.",
|
|
398
|
+
"分页记录卡片;report_month 与 taxon_month 对齐。",
|
|
399
|
+
),
|
|
400
|
+
},
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
start: int = Field(
|
|
404
|
+
default=1,
|
|
405
|
+
ge=1,
|
|
406
|
+
description=_schema_txt("1-based page.", "从 1 开始的页码。"),
|
|
407
|
+
)
|
|
408
|
+
limit: int = Field(
|
|
409
|
+
default=15,
|
|
410
|
+
ge=1,
|
|
411
|
+
description=_schema_txt("Page size.", "每页条数。"),
|
|
412
|
+
)
|
|
413
|
+
report_month: str = Field(
|
|
414
|
+
default="",
|
|
415
|
+
description=_schema_txt(
|
|
416
|
+
"Month like taxon_month (e.g. 03); empty = omit.",
|
|
417
|
+
"与 taxon_month 同形的月份(如 03);空表示不传。",
|
|
418
|
+
),
|
|
419
|
+
examples=["03"],
|
|
420
|
+
)
|
|
421
|
+
sqlid: Literal["searchChartActivity"] = Field(
|
|
422
|
+
default="searchChartActivity",
|
|
423
|
+
description=_schema_txt(
|
|
424
|
+
"sqlid for paged reports.",
|
|
425
|
+
"分页记录的 sqlid。",
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def coerce_common_activity_body(
|
|
431
|
+
body: CommonActivityQueryBody | Mapping[str, Any] | None,
|
|
432
|
+
) -> CommonActivityQueryBody:
|
|
433
|
+
"""Normalize CLI / caller input to ``CommonActivityQueryBody``."""
|
|
434
|
+
if body is None:
|
|
435
|
+
return CommonActivityQueryBody()
|
|
436
|
+
if isinstance(body, CommonActivityQueryBody):
|
|
437
|
+
return body
|
|
438
|
+
return CommonActivityQueryBody.model_validate(body)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def build_common_list_taxon_request(
|
|
442
|
+
base: CommonActivityQueryBody,
|
|
443
|
+
) -> CommonListActivityTaxonRequest:
|
|
444
|
+
"""Attach ``searchChartActivityTaxon`` sqlid to shared activity filters."""
|
|
445
|
+
return CommonListActivityTaxonRequest.model_validate(base.model_dump())
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def build_common_page_activity_request(
|
|
449
|
+
base: CommonActivityQueryBody,
|
|
450
|
+
) -> CommonPageActivityRequest:
|
|
451
|
+
"""Attach ``searchChartActivity`` sqlid and page defaults to shared activity filters."""
|
|
452
|
+
return CommonPageActivityRequest.model_validate(base.model_dump())
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class ReportGetRequest(BaseModel):
|
|
456
|
+
"""POST reports/get — one report by id."""
|
|
457
|
+
|
|
458
|
+
model_config = ConfigDict(
|
|
459
|
+
json_schema_extra={
|
|
460
|
+
"description": _schema_txt(
|
|
461
|
+
"Request id is string; payload id is numeric.",
|
|
462
|
+
"请求里 id 为字符串;载荷里 id 为数字。",
|
|
463
|
+
),
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
id: str = Field(
|
|
468
|
+
...,
|
|
469
|
+
description=_schema_txt("Report id string.", "记录 ID 字符串。"),
|
|
470
|
+
examples=["1948816"],
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class MemberGetRequest(BaseModel):
|
|
475
|
+
"""POST member/get — form-urlencoded (not JSON)."""
|
|
476
|
+
|
|
477
|
+
userid: int = Field(..., examples=[89963])
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class PointGetRequest(BaseModel):
|
|
481
|
+
"""POST point/get — hotspot detail."""
|
|
482
|
+
|
|
483
|
+
point_id: int = Field(..., examples=[125887])
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class RecordSummaryRequest(BaseModel):
|
|
487
|
+
"""POST record/summary — taxon counts for one activity."""
|
|
488
|
+
|
|
489
|
+
model_config = ConfigDict(
|
|
490
|
+
json_schema_extra={
|
|
491
|
+
"description": _schema_txt(
|
|
492
|
+
"Same id string as reports/get.",
|
|
493
|
+
"与 reports/get 相同的 id 字符串。",
|
|
494
|
+
),
|
|
495
|
+
},
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
activity_id: str = Field(
|
|
499
|
+
...,
|
|
500
|
+
description=_schema_txt(
|
|
501
|
+
"Report / activity id string.",
|
|
502
|
+
"记录/活动 ID 字符串。",
|
|
503
|
+
),
|
|
504
|
+
examples=["1948816"],
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
# Response envelope models (raw HTTP JSON before business JSON is extracted)
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class StandardApiEnvelope(BaseModel):
|
|
514
|
+
"""Wire JSON for most JSON POST APIs (adcode, taxon, charts, reports, point, record/summary)."""
|
|
515
|
+
|
|
516
|
+
model_config = ConfigDict(
|
|
517
|
+
extra="allow",
|
|
518
|
+
json_schema_extra={
|
|
519
|
+
"description": _schema_txt(
|
|
520
|
+
"Business payload in data/result; may be base64 ciphertext (see field + hasNeedEncrypt).",
|
|
521
|
+
"业务数据在 data/result;可能为 Base64 密文(见 field 与 hasNeedEncrypt)。",
|
|
522
|
+
),
|
|
523
|
+
},
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
advice: Optional[Any] = Field(
|
|
527
|
+
default=None, description=_schema_txt("Hint.", "提示。")
|
|
528
|
+
)
|
|
529
|
+
code: Optional[int] = Field(
|
|
530
|
+
default=None,
|
|
531
|
+
description=_schema_txt(
|
|
532
|
+
"App code; 0 = ok when set.",
|
|
533
|
+
"业务 code;有值时 0 表示成功。",
|
|
534
|
+
),
|
|
535
|
+
)
|
|
536
|
+
count: Optional[int] = Field(
|
|
537
|
+
default=None,
|
|
538
|
+
description=_schema_txt("Row count hint.", "行数提示。"),
|
|
539
|
+
)
|
|
540
|
+
data: Optional[Union[str, list[Any], dict[str, Any]]] = Field(
|
|
541
|
+
default=None,
|
|
542
|
+
description=_schema_txt("JSON or ciphertext.", "JSON 或密文。"),
|
|
543
|
+
)
|
|
544
|
+
errorCode: Optional[Any] = Field(
|
|
545
|
+
default=None, description=_schema_txt("Error id.", "错误码。")
|
|
546
|
+
)
|
|
547
|
+
field: Optional[str] = Field(
|
|
548
|
+
default=None,
|
|
549
|
+
description=_schema_txt("Key for ciphertext.", "密文字段名。"),
|
|
550
|
+
)
|
|
551
|
+
hasNeedEncrypt: Optional[bool] = Field(
|
|
552
|
+
default=None,
|
|
553
|
+
description=_schema_txt(
|
|
554
|
+
"If true, decrypt envelope[field].",
|
|
555
|
+
"为 true 时解密 envelope[field]。",
|
|
556
|
+
),
|
|
557
|
+
)
|
|
558
|
+
logId: Optional[Any] = Field(
|
|
559
|
+
default=None, description=_schema_txt("Log id.", "日志 ID。")
|
|
560
|
+
)
|
|
561
|
+
msg: Optional[Any] = Field(
|
|
562
|
+
default=None, description=_schema_txt("Message.", "消息。")
|
|
563
|
+
)
|
|
564
|
+
result: Optional[Union[str, list[Any], dict[str, Any]]] = Field(
|
|
565
|
+
default=None,
|
|
566
|
+
description=_schema_txt("Alt payload / ciphertext.", "备用载荷或密文。"),
|
|
567
|
+
)
|
|
568
|
+
sign: Optional[str] = Field(
|
|
569
|
+
default=None, description=_schema_txt("Digest.", "签名摘要。")
|
|
570
|
+
)
|
|
571
|
+
success: Optional[Any] = Field(
|
|
572
|
+
default=None,
|
|
573
|
+
description=_schema_txt("Success flag.", "成功标记。"),
|
|
574
|
+
)
|
|
575
|
+
timestamp: Optional[int] = Field(
|
|
576
|
+
default=None,
|
|
577
|
+
description=_schema_txt("Server unix time (s).", "服务器 Unix 时间(秒)。"),
|
|
578
|
+
)
|
|
579
|
+
trace: Optional[Any] = Field(
|
|
580
|
+
default=None, description=_schema_txt("Trace id.", "追踪 ID。")
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class CommonGetApiEnvelope(BaseModel):
|
|
585
|
+
"""Wire JSON for common/get, common/list, common/page (often paginated + ciphertext in result)."""
|
|
586
|
+
|
|
587
|
+
model_config = ConfigDict(extra="allow")
|
|
588
|
+
|
|
589
|
+
advice: Optional[Any] = None
|
|
590
|
+
code: Optional[int] = None
|
|
591
|
+
count: Optional[int] = None
|
|
592
|
+
data: Optional[Union[str, list[Any], dict[str, Any]]] = None
|
|
593
|
+
errorCode: Optional[Any] = None
|
|
594
|
+
field: Optional[str] = None
|
|
595
|
+
hasNeedEncrypt: Optional[bool] = None
|
|
596
|
+
logId: Optional[Any] = None
|
|
597
|
+
msg: Optional[Any] = None
|
|
598
|
+
page: Optional[int] = None
|
|
599
|
+
result: Optional[Union[str, list[Any], dict[str, Any]]] = None
|
|
600
|
+
sign: Optional[str] = None
|
|
601
|
+
size: Optional[int] = None
|
|
602
|
+
success: Optional[bool] = None
|
|
603
|
+
timestamp: Optional[int] = None
|
|
604
|
+
total: Optional[int] = None
|
|
605
|
+
trace: Optional[Any] = None
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class MemberGetApiEnvelope(BaseModel):
|
|
609
|
+
"""Wire JSON for member/get (minimal; encrypted data)."""
|
|
610
|
+
|
|
611
|
+
model_config = ConfigDict(extra="allow")
|
|
612
|
+
|
|
613
|
+
data: Optional[Union[str, list[Any], dict[str, Any]]] = None
|
|
614
|
+
field: Optional[str] = None
|
|
615
|
+
hasNeedEncrypt: Optional[bool] = None
|
|
616
|
+
sign: Optional[str] = None
|
|
617
|
+
success: Optional[bool] = None
|
|
618
|
+
timestamp: Optional[int] = None
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
# Decrypted payload models (business data)
|
|
623
|
+
# ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
class ProvinceRow(BaseModel):
|
|
627
|
+
"""Decrypted adcode/province row."""
|
|
628
|
+
|
|
629
|
+
model_config = ConfigDict(extra="allow")
|
|
630
|
+
|
|
631
|
+
province_code: str
|
|
632
|
+
province_name: str
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class CityRow(BaseModel):
|
|
636
|
+
"""Decrypted adcode/city row."""
|
|
637
|
+
|
|
638
|
+
model_config = ConfigDict(extra="allow")
|
|
639
|
+
|
|
640
|
+
city_name: str
|
|
641
|
+
city_code: str
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class TaxonRow(BaseModel):
|
|
645
|
+
"""taxon/search checklist row."""
|
|
646
|
+
|
|
647
|
+
model_config = ConfigDict(
|
|
648
|
+
extra="allow",
|
|
649
|
+
json_schema_extra={
|
|
650
|
+
"description": _schema_txt("One species.", "一个鸟种。"),
|
|
651
|
+
},
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
id: int = Field(..., description=_schema_txt("Taxon id.", "鸟种 ID。"))
|
|
655
|
+
taxonorderid: Optional[int] = Field(
|
|
656
|
+
default=None, description=_schema_txt("Order id.", "目 ID。")
|
|
657
|
+
)
|
|
658
|
+
taxonfamilyid: Optional[int] = Field(
|
|
659
|
+
default=None, description=_schema_txt("Family id.", "科 ID。")
|
|
660
|
+
)
|
|
661
|
+
name: Optional[str] = Field(
|
|
662
|
+
default=None, description=_schema_txt("Chinese name.", "中文名。")
|
|
663
|
+
)
|
|
664
|
+
latinname: Optional[str] = Field(
|
|
665
|
+
default=None, description=_schema_txt("Latin name.", "拉丁名。")
|
|
666
|
+
)
|
|
667
|
+
englishname: Optional[str] = Field(
|
|
668
|
+
default=None, description=_schema_txt("English name.", "英文名。")
|
|
669
|
+
)
|
|
670
|
+
pinyin: Optional[str] = Field(
|
|
671
|
+
default=None, description=_schema_txt("Pinyin.", "拼音。")
|
|
672
|
+
)
|
|
673
|
+
guidebooksn: Optional[Any] = Field(
|
|
674
|
+
default=None,
|
|
675
|
+
description=_schema_txt("Field guide ref.", "图鉴/手册编号。"),
|
|
676
|
+
)
|
|
677
|
+
szm: Optional[str] = Field(
|
|
678
|
+
default=None, description=_schema_txt("Initials.", "首字母。")
|
|
679
|
+
)
|
|
680
|
+
subspecies: Optional[Any] = Field(
|
|
681
|
+
default=None, description=_schema_txt("Subspecies.", "亚种。")
|
|
682
|
+
)
|
|
683
|
+
taxonordername: Optional[str] = Field(
|
|
684
|
+
default=None,
|
|
685
|
+
description=_schema_txt("Order (Chinese).", "目名(中文)。"),
|
|
686
|
+
)
|
|
687
|
+
taxonfamilyname: Optional[str] = Field(
|
|
688
|
+
default=None,
|
|
689
|
+
description=_schema_txt("Family (Chinese).", "科名(中文)。"),
|
|
690
|
+
)
|
|
691
|
+
uuid: Optional[Any] = Field(
|
|
692
|
+
default=None, description=_schema_txt("Uuid.", "UUID。")
|
|
693
|
+
)
|
|
694
|
+
serial_id: Optional[Any] = Field(
|
|
695
|
+
default=None, description=_schema_txt("Serial.", "流水号。")
|
|
696
|
+
)
|
|
697
|
+
message: Optional[Any] = Field(
|
|
698
|
+
default=None, description=_schema_txt("Message.", "消息。")
|
|
699
|
+
)
|
|
700
|
+
version: Optional[str] = Field(
|
|
701
|
+
default=None,
|
|
702
|
+
description=_schema_txt("Checklist version.", "清单版本。"),
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# Fields scanned by ``filter_taxon_rows_by_query`` (case-insensitive substring, OR).
|
|
707
|
+
TAXON_SEARCH_QUERY_FIELDS: tuple[str, ...] = (
|
|
708
|
+
"name",
|
|
709
|
+
"latinname",
|
|
710
|
+
"englishname",
|
|
711
|
+
"pinyin",
|
|
712
|
+
"szm",
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def filter_taxon_rows_by_query(
|
|
717
|
+
rows: list[TaxonRow], query: str | None
|
|
718
|
+
) -> list[TaxonRow]:
|
|
719
|
+
"""Keep rows where ``query`` is a case-insensitive substring of any of the name fields."""
|
|
720
|
+
q = (query or "").strip()
|
|
721
|
+
if not q:
|
|
722
|
+
return rows
|
|
723
|
+
q_fold = q.casefold()
|
|
724
|
+
|
|
725
|
+
def texts(row: TaxonRow) -> list[str]:
|
|
726
|
+
out: list[str] = []
|
|
727
|
+
for attr in TAXON_SEARCH_QUERY_FIELDS:
|
|
728
|
+
v = getattr(row, attr, None)
|
|
729
|
+
if v is None:
|
|
730
|
+
continue
|
|
731
|
+
s = v if isinstance(v, str) else str(v)
|
|
732
|
+
if s:
|
|
733
|
+
out.append(s)
|
|
734
|
+
return out
|
|
735
|
+
|
|
736
|
+
return [r for r in rows if any(q_fold in t.casefold() for t in texts(r))]
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _region_label_search_texts(label: str) -> list[str]:
|
|
740
|
+
"""Chinese label plus toneless pinyin and initials for substring matching."""
|
|
741
|
+
s = (label or "").strip()
|
|
742
|
+
if not s:
|
|
743
|
+
return []
|
|
744
|
+
parts = lazy_pinyin(s, style=Style.NORMAL, errors="ignore")
|
|
745
|
+
out: list[str] = [s]
|
|
746
|
+
if parts:
|
|
747
|
+
joined = "".join(parts)
|
|
748
|
+
if joined:
|
|
749
|
+
out.append(joined)
|
|
750
|
+
initials = "".join(p[0] for p in parts if p)
|
|
751
|
+
if initials:
|
|
752
|
+
out.append(initials)
|
|
753
|
+
return out
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def filter_region_rows_by_query(
|
|
757
|
+
rows: list[Any],
|
|
758
|
+
query: str | None,
|
|
759
|
+
*,
|
|
760
|
+
label_attr: str,
|
|
761
|
+
) -> list[Any]:
|
|
762
|
+
"""Keep rows where ``query`` matches province/city name (case-insensitive), pinyin, or initials."""
|
|
763
|
+
q = (query or "").strip()
|
|
764
|
+
if not q:
|
|
765
|
+
return rows
|
|
766
|
+
q_fold = q.casefold()
|
|
767
|
+
|
|
768
|
+
def matches(row: Any) -> bool:
|
|
769
|
+
v = getattr(row, label_attr, None)
|
|
770
|
+
if v is None:
|
|
771
|
+
return False
|
|
772
|
+
text = v if isinstance(v, str) else str(v)
|
|
773
|
+
return any(q_fold in t.casefold() for t in _region_label_search_texts(text))
|
|
774
|
+
|
|
775
|
+
return [r for r in rows if matches(r)]
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# Process-local full checklist per version (CLI taxon command).
|
|
779
|
+
_taxon_search_cache: dict[str, tuple[dict[str, Any], list[TaxonRow]]] = {}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _taxon_search_cache_dir() -> Path:
|
|
783
|
+
"""Directory for on-disk taxon/search JSON caches."""
|
|
784
|
+
override = os.environ.get("BIRDRECORD_CACHE_DIR")
|
|
785
|
+
if override:
|
|
786
|
+
return Path(override) / "taxon"
|
|
787
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
788
|
+
if xdg:
|
|
789
|
+
return Path(xdg) / "birdrecord" / "taxon"
|
|
790
|
+
return Path.home() / ".cache" / "birdrecord" / "taxon"
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _taxon_search_cache_path(version: str) -> Path:
|
|
794
|
+
h = hashlib.sha256(version.encode("utf-8")).hexdigest()
|
|
795
|
+
return _taxon_search_cache_dir() / f"{h}.json"
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _load_taxon_search_disk(version: str) -> tuple[dict[str, Any], list[TaxonRow]] | None:
|
|
799
|
+
path = _taxon_search_cache_path(version)
|
|
800
|
+
try:
|
|
801
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
802
|
+
except (OSError, json.JSONDecodeError):
|
|
803
|
+
return None
|
|
804
|
+
if doc.get("version") != version:
|
|
805
|
+
return None
|
|
806
|
+
env = doc.get("envelope")
|
|
807
|
+
rows_j = doc.get("rows")
|
|
808
|
+
if not isinstance(env, dict) or not isinstance(rows_j, list):
|
|
809
|
+
return None
|
|
810
|
+
try:
|
|
811
|
+
rows = [TaxonRow.model_validate(x) for x in rows_j]
|
|
812
|
+
except Exception:
|
|
813
|
+
return None
|
|
814
|
+
return env, rows
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _save_taxon_search_disk(
|
|
818
|
+
version: str, env_dump: dict[str, Any], rows: list[TaxonRow]
|
|
819
|
+
) -> None:
|
|
820
|
+
path = _taxon_search_cache_path(version)
|
|
821
|
+
try:
|
|
822
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
823
|
+
except OSError:
|
|
824
|
+
return
|
|
825
|
+
payload = {
|
|
826
|
+
"version": version,
|
|
827
|
+
"envelope": env_dump,
|
|
828
|
+
"rows": [r.model_dump(mode="json") for r in rows],
|
|
829
|
+
}
|
|
830
|
+
try:
|
|
831
|
+
text = json.dumps(payload, ensure_ascii=False)
|
|
832
|
+
tmp = path.with_suffix(".json.tmp")
|
|
833
|
+
tmp.write_text(text, encoding="utf-8")
|
|
834
|
+
tmp.replace(path)
|
|
835
|
+
except OSError:
|
|
836
|
+
pass
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
class ChartReportMonthRow(BaseModel):
|
|
840
|
+
"""chart/record/statistics/reports — one month."""
|
|
841
|
+
|
|
842
|
+
model_config = ConfigDict(extra="allow")
|
|
843
|
+
|
|
844
|
+
taxon_month: int
|
|
845
|
+
report_num: int
|
|
846
|
+
report_num_dubious: int
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
class ChartTaxonStatisticsRow(BaseModel):
|
|
850
|
+
"""chart/record/statistics/taxon — one month."""
|
|
851
|
+
|
|
852
|
+
model_config = ConfigDict(extra="allow")
|
|
853
|
+
|
|
854
|
+
taxon_month: int
|
|
855
|
+
taxon_num: int = 0
|
|
856
|
+
taxon_count: int = 0
|
|
857
|
+
taxon_num_dubious: int = 0
|
|
858
|
+
|
|
859
|
+
@field_validator("taxon_month", mode="before")
|
|
860
|
+
@classmethod
|
|
861
|
+
def _coerce_taxon_month(cls, v: Any) -> int:
|
|
862
|
+
if isinstance(v, float):
|
|
863
|
+
return int(v)
|
|
864
|
+
return int(v)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
class ChartRecordSummaryPayload(BaseModel):
|
|
868
|
+
"""common/get selectChartRecordSummary — aggregates."""
|
|
869
|
+
|
|
870
|
+
model_config = ConfigDict(
|
|
871
|
+
extra="allow",
|
|
872
|
+
json_schema_extra={
|
|
873
|
+
"description": _schema_txt(
|
|
874
|
+
"Record/taxon/report rollups.",
|
|
875
|
+
"record/taxon/report 三类汇总。",
|
|
876
|
+
),
|
|
877
|
+
},
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
record_num_dubious: int = Field(
|
|
881
|
+
default=0,
|
|
882
|
+
description=_schema_txt("Dubious records.", "存疑记录数。"),
|
|
883
|
+
)
|
|
884
|
+
taxon_num: int = Field(
|
|
885
|
+
default=0,
|
|
886
|
+
description=_schema_txt("Taxa.", "鸟种数。"),
|
|
887
|
+
)
|
|
888
|
+
taxon_num_dubious: int = Field(
|
|
889
|
+
default=0,
|
|
890
|
+
description=_schema_txt("Dubious taxa.", "存疑鸟种数。"),
|
|
891
|
+
)
|
|
892
|
+
report_num: int = Field(
|
|
893
|
+
default=0,
|
|
894
|
+
description=_schema_txt("Reports.", "记录条数。"),
|
|
895
|
+
)
|
|
896
|
+
record_num: int = Field(
|
|
897
|
+
default=0,
|
|
898
|
+
description=_schema_txt("Records.", "观测记录数。"),
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
class ChartQueryReportPayload(BaseModel):
|
|
903
|
+
"""common/get selectchartQueryReport — report rollup."""
|
|
904
|
+
|
|
905
|
+
model_config = ConfigDict(
|
|
906
|
+
extra="allow",
|
|
907
|
+
json_schema_extra={
|
|
908
|
+
"description": _schema_txt(
|
|
909
|
+
"Report counts only.",
|
|
910
|
+
"仅记录相关计数。",
|
|
911
|
+
),
|
|
912
|
+
},
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
report_num_dubious: int = Field(
|
|
916
|
+
default=0,
|
|
917
|
+
description=_schema_txt("Dubious reports.", "存疑记录数。"),
|
|
918
|
+
)
|
|
919
|
+
report_num: int = Field(
|
|
920
|
+
default=0,
|
|
921
|
+
description=_schema_txt("Reports.", "记录条数。"),
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
class DubiousAccurateCounts(BaseModel):
|
|
926
|
+
"""dubious + accurate pair (chart naming)."""
|
|
927
|
+
|
|
928
|
+
model_config = ConfigDict(extra="forbid")
|
|
929
|
+
|
|
930
|
+
dubious: int = Field(
|
|
931
|
+
default=0,
|
|
932
|
+
description=_schema_txt("Dubious count.", "存疑侧计数。"),
|
|
933
|
+
)
|
|
934
|
+
accurate: int = Field(
|
|
935
|
+
default=0,
|
|
936
|
+
description=_schema_txt(
|
|
937
|
+
"Accurate / primary count.",
|
|
938
|
+
"非存疑/主计数。",
|
|
939
|
+
),
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
class CommonChartBundleGrouped(BaseModel):
|
|
944
|
+
"""common/get chart bundle as record / taxon / report pairs."""
|
|
945
|
+
|
|
946
|
+
model_config = ConfigDict(extra="forbid")
|
|
947
|
+
|
|
948
|
+
record: DubiousAccurateCounts = Field(
|
|
949
|
+
...,
|
|
950
|
+
description=_schema_txt(
|
|
951
|
+
"From summary record_*.",
|
|
952
|
+
"来自汇总 record_*。",
|
|
953
|
+
),
|
|
954
|
+
)
|
|
955
|
+
taxon: DubiousAccurateCounts = Field(
|
|
956
|
+
...,
|
|
957
|
+
description=_schema_txt(
|
|
958
|
+
"From summary taxon_*.",
|
|
959
|
+
"来自汇总 taxon_*。",
|
|
960
|
+
),
|
|
961
|
+
)
|
|
962
|
+
report: DubiousAccurateCounts = Field(
|
|
963
|
+
...,
|
|
964
|
+
description=_schema_txt(
|
|
965
|
+
"From query_report report_*.",
|
|
966
|
+
"来自 query_report 的 report_*。",
|
|
967
|
+
),
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
class CommonChartBundleResult(BaseModel):
|
|
972
|
+
"""Both chart sqlids for one filter."""
|
|
973
|
+
|
|
974
|
+
model_config = ConfigDict(extra="forbid")
|
|
975
|
+
|
|
976
|
+
summary: ChartRecordSummaryPayload
|
|
977
|
+
query_report: ChartQueryReportPayload
|
|
978
|
+
|
|
979
|
+
def as_grouped(self) -> CommonChartBundleGrouped:
|
|
980
|
+
"""``{record, taxon, report}`` with dubious/accurate each."""
|
|
981
|
+
s, q = self.summary, self.query_report
|
|
982
|
+
return CommonChartBundleGrouped(
|
|
983
|
+
record=DubiousAccurateCounts(
|
|
984
|
+
dubious=s.record_num_dubious, accurate=s.record_num
|
|
985
|
+
),
|
|
986
|
+
taxon=DubiousAccurateCounts(
|
|
987
|
+
dubious=s.taxon_num_dubious, accurate=s.taxon_num
|
|
988
|
+
),
|
|
989
|
+
report=DubiousAccurateCounts(
|
|
990
|
+
dubious=q.report_num_dubious, accurate=q.report_num
|
|
991
|
+
),
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@dataclass(frozen=True)
|
|
996
|
+
class CommonChartBundleFetch:
|
|
997
|
+
"""``fetch_common_chart_bundle`` result + optional envelopes."""
|
|
998
|
+
|
|
999
|
+
bundle: CommonChartBundleResult
|
|
1000
|
+
envelopes: dict[str, Any]
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
class TaxonMonthSlice(BaseModel):
|
|
1004
|
+
"""Per-month taxon slice (dubious + accurate)."""
|
|
1005
|
+
|
|
1006
|
+
model_config = ConfigDict(extra="forbid")
|
|
1007
|
+
|
|
1008
|
+
dubious: int = Field(
|
|
1009
|
+
default=0,
|
|
1010
|
+
description=_schema_txt("taxon_num_dubious.", "对应 taxon_num_dubious。"),
|
|
1011
|
+
)
|
|
1012
|
+
accurate: int = Field(
|
|
1013
|
+
default=0,
|
|
1014
|
+
description=_schema_txt("taxon_num.", "对应 taxon_num。"),
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
class MonthSearchEntry(BaseModel):
|
|
1019
|
+
"""One month: report chart + taxon chart."""
|
|
1020
|
+
|
|
1021
|
+
model_config = ConfigDict(extra="forbid")
|
|
1022
|
+
|
|
1023
|
+
report: DubiousAccurateCounts = Field(
|
|
1024
|
+
...,
|
|
1025
|
+
description=_schema_txt(
|
|
1026
|
+
"From statistics/reports.",
|
|
1027
|
+
"来自 statistics/reports。",
|
|
1028
|
+
),
|
|
1029
|
+
)
|
|
1030
|
+
taxon: TaxonMonthSlice = Field(
|
|
1031
|
+
...,
|
|
1032
|
+
description=_schema_txt(
|
|
1033
|
+
"From statistics/taxon.",
|
|
1034
|
+
"来自 statistics/taxon。",
|
|
1035
|
+
),
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
class SearchStatisticResult(BaseModel):
|
|
1040
|
+
"""by_month = chart reports + taxon per month; total = common/get chart pair."""
|
|
1041
|
+
|
|
1042
|
+
model_config = ConfigDict(extra="forbid")
|
|
1043
|
+
|
|
1044
|
+
by_month: dict[str, MonthSearchEntry] = Field(
|
|
1045
|
+
...,
|
|
1046
|
+
description=_schema_txt(
|
|
1047
|
+
'Month key string (e.g. "3").',
|
|
1048
|
+
'月份键字符串(如 "3")。',
|
|
1049
|
+
),
|
|
1050
|
+
)
|
|
1051
|
+
total: CommonChartBundleGrouped = Field(
|
|
1052
|
+
...,
|
|
1053
|
+
description=_schema_txt(
|
|
1054
|
+
"Grouped common/get totals.",
|
|
1055
|
+
"common/get 图表对的汇总分组。",
|
|
1056
|
+
),
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
@dataclass(frozen=True)
|
|
1061
|
+
class SearchStatisticFetch:
|
|
1062
|
+
"""``fetch_search_statistic`` result + optional envelopes."""
|
|
1063
|
+
|
|
1064
|
+
result: SearchStatisticResult
|
|
1065
|
+
envelopes: dict[str, Any]
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
class ChartActivityTaxonRow(BaseModel):
|
|
1069
|
+
"""common/list activity taxon row."""
|
|
1070
|
+
|
|
1071
|
+
model_config = ConfigDict(
|
|
1072
|
+
extra="allow",
|
|
1073
|
+
json_schema_extra={
|
|
1074
|
+
"description": _schema_txt(
|
|
1075
|
+
"Taxon + count for chart month.",
|
|
1076
|
+
"图表月份内的鸟种及记录数。",
|
|
1077
|
+
),
|
|
1078
|
+
},
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
taxon_id: int = Field(..., description=_schema_txt("Taxon id.", "鸟种 ID。"))
|
|
1082
|
+
taxonname: str = Field(
|
|
1083
|
+
..., description=_schema_txt("Chinese name.", "中文名。")
|
|
1084
|
+
)
|
|
1085
|
+
latinname: str = Field(
|
|
1086
|
+
..., description=_schema_txt("Latin name.", "拉丁名。")
|
|
1087
|
+
)
|
|
1088
|
+
englishname: str = Field(
|
|
1089
|
+
default="",
|
|
1090
|
+
description=_schema_txt("English name.", "英文名。"),
|
|
1091
|
+
)
|
|
1092
|
+
recordcount: int = Field(
|
|
1093
|
+
...,
|
|
1094
|
+
description=_schema_txt("Record count.", "记录数量。"),
|
|
1095
|
+
)
|
|
1096
|
+
taxonordername: str = Field(
|
|
1097
|
+
default="",
|
|
1098
|
+
description=_schema_txt("Order (Chinese).", "目名(中文)。"),
|
|
1099
|
+
)
|
|
1100
|
+
taxonfamilyname: str = Field(
|
|
1101
|
+
default="",
|
|
1102
|
+
description=_schema_txt("Family (Chinese).", "科名(中文)。"),
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
class ChartActivityReportRow(BaseModel):
|
|
1107
|
+
"""common/page activity report row."""
|
|
1108
|
+
|
|
1109
|
+
model_config = ConfigDict(
|
|
1110
|
+
extra="allow",
|
|
1111
|
+
json_schema_extra={
|
|
1112
|
+
"description": _schema_txt("One report card.", "一条记录卡片。"),
|
|
1113
|
+
},
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
id: int = Field(..., description=_schema_txt("Report id.", "记录 ID。"))
|
|
1117
|
+
serial_id: str = Field(
|
|
1118
|
+
default="",
|
|
1119
|
+
description=_schema_txt("Serial.", "流水号。"),
|
|
1120
|
+
)
|
|
1121
|
+
name: str = Field(
|
|
1122
|
+
default="",
|
|
1123
|
+
description=_schema_txt("Title.", "标题。"),
|
|
1124
|
+
)
|
|
1125
|
+
username: str = Field(
|
|
1126
|
+
default="",
|
|
1127
|
+
description=_schema_txt("Submitter.", "提交者。"),
|
|
1128
|
+
)
|
|
1129
|
+
userid: int = Field(
|
|
1130
|
+
default=0,
|
|
1131
|
+
description=_schema_txt("User id.", "用户 ID。"),
|
|
1132
|
+
)
|
|
1133
|
+
province_name: str = Field(
|
|
1134
|
+
default="",
|
|
1135
|
+
description=_schema_txt("Province.", "省。"),
|
|
1136
|
+
)
|
|
1137
|
+
city_name: str = Field(
|
|
1138
|
+
default="",
|
|
1139
|
+
description=_schema_txt("City.", "市。"),
|
|
1140
|
+
)
|
|
1141
|
+
district_name: str = Field(
|
|
1142
|
+
default="",
|
|
1143
|
+
description=_schema_txt("District.", "区县。"),
|
|
1144
|
+
)
|
|
1145
|
+
point_name: str = Field(
|
|
1146
|
+
default="",
|
|
1147
|
+
description=_schema_txt("Point.", "观鸟点。"),
|
|
1148
|
+
)
|
|
1149
|
+
address: str = Field(
|
|
1150
|
+
default="",
|
|
1151
|
+
description=_schema_txt("Address.", "地址。"),
|
|
1152
|
+
)
|
|
1153
|
+
location: str = Field(
|
|
1154
|
+
default="",
|
|
1155
|
+
description=_schema_txt("Coords.", "坐标。"),
|
|
1156
|
+
)
|
|
1157
|
+
start_time: str = Field(
|
|
1158
|
+
default="",
|
|
1159
|
+
description=_schema_txt("Start time.", "开始时间。"),
|
|
1160
|
+
)
|
|
1161
|
+
end_time: str = Field(
|
|
1162
|
+
default="",
|
|
1163
|
+
description=_schema_txt("End time.", "结束时间。"),
|
|
1164
|
+
)
|
|
1165
|
+
ctime: str = Field(
|
|
1166
|
+
default="",
|
|
1167
|
+
description=_schema_txt("Created.", "创建时间。"),
|
|
1168
|
+
)
|
|
1169
|
+
version: str = Field(
|
|
1170
|
+
default="",
|
|
1171
|
+
description=_schema_txt("Protocol tag.", "协议标记。"),
|
|
1172
|
+
)
|
|
1173
|
+
state: int = Field(
|
|
1174
|
+
default=0,
|
|
1175
|
+
description=_schema_txt("State.", "状态。"),
|
|
1176
|
+
)
|
|
1177
|
+
taxoncount: int = Field(
|
|
1178
|
+
default=0,
|
|
1179
|
+
description=_schema_txt("Taxon count.", "鸟种数。"),
|
|
1180
|
+
)
|
|
1181
|
+
family_count: int = Field(
|
|
1182
|
+
default=0,
|
|
1183
|
+
description=_schema_txt("Family count.", "科数。"),
|
|
1184
|
+
)
|
|
1185
|
+
order_count: int = Field(
|
|
1186
|
+
default=0,
|
|
1187
|
+
description=_schema_txt("Order count.", "目数。"),
|
|
1188
|
+
)
|
|
1189
|
+
outside_count: int = Field(
|
|
1190
|
+
default=0,
|
|
1191
|
+
description=_schema_txt("Outside count.", "境外计数。"),
|
|
1192
|
+
)
|
|
1193
|
+
request_id: str = Field(
|
|
1194
|
+
default="",
|
|
1195
|
+
description=_schema_txt("Request id.", "请求 ID。"),
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
class SearchCliMergedResult(BaseModel):
|
|
1200
|
+
"""``search`` output when ``--taxon`` and/or ``--report`` is set (includes chart statistic)."""
|
|
1201
|
+
|
|
1202
|
+
model_config = ConfigDict(extra="forbid")
|
|
1203
|
+
|
|
1204
|
+
statistic: SearchStatisticResult
|
|
1205
|
+
taxon: list[ChartActivityTaxonRow] = Field(default_factory=list)
|
|
1206
|
+
report: list[ChartActivityReportRow] = Field(default_factory=list)
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
class ReportDetailPayload(BaseModel):
|
|
1210
|
+
"""reports/get — report header (not full line list)."""
|
|
1211
|
+
|
|
1212
|
+
model_config = ConfigDict(
|
|
1213
|
+
extra="allow",
|
|
1214
|
+
json_schema_extra={
|
|
1215
|
+
"description": _schema_txt(
|
|
1216
|
+
"Core metadata; common/page rows add location detail.",
|
|
1217
|
+
"核心元数据;common/page 行含更多地点细节。",
|
|
1218
|
+
),
|
|
1219
|
+
},
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
id: int = Field(..., description=_schema_txt("Report id.", "记录 ID。"))
|
|
1223
|
+
serial_id: str = Field(
|
|
1224
|
+
default="",
|
|
1225
|
+
description=_schema_txt("Serial.", "流水号。"),
|
|
1226
|
+
)
|
|
1227
|
+
name: str = Field(
|
|
1228
|
+
default="",
|
|
1229
|
+
description=_schema_txt("Title.", "标题。"),
|
|
1230
|
+
)
|
|
1231
|
+
member_id: int = Field(
|
|
1232
|
+
default=0,
|
|
1233
|
+
description=_schema_txt("Owner userid.", "所有者用户 ID。"),
|
|
1234
|
+
)
|
|
1235
|
+
point_id: int = Field(
|
|
1236
|
+
default=0,
|
|
1237
|
+
description=_schema_txt("Linked point id.", "关联观鸟点 ID。"),
|
|
1238
|
+
)
|
|
1239
|
+
state: int = Field(
|
|
1240
|
+
default=0,
|
|
1241
|
+
description=_schema_txt("State.", "状态。"),
|
|
1242
|
+
)
|
|
1243
|
+
status_type: int = Field(
|
|
1244
|
+
default=0,
|
|
1245
|
+
description=_schema_txt("Status type.", "状态类型。"),
|
|
1246
|
+
)
|
|
1247
|
+
domain_type: int = Field(
|
|
1248
|
+
default=0,
|
|
1249
|
+
description=_schema_txt("Domain type.", "领域类型。"),
|
|
1250
|
+
)
|
|
1251
|
+
watch_type: int = Field(
|
|
1252
|
+
default=0,
|
|
1253
|
+
description=_schema_txt("Watch type.", "观察类型。"),
|
|
1254
|
+
)
|
|
1255
|
+
show_copy: int = Field(
|
|
1256
|
+
default=0,
|
|
1257
|
+
description=_schema_txt("Copy UI flag.", "复制相关 UI 标记。"),
|
|
1258
|
+
)
|
|
1259
|
+
version: str = Field(
|
|
1260
|
+
default="",
|
|
1261
|
+
description=_schema_txt("Protocol tag.", "协议标记。"),
|
|
1262
|
+
)
|
|
1263
|
+
ctime: str = Field(
|
|
1264
|
+
default="",
|
|
1265
|
+
description=_schema_txt("Created.", "创建时间。"),
|
|
1266
|
+
)
|
|
1267
|
+
update_time: str = Field(
|
|
1268
|
+
default="",
|
|
1269
|
+
description=_schema_txt("Updated.", "更新时间。"),
|
|
1270
|
+
)
|
|
1271
|
+
start_time: str = Field(
|
|
1272
|
+
default="",
|
|
1273
|
+
description=_schema_txt("Obs start.", "观察开始时间。"),
|
|
1274
|
+
)
|
|
1275
|
+
end_time: str = Field(
|
|
1276
|
+
default="",
|
|
1277
|
+
description=_schema_txt("Obs end.", "观察结束时间。"),
|
|
1278
|
+
)
|
|
1279
|
+
effective_hours: str = Field(
|
|
1280
|
+
default="",
|
|
1281
|
+
description=_schema_txt("Duration string.", "有效时长字符串。"),
|
|
1282
|
+
)
|
|
1283
|
+
real_quantity: str = Field(
|
|
1284
|
+
default="",
|
|
1285
|
+
description=_schema_txt("Quantity string.", "数量字符串。"),
|
|
1286
|
+
)
|
|
1287
|
+
eye_all_birds: str = Field(
|
|
1288
|
+
default="",
|
|
1289
|
+
description=_schema_txt(
|
|
1290
|
+
"All-birds flag string.",
|
|
1291
|
+
"是否见全鸟种标记字符串。",
|
|
1292
|
+
),
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
class MemberProfilePayload(BaseModel):
|
|
1297
|
+
"""member/get — profile (PII; treat password as secret)."""
|
|
1298
|
+
|
|
1299
|
+
model_config = ConfigDict(
|
|
1300
|
+
extra="allow",
|
|
1301
|
+
json_schema_extra={
|
|
1302
|
+
"description": _schema_txt(
|
|
1303
|
+
"Sensitive; password often hash-like — do not log.",
|
|
1304
|
+
"敏感信息;password 常为类哈希字段——勿写入日志。",
|
|
1305
|
+
),
|
|
1306
|
+
},
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
id: int = Field(
|
|
1310
|
+
...,
|
|
1311
|
+
description=_schema_txt(
|
|
1312
|
+
"Member id (= request userid).",
|
|
1313
|
+
"会员 ID(等于请求的 userid)。",
|
|
1314
|
+
),
|
|
1315
|
+
)
|
|
1316
|
+
account: str = Field(
|
|
1317
|
+
default="",
|
|
1318
|
+
description=_schema_txt("Account.", "账号。"),
|
|
1319
|
+
)
|
|
1320
|
+
username: str = Field(
|
|
1321
|
+
default="",
|
|
1322
|
+
description=_schema_txt("Display name.", "显示名。"),
|
|
1323
|
+
)
|
|
1324
|
+
email: str = Field(
|
|
1325
|
+
default="",
|
|
1326
|
+
description=_schema_txt("Email.", "邮箱。"),
|
|
1327
|
+
)
|
|
1328
|
+
phone: str = Field(
|
|
1329
|
+
default="",
|
|
1330
|
+
description=_schema_txt("Phone.", "手机。"),
|
|
1331
|
+
)
|
|
1332
|
+
membertype: int = Field(
|
|
1333
|
+
default=0,
|
|
1334
|
+
description=_schema_txt("Tier / role.", "等级/角色。"),
|
|
1335
|
+
)
|
|
1336
|
+
isactived: int = Field(
|
|
1337
|
+
default=0,
|
|
1338
|
+
description=_schema_txt("Active flag.", "是否激活。"),
|
|
1339
|
+
)
|
|
1340
|
+
timecreated: str = Field(
|
|
1341
|
+
default="",
|
|
1342
|
+
description=_schema_txt("Created.", "创建时间。"),
|
|
1343
|
+
)
|
|
1344
|
+
wxopenid: str = Field(
|
|
1345
|
+
default="",
|
|
1346
|
+
description=_schema_txt("WeChat OpenID.", "微信 OpenID。"),
|
|
1347
|
+
)
|
|
1348
|
+
wxunionid: str = Field(
|
|
1349
|
+
default="",
|
|
1350
|
+
description=_schema_txt("WeChat UnionID.", "微信 UnionID。"),
|
|
1351
|
+
)
|
|
1352
|
+
password: str = Field(
|
|
1353
|
+
default="",
|
|
1354
|
+
description=_schema_txt(
|
|
1355
|
+
"Server secret field.",
|
|
1356
|
+
"服务端敏感字段。",
|
|
1357
|
+
),
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
class PointDetailPayload(BaseModel):
|
|
1362
|
+
"""point/get — hotspot."""
|
|
1363
|
+
|
|
1364
|
+
model_config = ConfigDict(
|
|
1365
|
+
extra="allow",
|
|
1366
|
+
json_schema_extra={
|
|
1367
|
+
"description": _schema_txt(
|
|
1368
|
+
"Point + admin + coords.",
|
|
1369
|
+
"观鸟点、行政区划与坐标。",
|
|
1370
|
+
),
|
|
1371
|
+
},
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
point_id: int = Field(
|
|
1375
|
+
...,
|
|
1376
|
+
description=_schema_txt("Point id.", "观鸟点 ID。"),
|
|
1377
|
+
)
|
|
1378
|
+
point_name: str = Field(
|
|
1379
|
+
default="",
|
|
1380
|
+
description=_schema_txt("Name.", "名称。"),
|
|
1381
|
+
)
|
|
1382
|
+
member_id: int = Field(
|
|
1383
|
+
default=0,
|
|
1384
|
+
description=_schema_txt("Creator id.", "创建者 ID。"),
|
|
1385
|
+
)
|
|
1386
|
+
province_name: str = Field(
|
|
1387
|
+
default="",
|
|
1388
|
+
description=_schema_txt("Province.", "省。"),
|
|
1389
|
+
)
|
|
1390
|
+
city_name: str = Field(
|
|
1391
|
+
default="",
|
|
1392
|
+
description=_schema_txt("City.", "市。"),
|
|
1393
|
+
)
|
|
1394
|
+
district_name: str = Field(
|
|
1395
|
+
default="",
|
|
1396
|
+
description=_schema_txt("District.", "区县。"),
|
|
1397
|
+
)
|
|
1398
|
+
adcode: int = Field(
|
|
1399
|
+
default=0,
|
|
1400
|
+
description=_schema_txt("Adcode.", "行政区划代码。"),
|
|
1401
|
+
)
|
|
1402
|
+
latitude: float = Field(
|
|
1403
|
+
default=0.0,
|
|
1404
|
+
description=_schema_txt("Lat.", "纬度。"),
|
|
1405
|
+
)
|
|
1406
|
+
longitude: float = Field(
|
|
1407
|
+
default=0.0,
|
|
1408
|
+
description=_schema_txt("Lon.", "经度。"),
|
|
1409
|
+
)
|
|
1410
|
+
ctime: str = Field(
|
|
1411
|
+
default="",
|
|
1412
|
+
description=_schema_txt("Created.", "创建时间。"),
|
|
1413
|
+
)
|
|
1414
|
+
state: int = Field(
|
|
1415
|
+
default=0,
|
|
1416
|
+
description=_schema_txt("State.", "状态。"),
|
|
1417
|
+
)
|
|
1418
|
+
isopen: int = Field(
|
|
1419
|
+
default=0,
|
|
1420
|
+
description=_schema_txt("Public flag.", "是否公开。"),
|
|
1421
|
+
)
|
|
1422
|
+
is_hot: int = Field(
|
|
1423
|
+
default=0,
|
|
1424
|
+
description=_schema_txt("Hot flag.", "是否热门。"),
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
class RecordSummaryPayload(BaseModel):
|
|
1429
|
+
"""record/summary — taxon / family / order counts."""
|
|
1430
|
+
|
|
1431
|
+
model_config = ConfigDict(
|
|
1432
|
+
extra="allow",
|
|
1433
|
+
json_schema_extra={
|
|
1434
|
+
"description": _schema_txt(
|
|
1435
|
+
"Diversity counts for one activity.",
|
|
1436
|
+
"单条活动下的多样性计数。",
|
|
1437
|
+
),
|
|
1438
|
+
},
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
taxon_count: int = Field(
|
|
1442
|
+
default=0,
|
|
1443
|
+
description=_schema_txt("Species count.", "鸟种数。"),
|
|
1444
|
+
)
|
|
1445
|
+
taxon_family_count: int = Field(
|
|
1446
|
+
default=0,
|
|
1447
|
+
description=_schema_txt("Family count.", "科数。"),
|
|
1448
|
+
)
|
|
1449
|
+
taxon_order_count: int = Field(
|
|
1450
|
+
default=0,
|
|
1451
|
+
description=_schema_txt("Order count.", "目数。"),
|
|
1452
|
+
)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
class ReportBundleResult(BaseModel):
|
|
1456
|
+
"""One report: reports/get + record/summary + optional member + point."""
|
|
1457
|
+
|
|
1458
|
+
model_config = ConfigDict(extra="forbid")
|
|
1459
|
+
|
|
1460
|
+
report_id: str = Field(
|
|
1461
|
+
...,
|
|
1462
|
+
description=_schema_txt(
|
|
1463
|
+
"Id string for get/summary.",
|
|
1464
|
+
"用于 get/summary 的 ID 字符串。",
|
|
1465
|
+
),
|
|
1466
|
+
)
|
|
1467
|
+
report: ReportDetailPayload = Field(
|
|
1468
|
+
...,
|
|
1469
|
+
description=_schema_txt("reports/get.", "reports/get 载荷。"),
|
|
1470
|
+
)
|
|
1471
|
+
record_summary: RecordSummaryPayload = Field(
|
|
1472
|
+
...,
|
|
1473
|
+
description=_schema_txt("record/summary.", "record/summary 载荷。"),
|
|
1474
|
+
)
|
|
1475
|
+
member: Optional[MemberProfilePayload] = Field(
|
|
1476
|
+
default=None,
|
|
1477
|
+
description=_schema_txt(
|
|
1478
|
+
"member/get if owner id ≠ 0.",
|
|
1479
|
+
"所有者 ID 非 0 时来自 member/get。",
|
|
1480
|
+
),
|
|
1481
|
+
)
|
|
1482
|
+
point: Optional[PointDetailPayload] = Field(
|
|
1483
|
+
default=None,
|
|
1484
|
+
description=_schema_txt(
|
|
1485
|
+
"point/get if point_id ≠ 0.",
|
|
1486
|
+
"point_id 非 0 时来自 point/get。",
|
|
1487
|
+
),
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
@dataclass(frozen=True)
|
|
1492
|
+
class ReportBundleFetch:
|
|
1493
|
+
"""``fetch_report_bundle`` + optional envelopes."""
|
|
1494
|
+
|
|
1495
|
+
bundle: ReportBundleResult
|
|
1496
|
+
envelopes: dict[str, Any]
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
EnvelopeT = TypeVar("EnvelopeT", bound=BaseModel)
|
|
1500
|
+
PayloadT = TypeVar("PayloadT")
|
|
1501
|
+
DictPayloadT = TypeVar("DictPayloadT", bound=BaseModel)
|
|
1502
|
+
RowModelT = TypeVar("RowModelT", bound=BaseModel)
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
class BirdrecordApiError(Exception):
|
|
1506
|
+
def __init__(
|
|
1507
|
+
self, message: str, *, code: Any = None, envelope: Optional[dict] = None
|
|
1508
|
+
):
|
|
1509
|
+
super().__init__(message)
|
|
1510
|
+
self.code = code
|
|
1511
|
+
self.envelope = envelope
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def _require_list(inner: Any, err_msg: str, envelope: dict[str, Any]) -> list[Any]:
|
|
1515
|
+
if not isinstance(inner, list):
|
|
1516
|
+
raise BirdrecordApiError(err_msg, envelope=envelope)
|
|
1517
|
+
return inner
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def _require_dict(inner: Any, err_msg: str, envelope: dict[str, Any]) -> dict[str, Any]:
|
|
1521
|
+
if not isinstance(inner, dict):
|
|
1522
|
+
raise BirdrecordApiError(err_msg, envelope=envelope)
|
|
1523
|
+
return inner
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _request_body_mapping(body: BaseModel | Mapping[str, Any]) -> dict[str, Any]:
|
|
1527
|
+
return body.model_dump() if isinstance(body, BaseModel) else dict(body)
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def decrypt_aes_cbc_b64(ciphertext_b64: str) -> bytes:
|
|
1531
|
+
raw = base64.b64decode(ciphertext_b64)
|
|
1532
|
+
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
|
|
1533
|
+
return unpad(cipher.decrypt(raw), AES.block_size)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def parse_encrypted_envelope(envelope: Mapping[str, Any]) -> Any:
|
|
1537
|
+
"""Decrypt ``envelope[field]`` when encrypted; else first non-null of ``data`` / ``result``."""
|
|
1538
|
+
if envelope.get("hasNeedEncrypt") and envelope.get("field"):
|
|
1539
|
+
field = envelope["field"]
|
|
1540
|
+
blob = envelope.get(field)
|
|
1541
|
+
if isinstance(blob, str):
|
|
1542
|
+
plain = decrypt_aes_cbc_b64(blob)
|
|
1543
|
+
return json.loads(plain.decode("utf-8"))
|
|
1544
|
+
if envelope.get("data") is not None:
|
|
1545
|
+
return envelope["data"]
|
|
1546
|
+
if envelope.get("result") is not None:
|
|
1547
|
+
return envelope["result"]
|
|
1548
|
+
return None
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _check_standard_envelope(envelope: dict) -> None:
|
|
1552
|
+
code = envelope.get("code")
|
|
1553
|
+
if code is not None and code != 0:
|
|
1554
|
+
msg = envelope.get("msg") or envelope.get("errorCode") or "API error"
|
|
1555
|
+
raise BirdrecordApiError(str(msg), code=code, envelope=envelope)
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def _check_common_envelope(envelope: dict) -> None:
|
|
1559
|
+
if envelope.get("success") is False:
|
|
1560
|
+
msg = envelope.get("msg") or "API error"
|
|
1561
|
+
raise BirdrecordApiError(str(msg), code=envelope.get("code"), envelope=envelope)
|
|
1562
|
+
code = envelope.get("code")
|
|
1563
|
+
if code is not None and code != 0:
|
|
1564
|
+
msg = envelope.get("msg") or envelope.get("errorCode") or "API error"
|
|
1565
|
+
raise BirdrecordApiError(str(msg), code=code, envelope=envelope)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def _check_member_get_envelope(envelope: dict) -> None:
|
|
1569
|
+
if envelope.get("success") is False:
|
|
1570
|
+
msg = envelope.get("msg") or "API error"
|
|
1571
|
+
raise BirdrecordApiError(str(msg), code=envelope.get("code"), envelope=envelope)
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
@dataclass(frozen=True)
|
|
1575
|
+
class BirdrecordCall(Generic[EnvelopeT, PayloadT]):
|
|
1576
|
+
envelope: EnvelopeT
|
|
1577
|
+
payload: PayloadT
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def _taxon_call_for_emit(
|
|
1581
|
+
env_dump: dict[str, Any],
|
|
1582
|
+
full_rows: list[TaxonRow],
|
|
1583
|
+
*,
|
|
1584
|
+
query: str | None,
|
|
1585
|
+
) -> BirdrecordCall[StandardApiEnvelope, list[TaxonRow]]:
|
|
1586
|
+
filtered = filter_taxon_rows_by_query(full_rows, query)
|
|
1587
|
+
env_d = dict(env_dump)
|
|
1588
|
+
if (query or "").strip():
|
|
1589
|
+
env_d["count"] = len(filtered)
|
|
1590
|
+
return BirdrecordCall(
|
|
1591
|
+
envelope=StandardApiEnvelope.model_validate(env_d),
|
|
1592
|
+
payload=filtered,
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def _standard_list_call_after_query_filter[T](
|
|
1597
|
+
raw: BirdrecordCall[StandardApiEnvelope, list[T]],
|
|
1598
|
+
filtered: list[T],
|
|
1599
|
+
*,
|
|
1600
|
+
query: str | None,
|
|
1601
|
+
) -> BirdrecordCall[StandardApiEnvelope, list[T]]:
|
|
1602
|
+
env_d = dict(raw.envelope.model_dump())
|
|
1603
|
+
if (query or "").strip():
|
|
1604
|
+
env_d["count"] = len(filtered)
|
|
1605
|
+
return BirdrecordCall(
|
|
1606
|
+
envelope=StandardApiEnvelope.model_validate(env_d),
|
|
1607
|
+
payload=filtered,
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
@dataclass
|
|
1612
|
+
class BirdrecordResponse:
|
|
1613
|
+
"""Raw envelope dict + extracted payload (untyped)."""
|
|
1614
|
+
|
|
1615
|
+
envelope: dict[str, Any]
|
|
1616
|
+
payload: Any
|
|
1617
|
+
|
|
1618
|
+
@property
|
|
1619
|
+
def code(self) -> Any:
|
|
1620
|
+
return self.envelope.get("code")
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
ChartStatisticsPayload: TypeAlias = list[ChartReportMonthRow]
|
|
1624
|
+
ChartTaxonStatisticsPayload: TypeAlias = list[ChartTaxonStatisticsRow]
|
|
1625
|
+
ChartActivityTaxonListPayload: TypeAlias = list[ChartActivityTaxonRow]
|
|
1626
|
+
ChartActivityReportListPayload: TypeAlias = list[ChartActivityReportRow]
|
|
1627
|
+
|
|
1628
|
+
MEMBER_GET_PATH = "/api/weixin/member/get"
|
|
1629
|
+
POINT_GET_PATH = "/api/weixin/point/get"
|
|
1630
|
+
RECORD_SUMMARY_PATH = "/api/weixin/record/summary"
|
|
1631
|
+
CHART_STATISTICS_REPORTS_PATH = "/api/weixin/chart/record/statistics/reports"
|
|
1632
|
+
CHART_STATISTICS_TAXON_PATH = "/api/weixin/chart/record/statistics/taxon"
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
class BirdrecordClient:
|
|
1636
|
+
def __init__(
|
|
1637
|
+
self,
|
|
1638
|
+
token: str = "share",
|
|
1639
|
+
*,
|
|
1640
|
+
base_url: str = BASE_URL,
|
|
1641
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
1642
|
+
referer: str = DEFAULT_REFERER,
|
|
1643
|
+
verify: bool = True,
|
|
1644
|
+
timeout: float = 60.0,
|
|
1645
|
+
) -> None:
|
|
1646
|
+
self._token = token
|
|
1647
|
+
self._client = httpx.Client(
|
|
1648
|
+
base_url=base_url.rstrip("/"),
|
|
1649
|
+
timeout=timeout,
|
|
1650
|
+
verify=verify,
|
|
1651
|
+
headers={
|
|
1652
|
+
"User-Agent": user_agent,
|
|
1653
|
+
"Content-Type": "application/json",
|
|
1654
|
+
"xweb_xhr": "1",
|
|
1655
|
+
"Referer": referer,
|
|
1656
|
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
|
1657
|
+
},
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
def close(self) -> None:
|
|
1661
|
+
self._client.close()
|
|
1662
|
+
|
|
1663
|
+
def __enter__(self) -> BirdrecordClient:
|
|
1664
|
+
return self
|
|
1665
|
+
|
|
1666
|
+
def __exit__(self, *args: object) -> None:
|
|
1667
|
+
self.close()
|
|
1668
|
+
|
|
1669
|
+
@property
|
|
1670
|
+
def token(self) -> str:
|
|
1671
|
+
return self._token
|
|
1672
|
+
|
|
1673
|
+
@token.setter
|
|
1674
|
+
def token(self, value: str) -> None:
|
|
1675
|
+
self._token = value
|
|
1676
|
+
|
|
1677
|
+
def _headers(self) -> dict[str, str]:
|
|
1678
|
+
return {
|
|
1679
|
+
"timestamp": str(int(time.time() * 1000)),
|
|
1680
|
+
"token": self._token,
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
def post(
|
|
1684
|
+
self, path: str, body: Mapping[str, Any], *, check: bool = True
|
|
1685
|
+
) -> BirdrecordResponse:
|
|
1686
|
+
"""POST JSON; raw envelope + parsed payload."""
|
|
1687
|
+
path = path if path.startswith("/") else f"/{path}"
|
|
1688
|
+
r = self._client.post(path, json=dict(body), headers=self._headers())
|
|
1689
|
+
r.raise_for_status()
|
|
1690
|
+
envelope = r.json()
|
|
1691
|
+
if not isinstance(envelope, dict):
|
|
1692
|
+
raise BirdrecordApiError("Expected JSON object body", envelope=None)
|
|
1693
|
+
if check:
|
|
1694
|
+
_check_standard_envelope(envelope)
|
|
1695
|
+
payload = parse_encrypted_envelope(envelope)
|
|
1696
|
+
return BirdrecordResponse(envelope=envelope, payload=payload)
|
|
1697
|
+
|
|
1698
|
+
def _post_common(
|
|
1699
|
+
self, subpath: str, body: Mapping[str, Any], *, check: bool = True
|
|
1700
|
+
) -> BirdrecordResponse:
|
|
1701
|
+
"""POST common/{subpath}; validates common envelope."""
|
|
1702
|
+
sub = subpath.removeprefix("/")
|
|
1703
|
+
path = f"/api/weixin/common/{sub}"
|
|
1704
|
+
r = self._client.post(path, json=dict(body), headers=self._headers())
|
|
1705
|
+
r.raise_for_status()
|
|
1706
|
+
envelope = r.json()
|
|
1707
|
+
if not isinstance(envelope, dict):
|
|
1708
|
+
raise BirdrecordApiError("Expected JSON object body", envelope=None)
|
|
1709
|
+
if check:
|
|
1710
|
+
_check_common_envelope(envelope)
|
|
1711
|
+
payload = parse_encrypted_envelope(envelope)
|
|
1712
|
+
return BirdrecordResponse(envelope=envelope, payload=payload)
|
|
1713
|
+
|
|
1714
|
+
def post_common_get(
|
|
1715
|
+
self, body: Mapping[str, Any], *, check: bool = True
|
|
1716
|
+
) -> BirdrecordResponse:
|
|
1717
|
+
"""POST common/get."""
|
|
1718
|
+
return self._post_common("get", body, check=check)
|
|
1719
|
+
|
|
1720
|
+
def _post_member_get_form(
|
|
1721
|
+
self, form: Mapping[str, Any], *, check: bool = True
|
|
1722
|
+
) -> BirdrecordResponse:
|
|
1723
|
+
"""POST member/get (form body)."""
|
|
1724
|
+
path = MEMBER_GET_PATH
|
|
1725
|
+
headers = {
|
|
1726
|
+
**self._headers(),
|
|
1727
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1728
|
+
}
|
|
1729
|
+
r = self._client.post(path, data=dict(form), headers=headers)
|
|
1730
|
+
r.raise_for_status()
|
|
1731
|
+
envelope = r.json()
|
|
1732
|
+
if not isinstance(envelope, dict):
|
|
1733
|
+
raise BirdrecordApiError("Expected JSON object body", envelope=None)
|
|
1734
|
+
if check:
|
|
1735
|
+
_check_member_get_envelope(envelope)
|
|
1736
|
+
payload = parse_encrypted_envelope(envelope)
|
|
1737
|
+
return BirdrecordResponse(envelope=envelope, payload=payload)
|
|
1738
|
+
|
|
1739
|
+
def _post_standard_list(
|
|
1740
|
+
self,
|
|
1741
|
+
path: str,
|
|
1742
|
+
body: dict[str, Any],
|
|
1743
|
+
*,
|
|
1744
|
+
row_model: type[RowModelT],
|
|
1745
|
+
err_msg: str,
|
|
1746
|
+
) -> BirdrecordCall[StandardApiEnvelope, list[RowModelT]]:
|
|
1747
|
+
raw = self.post(path, body, check=True)
|
|
1748
|
+
env = StandardApiEnvelope.model_validate(raw.envelope)
|
|
1749
|
+
inner = _require_list(raw.payload, err_msg, raw.envelope)
|
|
1750
|
+
rows = [row_model.model_validate(x) for x in inner]
|
|
1751
|
+
return BirdrecordCall(envelope=env, payload=rows)
|
|
1752
|
+
|
|
1753
|
+
def _post_standard_dict(
|
|
1754
|
+
self,
|
|
1755
|
+
path: str,
|
|
1756
|
+
body: dict[str, Any],
|
|
1757
|
+
*,
|
|
1758
|
+
payload_model: type[DictPayloadT],
|
|
1759
|
+
err_msg: str,
|
|
1760
|
+
) -> BirdrecordCall[StandardApiEnvelope, DictPayloadT]:
|
|
1761
|
+
raw = self.post(path, body, check=True)
|
|
1762
|
+
env = StandardApiEnvelope.model_validate(raw.envelope)
|
|
1763
|
+
inner = _require_dict(raw.payload, err_msg, raw.envelope)
|
|
1764
|
+
parsed = payload_model.model_validate(inner)
|
|
1765
|
+
return BirdrecordCall(envelope=env, payload=parsed)
|
|
1766
|
+
|
|
1767
|
+
def _post_common_list(
|
|
1768
|
+
self,
|
|
1769
|
+
subpath: str,
|
|
1770
|
+
body: dict[str, Any],
|
|
1771
|
+
*,
|
|
1772
|
+
row_model: type[RowModelT],
|
|
1773
|
+
err_msg: str,
|
|
1774
|
+
) -> BirdrecordCall[CommonGetApiEnvelope, list[RowModelT]]:
|
|
1775
|
+
raw = self._post_common(subpath, body, check=True)
|
|
1776
|
+
env = CommonGetApiEnvelope.model_validate(raw.envelope)
|
|
1777
|
+
inner = _require_list(raw.payload, err_msg, raw.envelope)
|
|
1778
|
+
rows = [row_model.model_validate(x) for x in inner]
|
|
1779
|
+
return BirdrecordCall(envelope=env, payload=rows)
|
|
1780
|
+
|
|
1781
|
+
def _post_common_get_chart(
|
|
1782
|
+
self,
|
|
1783
|
+
filters: RegionChartQueryBody,
|
|
1784
|
+
*,
|
|
1785
|
+
sqlid: str,
|
|
1786
|
+
err_msg: str,
|
|
1787
|
+
payload_model: type[DictPayloadT],
|
|
1788
|
+
) -> BirdrecordCall[CommonGetApiEnvelope, DictPayloadT]:
|
|
1789
|
+
payload = build_common_get_chart_payload(filters, sqlid=sqlid)
|
|
1790
|
+
raw = self.post_common_get(payload, check=True)
|
|
1791
|
+
env = CommonGetApiEnvelope.model_validate(raw.envelope)
|
|
1792
|
+
inner = _require_dict(raw.payload, err_msg, raw.envelope)
|
|
1793
|
+
parsed = payload_model.model_validate(inner)
|
|
1794
|
+
return BirdrecordCall(envelope=env, payload=parsed)
|
|
1795
|
+
|
|
1796
|
+
def _post_chart_statistics_rows(
|
|
1797
|
+
self,
|
|
1798
|
+
path: str,
|
|
1799
|
+
payload: Mapping[str, Any],
|
|
1800
|
+
*,
|
|
1801
|
+
row_model: type[RowModelT],
|
|
1802
|
+
err_msg: str,
|
|
1803
|
+
) -> BirdrecordCall[StandardApiEnvelope, list[RowModelT]]:
|
|
1804
|
+
raw = self.post(path, dict(payload), check=True)
|
|
1805
|
+
env = StandardApiEnvelope.model_validate(raw.envelope)
|
|
1806
|
+
inner = _require_list(raw.payload, err_msg, raw.envelope)
|
|
1807
|
+
rows = [row_model.model_validate(x) for x in inner]
|
|
1808
|
+
return BirdrecordCall(envelope=env, payload=rows)
|
|
1809
|
+
|
|
1810
|
+
# --- typed endpoints ---
|
|
1811
|
+
|
|
1812
|
+
def adcode_provinces(
|
|
1813
|
+
self,
|
|
1814
|
+
) -> BirdrecordCall[StandardApiEnvelope, list[ProvinceRow]]:
|
|
1815
|
+
return self._post_standard_list(
|
|
1816
|
+
"/api/weixin/adcode/province",
|
|
1817
|
+
_request_body_mapping(AdcodeProvinceRequest()),
|
|
1818
|
+
row_model=ProvinceRow,
|
|
1819
|
+
err_msg="Expected list payload for provinces",
|
|
1820
|
+
)
|
|
1821
|
+
|
|
1822
|
+
def adcode_cities(
|
|
1823
|
+
self, province_code: str
|
|
1824
|
+
) -> BirdrecordCall[StandardApiEnvelope, list[CityRow]]:
|
|
1825
|
+
return self._post_standard_list(
|
|
1826
|
+
"/api/weixin/adcode/city",
|
|
1827
|
+
_request_body_mapping(AdcodeCityRequest(province_code=province_code)),
|
|
1828
|
+
row_model=CityRow,
|
|
1829
|
+
err_msg="Expected list payload for cities",
|
|
1830
|
+
)
|
|
1831
|
+
|
|
1832
|
+
def taxon_search(
|
|
1833
|
+
self, version: Optional[str] = None
|
|
1834
|
+
) -> BirdrecordCall[StandardApiEnvelope, list[TaxonRow]]:
|
|
1835
|
+
return self._post_standard_list(
|
|
1836
|
+
"/api/weixin/taxon/search",
|
|
1837
|
+
_request_body_mapping(
|
|
1838
|
+
TaxonSearchRequest(version=version or DEFAULT_TAXON_VERSION)
|
|
1839
|
+
),
|
|
1840
|
+
row_model=TaxonRow,
|
|
1841
|
+
err_msg="Expected list payload for taxon search",
|
|
1842
|
+
)
|
|
1843
|
+
|
|
1844
|
+
def chart_record_statistics_reports(
|
|
1845
|
+
self, body: ChartStatisticsReportsRequest | Mapping[str, Any] | None = None
|
|
1846
|
+
) -> BirdrecordCall[StandardApiEnvelope, ChartStatisticsPayload]:
|
|
1847
|
+
if body is None:
|
|
1848
|
+
body = ChartStatisticsReportsRequest()
|
|
1849
|
+
return self._post_chart_statistics_rows(
|
|
1850
|
+
CHART_STATISTICS_REPORTS_PATH,
|
|
1851
|
+
_request_body_mapping(body),
|
|
1852
|
+
row_model=ChartReportMonthRow,
|
|
1853
|
+
err_msg="Expected list payload for statistics reports",
|
|
1854
|
+
)
|
|
1855
|
+
|
|
1856
|
+
def chart_record_statistics_taxon(
|
|
1857
|
+
self,
|
|
1858
|
+
body: ChartStatisticsTaxonRequest
|
|
1859
|
+
| ChartStatisticsReportsRequest
|
|
1860
|
+
| Mapping[str, Any]
|
|
1861
|
+
| None = None,
|
|
1862
|
+
) -> BirdrecordCall[StandardApiEnvelope, ChartTaxonStatisticsPayload]:
|
|
1863
|
+
if body is None:
|
|
1864
|
+
body = ChartStatisticsTaxonRequest()
|
|
1865
|
+
return self._post_chart_statistics_rows(
|
|
1866
|
+
CHART_STATISTICS_TAXON_PATH,
|
|
1867
|
+
_request_body_mapping(body),
|
|
1868
|
+
row_model=ChartTaxonStatisticsRow,
|
|
1869
|
+
err_msg="Expected list payload for chart/record/statistics/taxon",
|
|
1870
|
+
)
|
|
1871
|
+
|
|
1872
|
+
def common_get_chart_summary(
|
|
1873
|
+
self, body: RegionChartQueryBody | Mapping[str, Any] | None = None
|
|
1874
|
+
) -> BirdrecordCall[CommonGetApiEnvelope, ChartRecordSummaryPayload]:
|
|
1875
|
+
return self._post_common_get_chart(
|
|
1876
|
+
coerce_region_chart_body(body),
|
|
1877
|
+
sqlid=COMMON_GET_SQLID_CHART_RECORD_SUMMARY,
|
|
1878
|
+
err_msg="Expected dict payload for chart summary",
|
|
1879
|
+
payload_model=ChartRecordSummaryPayload,
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
def common_get_chart_query_report(
|
|
1883
|
+
self, body: RegionChartQueryBody | Mapping[str, Any] | None = None
|
|
1884
|
+
) -> BirdrecordCall[CommonGetApiEnvelope, ChartQueryReportPayload]:
|
|
1885
|
+
return self._post_common_get_chart(
|
|
1886
|
+
coerce_region_chart_body(body),
|
|
1887
|
+
sqlid=COMMON_GET_SQLID_CHART_QUERY_REPORT,
|
|
1888
|
+
err_msg="Expected dict payload for chart query report",
|
|
1889
|
+
payload_model=ChartQueryReportPayload,
|
|
1890
|
+
)
|
|
1891
|
+
|
|
1892
|
+
def fetch_common_chart_bundle(
|
|
1893
|
+
self,
|
|
1894
|
+
body: RegionChartQueryBody | Mapping[str, Any] | None = None,
|
|
1895
|
+
*,
|
|
1896
|
+
collect_envelopes: bool = False,
|
|
1897
|
+
) -> CommonChartBundleFetch:
|
|
1898
|
+
"""Two common/get calls (both chart sqlids) for one filter."""
|
|
1899
|
+
filters = coerce_region_chart_body(body)
|
|
1900
|
+
c_summary = self._post_common_get_chart(
|
|
1901
|
+
filters,
|
|
1902
|
+
sqlid=COMMON_GET_SQLID_CHART_RECORD_SUMMARY,
|
|
1903
|
+
err_msg="Expected dict payload for chart summary",
|
|
1904
|
+
payload_model=ChartRecordSummaryPayload,
|
|
1905
|
+
)
|
|
1906
|
+
c_query = self._post_common_get_chart(
|
|
1907
|
+
filters,
|
|
1908
|
+
sqlid=COMMON_GET_SQLID_CHART_QUERY_REPORT,
|
|
1909
|
+
err_msg="Expected dict payload for chart query report",
|
|
1910
|
+
payload_model=ChartQueryReportPayload,
|
|
1911
|
+
)
|
|
1912
|
+
bundle = CommonChartBundleResult(
|
|
1913
|
+
summary=c_summary.payload, query_report=c_query.payload
|
|
1914
|
+
)
|
|
1915
|
+
envelopes: dict[str, Any] = {}
|
|
1916
|
+
if collect_envelopes:
|
|
1917
|
+
envelopes[COMMON_GET_SQLID_CHART_RECORD_SUMMARY] = (
|
|
1918
|
+
c_summary.envelope.model_dump()
|
|
1919
|
+
)
|
|
1920
|
+
envelopes[COMMON_GET_SQLID_CHART_QUERY_REPORT] = (
|
|
1921
|
+
c_query.envelope.model_dump()
|
|
1922
|
+
)
|
|
1923
|
+
return CommonChartBundleFetch(bundle=bundle, envelopes=envelopes)
|
|
1924
|
+
|
|
1925
|
+
def fetch_search_statistic(
|
|
1926
|
+
self,
|
|
1927
|
+
body: RegionChartQueryBody | Mapping[str, Any] | None = None,
|
|
1928
|
+
*,
|
|
1929
|
+
collect_envelopes: bool = False,
|
|
1930
|
+
) -> SearchStatisticFetch:
|
|
1931
|
+
"""Chart reports + taxon + common/get pair → ``by_month`` + ``total``."""
|
|
1932
|
+
filters = coerce_region_chart_body(body)
|
|
1933
|
+
fd = filters.model_dump()
|
|
1934
|
+
chart_body = ChartStatisticsReportsRequest.model_validate(fd)
|
|
1935
|
+
taxon_body = ChartStatisticsTaxonRequest.model_validate(fd)
|
|
1936
|
+
rep_call = self.chart_record_statistics_reports(chart_body)
|
|
1937
|
+
tax_call = self.chart_record_statistics_taxon(taxon_body)
|
|
1938
|
+
common_fetch = self.fetch_common_chart_bundle(
|
|
1939
|
+
filters, collect_envelopes=collect_envelopes
|
|
1940
|
+
)
|
|
1941
|
+
grouped = common_fetch.bundle.as_grouped()
|
|
1942
|
+
reports_by_m = {str(int(r.taxon_month)): r for r in rep_call.payload}
|
|
1943
|
+
taxon_by_m = {str(int(r.taxon_month)): r for r in tax_call.payload}
|
|
1944
|
+
month_keys = sorted(set(reports_by_m) | set(taxon_by_m), key=lambda k: int(k))
|
|
1945
|
+
by_month: dict[str, MonthSearchEntry] = {}
|
|
1946
|
+
for m in month_keys:
|
|
1947
|
+
rr = reports_by_m.get(m)
|
|
1948
|
+
tt = taxon_by_m.get(m)
|
|
1949
|
+
by_month[m] = MonthSearchEntry(
|
|
1950
|
+
report=DubiousAccurateCounts(
|
|
1951
|
+
dubious=rr.report_num_dubious if rr else 0,
|
|
1952
|
+
accurate=rr.report_num if rr else 0,
|
|
1953
|
+
),
|
|
1954
|
+
taxon=TaxonMonthSlice(
|
|
1955
|
+
dubious=tt.taxon_num_dubious if tt else 0,
|
|
1956
|
+
accurate=tt.taxon_num if tt else 0,
|
|
1957
|
+
),
|
|
1958
|
+
)
|
|
1959
|
+
result = SearchStatisticResult(by_month=by_month, total=grouped)
|
|
1960
|
+
envelopes: dict[str, Any] = {}
|
|
1961
|
+
if collect_envelopes:
|
|
1962
|
+
envelopes["chart_record_statistics_reports"] = (
|
|
1963
|
+
rep_call.envelope.model_dump()
|
|
1964
|
+
)
|
|
1965
|
+
envelopes["chart_record_statistics_taxon"] = tax_call.envelope.model_dump()
|
|
1966
|
+
envelopes["common_get"] = dict(common_fetch.envelopes)
|
|
1967
|
+
return SearchStatisticFetch(result=result, envelopes=envelopes)
|
|
1968
|
+
|
|
1969
|
+
def common_list_activity_taxon(
|
|
1970
|
+
self, body: CommonListActivityTaxonRequest | Mapping[str, Any] | None = None
|
|
1971
|
+
) -> BirdrecordCall[CommonGetApiEnvelope, ChartActivityTaxonListPayload]:
|
|
1972
|
+
if body is None:
|
|
1973
|
+
body = CommonListActivityTaxonRequest()
|
|
1974
|
+
return self._post_common_list(
|
|
1975
|
+
"list",
|
|
1976
|
+
_request_body_mapping(body),
|
|
1977
|
+
row_model=ChartActivityTaxonRow,
|
|
1978
|
+
err_msg="Expected list payload for common/list activity taxon",
|
|
1979
|
+
)
|
|
1980
|
+
|
|
1981
|
+
def common_page_activity(
|
|
1982
|
+
self, body: CommonPageActivityRequest | Mapping[str, Any] | None = None
|
|
1983
|
+
) -> BirdrecordCall[CommonGetApiEnvelope, ChartActivityReportListPayload]:
|
|
1984
|
+
if body is None:
|
|
1985
|
+
body = CommonPageActivityRequest()
|
|
1986
|
+
return self._post_common_list(
|
|
1987
|
+
"page",
|
|
1988
|
+
_request_body_mapping(body),
|
|
1989
|
+
row_model=ChartActivityReportRow,
|
|
1990
|
+
err_msg="Expected list payload for common/page activity",
|
|
1991
|
+
)
|
|
1992
|
+
|
|
1993
|
+
def reports_get(
|
|
1994
|
+
self,
|
|
1995
|
+
body: ReportGetRequest | Mapping[str, Any] | None = None,
|
|
1996
|
+
*,
|
|
1997
|
+
report_id: Optional[str] = None,
|
|
1998
|
+
) -> BirdrecordCall[StandardApiEnvelope, ReportDetailPayload]:
|
|
1999
|
+
if body is None:
|
|
2000
|
+
if report_id is None:
|
|
2001
|
+
body = ReportGetRequest(id="1948816")
|
|
2002
|
+
else:
|
|
2003
|
+
body = ReportGetRequest(id=str(report_id))
|
|
2004
|
+
return self._post_standard_dict(
|
|
2005
|
+
"/api/weixin/reports/get",
|
|
2006
|
+
_request_body_mapping(body),
|
|
2007
|
+
payload_model=ReportDetailPayload,
|
|
2008
|
+
err_msg="Expected dict payload for reports/get",
|
|
2009
|
+
)
|
|
2010
|
+
|
|
2011
|
+
def member_get(
|
|
2012
|
+
self,
|
|
2013
|
+
body: MemberGetRequest | Mapping[str, Any] | None = None,
|
|
2014
|
+
*,
|
|
2015
|
+
userid: Optional[int] = None,
|
|
2016
|
+
) -> BirdrecordCall[MemberGetApiEnvelope, MemberProfilePayload]:
|
|
2017
|
+
if body is None:
|
|
2018
|
+
uid = 89963 if userid is None else userid
|
|
2019
|
+
body = MemberGetRequest(userid=uid)
|
|
2020
|
+
form = _request_body_mapping(body)
|
|
2021
|
+
raw = self._post_member_get_form(form, check=True)
|
|
2022
|
+
env = MemberGetApiEnvelope.model_validate(raw.envelope)
|
|
2023
|
+
inner = _require_dict(
|
|
2024
|
+
raw.payload, "Expected dict payload for member/get", raw.envelope
|
|
2025
|
+
)
|
|
2026
|
+
parsed = MemberProfilePayload.model_validate(inner)
|
|
2027
|
+
return BirdrecordCall(envelope=env, payload=parsed)
|
|
2028
|
+
|
|
2029
|
+
def point_get(
|
|
2030
|
+
self,
|
|
2031
|
+
body: PointGetRequest | Mapping[str, Any] | None = None,
|
|
2032
|
+
*,
|
|
2033
|
+
point_id: Optional[int] = None,
|
|
2034
|
+
) -> BirdrecordCall[StandardApiEnvelope, PointDetailPayload]:
|
|
2035
|
+
if body is None:
|
|
2036
|
+
pid = 125887 if point_id is None else point_id
|
|
2037
|
+
body = PointGetRequest(point_id=pid)
|
|
2038
|
+
return self._post_standard_dict(
|
|
2039
|
+
POINT_GET_PATH,
|
|
2040
|
+
_request_body_mapping(body),
|
|
2041
|
+
payload_model=PointDetailPayload,
|
|
2042
|
+
err_msg="Expected dict payload for point/get",
|
|
2043
|
+
)
|
|
2044
|
+
|
|
2045
|
+
def record_summary(
|
|
2046
|
+
self,
|
|
2047
|
+
body: RecordSummaryRequest | Mapping[str, Any] | None = None,
|
|
2048
|
+
*,
|
|
2049
|
+
activity_id: Optional[str] = None,
|
|
2050
|
+
) -> BirdrecordCall[StandardApiEnvelope, RecordSummaryPayload]:
|
|
2051
|
+
if body is None:
|
|
2052
|
+
aid = "1948816" if activity_id is None else str(activity_id)
|
|
2053
|
+
body = RecordSummaryRequest(activity_id=aid)
|
|
2054
|
+
return self._post_standard_dict(
|
|
2055
|
+
RECORD_SUMMARY_PATH,
|
|
2056
|
+
_request_body_mapping(body),
|
|
2057
|
+
payload_model=RecordSummaryPayload,
|
|
2058
|
+
err_msg="Expected dict payload for record/summary",
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
def fetch_report_bundle(
|
|
2062
|
+
self,
|
|
2063
|
+
report_id: str,
|
|
2064
|
+
*,
|
|
2065
|
+
member_id: Optional[int] = None,
|
|
2066
|
+
collect_envelopes: bool = False,
|
|
2067
|
+
) -> ReportBundleFetch:
|
|
2068
|
+
"""reports/get + record/summary + member/get (if owner) + point/get (if point).
|
|
2069
|
+
|
|
2070
|
+
When ``member_id`` is set, use it for member/get instead of ``report.member_id``.
|
|
2071
|
+
"""
|
|
2072
|
+
rid = str(report_id)
|
|
2073
|
+
rg = self.reports_get(report_id=rid)
|
|
2074
|
+
rep = rg.payload
|
|
2075
|
+
mid_eff = int(member_id) if member_id is not None else int(rep.member_id)
|
|
2076
|
+
|
|
2077
|
+
rs = self.record_summary(activity_id=rid)
|
|
2078
|
+
|
|
2079
|
+
mg_call: Optional[
|
|
2080
|
+
BirdrecordCall[MemberGetApiEnvelope, MemberProfilePayload]
|
|
2081
|
+
] = None
|
|
2082
|
+
member_payload: Optional[MemberProfilePayload] = None
|
|
2083
|
+
if mid_eff:
|
|
2084
|
+
mg_call = self.member_get(userid=mid_eff)
|
|
2085
|
+
member_payload = mg_call.payload
|
|
2086
|
+
|
|
2087
|
+
pg_call: Optional[BirdrecordCall[StandardApiEnvelope, PointDetailPayload]] = (
|
|
2088
|
+
None
|
|
2089
|
+
)
|
|
2090
|
+
point_payload: Optional[PointDetailPayload] = None
|
|
2091
|
+
pid = int(rep.point_id) if getattr(rep, "point_id", 0) else 0
|
|
2092
|
+
if pid:
|
|
2093
|
+
pg_call = self.point_get(point_id=pid)
|
|
2094
|
+
point_payload = pg_call.payload
|
|
2095
|
+
|
|
2096
|
+
bundle = ReportBundleResult(
|
|
2097
|
+
report_id=rid,
|
|
2098
|
+
report=rep,
|
|
2099
|
+
record_summary=rs.payload,
|
|
2100
|
+
member=member_payload,
|
|
2101
|
+
point=point_payload,
|
|
2102
|
+
)
|
|
2103
|
+
|
|
2104
|
+
envelopes: dict[str, Any] = {}
|
|
2105
|
+
if collect_envelopes:
|
|
2106
|
+
envelopes["reports_get"] = rg.envelope.model_dump()
|
|
2107
|
+
envelopes["record_summary"] = rs.envelope.model_dump()
|
|
2108
|
+
if mg_call is not None:
|
|
2109
|
+
envelopes["member_get"] = mg_call.envelope.model_dump()
|
|
2110
|
+
if pg_call is not None:
|
|
2111
|
+
envelopes["point_get"] = pg_call.envelope.model_dump()
|
|
2112
|
+
|
|
2113
|
+
return ReportBundleFetch(bundle=bundle, envelopes=envelopes)
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
# ---------------------------------------------------------------------------
|
|
2117
|
+
# CLI (``birdrecord-cli`` console script / ``uv run main.py``) — Click
|
|
2118
|
+
# ---------------------------------------------------------------------------
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def strip_json_schema_titles(obj: Any) -> Any:
|
|
2122
|
+
"""Drop ``title`` keys from a JSON Schema tree."""
|
|
2123
|
+
if isinstance(obj, dict):
|
|
2124
|
+
return {k: strip_json_schema_titles(v) for k, v in obj.items() if k != "title"}
|
|
2125
|
+
if isinstance(obj, list):
|
|
2126
|
+
return [strip_json_schema_titles(x) for x in obj]
|
|
2127
|
+
return obj
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def json_schema_text(model: Type[BaseModel]) -> str:
|
|
2131
|
+
"""Pretty JSON Schema for one model (no titles)."""
|
|
2132
|
+
raw = model.model_json_schema()
|
|
2133
|
+
return json.dumps(strip_json_schema_titles(raw), ensure_ascii=False, indent=2)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def json_schema_text_object(schemas: Mapping[str, Type[BaseModel]]) -> str:
|
|
2137
|
+
"""Pretty JSON object of named schemas (no titles)."""
|
|
2138
|
+
out: dict[str, Any] = {}
|
|
2139
|
+
for name, m in schemas.items():
|
|
2140
|
+
out[name] = strip_json_schema_titles(m.model_json_schema())
|
|
2141
|
+
return json.dumps(out, ensure_ascii=False, indent=2)
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
@dataclass
|
|
2145
|
+
class CliConfig:
|
|
2146
|
+
token: str
|
|
2147
|
+
base_url: str
|
|
2148
|
+
verify: bool
|
|
2149
|
+
timeout: float
|
|
2150
|
+
pretty: bool
|
|
2151
|
+
envelope: bool
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
def _payload_to_jsonable(payload: Any) -> Any:
|
|
2155
|
+
if isinstance(payload, BaseModel):
|
|
2156
|
+
return payload.model_dump()
|
|
2157
|
+
if isinstance(payload, list):
|
|
2158
|
+
if payload and isinstance(payload[0], BaseModel):
|
|
2159
|
+
return [x.model_dump() for x in payload]
|
|
2160
|
+
return payload
|
|
2161
|
+
return payload
|
|
2162
|
+
|
|
2163
|
+
|
|
2164
|
+
def _emit_json(data: Any, *, pretty: bool) -> None:
|
|
2165
|
+
indent = 2 if pretty else None
|
|
2166
|
+
print(json.dumps(data, ensure_ascii=False, indent=indent))
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
def _emit_call(cfg: CliConfig, call: BirdrecordCall[Any, Any]) -> None:
|
|
2170
|
+
if cfg.envelope:
|
|
2171
|
+
_emit_json(
|
|
2172
|
+
{
|
|
2173
|
+
"envelope": call.envelope.model_dump(),
|
|
2174
|
+
"payload": _payload_to_jsonable(call.payload),
|
|
2175
|
+
},
|
|
2176
|
+
pretty=cfg.pretty,
|
|
2177
|
+
)
|
|
2178
|
+
else:
|
|
2179
|
+
_emit_json(_payload_to_jsonable(call.payload), pretty=cfg.pretty)
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def _emit_enveloped_model(
|
|
2183
|
+
cfg: CliConfig, core: BaseModel, envelopes: dict[str, Any]
|
|
2184
|
+
) -> None:
|
|
2185
|
+
"""Print model dump, optionally wrapped with multi-call envelopes."""
|
|
2186
|
+
data = core.model_dump()
|
|
2187
|
+
if cfg.envelope:
|
|
2188
|
+
_emit_json({"envelope": envelopes, "payload": data}, pretty=cfg.pretty)
|
|
2189
|
+
else:
|
|
2190
|
+
_emit_json(data, pretty=cfg.pretty)
|
|
2191
|
+
|
|
2192
|
+
|
|
2193
|
+
def _client_from_cfg(cfg: CliConfig) -> BirdrecordClient:
|
|
2194
|
+
return BirdrecordClient(
|
|
2195
|
+
token=cfg.token,
|
|
2196
|
+
base_url=cfg.base_url,
|
|
2197
|
+
verify=cfg.verify,
|
|
2198
|
+
timeout=cfg.timeout,
|
|
2199
|
+
)
|
|
2200
|
+
|
|
2201
|
+
|
|
2202
|
+
def _parse_cli_body_json(body_json: str | None) -> dict[str, Any] | None:
|
|
2203
|
+
if body_json:
|
|
2204
|
+
return json.loads(body_json)
|
|
2205
|
+
return None
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
class BirdrecordGroup(click.Group):
|
|
2209
|
+
"""API errors → exit 1; HTTP errors → exit 2."""
|
|
2210
|
+
|
|
2211
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
2212
|
+
try:
|
|
2213
|
+
return super().invoke(ctx)
|
|
2214
|
+
except BirdrecordApiError as e:
|
|
2215
|
+
click.echo(
|
|
2216
|
+
f"{_cli_txt('API error:', 'API 错误:')} {e}",
|
|
2217
|
+
err=True,
|
|
2218
|
+
)
|
|
2219
|
+
cfg = ctx.obj
|
|
2220
|
+
pretty = cfg.pretty if isinstance(cfg, CliConfig) else False
|
|
2221
|
+
if e.envelope is not None:
|
|
2222
|
+
_emit_json(e.envelope, pretty=pretty)
|
|
2223
|
+
raise click.exceptions.Exit(1) from e
|
|
2224
|
+
except httpx.HTTPError as e:
|
|
2225
|
+
click.echo(
|
|
2226
|
+
f"{_cli_txt('HTTP error:', 'HTTP 错误:')} {e}",
|
|
2227
|
+
err=True,
|
|
2228
|
+
)
|
|
2229
|
+
raise click.exceptions.Exit(2) from e
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def with_client_config(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
2233
|
+
"""Shared auth, HTTP, and JSON output flags."""
|
|
2234
|
+
|
|
2235
|
+
@click.option(
|
|
2236
|
+
"--envelope",
|
|
2237
|
+
is_flag=True,
|
|
2238
|
+
help=_cli_txt(
|
|
2239
|
+
"Include wire envelope(s) in JSON output.",
|
|
2240
|
+
"在 JSON 输出中包含原始响应信封(envelope)。",
|
|
2241
|
+
),
|
|
2242
|
+
)
|
|
2243
|
+
@click.option(
|
|
2244
|
+
"--pretty",
|
|
2245
|
+
is_flag=True,
|
|
2246
|
+
help=_cli_txt("Pretty-print JSON.", "格式化缩进输出 JSON。"),
|
|
2247
|
+
)
|
|
2248
|
+
@click.option(
|
|
2249
|
+
"--timeout",
|
|
2250
|
+
default=60.0,
|
|
2251
|
+
show_default=True,
|
|
2252
|
+
type=float,
|
|
2253
|
+
help=_cli_txt("HTTP timeout (seconds).", "HTTP 超时(秒)。"),
|
|
2254
|
+
)
|
|
2255
|
+
@click.option(
|
|
2256
|
+
"--no-verify",
|
|
2257
|
+
is_flag=True,
|
|
2258
|
+
help=_cli_txt(
|
|
2259
|
+
"Skip TLS certificate verification.",
|
|
2260
|
+
"跳过 TLS 证书校验。",
|
|
2261
|
+
),
|
|
2262
|
+
)
|
|
2263
|
+
@click.option(
|
|
2264
|
+
"--base-url",
|
|
2265
|
+
default=BASE_URL,
|
|
2266
|
+
show_default=True,
|
|
2267
|
+
help=_cli_txt("API base URL.", "API 根地址。"),
|
|
2268
|
+
)
|
|
2269
|
+
@click.option(
|
|
2270
|
+
"--token",
|
|
2271
|
+
default="share",
|
|
2272
|
+
show_default=True,
|
|
2273
|
+
help=_cli_txt(
|
|
2274
|
+
"Bearer token (e.g. share or JWT).",
|
|
2275
|
+
"Bearer 令牌(如 share 或 JWT)。",
|
|
2276
|
+
),
|
|
2277
|
+
)
|
|
2278
|
+
@functools.wraps(f)
|
|
2279
|
+
def wrapped(
|
|
2280
|
+
ctx: click.Context,
|
|
2281
|
+
token: str,
|
|
2282
|
+
base_url: str,
|
|
2283
|
+
no_verify: bool,
|
|
2284
|
+
timeout: float,
|
|
2285
|
+
pretty: bool,
|
|
2286
|
+
envelope: bool,
|
|
2287
|
+
**kwargs: Any,
|
|
2288
|
+
) -> Any:
|
|
2289
|
+
ctx.obj = CliConfig(
|
|
2290
|
+
token=token,
|
|
2291
|
+
base_url=base_url,
|
|
2292
|
+
verify=not no_verify,
|
|
2293
|
+
timeout=timeout,
|
|
2294
|
+
pretty=pretty,
|
|
2295
|
+
envelope=envelope,
|
|
2296
|
+
)
|
|
2297
|
+
return f(ctx, **kwargs)
|
|
2298
|
+
|
|
2299
|
+
return wrapped
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
@click.group(
|
|
2303
|
+
cls=BirdrecordGroup,
|
|
2304
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
2305
|
+
help=_cli_txt(
|
|
2306
|
+
"Birdrecord mini-program API CLI.",
|
|
2307
|
+
"Birdrecord 小程序 API 命令行工具。",
|
|
2308
|
+
),
|
|
2309
|
+
)
|
|
2310
|
+
def cli() -> None:
|
|
2311
|
+
pass
|
|
2312
|
+
|
|
2313
|
+
|
|
2314
|
+
@cli.command(
|
|
2315
|
+
"provinces",
|
|
2316
|
+
short_help=_cli_txt(
|
|
2317
|
+
"List provinces; optional -q filter by Chinese / pinyin / initials.",
|
|
2318
|
+
"列出省份;可用 -q 按中文/拼音/首字母过滤。",
|
|
2319
|
+
),
|
|
2320
|
+
help=_cli_txt(
|
|
2321
|
+
(
|
|
2322
|
+
"List provinces for the region picker. "
|
|
2323
|
+
"-q filters by case-insensitive substring on Chinese name, toneless pinyin, or initials."
|
|
2324
|
+
),
|
|
2325
|
+
(
|
|
2326
|
+
"列出地区选择器用的省份列表。"
|
|
2327
|
+
"-q 按不区分大小写的子串匹配中文名、无声调全拼或首字母。"
|
|
2328
|
+
),
|
|
2329
|
+
),
|
|
2330
|
+
)
|
|
2331
|
+
@click.option(
|
|
2332
|
+
"-q",
|
|
2333
|
+
"--query",
|
|
2334
|
+
default=None,
|
|
2335
|
+
help=_cli_txt(
|
|
2336
|
+
"Filter by Chinese name, pinyin, or initials (substring, case-insensitive).",
|
|
2337
|
+
"按中文名、全拼或首字母子串过滤(不区分大小写)。",
|
|
2338
|
+
),
|
|
2339
|
+
)
|
|
2340
|
+
@click.option(
|
|
2341
|
+
"--schema",
|
|
2342
|
+
is_flag=True,
|
|
2343
|
+
help=_cli_txt(
|
|
2344
|
+
"Print request JSON Schema only (no HTTP).",
|
|
2345
|
+
"仅打印请求 JSON Schema(不发起 HTTP)。",
|
|
2346
|
+
),
|
|
2347
|
+
)
|
|
2348
|
+
@click.pass_context
|
|
2349
|
+
@with_client_config
|
|
2350
|
+
def cmd_provinces(ctx: click.Context, query: str | None, schema: bool) -> None:
|
|
2351
|
+
if schema:
|
|
2352
|
+
click.echo(json_schema_text(AdcodeProvinceRequest))
|
|
2353
|
+
return
|
|
2354
|
+
cfg = ctx.obj
|
|
2355
|
+
assert isinstance(cfg, CliConfig)
|
|
2356
|
+
with _client_from_cfg(cfg) as client:
|
|
2357
|
+
raw = client.adcode_provinces()
|
|
2358
|
+
filtered = filter_region_rows_by_query(
|
|
2359
|
+
list(raw.payload), query, label_attr="province_name"
|
|
2360
|
+
)
|
|
2361
|
+
_emit_call(cfg, _standard_list_call_after_query_filter(raw, filtered, query=query))
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
@cli.command(
|
|
2365
|
+
"cities",
|
|
2366
|
+
short_help=_cli_txt(
|
|
2367
|
+
"List cities under a province; optional -q filter by Chinese / pinyin / initials.",
|
|
2368
|
+
"列出省份下城市;可用 -q 按中文/拼音/首字母过滤。",
|
|
2369
|
+
),
|
|
2370
|
+
help=_cli_txt(
|
|
2371
|
+
(
|
|
2372
|
+
"List cities under one province. "
|
|
2373
|
+
"-q filters by case-insensitive substring on Chinese name, toneless pinyin, or initials."
|
|
2374
|
+
),
|
|
2375
|
+
(
|
|
2376
|
+
"列出指定省份下的城市。"
|
|
2377
|
+
"-q 按不区分大小写的子串匹配中文名、无声调全拼或首字母。"
|
|
2378
|
+
),
|
|
2379
|
+
),
|
|
2380
|
+
)
|
|
2381
|
+
@click.option(
|
|
2382
|
+
"--province-code",
|
|
2383
|
+
required=True,
|
|
2384
|
+
help=_cli_txt(
|
|
2385
|
+
"6-digit province code, e.g. 110000.",
|
|
2386
|
+
"6 位省级行政区划代码,例如 110000。",
|
|
2387
|
+
),
|
|
2388
|
+
)
|
|
2389
|
+
@click.option(
|
|
2390
|
+
"-q",
|
|
2391
|
+
"--query",
|
|
2392
|
+
default=None,
|
|
2393
|
+
help=_cli_txt(
|
|
2394
|
+
"Filter by Chinese name, pinyin, or initials (substring, case-insensitive).",
|
|
2395
|
+
"按中文名、全拼或首字母子串过滤(不区分大小写)。",
|
|
2396
|
+
),
|
|
2397
|
+
)
|
|
2398
|
+
@click.option(
|
|
2399
|
+
"--schema",
|
|
2400
|
+
is_flag=True,
|
|
2401
|
+
help=_cli_txt(
|
|
2402
|
+
"Print request JSON Schema only (no HTTP).",
|
|
2403
|
+
"仅打印请求 JSON Schema(不发起 HTTP)。",
|
|
2404
|
+
),
|
|
2405
|
+
)
|
|
2406
|
+
@click.pass_context
|
|
2407
|
+
@with_client_config
|
|
2408
|
+
def cmd_cities(ctx: click.Context, province_code: str, query: str | None, schema: bool) -> None:
|
|
2409
|
+
if schema:
|
|
2410
|
+
click.echo(json_schema_text(AdcodeCityRequest))
|
|
2411
|
+
return
|
|
2412
|
+
cfg = ctx.obj
|
|
2413
|
+
assert isinstance(cfg, CliConfig)
|
|
2414
|
+
with _client_from_cfg(cfg) as client:
|
|
2415
|
+
raw = client.adcode_cities(province_code)
|
|
2416
|
+
filtered = filter_region_rows_by_query(
|
|
2417
|
+
list(raw.payload), query, label_attr="city_name"
|
|
2418
|
+
)
|
|
2419
|
+
_emit_call(cfg, _standard_list_call_after_query_filter(raw, filtered, query=query))
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def _ensure_taxon_full_list(
|
|
2423
|
+
client: BirdrecordClient, version: str, *, refresh: bool
|
|
2424
|
+
) -> tuple[dict[str, Any], list[TaxonRow]]:
|
|
2425
|
+
"""Return full checklist: memory → disk → network; persist to disk after fetch."""
|
|
2426
|
+
if not refresh and version in _taxon_search_cache:
|
|
2427
|
+
return _taxon_search_cache[version]
|
|
2428
|
+
if not refresh:
|
|
2429
|
+
disk = _load_taxon_search_disk(version)
|
|
2430
|
+
if disk is not None:
|
|
2431
|
+
_taxon_search_cache[version] = disk
|
|
2432
|
+
return disk
|
|
2433
|
+
raw = client.taxon_search(version=version)
|
|
2434
|
+
env_dump = raw.envelope.model_dump()
|
|
2435
|
+
full_rows = list(raw.payload)
|
|
2436
|
+
_taxon_search_cache[version] = (env_dump, full_rows)
|
|
2437
|
+
_save_taxon_search_disk(version, env_dump, full_rows)
|
|
2438
|
+
return env_dump, full_rows
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
@cli.command(
|
|
2442
|
+
"taxon",
|
|
2443
|
+
short_help=_cli_txt(
|
|
2444
|
+
"Download & cache full taxon checklist; optional -q filter.",
|
|
2445
|
+
"下载并缓存完整鸟种清单,可用 -q 过滤。",
|
|
2446
|
+
),
|
|
2447
|
+
help=_cli_txt(
|
|
2448
|
+
(
|
|
2449
|
+
"Download the full species checklist for a build version. "
|
|
2450
|
+
"Results are cached in memory and on disk (override dir with BIRDRECORD_CACHE_DIR); "
|
|
2451
|
+
"--refresh refetches. -q filters Chinese/Latin/English name, pinyin, or initials."
|
|
2452
|
+
),
|
|
2453
|
+
(
|
|
2454
|
+
"按构建版本下载完整鸟种清单。"
|
|
2455
|
+
"结果缓存在内存与磁盘(可用 BIRDRECORD_CACHE_DIR 覆盖目录);"
|
|
2456
|
+
"--refresh 强制重新拉取。-q 按中文名/拉丁名/英文名/拼音/首字母子串过滤。"
|
|
2457
|
+
),
|
|
2458
|
+
),
|
|
2459
|
+
)
|
|
2460
|
+
@click.option(
|
|
2461
|
+
"--version",
|
|
2462
|
+
default=None,
|
|
2463
|
+
help=_cli_txt(
|
|
2464
|
+
f"Checklist version (default {DEFAULT_TAXON_VERSION}).",
|
|
2465
|
+
f"清单版本(默认 {DEFAULT_TAXON_VERSION})。",
|
|
2466
|
+
),
|
|
2467
|
+
)
|
|
2468
|
+
@click.option(
|
|
2469
|
+
"-q",
|
|
2470
|
+
"--query",
|
|
2471
|
+
default=None,
|
|
2472
|
+
help=_cli_txt(
|
|
2473
|
+
"Case-insensitive substring on name, latinname, englishname, pinyin, szm.",
|
|
2474
|
+
"在中文名、拉丁名、英文名、拼音、首字母(szm)上按不区分大小写的子串过滤。",
|
|
2475
|
+
),
|
|
2476
|
+
)
|
|
2477
|
+
@click.option(
|
|
2478
|
+
"--refresh",
|
|
2479
|
+
is_flag=True,
|
|
2480
|
+
help=_cli_txt(
|
|
2481
|
+
"Ignore cache and refetch (updates cache for this version).",
|
|
2482
|
+
"忽略缓存并重新拉取(会更新该版本的缓存)。",
|
|
2483
|
+
),
|
|
2484
|
+
)
|
|
2485
|
+
@click.option(
|
|
2486
|
+
"--schema",
|
|
2487
|
+
is_flag=True,
|
|
2488
|
+
help=_cli_txt(
|
|
2489
|
+
"Print request JSON Schema only (no HTTP).",
|
|
2490
|
+
"仅打印请求 JSON Schema(不发起 HTTP)。",
|
|
2491
|
+
),
|
|
2492
|
+
)
|
|
2493
|
+
@click.pass_context
|
|
2494
|
+
@with_client_config
|
|
2495
|
+
def cmd_taxon(
|
|
2496
|
+
ctx: click.Context,
|
|
2497
|
+
version: str | None,
|
|
2498
|
+
query: str | None,
|
|
2499
|
+
refresh: bool,
|
|
2500
|
+
schema: bool,
|
|
2501
|
+
) -> None:
|
|
2502
|
+
if schema:
|
|
2503
|
+
click.echo(json_schema_text(TaxonSearchRequest))
|
|
2504
|
+
return
|
|
2505
|
+
cfg = ctx.obj
|
|
2506
|
+
assert isinstance(cfg, CliConfig)
|
|
2507
|
+
ver = version or DEFAULT_TAXON_VERSION
|
|
2508
|
+
with _client_from_cfg(cfg) as client:
|
|
2509
|
+
env_dump, full_rows = _ensure_taxon_full_list(client, ver, refresh=refresh)
|
|
2510
|
+
call = _taxon_call_for_emit(env_dump, full_rows, query=query)
|
|
2511
|
+
_emit_call(cfg, call)
|
|
2512
|
+
|
|
2513
|
+
|
|
2514
|
+
@cli.command(
|
|
2515
|
+
"report",
|
|
2516
|
+
help=_cli_txt(
|
|
2517
|
+
"One observation report: detail, species summary, author profile, linked hotspot when set.",
|
|
2518
|
+
"单条观鸟记录:详情、鸟种摘要、作者资料、若有则含关联热点。",
|
|
2519
|
+
),
|
|
2520
|
+
)
|
|
2521
|
+
@click.option(
|
|
2522
|
+
"--id",
|
|
2523
|
+
"report_id",
|
|
2524
|
+
required=True,
|
|
2525
|
+
help=_cli_txt("Report id string.", "记录 ID 字符串。"),
|
|
2526
|
+
)
|
|
2527
|
+
@click.option(
|
|
2528
|
+
"--schema",
|
|
2529
|
+
is_flag=True,
|
|
2530
|
+
help=_cli_txt(
|
|
2531
|
+
"Print result JSON Schema only (no HTTP).",
|
|
2532
|
+
"仅打印结果 JSON Schema(不发起 HTTP)。",
|
|
2533
|
+
),
|
|
2534
|
+
)
|
|
2535
|
+
@click.pass_context
|
|
2536
|
+
@with_client_config
|
|
2537
|
+
def cmd_report_bundle(
|
|
2538
|
+
ctx: click.Context,
|
|
2539
|
+
report_id: str,
|
|
2540
|
+
schema: bool,
|
|
2541
|
+
) -> None:
|
|
2542
|
+
if schema:
|
|
2543
|
+
click.echo(json_schema_text(ReportBundleResult))
|
|
2544
|
+
return
|
|
2545
|
+
cfg = ctx.obj
|
|
2546
|
+
assert isinstance(cfg, CliConfig)
|
|
2547
|
+
with _client_from_cfg(cfg) as client:
|
|
2548
|
+
fetch = client.fetch_report_bundle(
|
|
2549
|
+
report_id,
|
|
2550
|
+
collect_envelopes=cfg.envelope,
|
|
2551
|
+
)
|
|
2552
|
+
_emit_enveloped_model(cfg, fetch.bundle, fetch.envelopes)
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
@cli.command(
|
|
2556
|
+
"search",
|
|
2557
|
+
short_help=_cli_txt(
|
|
2558
|
+
"Chart search statistic; optional activity --taxon / --report.",
|
|
2559
|
+
"图表检索统计;可选活动下钻 --taxon / --report。",
|
|
2560
|
+
),
|
|
2561
|
+
help=_cli_txt(
|
|
2562
|
+
(
|
|
2563
|
+
"Chart search: per-month breakdown and rolled-up totals (--body-json). "
|
|
2564
|
+
"Add --taxon for species ranking and/or --report for paged cards; omit both to skip those calls."
|
|
2565
|
+
),
|
|
2566
|
+
(
|
|
2567
|
+
"图表检索:按月拆分与汇总(--body-json)。"
|
|
2568
|
+
"需要活动下钻时加 --taxon(鸟种排行)和/或 --report(分页记录);两者都不传则不请求这两项。"
|
|
2569
|
+
),
|
|
2570
|
+
),
|
|
2571
|
+
)
|
|
2572
|
+
@click.option(
|
|
2573
|
+
"--taxon",
|
|
2574
|
+
"want_taxon",
|
|
2575
|
+
is_flag=True,
|
|
2576
|
+
help=_cli_txt(
|
|
2577
|
+
"Include per-species record counts for the chart month (common/list).",
|
|
2578
|
+
"包含图表月份内各鸟种记录数(common/list)。",
|
|
2579
|
+
),
|
|
2580
|
+
)
|
|
2581
|
+
@click.option(
|
|
2582
|
+
"--report",
|
|
2583
|
+
"want_report",
|
|
2584
|
+
is_flag=True,
|
|
2585
|
+
help=_cli_txt(
|
|
2586
|
+
"Include paged observation report cards (common/page).",
|
|
2587
|
+
"包含分页观鸟记录卡片(common/page)。",
|
|
2588
|
+
),
|
|
2589
|
+
)
|
|
2590
|
+
@click.option(
|
|
2591
|
+
"--body-json",
|
|
2592
|
+
default=None,
|
|
2593
|
+
help=_cli_txt(
|
|
2594
|
+
"Filter fields as JSON (chart statistic; activity drill-down uses the same object, coerced per API).",
|
|
2595
|
+
"筛选 JSON(图表统计;活动下钻共用同一对象,按接口分别校验)。",
|
|
2596
|
+
),
|
|
2597
|
+
)
|
|
2598
|
+
@click.option(
|
|
2599
|
+
"--schema",
|
|
2600
|
+
is_flag=True,
|
|
2601
|
+
help=_cli_txt(
|
|
2602
|
+
"Print JSON Schemas for filters and responses only (no HTTP).",
|
|
2603
|
+
"仅打印筛选与多种响应形态的 JSON Schema(不发起 HTTP)。",
|
|
2604
|
+
),
|
|
2605
|
+
)
|
|
2606
|
+
@click.pass_context
|
|
2607
|
+
@with_client_config
|
|
2608
|
+
def cmd_search(
|
|
2609
|
+
ctx: click.Context,
|
|
2610
|
+
want_taxon: bool,
|
|
2611
|
+
want_report: bool,
|
|
2612
|
+
body_json: str | None,
|
|
2613
|
+
schema: bool,
|
|
2614
|
+
) -> None:
|
|
2615
|
+
if schema:
|
|
2616
|
+
click.echo(
|
|
2617
|
+
json_schema_text_object(
|
|
2618
|
+
{
|
|
2619
|
+
"request_chart_filters": RegionChartQueryBody,
|
|
2620
|
+
"request_activity_drilldown": CommonActivityQueryBody,
|
|
2621
|
+
"response_statistics_only": SearchStatisticResult,
|
|
2622
|
+
"response_with_taxon_or_report": SearchCliMergedResult,
|
|
2623
|
+
}
|
|
2624
|
+
)
|
|
2625
|
+
)
|
|
2626
|
+
return
|
|
2627
|
+
cfg = ctx.obj
|
|
2628
|
+
assert isinstance(cfg, CliConfig)
|
|
2629
|
+
raw_body = _parse_cli_body_json(body_json)
|
|
2630
|
+
with _client_from_cfg(cfg) as client:
|
|
2631
|
+
stat_fetch = client.fetch_search_statistic(
|
|
2632
|
+
raw_body, collect_envelopes=cfg.envelope
|
|
2633
|
+
)
|
|
2634
|
+
taxon_rows: list[ChartActivityTaxonRow] = []
|
|
2635
|
+
report_rows: list[ChartActivityReportRow] = []
|
|
2636
|
+
envelopes = dict(stat_fetch.envelopes)
|
|
2637
|
+
if want_taxon or want_report:
|
|
2638
|
+
base = coerce_common_activity_body(raw_body)
|
|
2639
|
+
if want_taxon:
|
|
2640
|
+
tcall = client.common_list_activity_taxon(
|
|
2641
|
+
build_common_list_taxon_request(base)
|
|
2642
|
+
)
|
|
2643
|
+
taxon_rows = tcall.payload
|
|
2644
|
+
if cfg.envelope:
|
|
2645
|
+
envelopes["taxon"] = tcall.envelope.model_dump()
|
|
2646
|
+
if want_report:
|
|
2647
|
+
rcall = client.common_page_activity(
|
|
2648
|
+
build_common_page_activity_request(base)
|
|
2649
|
+
)
|
|
2650
|
+
report_rows = rcall.payload
|
|
2651
|
+
if cfg.envelope:
|
|
2652
|
+
envelopes["report"] = rcall.envelope.model_dump()
|
|
2653
|
+
if want_taxon or want_report:
|
|
2654
|
+
payload: dict[str, Any] = {
|
|
2655
|
+
"statistic": stat_fetch.result.model_dump(mode="json"),
|
|
2656
|
+
}
|
|
2657
|
+
if want_taxon:
|
|
2658
|
+
payload["taxon"] = [r.model_dump(mode="json") for r in taxon_rows]
|
|
2659
|
+
if want_report:
|
|
2660
|
+
payload["report"] = [r.model_dump(mode="json") for r in report_rows]
|
|
2661
|
+
if cfg.envelope:
|
|
2662
|
+
_emit_json({"envelope": envelopes, "payload": payload}, pretty=cfg.pretty)
|
|
2663
|
+
else:
|
|
2664
|
+
_emit_json(payload, pretty=cfg.pretty)
|
|
2665
|
+
else:
|
|
2666
|
+
_emit_enveloped_model(cfg, stat_fetch.result, stat_fetch.envelopes)
|
|
2667
|
+
|
|
2668
|
+
|
|
2669
|
+
def main() -> None:
|
|
2670
|
+
"""Entry point for the ``birdrecord-cli`` console script."""
|
|
2671
|
+
cli.main(prog_name="birdrecord-cli", standalone_mode=True)
|
|
2672
|
+
|
|
2673
|
+
|
|
2674
|
+
if __name__ == "__main__":
|
|
2675
|
+
main()
|