birdrecord-cli 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/PKG-INFO +3 -3
  2. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/README.md +2 -2
  3. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/adcode/__init__.py +6 -2
  4. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/report/__init__.py +7 -1
  5. birdrecord_cli-0.1.3/birdrecord_cli/cli/search/__init__.py +282 -0
  6. birdrecord_cli-0.1.3/birdrecord_cli/cli/search/report_map.py +433 -0
  7. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/taxon/__init__.py +7 -1
  8. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/i18n.py +1 -0
  9. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/cli/stdout.py +7 -0
  10. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/cli/unified_search.py +3 -1
  11. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/activity_payloads.py +2 -6
  12. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/activity_requests.py +3 -1
  13. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/taxon.py +4 -4
  14. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/PKG-INFO +3 -3
  15. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/SOURCES.txt +1 -0
  16. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/pyproject.toml +1 -1
  17. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/tests/test_birdrecord_client.py +46 -19
  18. birdrecord_cli-0.1.2/birdrecord_cli/cli/search/__init__.py +0 -143
  19. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/LICENSE +0 -0
  20. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/__init__.py +0 -0
  21. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/__init__.py +0 -0
  22. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/core.py +0 -0
  23. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli_main.py +0 -0
  24. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/client.py +0 -0
  25. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/constants.py +0 -0
  26. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/crypto.py +1 -1
  27. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/__init__.py +0 -0
  28. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/cli/__init__.py +0 -0
  29. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/__init__.py +0 -0
  30. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/adcode.py +0 -0
  31. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/chart_payloads.py +0 -0
  32. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/chart_requests.py +0 -0
  33. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/envelopes.py +0 -0
  34. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/report_payloads.py +0 -0
  35. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/report_requests.py +0 -0
  36. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/dependency_links.txt +0 -0
  37. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/entry_points.txt +0 -0
  38. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/requires.txt +0 -0
  39. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/top_level.txt +0 -0
  40. {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: birdrecord-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: CLI for China Bird Record (birdreport.cn); default API host weixin.birdrecord.cn.
5
5
  Author: yoshino-s
6
6
  License-Expression: MIT
@@ -69,8 +69,8 @@ Package index: [pypi.org/project/birdrecord-cli](https://pypi.org/project/birdre
69
69
  [uv](https://docs.astral.sh/uv/) downloads the package into an ephemeral environment. Pin the version for reproducible behavior:
70
70
 
71
71
  ```bash
72
- uvx --from 'birdrecord-cli==0.1.2' birdrecord-cli --help
73
- uvx --from 'birdrecord-cli==0.1.2' birdrecord-cli provinces --pretty
72
+ uvx --from 'birdrecord-cli==0.1.3' birdrecord-cli --help
73
+ uvx --from 'birdrecord-cli==0.1.3' birdrecord-cli provinces --pretty
74
74
  ```
75
75
 
76
76
  Use the latest release version from PyPI if it differs from the example above.
@@ -39,8 +39,8 @@ Package index: [pypi.org/project/birdrecord-cli](https://pypi.org/project/birdre
39
39
  [uv](https://docs.astral.sh/uv/) downloads the package into an ephemeral environment. Pin the version for reproducible behavior:
40
40
 
41
41
  ```bash
42
- uvx --from 'birdrecord-cli==0.1.2' birdrecord-cli --help
43
- uvx --from 'birdrecord-cli==0.1.2' birdrecord-cli provinces --pretty
42
+ uvx --from 'birdrecord-cli==0.1.3' birdrecord-cli --help
43
+ uvx --from 'birdrecord-cli==0.1.3' birdrecord-cli provinces --pretty
44
44
  ```
45
45
 
46
46
  Use the latest release version from PyPI if it differs from the example above.
@@ -68,7 +68,9 @@ def register_adcode_commands(group: click.Group) -> None:
68
68
  filtered = filter_region_rows_by_query(
69
69
  list(raw.payload), query, label_attr="province_name"
70
70
  )
71
- emit_call(cfg, _standard_list_call_after_query_filter(raw, filtered, query=query))
71
+ emit_call(
72
+ cfg, _standard_list_call_after_query_filter(raw, filtered, query=query)
73
+ )
72
74
 
73
75
  @group.command(
74
76
  "cities",
@@ -127,4 +129,6 @@ def register_adcode_commands(group: click.Group) -> None:
127
129
  filtered = filter_region_rows_by_query(
128
130
  list(raw.payload), query, label_attr="city_name"
129
131
  )
130
- emit_call(cfg, _standard_list_call_after_query_filter(raw, filtered, query=query))
132
+ emit_call(
133
+ cfg, _standard_list_call_after_query_filter(raw, filtered, query=query)
134
+ )
@@ -4,7 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import click
6
6
 
7
- from birdrecord_cli.cli.core import CliConfig, client_from_cfg, emit_enveloped_model, json_schema_text, with_client_config
7
+ from birdrecord_cli.cli.core import (
8
+ CliConfig,
9
+ client_from_cfg,
10
+ emit_enveloped_model,
11
+ json_schema_text,
12
+ with_client_config,
13
+ )
8
14
  from birdrecord_cli.i18n import _cli_txt
9
15
  from birdrecord_cli.models.client import ReportBundleResult
10
16
 
@@ -0,0 +1,282 @@
1
+ """CLI: chart search statistic and optional activity drill-down."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ import click
8
+ import requests
9
+ from click._utils import FLAG_NEEDS_VALUE
10
+
11
+ from birdrecord_cli.cli.core import (
12
+ CliConfig,
13
+ client_from_cfg,
14
+ emit_json,
15
+ json_schema_text_object,
16
+ parse_cli_body_json,
17
+ with_client_config,
18
+ )
19
+ from birdrecord_cli.i18n import _cli_txt
20
+ from birdrecord_cli.models.client import (
21
+ ChartActivityReportRow,
22
+ ChartActivityTaxonRow,
23
+ build_common_list_taxon_request,
24
+ build_common_page_activity_request,
25
+ )
26
+ from birdrecord_cli.models.cli import (
27
+ UnifiedSearchRequest,
28
+ UnifiedSearchResult,
29
+ coerce_unified_search_request,
30
+ unified_search_to_common_activity,
31
+ unified_search_to_region_chart,
32
+ )
33
+ from birdrecord_cli.cli.search.report_map import (
34
+ render_report_map_html,
35
+ upload_report_map_html,
36
+ write_report_map_html,
37
+ )
38
+
39
+
40
+ class OptionalValueFlagOption(click.Option):
41
+ """Flag option that optionally consumes a following value token."""
42
+
43
+ _previous_parser_process: Callable[[Any, Any], None]
44
+
45
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
46
+ self.optional_value = kwargs.pop("optional_value", None)
47
+ super().__init__(*args, **kwargs)
48
+
49
+ def add_to_parser(self, parser: Any, ctx: click.Context) -> None:
50
+ super().add_to_parser(parser, ctx)
51
+
52
+ def parser_process(value: Any, state: Any) -> None:
53
+ if value is FLAG_NEEDS_VALUE:
54
+ value = self.optional_value
55
+ self._previous_parser_process(value, state)
56
+
57
+ for opt in self.opts:
58
+ option = parser._long_opt.get(opt) or parser._short_opt.get(opt)
59
+ if option is not None:
60
+ self._previous_parser_process = option.process
61
+ option.process = parser_process
62
+ break
63
+
64
+
65
+ def register_search_commands(group: click.Group) -> None:
66
+ @group.command(
67
+ "search",
68
+ short_help=_cli_txt(
69
+ "Chart search statistic; optional activity --taxon / --report.",
70
+ "图表检索统计;可选活动下钻 --taxon / --report。",
71
+ ),
72
+ help=_cli_txt(
73
+ (
74
+ "Chart search: per-month breakdown and rolled-up totals (--body-json). "
75
+ "Add --taxon for species ranking and/or --report for paged cards; omit both to skip those calls."
76
+ ),
77
+ (
78
+ "图表检索:按月拆分与汇总(--body-json)。"
79
+ "需要活动下钻时加 --taxon(鸟种排行)和/或 --report(分页记录);两者都不传则不请求这两项。"
80
+ ),
81
+ ),
82
+ )
83
+ @click.option(
84
+ "--taxon",
85
+ "want_taxon",
86
+ is_flag=True,
87
+ help=_cli_txt(
88
+ "Include per-species record counts for the chart month (common/list).",
89
+ "包含图表月份内各鸟种记录数(common/list)。",
90
+ ),
91
+ )
92
+ @click.option(
93
+ "--report",
94
+ "want_report",
95
+ is_flag=True,
96
+ help=_cli_txt(
97
+ "Include paged observation report cards (common/page).",
98
+ "包含分页观鸟记录卡片(common/page)。",
99
+ ),
100
+ )
101
+ @click.option(
102
+ "--report-map",
103
+ "report_map",
104
+ cls=OptionalValueFlagOption,
105
+ is_flag=False,
106
+ required=False,
107
+ type=str,
108
+ optional_value="output/report_map.html",
109
+ metavar="[OUTPUT_HTML]",
110
+ help=_cli_txt(
111
+ "Generate REPORT MAP: local HTML path (default output/report_map.html) or ONLINE to upload and return URL.",
112
+ "生成 REPORT MAP:本地 HTML 路径(默认 output/report_map.html)或 ONLINE(上传并返回 URL)。",
113
+ ),
114
+ )
115
+ @click.option(
116
+ "--report-limit",
117
+ "report_limit",
118
+ default=None,
119
+ type=int,
120
+ help=_cli_txt(
121
+ "Max total report rows to fetch across pages (stops paging once reached); default = fetch all pages.",
122
+ "跨分页最多获取的记录条数上限(达到后停止翻页);默认获取全部分页。",
123
+ ),
124
+ )
125
+ @click.option(
126
+ "--body-json",
127
+ default=None,
128
+ help=_cli_txt(
129
+ "Unified filter JSON (UnifiedSearchRequest): chart fields plus optional taxon_month, report_month, outside_type for drill-down.",
130
+ "统一筛选 JSON(UnifiedSearchRequest):图表字段 + 下钻可选 taxon_month、report_month、outside_type。",
131
+ ),
132
+ )
133
+ @click.option(
134
+ "--schema",
135
+ is_flag=True,
136
+ help=_cli_txt(
137
+ "Print JSON Schemas for request (UnifiedSearchRequest) and response (UnifiedSearchResult) only (no HTTP).",
138
+ "仅打印请求(UnifiedSearchRequest)与响应(UnifiedSearchResult)的 JSON Schema(不发起 HTTP)。",
139
+ ),
140
+ )
141
+ @click.pass_context
142
+ @with_client_config
143
+ def cmd_search(
144
+ ctx: click.Context,
145
+ want_taxon: bool,
146
+ want_report: bool,
147
+ report_map: str | None,
148
+ report_limit: int | None,
149
+ body_json: str | None,
150
+ schema: bool,
151
+ ) -> None:
152
+ if report_map and not want_report:
153
+ raise click.UsageError(
154
+ _cli_txt(
155
+ "--report-map requires --report.",
156
+ "--report-map 需要与 --report 一起使用。",
157
+ )
158
+ )
159
+ if schema:
160
+ click.echo(
161
+ json_schema_text_object(
162
+ {
163
+ "request": UnifiedSearchRequest,
164
+ "response": UnifiedSearchResult,
165
+ }
166
+ )
167
+ )
168
+ return
169
+ cfg = ctx.obj
170
+ assert isinstance(cfg, CliConfig)
171
+ raw_body = parse_cli_body_json(body_json)
172
+ unified = coerce_unified_search_request(raw_body)
173
+ with client_from_cfg(cfg) as client:
174
+ stat_fetch = client.fetch_search_statistic(
175
+ unified_search_to_region_chart(unified),
176
+ collect_envelopes=cfg.envelope,
177
+ )
178
+ taxon_rows: list[ChartActivityTaxonRow] | None = None
179
+ report_rows: list[ChartActivityReportRow] | None = None
180
+ envelopes = dict(stat_fetch.envelopes)
181
+ if want_taxon or want_report:
182
+ base = unified_search_to_common_activity(unified)
183
+ if want_taxon:
184
+ tcall = client.common_list_activity_taxon(
185
+ build_common_list_taxon_request(base)
186
+ )
187
+ taxon_rows = tcall.payload
188
+ if cfg.envelope:
189
+ envelopes["taxon"] = tcall.envelope.model_dump()
190
+ if want_report:
191
+ report_rows = []
192
+ page = 1
193
+ while True:
194
+ rcall = client.common_page_activity(
195
+ build_common_page_activity_request(
196
+ base,
197
+ report_month=unified.report_month,
198
+ start=page,
199
+ )
200
+ )
201
+ batch = rcall.payload or []
202
+ report_rows.extend(batch)
203
+ if cfg.envelope:
204
+ envelopes[f"report_page_{page}"] = (
205
+ rcall.envelope.model_dump()
206
+ )
207
+ # Stop if no rows returned
208
+ if not batch:
209
+ break
210
+ # Stop if reached the limit
211
+ if (
212
+ report_limit is not None
213
+ and len(report_rows) >= report_limit
214
+ ):
215
+ report_rows = report_rows[:report_limit]
216
+ break
217
+ # Stop if we've consumed all available pages
218
+ env = rcall.envelope
219
+ total = getattr(env, "total", None)
220
+ size = getattr(env, "size", None) or len(batch)
221
+ if total is not None and len(report_rows) >= total:
222
+ break
223
+ if len(batch) < size:
224
+ break
225
+ page += 1
226
+ report_map_ref: str | None = None
227
+ if report_map and report_rows is not None:
228
+ if report_map.strip().casefold() == "online":
229
+ html = render_report_map_html(
230
+ report_rows,
231
+ province=unified.province,
232
+ city=unified.city,
233
+ district=unified.district,
234
+ )
235
+ try:
236
+ report_map_ref = upload_report_map_html(
237
+ html,
238
+ timeout=cfg.timeout,
239
+ )
240
+ except (requests.RequestException, ValueError) as exc:
241
+ raise click.ClickException(
242
+ _cli_txt(
243
+ f"Failed to upload REPORT MAP ONLINE: {exc}",
244
+ f"REPORT MAP ONLINE 上传失败:{exc}",
245
+ )
246
+ ) from exc
247
+ click.echo(
248
+ _cli_txt(
249
+ f"Generated REPORT MAP URL: {report_map_ref}",
250
+ f"已生成 REPORT MAP URL:{report_map_ref}",
251
+ ),
252
+ err=True,
253
+ )
254
+ else:
255
+ out_path = write_report_map_html(
256
+ report_rows,
257
+ output_path=report_map,
258
+ province=unified.province,
259
+ city=unified.city,
260
+ district=unified.district,
261
+ )
262
+ report_map_ref = str(out_path)
263
+ click.echo(
264
+ _cli_txt(
265
+ f"Generated REPORT MAP HTML: {out_path}",
266
+ f"已生成 REPORT MAP HTML:{out_path}",
267
+ ),
268
+ err=True,
269
+ )
270
+ out = UnifiedSearchResult(
271
+ statistic=stat_fetch.result,
272
+ taxon=taxon_rows,
273
+ report=report_rows,
274
+ report_map=report_map_ref,
275
+ )
276
+ if cfg.envelope:
277
+ emit_json(
278
+ {"envelope": envelopes, "payload": out.model_dump(mode="json")},
279
+ pretty=cfg.pretty,
280
+ )
281
+ else:
282
+ emit_json(out.model_dump(mode="json"), pretty=cfg.pretty)
@@ -0,0 +1,433 @@
1
+ """Generate Baidu report-map HTML from common/page activity rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from birdrecord_cli.models.client import ChartActivityReportRow
12
+
13
+ BAIDU_AK = "rN63jsl7xBONLrQFzzqjCx0kdds5BJa7"
14
+ UPLOAD_URL = "https://htmlbin.yoshino-s.workers.dev/create"
15
+
16
+
17
+ def _parse_location(location: str) -> tuple[float, float] | None:
18
+ raw = (location or "").strip()
19
+ if not raw:
20
+ return None
21
+ parts = [p.strip() for p in raw.split(",")]
22
+ if len(parts) != 2:
23
+ return None
24
+ try:
25
+ lng = float(parts[0])
26
+ lat = float(parts[1])
27
+ except ValueError:
28
+ return None
29
+ if not (-180.0 <= lng <= 180.0 and -90.0 <= lat <= 90.0):
30
+ return None
31
+ return lng, lat
32
+
33
+
34
+ def build_report_map_points(rows: list[ChartActivityReportRow]) -> list[dict[str, Any]]:
35
+ out: list[dict[str, Any]] = []
36
+ for row in rows:
37
+ loc = _parse_location(row.location)
38
+ if loc is None:
39
+ continue
40
+ lng, lat = loc
41
+ out.append(
42
+ {
43
+ "id": row.id,
44
+ "title": row.name or row.serial_id or f"report-{row.id}",
45
+ "username": row.username,
46
+ "province_name": row.province_name,
47
+ "city_name": row.city_name,
48
+ "district_name": row.district_name,
49
+ "point_name": row.point_name,
50
+ "address": row.address,
51
+ "start_time": row.start_time,
52
+ "end_time": row.end_time,
53
+ "taxoncount": row.taxoncount,
54
+ "family_count": row.family_count,
55
+ "order_count": row.order_count,
56
+ "lng": lng,
57
+ "lat": lat,
58
+ }
59
+ )
60
+ return out
61
+
62
+
63
+ def _html_template(region_label: str, reports: list[dict[str, Any]]) -> str:
64
+ region_json = json.dumps(region_label, ensure_ascii=False)
65
+ report_json = json.dumps(reports, ensure_ascii=False)
66
+ return f"""<!doctype html>
67
+ <html lang=\"en\">
68
+ <head>
69
+ <meta charset=\"utf-8\" />
70
+ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
71
+ <title>REPORT MAP</title>
72
+ <style>
73
+ :root {{
74
+ --bg: #f4f6f9;
75
+ --card: rgba(255, 255, 255, 0.92);
76
+ --line: #d6dde8;
77
+ --text: #102339;
78
+ --muted: #5f7085;
79
+ --accent: #0c79f2;
80
+ --accent-strong: #0058ba;
81
+ }}
82
+
83
+ * {{ box-sizing: border-box; }}
84
+ html, body {{ margin: 0; padding: 0; height: 100%; font-family: "SF Mono", "Menlo", monospace; background: radial-gradient(circle at 20% 10%, #e7efff 0%, #f4f6f9 40%, #eef3f8 100%); color: var(--text); }}
85
+
86
+ .layout {{
87
+ display: grid;
88
+ grid-template-columns: 360px minmax(0, 1fr);
89
+ height: 100vh;
90
+ gap: 10px;
91
+ padding: 10px;
92
+ }}
93
+
94
+ .panel {{
95
+ background: var(--card);
96
+ border: 1px solid var(--line);
97
+ border-radius: 14px;
98
+ overflow: hidden;
99
+ backdrop-filter: blur(6px);
100
+ box-shadow: 0 10px 28px rgba(16, 35, 57, 0.12);
101
+ display: flex;
102
+ flex-direction: column;
103
+ }}
104
+
105
+ .panel-header {{ padding: 14px 14px 10px; border-bottom: 1px solid var(--line); }}
106
+ .title {{ margin: 0; font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; }}
107
+ .meta {{ margin-top: 8px; color: var(--muted); font-size: 12px; line-height: 1.4; }}
108
+
109
+ .summary {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); }}
110
+ .stat {{ border: 1px solid var(--line); border-radius: 10px; padding: 8px; background: #fff; }}
111
+ .stat .k {{ color: var(--muted); font-size: 11px; }}
112
+ .stat .v {{ margin-top: 4px; font-size: 18px; font-weight: 600; }}
113
+
114
+ .filter-wrap {{ padding: 10px 14px; border-bottom: 1px solid var(--line); }}
115
+ .filter-wrap input {{ width: 100%; border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; font-family: inherit; font-size: 12px; outline: none; }}
116
+ .filter-wrap input:focus {{ border-color: var(--accent); box-shadow: 0 0 0 2px rgba(12, 121, 242, 0.15); }}
117
+
118
+ .list {{ overflow: auto; padding: 8px; display: grid; gap: 8px; }}
119
+ .item {{ border: 1px solid var(--line); border-radius: 10px; padding: 8px; background: #fff; cursor: pointer; transition: transform 120ms ease, border-color 120ms ease; }}
120
+ .item:hover {{ transform: translateY(-1px); border-color: var(--accent); }}
121
+ .item.active {{ border-color: var(--accent-strong); box-shadow: 0 0 0 2px rgba(0, 88, 186, 0.16); }}
122
+ .item-title {{ font-size: 12px; font-weight: 700; line-height: 1.4; }}
123
+ .item-sub {{ margin-top: 4px; font-size: 11px; color: var(--muted); line-height: 1.35; }}
124
+
125
+ .map-wrap {{
126
+ border: 1px solid var(--line);
127
+ border-radius: 14px;
128
+ overflow: hidden;
129
+ box-shadow: 0 10px 28px rgba(16, 35, 57, 0.12);
130
+ background: #dfe7f2;
131
+ position: relative;
132
+ min-height: 420px;
133
+ }}
134
+ #map {{ width: 100%; height: 100%; min-height: 420px; }}
135
+
136
+ .hint {{
137
+ position: absolute;
138
+ right: 10px;
139
+ bottom: 10px;
140
+ z-index: 2;
141
+ border: 1px solid var(--line);
142
+ border-radius: 10px;
143
+ background: rgba(255, 255, 255, 0.92);
144
+ padding: 8px 10px;
145
+ font-size: 11px;
146
+ color: var(--muted);
147
+ backdrop-filter: blur(4px);
148
+ }}
149
+
150
+ @media (max-width: 980px) {{
151
+ .layout {{
152
+ grid-template-columns: 1fr;
153
+ grid-template-rows: minmax(320px, 48vh) minmax(0, 1fr);
154
+ }}
155
+ .panel {{ order: 2; }}
156
+ .map-wrap {{ order: 1; min-height: 320px; }}
157
+ #map {{ min-height: 320px; }}
158
+ }}
159
+ </style>
160
+ </head>
161
+ <body>
162
+ <div class=\"layout\">
163
+ <aside class=\"panel\">
164
+ <div class=\"panel-header\">
165
+ <h1 class=\"title\">REPORT MAP</h1>
166
+ <div class=\"meta\" id=\"regionMeta\"></div>
167
+ </div>
168
+ <div class=\"summary\">
169
+ <div class=\"stat\"><div class=\"k\">Total Reports</div><div class=\"v\" id=\"totalReports\">0</div></div>
170
+ <div class=\"stat\"><div class=\"k\">Plotted Points</div><div class=\"v\" id=\"plottedReports\">0</div></div>
171
+ </div>
172
+ <div class=\"filter-wrap\">
173
+ <input id=\"filterInput\" type=\"text\" placeholder=\"filter by title / point / district\" />
174
+ </div>
175
+ <div class=\"list\" id=\"reportList\"></div>
176
+ </aside>
177
+
178
+ <section class=\"map-wrap\">
179
+ <div id=\"map\"></div>
180
+ <div class=\"hint\">click list item to locate marker</div>
181
+ </section>
182
+ </div>
183
+
184
+ <script src=\"https://api.map.baidu.com/api?v=3.0&ak={BAIDU_AK}\"></script>
185
+ <script>
186
+ const REGION_LABEL = {region_json};
187
+ const REPORTS = {report_json};
188
+
189
+ const totalReportsEl = document.getElementById("totalReports");
190
+ const plottedReportsEl = document.getElementById("plottedReports");
191
+ const regionMetaEl = document.getElementById("regionMeta");
192
+ const reportListEl = document.getElementById("reportList");
193
+ const filterInputEl = document.getElementById("filterInput");
194
+
195
+ const map = new BMap.Map("map");
196
+ map.enableScrollWheelZoom(true);
197
+ map.enableKeyboard();
198
+ map.addControl(new BMap.NavigationControl());
199
+ map.addControl(new BMap.ScaleControl());
200
+
201
+ const markerById = new Map();
202
+ const reportById = new Map(REPORTS.map((r) => [r.id, r]));
203
+ let activeId = null;
204
+
205
+ function escapeHtml(text) {{
206
+ return String(text || "")
207
+ .replaceAll("&", "&amp;")
208
+ .replaceAll("<", "&lt;")
209
+ .replaceAll(">", "&gt;")
210
+ .replaceAll('"', "&quot;")
211
+ .replaceAll("'", "&#39;");
212
+ }}
213
+
214
+ function itemSearchText(item) {{
215
+ return [item.title, item.point_name, item.district_name, item.address].join(" ").toLowerCase();
216
+ }}
217
+
218
+ function markerPageUrl(item) {{
219
+ const title = item.title || `report-${{item.id}}`;
220
+ const content = [
221
+ item.point_name || "",
222
+ item.address || "",
223
+ [item.province_name, item.city_name, item.district_name].filter(Boolean).join("/"),
224
+ ].filter(Boolean).join("|");
225
+ const params = new URLSearchParams({{
226
+ location: `${{item.lat}},${{item.lng}}`,
227
+ title,
228
+ content,
229
+ output: "html",
230
+ src: "webapp.baidu.openAPIdemo",
231
+ }});
232
+ return `http://api.map.baidu.com/marker?${{params.toString()}}`;
233
+ }}
234
+
235
+ function openMarkerPageById(id) {{
236
+ const item = reportById.get(id);
237
+ if (!item) return;
238
+ window.open(markerPageUrl(item), "_blank", "noopener,noreferrer");
239
+ }}
240
+
241
+ function popupHtml(item) {{
242
+ return `
243
+ <div style=\"font-family: Menlo, Monaco, monospace; font-size: 12px; line-height: 1.4; max-width: 280px;\">
244
+ <div style=\"font-weight: 700; margin-bottom: 6px;\">${{escapeHtml(item.title)}}</div>
245
+ <div><b>ID</b>: ${{item.id}}</div>
246
+ <div><b>User</b>: ${{escapeHtml(item.username)}}</div>
247
+ <div><b>Region</b>: ${{escapeHtml([item.province_name, item.city_name, item.district_name].filter(Boolean).join("/"))}}</div>
248
+ <div><b>Point</b>: ${{escapeHtml(item.point_name)}}</div>
249
+ <div><b>Address</b>: ${{escapeHtml(item.address)}}</div>
250
+ <div><b>Time</b>: ${{escapeHtml(item.start_time)}} ~ ${{escapeHtml(item.end_time)}}</div>
251
+ <div><b>Taxon</b>: ${{item.taxoncount}} | <b>Family</b>: ${{item.family_count}} | <b>Order</b>: ${{item.order_count}}</div>
252
+ <div style=\"margin-top: 8px;\">
253
+ <button type=\"button\" onclick=\"openMarkerPageById(${{item.id}})\" style=\"border: 1px solid #d6dde8; background: #0c79f2; color: #fff; border-radius: 6px; padding: 4px 8px; font-family: Menlo, Monaco, monospace; font-size: 12px; cursor: pointer;\">Open Baidu Marker</button>
254
+ </div>
255
+ </div>
256
+ `;
257
+ }}
258
+
259
+ function setActiveItem(id) {{
260
+ activeId = id;
261
+ for (const el of reportListEl.querySelectorAll(".item")) {{
262
+ if (Number(el.dataset.id) === id) {{
263
+ el.classList.add("active");
264
+ el.scrollIntoView({{ block: "nearest" }});
265
+ }} else {{
266
+ el.classList.remove("active");
267
+ }}
268
+ }}
269
+ }}
270
+
271
+ function focusReport(item) {{
272
+ const marker = markerById.get(item.id);
273
+ if (!marker) return;
274
+ setActiveItem(item.id);
275
+ map.panTo(new BMap.Point(item.lng, item.lat));
276
+ map.openInfoWindow(new BMap.InfoWindow(popupHtml(item), {{ width: 320, title: escapeHtml(item.title) }}), marker.getPosition());
277
+ }}
278
+
279
+ function renderList(filtered) {{
280
+ reportListEl.innerHTML = "";
281
+ if (!filtered.length) {{
282
+ const empty = document.createElement("div");
283
+ empty.className = "item";
284
+ empty.style.cursor = "default";
285
+ empty.textContent = "No rows match current filter";
286
+ reportListEl.appendChild(empty);
287
+ return;
288
+ }}
289
+
290
+ for (const item of filtered) {{
291
+ const row = document.createElement("div");
292
+ row.className = "item";
293
+ row.dataset.id = String(item.id);
294
+ row.innerHTML = `
295
+ <div class=\"item-title\">${{escapeHtml(item.title)}}</div>
296
+ <div class=\"item-sub\">${{escapeHtml(item.point_name || item.address || "(no place)")}}</div>
297
+ <div class=\"item-sub\">${{escapeHtml(item.start_time)}} | taxon=${{item.taxoncount}}</div>
298
+ `;
299
+ row.addEventListener("click", () => focusReport(item));
300
+ reportListEl.appendChild(row);
301
+ }}
302
+ if (activeId !== null) {{
303
+ setActiveItem(activeId);
304
+ }}
305
+ }}
306
+
307
+ function init() {{
308
+ totalReportsEl.textContent = String(REPORTS.length);
309
+ plottedReportsEl.textContent = String(REPORTS.length);
310
+ regionMetaEl.textContent = REGION_LABEL || "region metadata not provided";
311
+
312
+ const points = [];
313
+ for (const item of REPORTS) {{
314
+ const point = new BMap.Point(item.lng, item.lat);
315
+ points.push(point);
316
+
317
+ const marker = new BMap.Marker(point);
318
+ markerById.set(item.id, marker);
319
+ map.addOverlay(marker);
320
+
321
+ marker.addEventListener("click", () => {{
322
+ setActiveItem(item.id);
323
+ map.openInfoWindow(new BMap.InfoWindow(popupHtml(item), {{ width: 320, title: escapeHtml(item.title) }}), point);
324
+ }});
325
+ }}
326
+
327
+ if (points.length) {{
328
+ const viewport = map.getViewport(points);
329
+ map.centerAndZoom(viewport.center, Math.max(viewport.zoom - 1, 6));
330
+ }} else if (REGION_LABEL) {{
331
+ const geocoder = new BMap.Geocoder();
332
+ geocoder.getPoint(REGION_LABEL, (pt) => {{
333
+ if (pt) {{
334
+ map.centerAndZoom(pt, 10);
335
+ }} else {{
336
+ map.centerAndZoom(new BMap.Point(116.404, 39.915), 5);
337
+ }}
338
+ }});
339
+ }} else {{
340
+ map.centerAndZoom(new BMap.Point(116.404, 39.915), 5);
341
+ }}
342
+
343
+ renderList(REPORTS);
344
+
345
+ filterInputEl.addEventListener("input", () => {{
346
+ const q = filterInputEl.value.trim().toLowerCase();
347
+ if (!q) {{
348
+ renderList(REPORTS);
349
+ return;
350
+ }}
351
+ const filtered = REPORTS.filter((r) => itemSearchText(r).includes(q));
352
+ renderList(filtered);
353
+ }});
354
+ }}
355
+
356
+ init();
357
+ </script>
358
+ </body>
359
+ </html>
360
+ """
361
+
362
+
363
+ def _region_label_from_rows_or_filters(
364
+ rows: list[ChartActivityReportRow],
365
+ *,
366
+ province: str,
367
+ city: str,
368
+ district: str,
369
+ ) -> str:
370
+ from_filters = "/".join(v for v in (province, city, district) if v)
371
+ if from_filters:
372
+ return from_filters
373
+ if rows:
374
+ first = rows[0]
375
+ return "/".join(
376
+ v for v in (first.province_name, first.city_name, first.district_name) if v
377
+ )
378
+ return ""
379
+
380
+
381
+ def render_report_map_html(
382
+ rows: list[ChartActivityReportRow],
383
+ *,
384
+ province: str = "",
385
+ city: str = "",
386
+ district: str = "",
387
+ ) -> str:
388
+ points = build_report_map_points(rows)
389
+ region_label = _region_label_from_rows_or_filters(
390
+ rows,
391
+ province=province,
392
+ city=city,
393
+ district=district,
394
+ )
395
+ return _html_template(region_label=region_label, reports=points)
396
+
397
+
398
+ def write_report_map_html(
399
+ rows: list[ChartActivityReportRow],
400
+ *,
401
+ output_path: str,
402
+ province: str = "",
403
+ city: str = "",
404
+ district: str = "",
405
+ ) -> Path:
406
+ html = render_report_map_html(
407
+ rows,
408
+ province=province,
409
+ city=city,
410
+ district=district,
411
+ )
412
+ out = Path(output_path)
413
+ out.parent.mkdir(parents=True, exist_ok=True)
414
+ out.write_text(html, encoding="utf-8")
415
+ return out
416
+
417
+
418
+ def upload_report_map_html(
419
+ html: str,
420
+ *,
421
+ timeout: float = 60.0,
422
+ ) -> str:
423
+ resp = requests.post(
424
+ UPLOAD_URL,
425
+ json={"html": html},
426
+ timeout=timeout,
427
+ )
428
+ resp.raise_for_status()
429
+ data = resp.json()
430
+ view_url = data.get("viewUrl")
431
+ if not isinstance(view_url, str):
432
+ raise ValueError("online upload response missing viewUrl")
433
+ return view_url
@@ -7,7 +7,13 @@ from typing import Any
7
7
  import click
8
8
 
9
9
  from birdrecord_cli.client import BirdrecordClient, _taxon_call_for_emit
10
- from birdrecord_cli.cli.core import CliConfig, client_from_cfg, emit_call, json_schema_text, with_client_config
10
+ from birdrecord_cli.cli.core import (
11
+ CliConfig,
12
+ client_from_cfg,
13
+ emit_call,
14
+ json_schema_text,
15
+ with_client_config,
16
+ )
11
17
  from birdrecord_cli.constants import DEFAULT_TAXON_VERSION
12
18
  from birdrecord_cli.i18n import _cli_txt
13
19
  from birdrecord_cli.models.client import (
@@ -2,6 +2,7 @@
2
2
 
3
3
  import os
4
4
 
5
+
5
6
  def _cli_use_zh_cn() -> bool:
6
7
  """True when BIRDRECORD_CLI_CN is set to a truthy value (0/false/no/off disable)."""
7
8
  raw = os.environ.get("BIRDRECORD_CLI_CN")
@@ -46,3 +46,10 @@ class UnifiedSearchResult(BaseModel):
46
46
  "传了 --report 时为分页记录;未传该标志时为 null。",
47
47
  ),
48
48
  )
49
+ report_map: str | None = Field(
50
+ default=None,
51
+ description=_schema_txt(
52
+ "REPORT MAP reference when --report-map is used: local output path or online URL.",
53
+ "传 --report-map 时的 REPORT MAP 引用:本地输出路径或在线 URL。",
54
+ ),
55
+ )
@@ -81,7 +81,9 @@ def unified_search_to_region_chart(body: UnifiedSearchRequest) -> RegionChartReq
81
81
  return RegionChartRequest.model_validate(data)
82
82
 
83
83
 
84
- def unified_search_to_common_activity(body: UnifiedSearchRequest) -> CommonActivityRequest:
84
+ def unified_search_to_common_activity(
85
+ body: UnifiedSearchRequest,
86
+ ) -> CommonActivityRequest:
85
87
  """Map chart taxonid (int) to activity string taxonid; drop report_month (page request adds it)."""
86
88
  data = body.model_dump(exclude={"report_month"}, mode="python")
87
89
  tid = data.get("taxonid", 0)
@@ -21,12 +21,8 @@ class ChartActivityTaxonRow(BaseModel):
21
21
  )
22
22
 
23
23
  taxon_id: int = Field(..., description=_schema_txt("Taxon id.", "鸟种 ID。"))
24
- taxonname: str = Field(
25
- ..., description=_schema_txt("Chinese name.", "中文名。")
26
- )
27
- latinname: str = Field(
28
- ..., description=_schema_txt("Latin name.", "拉丁名。")
29
- )
24
+ taxonname: str = Field(..., description=_schema_txt("Chinese name.", "中文名。"))
25
+ latinname: str = Field(..., description=_schema_txt("Latin name.", "拉丁名。"))
30
26
  englishname: str = Field(
31
27
  default="",
32
28
  description=_schema_txt("English name.", "英文名。"),
@@ -143,7 +143,7 @@ class CommonPageActivityRequest(CommonActivityRequest):
143
143
  description=_schema_txt("1-based page.", "从 1 开始的页码。"),
144
144
  )
145
145
  limit: int = Field(
146
- default=15,
146
+ default=50,
147
147
  ge=1,
148
148
  description=_schema_txt("Page size.", "每页条数。"),
149
149
  )
@@ -186,9 +186,11 @@ def build_common_page_activity_request(
186
186
  base: CommonActivityRequest,
187
187
  *,
188
188
  report_month: str | None = None,
189
+ start: int = 1,
189
190
  ) -> CommonPageActivityRequest:
190
191
  """Attach ``searchChartActivity`` sqlid and page defaults to shared activity filters."""
191
192
  d = base.model_dump()
192
193
  if report_month is not None:
193
194
  d["report_month"] = report_month
195
+ d["start"] = start
194
196
  return CommonPageActivityRequest.model_validate(d)
@@ -79,9 +79,7 @@ class TaxonRow(BaseModel):
79
79
  default=None,
80
80
  description=_schema_txt("Family (Chinese).", "科名(中文)。"),
81
81
  )
82
- uuid: Any | None = Field(
83
- default=None, description=_schema_txt("Uuid.", "UUID。")
84
- )
82
+ uuid: Any | None = Field(default=None, description=_schema_txt("Uuid.", "UUID。"))
85
83
  serial_id: Any | None = Field(
86
84
  default=None, description=_schema_txt("Serial.", "流水号。")
87
85
  )
@@ -145,7 +143,9 @@ def _taxon_search_cache_path(version: str) -> Path:
145
143
  return _taxon_search_cache_dir() / f"{h}.json"
146
144
 
147
145
 
148
- def _load_taxon_search_disk(version: str) -> tuple[dict[str, Any], list[TaxonRow]] | None:
146
+ def _load_taxon_search_disk(
147
+ version: str,
148
+ ) -> tuple[dict[str, Any], list[TaxonRow]] | None:
149
149
  path = _taxon_search_cache_path(version)
150
150
  try:
151
151
  doc = json.loads(path.read_text(encoding="utf-8"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: birdrecord-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: CLI for China Bird Record (birdreport.cn); default API host weixin.birdrecord.cn.
5
5
  Author: yoshino-s
6
6
  License-Expression: MIT
@@ -69,8 +69,8 @@ Package index: [pypi.org/project/birdrecord-cli](https://pypi.org/project/birdre
69
69
  [uv](https://docs.astral.sh/uv/) downloads the package into an ephemeral environment. Pin the version for reproducible behavior:
70
70
 
71
71
  ```bash
72
- uvx --from 'birdrecord-cli==0.1.2' birdrecord-cli --help
73
- uvx --from 'birdrecord-cli==0.1.2' birdrecord-cli provinces --pretty
72
+ uvx --from 'birdrecord-cli==0.1.3' birdrecord-cli --help
73
+ uvx --from 'birdrecord-cli==0.1.3' birdrecord-cli provinces --pretty
74
74
  ```
75
75
 
76
76
  Use the latest release version from PyPI if it differs from the example above.
@@ -18,6 +18,7 @@ birdrecord_cli/cli/core.py
18
18
  birdrecord_cli/cli/adcode/__init__.py
19
19
  birdrecord_cli/cli/report/__init__.py
20
20
  birdrecord_cli/cli/search/__init__.py
21
+ birdrecord_cli/cli/search/report_map.py
21
22
  birdrecord_cli/cli/taxon/__init__.py
22
23
  birdrecord_cli/models/__init__.py
23
24
  birdrecord_cli/models/cli/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "birdrecord-cli"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "CLI for China Bird Record (birdreport.cn); default API host weixin.birdrecord.cn."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -69,7 +69,7 @@ def test_common_activity_body_builds_sqlid_requests():
69
69
  pg = build_common_page_activity_request(base)
70
70
  assert pg.sqlid == "searchChartActivity"
71
71
  assert pg.start == 1
72
- assert pg.limit == 15
72
+ assert pg.limit == 50
73
73
  assert pg.report_month == ""
74
74
  pg2 = build_common_page_activity_request(base, report_month="03")
75
75
  assert pg2.report_month == "03"
@@ -98,7 +98,9 @@ def test_unified_search_request_month_fields() -> None:
98
98
 
99
99
  def test_request_models_serialize():
100
100
  assert AdcodeProvinceRequest().model_dump() == {}
101
- assert AdcodeCityRequest(province_code="130000").model_dump() == {"province_code": "130000"}
101
+ assert AdcodeCityRequest(province_code="130000").model_dump() == {
102
+ "province_code": "130000"
103
+ }
102
104
  assert TaxonSearchRequest().version == DEFAULT_TAXON_VERSION
103
105
  assert "version" in TaxonSearchRequest().model_dump()
104
106
 
@@ -176,28 +178,53 @@ def test_filter_region_rows_by_query() -> None:
176
178
  ProvinceRow(province_code="110000", province_name="北京市"),
177
179
  ProvinceRow(province_code="130000", province_name="河北省"),
178
180
  ]
179
- assert len(filter_region_rows_by_query(provinces, None, label_attr="province_name")) == 2
180
- assert len(filter_region_rows_by_query(provinces, "", label_attr="province_name")) == 2
181
- assert len(filter_region_rows_by_query(provinces, " ", label_attr="province_name")) == 2
182
- assert {r.province_code for r in filter_region_rows_by_query(provinces, "河北", label_attr="province_name")} == {
183
- "130000"
184
- }
185
- assert {r.province_code for r in filter_region_rows_by_query(provinces, "hebei", label_attr="province_name")} == {
186
- "130000"
187
- }
188
- assert {r.province_code for r in filter_region_rows_by_query(provinces, "HEBEI", label_attr="province_name")} == {
189
- "130000"
190
- }
191
- assert {r.province_code for r in filter_region_rows_by_query(provinces, "hb", label_attr="province_name")} == {
192
- "130000"
193
- }
181
+ assert (
182
+ len(filter_region_rows_by_query(provinces, None, label_attr="province_name"))
183
+ == 2
184
+ )
185
+ assert (
186
+ len(filter_region_rows_by_query(provinces, "", label_attr="province_name")) == 2
187
+ )
188
+ assert (
189
+ len(filter_region_rows_by_query(provinces, " ", label_attr="province_name"))
190
+ == 2
191
+ )
192
+ assert {
193
+ r.province_code
194
+ for r in filter_region_rows_by_query(
195
+ provinces, "河北", label_attr="province_name"
196
+ )
197
+ } == {"130000"}
198
+ assert {
199
+ r.province_code
200
+ for r in filter_region_rows_by_query(
201
+ provinces, "hebei", label_attr="province_name"
202
+ )
203
+ } == {"130000"}
204
+ assert {
205
+ r.province_code
206
+ for r in filter_region_rows_by_query(
207
+ provinces, "HEBEI", label_attr="province_name"
208
+ )
209
+ } == {"130000"}
210
+ assert {
211
+ r.province_code
212
+ for r in filter_region_rows_by_query(
213
+ provinces, "hb", label_attr="province_name"
214
+ )
215
+ } == {"130000"}
194
216
  cities = [
195
217
  CityRow(city_code="110100", city_name="市辖区"),
196
218
  CityRow(city_code="110200", city_name="县"),
197
219
  ]
198
220
  assert len(filter_region_rows_by_query(cities, "辖区", label_attr="city_name")) == 1
199
- assert len(filter_region_rows_by_query(cities, "xiaqu", label_attr="city_name")) == 1
200
- assert len(filter_region_rows_by_query(provinces, "zzz", label_attr="province_name")) == 0
221
+ assert (
222
+ len(filter_region_rows_by_query(cities, "xiaqu", label_attr="city_name")) == 1
223
+ )
224
+ assert (
225
+ len(filter_region_rows_by_query(provinces, "zzz", label_attr="province_name"))
226
+ == 0
227
+ )
201
228
 
202
229
 
203
230
  def test_taxon_search_uses_default_version(client: BirdrecordClient) -> None:
@@ -1,143 +0,0 @@
1
- """CLI: chart search statistic and optional activity drill-down."""
2
-
3
- from __future__ import annotations
4
-
5
- import click
6
-
7
- from birdrecord_cli.cli.core import (
8
- CliConfig,
9
- client_from_cfg,
10
- emit_json,
11
- json_schema_text_object,
12
- parse_cli_body_json,
13
- with_client_config,
14
- )
15
- from birdrecord_cli.i18n import _cli_txt
16
- from birdrecord_cli.models.client import (
17
- ChartActivityReportRow,
18
- ChartActivityTaxonRow,
19
- build_common_list_taxon_request,
20
- build_common_page_activity_request,
21
- )
22
- from birdrecord_cli.models.cli import (
23
- UnifiedSearchRequest,
24
- UnifiedSearchResult,
25
- coerce_unified_search_request,
26
- unified_search_to_common_activity,
27
- unified_search_to_region_chart,
28
- )
29
-
30
-
31
- def register_search_commands(group: click.Group) -> None:
32
- @group.command(
33
- "search",
34
- short_help=_cli_txt(
35
- "Chart search statistic; optional activity --taxon / --report.",
36
- "图表检索统计;可选活动下钻 --taxon / --report。",
37
- ),
38
- help=_cli_txt(
39
- (
40
- "Chart search: per-month breakdown and rolled-up totals (--body-json). "
41
- "Add --taxon for species ranking and/or --report for paged cards; omit both to skip those calls."
42
- ),
43
- (
44
- "图表检索:按月拆分与汇总(--body-json)。"
45
- "需要活动下钻时加 --taxon(鸟种排行)和/或 --report(分页记录);两者都不传则不请求这两项。"
46
- ),
47
- ),
48
- )
49
- @click.option(
50
- "--taxon",
51
- "want_taxon",
52
- is_flag=True,
53
- help=_cli_txt(
54
- "Include per-species record counts for the chart month (common/list).",
55
- "包含图表月份内各鸟种记录数(common/list)。",
56
- ),
57
- )
58
- @click.option(
59
- "--report",
60
- "want_report",
61
- is_flag=True,
62
- help=_cli_txt(
63
- "Include paged observation report cards (common/page).",
64
- "包含分页观鸟记录卡片(common/page)。",
65
- ),
66
- )
67
- @click.option(
68
- "--body-json",
69
- default=None,
70
- help=_cli_txt(
71
- "Unified filter JSON (UnifiedSearchRequest): chart fields plus optional taxon_month, report_month, outside_type for drill-down.",
72
- "统一筛选 JSON(UnifiedSearchRequest):图表字段 + 下钻可选 taxon_month、report_month、outside_type。",
73
- ),
74
- )
75
- @click.option(
76
- "--schema",
77
- is_flag=True,
78
- help=_cli_txt(
79
- "Print JSON Schemas for request (UnifiedSearchRequest) and response (UnifiedSearchResult) only (no HTTP).",
80
- "仅打印请求(UnifiedSearchRequest)与响应(UnifiedSearchResult)的 JSON Schema(不发起 HTTP)。",
81
- ),
82
- )
83
- @click.pass_context
84
- @with_client_config
85
- def cmd_search(
86
- ctx: click.Context,
87
- want_taxon: bool,
88
- want_report: bool,
89
- body_json: str | None,
90
- schema: bool,
91
- ) -> None:
92
- if schema:
93
- click.echo(
94
- json_schema_text_object(
95
- {
96
- "request": UnifiedSearchRequest,
97
- "response": UnifiedSearchResult,
98
- }
99
- )
100
- )
101
- return
102
- cfg = ctx.obj
103
- assert isinstance(cfg, CliConfig)
104
- raw_body = parse_cli_body_json(body_json)
105
- unified = coerce_unified_search_request(raw_body)
106
- with client_from_cfg(cfg) as client:
107
- stat_fetch = client.fetch_search_statistic(
108
- unified_search_to_region_chart(unified),
109
- collect_envelopes=cfg.envelope,
110
- )
111
- taxon_rows: list[ChartActivityTaxonRow] | None = None
112
- report_rows: list[ChartActivityReportRow] | None = None
113
- envelopes = dict(stat_fetch.envelopes)
114
- if want_taxon or want_report:
115
- base = unified_search_to_common_activity(unified)
116
- if want_taxon:
117
- tcall = client.common_list_activity_taxon(
118
- build_common_list_taxon_request(base)
119
- )
120
- taxon_rows = tcall.payload
121
- if cfg.envelope:
122
- envelopes["taxon"] = tcall.envelope.model_dump()
123
- if want_report:
124
- rcall = client.common_page_activity(
125
- build_common_page_activity_request(
126
- base, report_month=unified.report_month
127
- )
128
- )
129
- report_rows = rcall.payload
130
- if cfg.envelope:
131
- envelopes["report"] = rcall.envelope.model_dump()
132
- out = UnifiedSearchResult(
133
- statistic=stat_fetch.result,
134
- taxon=taxon_rows,
135
- report=report_rows,
136
- )
137
- if cfg.envelope:
138
- emit_json(
139
- {"envelope": envelopes, "payload": out.model_dump(mode="json")},
140
- pretty=cfg.pretty,
141
- )
142
- else:
143
- emit_json(out.model_dump(mode="json"), pretty=cfg.pretty)
File without changes
@@ -9,6 +9,7 @@ from Crypto.Util.Padding import unpad
9
9
 
10
10
  from birdrecord_cli.constants import AES_IV, AES_KEY
11
11
 
12
+
12
13
  def decrypt_aes_cbc_b64(ciphertext_b64: str) -> bytes:
13
14
  raw = base64.b64decode(ciphertext_b64)
14
15
  cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
@@ -28,4 +29,3 @@ def parse_encrypted_envelope(envelope: Mapping[str, Any]) -> Any:
28
29
  if envelope.get("result") is not None:
29
30
  return envelope["result"]
30
31
  return None
31
-
File without changes