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.
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/PKG-INFO +3 -3
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/README.md +2 -2
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/adcode/__init__.py +6 -2
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/report/__init__.py +7 -1
- birdrecord_cli-0.1.3/birdrecord_cli/cli/search/__init__.py +282 -0
- birdrecord_cli-0.1.3/birdrecord_cli/cli/search/report_map.py +433 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/taxon/__init__.py +7 -1
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/i18n.py +1 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/cli/stdout.py +7 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/cli/unified_search.py +3 -1
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/activity_payloads.py +2 -6
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/activity_requests.py +3 -1
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/taxon.py +4 -4
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/PKG-INFO +3 -3
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/SOURCES.txt +1 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/pyproject.toml +1 -1
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/tests/test_birdrecord_client.py +46 -19
- birdrecord_cli-0.1.2/birdrecord_cli/cli/search/__init__.py +0 -143
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/LICENSE +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/__init__.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/__init__.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli/core.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/cli_main.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/client.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/constants.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/crypto.py +1 -1
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/__init__.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/cli/__init__.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/__init__.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/adcode.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/chart_payloads.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/chart_requests.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/envelopes.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/report_payloads.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/report_requests.py +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/dependency_links.txt +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/entry_points.txt +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/requires.txt +0 -0
- {birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
73
|
-
uvx --from 'birdrecord-cli==0.1.
|
|
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.
|
|
43
|
-
uvx --from 'birdrecord-cli==0.1.
|
|
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(
|
|
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(
|
|
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
|
|
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("&", "&")
|
|
208
|
+
.replaceAll("<", "<")
|
|
209
|
+
.replaceAll(">", ">")
|
|
210
|
+
.replaceAll('"', """)
|
|
211
|
+
.replaceAll("'", "'");
|
|
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
|
|
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 (
|
|
@@ -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(
|
|
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)
|
{birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/activity_payloads.py
RENAMED
|
@@ -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
|
-
|
|
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.", "英文名。"),
|
{birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/activity_requests.py
RENAMED
|
@@ -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=
|
|
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(
|
|
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.
|
|
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.
|
|
73
|
-
uvx --from 'birdrecord-cli==0.1.
|
|
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
|
|
@@ -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 ==
|
|
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() == {
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
200
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/chart_payloads.py
RENAMED
|
File without changes
|
{birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/chart_requests.py
RENAMED
|
File without changes
|
|
File without changes
|
{birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/report_payloads.py
RENAMED
|
File without changes
|
{birdrecord_cli-0.1.2 → birdrecord_cli-0.1.3}/birdrecord_cli/models/client/report_requests.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|