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.
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()