amap-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- amap_cli/__init__.py +5 -0
- amap_cli/__main__.py +9 -0
- amap_cli/api.py +90 -0
- amap_cli/cli.py +129 -0
- amap_cli/config.py +155 -0
- amap_cli/distance.py +194 -0
- amap_cli/errors.py +87 -0
- amap_cli/geocode.py +61 -0
- amap_cli/output.py +45 -0
- amap_cli/params.py +77 -0
- amap_cli/route.py +625 -0
- amap_cli/search_poi.py +281 -0
- amap_cli-0.1.0.dist-info/METADATA +290 -0
- amap_cli-0.1.0.dist-info/RECORD +16 -0
- amap_cli-0.1.0.dist-info/WHEEL +4 -0
- amap_cli-0.1.0.dist-info/entry_points.txt +2 -0
amap_cli/route.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"""Route command argument registration and route planning handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from amap_cli.api import AmapApiClient
|
|
10
|
+
from amap_cli.errors import ApiResponseError, ValidationError
|
|
11
|
+
from amap_cli.geocode import geocode_text
|
|
12
|
+
from amap_cli.params import Coordinate, LocationInput, normalize_text_argument, parse_location_input
|
|
13
|
+
|
|
14
|
+
ROUTE_TYPES = ("driving", "walking", "riding", "transit")
|
|
15
|
+
DRIVING_POLICIES = ("fastest", "least_fee", "shortest", "no_highway", "avoid_jam")
|
|
16
|
+
TRANSIT_STRATEGIES = (
|
|
17
|
+
"fastest",
|
|
18
|
+
"least_cost",
|
|
19
|
+
"least_walk",
|
|
20
|
+
"most_comfort",
|
|
21
|
+
"no_subway",
|
|
22
|
+
)
|
|
23
|
+
DRIVING_POLICY_TO_STRATEGY = {
|
|
24
|
+
"fastest": "0",
|
|
25
|
+
"least_fee": "1",
|
|
26
|
+
"shortest": "2",
|
|
27
|
+
"no_highway": "3",
|
|
28
|
+
"avoid_jam": "4",
|
|
29
|
+
}
|
|
30
|
+
TRANSIT_STRATEGY_TO_POLICY = {
|
|
31
|
+
"fastest": "0",
|
|
32
|
+
"least_cost": "1",
|
|
33
|
+
"least_walk": "3",
|
|
34
|
+
"most_comfort": "4",
|
|
35
|
+
"no_subway": "5",
|
|
36
|
+
}
|
|
37
|
+
MAX_WAYPOINTS = 16
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class PreparedRouteRequest:
|
|
42
|
+
"""Validated route command arguments before geocoding."""
|
|
43
|
+
|
|
44
|
+
origin: LocationInput
|
|
45
|
+
origin_name: str | None
|
|
46
|
+
destination: LocationInput
|
|
47
|
+
destination_name: str | None
|
|
48
|
+
route_type: str
|
|
49
|
+
waypoints: list[LocationInput]
|
|
50
|
+
policy: str | None
|
|
51
|
+
strategy: str | None
|
|
52
|
+
city: str | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class ResolvedRoutePoint:
|
|
57
|
+
"""Route point after converting names to coordinates when needed."""
|
|
58
|
+
|
|
59
|
+
raw: str
|
|
60
|
+
name: str
|
|
61
|
+
coordinate: Coordinate
|
|
62
|
+
formatted_address: str | None = None
|
|
63
|
+
|
|
64
|
+
def to_state(self) -> dict[str, Any]:
|
|
65
|
+
"""Convert the point to the CLI state shape."""
|
|
66
|
+
state: dict[str, Any] = {
|
|
67
|
+
"name": self.name,
|
|
68
|
+
"position": [
|
|
69
|
+
round(self.coordinate.longitude, 6),
|
|
70
|
+
round(self.coordinate.latitude, 6),
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
if self.formatted_address is not None:
|
|
74
|
+
state["formattedAddress"] = self.formatted_address
|
|
75
|
+
return state
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def register_route_command(
|
|
79
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Register the `route` command and its arguments."""
|
|
82
|
+
parser = subparsers.add_parser("route", help="执行路径规划")
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--from",
|
|
85
|
+
dest="origin",
|
|
86
|
+
required=True,
|
|
87
|
+
help="起点,支持地名或 `经度,纬度`",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--from-name",
|
|
91
|
+
dest="origin_name",
|
|
92
|
+
help="起点显示名称,通常配合坐标使用",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--to",
|
|
96
|
+
dest="destination",
|
|
97
|
+
required=True,
|
|
98
|
+
help="终点,支持地名或 `经度,纬度`",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--to-name",
|
|
102
|
+
dest="destination_name",
|
|
103
|
+
help="终点显示名称,通常配合坐标使用",
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--type",
|
|
107
|
+
dest="route_type",
|
|
108
|
+
required=True,
|
|
109
|
+
choices=ROUTE_TYPES,
|
|
110
|
+
help="路径类型",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--waypoints",
|
|
114
|
+
help="加号分隔的途经点,仅支持 driving,例如 `A+B` 或 `116.1,39.9+中关村`",
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--policy",
|
|
118
|
+
choices=DRIVING_POLICIES,
|
|
119
|
+
help="驾车策略,仅支持 driving",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--strategy",
|
|
123
|
+
choices=TRANSIT_STRATEGIES,
|
|
124
|
+
help="公交策略,仅支持 transit",
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument("--city", help="公交路线规划的城市名")
|
|
127
|
+
parser.set_defaults(handler=handle_route_command)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def handle_route_command(
|
|
131
|
+
args: argparse.Namespace,
|
|
132
|
+
*,
|
|
133
|
+
client: AmapApiClient | None = None,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""Handle the `route` command and return normalized JSON data."""
|
|
136
|
+
prepared = _prepare_route_request(args)
|
|
137
|
+
api_client = client or AmapApiClient()
|
|
138
|
+
|
|
139
|
+
geocode_city = prepared.city if prepared.route_type == "transit" else None
|
|
140
|
+
origin = _resolve_route_point(
|
|
141
|
+
prepared.origin,
|
|
142
|
+
display_name=prepared.origin_name,
|
|
143
|
+
client=api_client,
|
|
144
|
+
city=geocode_city,
|
|
145
|
+
)
|
|
146
|
+
destination = _resolve_route_point(
|
|
147
|
+
prepared.destination,
|
|
148
|
+
display_name=prepared.destination_name,
|
|
149
|
+
client=api_client,
|
|
150
|
+
city=geocode_city,
|
|
151
|
+
)
|
|
152
|
+
waypoints = [
|
|
153
|
+
_resolve_route_point(waypoint, display_name=None, client=api_client, city=None)
|
|
154
|
+
for waypoint in prepared.waypoints
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
payload = _request_route_payload(
|
|
158
|
+
api_client,
|
|
159
|
+
route_type=prepared.route_type,
|
|
160
|
+
origin=origin,
|
|
161
|
+
destination=destination,
|
|
162
|
+
waypoints=waypoints,
|
|
163
|
+
policy=prepared.policy,
|
|
164
|
+
strategy=prepared.strategy,
|
|
165
|
+
city=prepared.city,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"state": _build_route_state(
|
|
170
|
+
origin=origin,
|
|
171
|
+
destination=destination,
|
|
172
|
+
waypoints=waypoints,
|
|
173
|
+
route_type=prepared.route_type,
|
|
174
|
+
policy=prepared.policy,
|
|
175
|
+
strategy=prepared.strategy,
|
|
176
|
+
city=prepared.city,
|
|
177
|
+
),
|
|
178
|
+
"summary": _build_route_summary(prepared.route_type, payload),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _prepare_route_request(args: argparse.Namespace) -> PreparedRouteRequest:
|
|
183
|
+
"""Validate command arguments and normalize the request model."""
|
|
184
|
+
origin = parse_location_input(args.origin, "from")
|
|
185
|
+
destination = parse_location_input(args.destination, "to")
|
|
186
|
+
route_type = normalize_text_argument(args.route_type, "type")
|
|
187
|
+
origin_name = _normalize_optional_text(args.origin_name, "from-name")
|
|
188
|
+
destination_name = _normalize_optional_text(args.destination_name, "to-name")
|
|
189
|
+
policy = _normalize_optional_text(args.policy, "policy")
|
|
190
|
+
strategy = _normalize_optional_text(args.strategy, "strategy")
|
|
191
|
+
city = _normalize_optional_text(args.city, "city")
|
|
192
|
+
waypoints = _parse_waypoints(args.waypoints)
|
|
193
|
+
|
|
194
|
+
if route_type != "driving" and waypoints:
|
|
195
|
+
raise ValidationError("`--waypoints` 仅支持 `--type driving`。")
|
|
196
|
+
if route_type != "driving" and policy is not None:
|
|
197
|
+
raise ValidationError("`--policy` 仅支持 `--type driving`。")
|
|
198
|
+
if route_type != "transit" and strategy is not None:
|
|
199
|
+
raise ValidationError("`--strategy` 仅支持 `--type transit`。")
|
|
200
|
+
if route_type == "transit" and city is None:
|
|
201
|
+
raise ValidationError("`--type transit` 时必须提供 `--city`。")
|
|
202
|
+
if route_type != "transit" and city is not None:
|
|
203
|
+
raise ValidationError("`--city` 仅支持 `--type transit`。")
|
|
204
|
+
|
|
205
|
+
return PreparedRouteRequest(
|
|
206
|
+
origin=origin,
|
|
207
|
+
origin_name=origin_name,
|
|
208
|
+
destination=destination,
|
|
209
|
+
destination_name=destination_name,
|
|
210
|
+
route_type=route_type,
|
|
211
|
+
waypoints=waypoints,
|
|
212
|
+
policy=policy,
|
|
213
|
+
strategy=strategy,
|
|
214
|
+
city=city,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
|
|
219
|
+
"""Trim an optional text argument when present."""
|
|
220
|
+
if value is None:
|
|
221
|
+
return None
|
|
222
|
+
return normalize_text_argument(value, field_name)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_waypoints(value: str | None) -> list[LocationInput]:
|
|
226
|
+
"""Parse `--waypoints` into validated route points."""
|
|
227
|
+
if value is None:
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
normalized = normalize_text_argument(value, "waypoints")
|
|
231
|
+
items = [item.strip() for item in normalized.split("+")]
|
|
232
|
+
if any(not item for item in items):
|
|
233
|
+
raise ValidationError(
|
|
234
|
+
"`--waypoints` 必须使用加号分隔非空途经点,例如 `A+B`。"
|
|
235
|
+
)
|
|
236
|
+
if len(items) > MAX_WAYPOINTS:
|
|
237
|
+
raise ValidationError(f"`--waypoints` 最多支持 {MAX_WAYPOINTS} 个途经点。")
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
parse_location_input(item, f"waypoints[{index}]")
|
|
241
|
+
for index, item in enumerate(items, start=1)
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _resolve_route_point(
|
|
246
|
+
location: LocationInput,
|
|
247
|
+
*,
|
|
248
|
+
display_name: str | None,
|
|
249
|
+
client: AmapApiClient,
|
|
250
|
+
city: str | None,
|
|
251
|
+
) -> ResolvedRoutePoint:
|
|
252
|
+
"""Resolve a route point to a display name and coordinate."""
|
|
253
|
+
if location.coordinate is not None:
|
|
254
|
+
return ResolvedRoutePoint(
|
|
255
|
+
raw=location.raw,
|
|
256
|
+
name=display_name or location.raw,
|
|
257
|
+
coordinate=location.coordinate,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
geocoded = geocode_text(location.name or location.raw, client=client, city=city)
|
|
261
|
+
return ResolvedRoutePoint(
|
|
262
|
+
raw=location.raw,
|
|
263
|
+
name=display_name or location.name or location.raw,
|
|
264
|
+
coordinate=geocoded.coordinate,
|
|
265
|
+
formatted_address=geocoded.formatted_address,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _request_route_payload(
|
|
270
|
+
client: AmapApiClient,
|
|
271
|
+
*,
|
|
272
|
+
route_type: str,
|
|
273
|
+
origin: ResolvedRoutePoint,
|
|
274
|
+
destination: ResolvedRoutePoint,
|
|
275
|
+
waypoints: list[ResolvedRoutePoint],
|
|
276
|
+
policy: str | None,
|
|
277
|
+
strategy: str | None,
|
|
278
|
+
city: str | None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
"""Call the corresponding Amap route API for the requested mode."""
|
|
281
|
+
if route_type == "driving":
|
|
282
|
+
params: dict[str, Any] = {
|
|
283
|
+
"origin": origin.coordinate.to_amap_value(),
|
|
284
|
+
"destination": destination.coordinate.to_amap_value(),
|
|
285
|
+
"extensions": "all",
|
|
286
|
+
}
|
|
287
|
+
if waypoints:
|
|
288
|
+
params["waypoints"] = ";".join(
|
|
289
|
+
waypoint.coordinate.to_amap_value() for waypoint in waypoints
|
|
290
|
+
)
|
|
291
|
+
if policy is not None:
|
|
292
|
+
params["strategy"] = DRIVING_POLICY_TO_STRATEGY[policy]
|
|
293
|
+
return client.get("/v3/direction/driving", params=params)
|
|
294
|
+
|
|
295
|
+
if route_type == "walking":
|
|
296
|
+
return client.get(
|
|
297
|
+
"/v3/direction/walking",
|
|
298
|
+
params={
|
|
299
|
+
"origin": origin.coordinate.to_amap_value(),
|
|
300
|
+
"destination": destination.coordinate.to_amap_value(),
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if route_type == "riding":
|
|
305
|
+
payload = client.get(
|
|
306
|
+
"/v4/direction/bicycling",
|
|
307
|
+
params={
|
|
308
|
+
"origin": origin.coordinate.to_amap_value(),
|
|
309
|
+
"destination": destination.coordinate.to_amap_value(),
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
_raise_for_bicycling_error(payload)
|
|
313
|
+
return payload
|
|
314
|
+
|
|
315
|
+
return client.get(
|
|
316
|
+
"/v3/direction/transit/integrated",
|
|
317
|
+
params={
|
|
318
|
+
"origin": origin.coordinate.to_amap_value(),
|
|
319
|
+
"destination": destination.coordinate.to_amap_value(),
|
|
320
|
+
"city": city,
|
|
321
|
+
"extensions": "all",
|
|
322
|
+
**(
|
|
323
|
+
{"strategy": TRANSIT_STRATEGY_TO_POLICY[strategy]}
|
|
324
|
+
if strategy is not None
|
|
325
|
+
else {}
|
|
326
|
+
),
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _raise_for_bicycling_error(payload: dict[str, Any]) -> None:
|
|
332
|
+
"""Normalize bicycling API errors which use `errcode` instead of `status`."""
|
|
333
|
+
errcode = payload.get("errcode")
|
|
334
|
+
if errcode in (None, 0, "0"):
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
raise ApiResponseError(
|
|
338
|
+
payload.get("errmsg") or "高德骑行路径规划失败。",
|
|
339
|
+
details={
|
|
340
|
+
"errcode": errcode,
|
|
341
|
+
"errdetail": payload.get("errdetail"),
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _build_route_state(
|
|
347
|
+
*,
|
|
348
|
+
origin: ResolvedRoutePoint,
|
|
349
|
+
destination: ResolvedRoutePoint,
|
|
350
|
+
waypoints: list[ResolvedRoutePoint],
|
|
351
|
+
route_type: str,
|
|
352
|
+
policy: str | None,
|
|
353
|
+
strategy: str | None,
|
|
354
|
+
city: str | None,
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
"""Build the normalized route state payload."""
|
|
357
|
+
state = {
|
|
358
|
+
"from": origin.to_state(),
|
|
359
|
+
"to": destination.to_state(),
|
|
360
|
+
"waypoints": [waypoint.to_state() for waypoint in waypoints],
|
|
361
|
+
"type": route_type,
|
|
362
|
+
}
|
|
363
|
+
if policy is not None:
|
|
364
|
+
state["policy"] = policy
|
|
365
|
+
if strategy is not None:
|
|
366
|
+
state["strategy"] = strategy
|
|
367
|
+
if city is not None:
|
|
368
|
+
state["city"] = city
|
|
369
|
+
return state
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _build_route_summary(route_type: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
373
|
+
"""Convert raw Amap route results to a CLI-friendly summary."""
|
|
374
|
+
if route_type == "driving":
|
|
375
|
+
return _build_path_summary(_extract_route_path(payload, "driving"), include_tolls=True)
|
|
376
|
+
if route_type == "walking":
|
|
377
|
+
return _build_path_summary(_extract_route_path(payload, "walking"), include_tolls=False)
|
|
378
|
+
if route_type == "riding":
|
|
379
|
+
return _build_path_summary(_extract_bicycling_path(payload), include_tolls=False)
|
|
380
|
+
return _build_transit_summary(payload)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _extract_route_path(payload: dict[str, Any], route_type: str) -> dict[str, Any]:
|
|
384
|
+
"""Extract the first path from v3 direction APIs."""
|
|
385
|
+
route = payload.get("route")
|
|
386
|
+
if not isinstance(route, dict):
|
|
387
|
+
raise ApiResponseError(f"{route_type} 路径规划返回缺少 `route`。")
|
|
388
|
+
|
|
389
|
+
paths = route.get("paths")
|
|
390
|
+
if not isinstance(paths, list) or not paths:
|
|
391
|
+
raise ApiResponseError(f"{route_type} 路径规划未返回可用路线。")
|
|
392
|
+
|
|
393
|
+
first = paths[0]
|
|
394
|
+
if not isinstance(first, dict):
|
|
395
|
+
raise ApiResponseError(f"{route_type} 路径规划返回的路径格式无效。")
|
|
396
|
+
return first
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _extract_bicycling_path(payload: dict[str, Any]) -> dict[str, Any]:
|
|
400
|
+
"""Extract the first path from the bicycling API payload."""
|
|
401
|
+
data = payload.get("data", payload)
|
|
402
|
+
if not isinstance(data, dict):
|
|
403
|
+
raise ApiResponseError("骑行路径规划返回格式无效。")
|
|
404
|
+
|
|
405
|
+
paths = data.get("paths")
|
|
406
|
+
if not isinstance(paths, list) or not paths:
|
|
407
|
+
raise ApiResponseError("骑行路径规划未返回可用路线。")
|
|
408
|
+
|
|
409
|
+
first = paths[0]
|
|
410
|
+
if not isinstance(first, dict):
|
|
411
|
+
raise ApiResponseError("骑行路径规划返回的路径格式无效。")
|
|
412
|
+
return first
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _build_path_summary(path: dict[str, Any], *, include_tolls: bool) -> dict[str, Any]:
|
|
416
|
+
"""Build a summary for driving, walking, or riding path responses."""
|
|
417
|
+
summary = {
|
|
418
|
+
"distance": _parse_int(path.get("distance")),
|
|
419
|
+
"time": _parse_int(path.get("duration")),
|
|
420
|
+
"steps": [_build_step_summary(step) for step in _iter_dict_list(path.get("steps"))],
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if include_tolls:
|
|
424
|
+
summary["tolls"] = _parse_float(path.get("tolls"))
|
|
425
|
+
|
|
426
|
+
return summary
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _build_step_summary(step: dict[str, Any]) -> dict[str, Any]:
|
|
430
|
+
"""Build the normalized step payload for path-based modes."""
|
|
431
|
+
return {
|
|
432
|
+
"instruction": _string_value(step.get("instruction")),
|
|
433
|
+
"road": _string_value(step.get("road")),
|
|
434
|
+
"distance": _parse_int(step.get("distance")),
|
|
435
|
+
"time": _parse_int(step.get("duration")),
|
|
436
|
+
"action": _string_value(step.get("action")),
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _build_transit_summary(payload: dict[str, Any]) -> dict[str, Any]:
|
|
441
|
+
"""Build the summary payload for transit route results."""
|
|
442
|
+
route = payload.get("route")
|
|
443
|
+
if not isinstance(route, dict):
|
|
444
|
+
raise ApiResponseError("公交路径规划返回缺少 `route`。")
|
|
445
|
+
|
|
446
|
+
transits = route.get("transits")
|
|
447
|
+
if not isinstance(transits, list) or not transits:
|
|
448
|
+
raise ApiResponseError("公交路径规划未返回可用路线。")
|
|
449
|
+
|
|
450
|
+
transit = transits[0]
|
|
451
|
+
if not isinstance(transit, dict):
|
|
452
|
+
raise ApiResponseError("公交路径规划返回的线路格式无效。")
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
"distance": _parse_int(transit.get("distance")),
|
|
456
|
+
"time": _parse_int(transit.get("duration")),
|
|
457
|
+
"cost": _parse_float(transit.get("cost")),
|
|
458
|
+
"walking_distance": _parse_int(transit.get("walking_distance")),
|
|
459
|
+
"nightflag": _string_value(transit.get("nightflag")) == "1",
|
|
460
|
+
"steps": _build_transit_steps(transit),
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _build_transit_steps(transit: dict[str, Any]) -> list[dict[str, Any]]:
|
|
465
|
+
"""Flatten transit segments into CLI-friendly steps."""
|
|
466
|
+
steps: list[dict[str, Any]] = []
|
|
467
|
+
for segment in _iter_dict_list(transit.get("segments")):
|
|
468
|
+
walking = segment.get("walking")
|
|
469
|
+
if isinstance(walking, dict):
|
|
470
|
+
steps.extend(_build_transit_walking_steps(walking))
|
|
471
|
+
|
|
472
|
+
bus = segment.get("bus")
|
|
473
|
+
if isinstance(bus, dict):
|
|
474
|
+
steps.extend(_build_transit_bus_steps(bus))
|
|
475
|
+
|
|
476
|
+
railway = segment.get("railway")
|
|
477
|
+
if isinstance(railway, dict) and railway:
|
|
478
|
+
railway_step = _build_transit_railway_step(railway)
|
|
479
|
+
if railway_step is not None:
|
|
480
|
+
steps.append(railway_step)
|
|
481
|
+
|
|
482
|
+
taxi = segment.get("taxi")
|
|
483
|
+
if isinstance(taxi, dict) and taxi:
|
|
484
|
+
taxi_step = _build_transit_taxi_step(taxi)
|
|
485
|
+
if taxi_step is not None:
|
|
486
|
+
steps.append(taxi_step)
|
|
487
|
+
|
|
488
|
+
return steps
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _build_transit_walking_steps(walking: dict[str, Any]) -> list[dict[str, Any]]:
|
|
492
|
+
"""Convert a transit walking segment into individual steps."""
|
|
493
|
+
raw_steps = _iter_dict_list(walking.get("steps"))
|
|
494
|
+
if raw_steps:
|
|
495
|
+
return [
|
|
496
|
+
{
|
|
497
|
+
"type": "walking",
|
|
498
|
+
"instruction": _string_value(step.get("instruction")),
|
|
499
|
+
"road": _string_value(step.get("road")),
|
|
500
|
+
"distance": _parse_int(step.get("distance")),
|
|
501
|
+
"time": _parse_int(step.get("duration")),
|
|
502
|
+
"action": _string_value(step.get("action")),
|
|
503
|
+
}
|
|
504
|
+
for step in raw_steps
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
return [
|
|
508
|
+
{
|
|
509
|
+
"type": "walking",
|
|
510
|
+
"instruction": "步行",
|
|
511
|
+
"road": "",
|
|
512
|
+
"distance": _parse_int(walking.get("distance")),
|
|
513
|
+
"time": _parse_int(walking.get("duration")),
|
|
514
|
+
"action": "",
|
|
515
|
+
}
|
|
516
|
+
]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _build_transit_bus_steps(bus: dict[str, Any]) -> list[dict[str, Any]]:
|
|
520
|
+
"""Convert transit bus sub-segments into CLI-friendly steps."""
|
|
521
|
+
steps: list[dict[str, Any]] = []
|
|
522
|
+
|
|
523
|
+
for line in _iter_dict_list(bus.get("buslines")):
|
|
524
|
+
bus_name = _string_value(line.get("name"))
|
|
525
|
+
departure_stop = _extract_stop_name(line.get("departure_stop"))
|
|
526
|
+
arrival_stop = _extract_stop_name(line.get("arrival_stop"))
|
|
527
|
+
via_num = _parse_int(line.get("via_num"))
|
|
528
|
+
|
|
529
|
+
instruction = f"乘坐 {bus_name}".strip()
|
|
530
|
+
if departure_stop or arrival_stop:
|
|
531
|
+
instruction = (
|
|
532
|
+
f"{instruction},从 {departure_stop or '上车站'} 到 {arrival_stop or '下车站'}"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
steps.append(
|
|
536
|
+
{
|
|
537
|
+
"type": "bus",
|
|
538
|
+
"instruction": instruction,
|
|
539
|
+
"line": bus_name,
|
|
540
|
+
"distance": _parse_int(line.get("distance")),
|
|
541
|
+
"time": _parse_int(line.get("duration")),
|
|
542
|
+
"departure_stop": departure_stop,
|
|
543
|
+
"arrival_stop": arrival_stop,
|
|
544
|
+
"via_num": via_num,
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return steps
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _build_transit_railway_step(railway: dict[str, Any]) -> dict[str, Any] | None:
|
|
552
|
+
"""Convert a railway transit segment to a step."""
|
|
553
|
+
name = _string_value(railway.get("name"))
|
|
554
|
+
trip = _string_value(railway.get("trip"))
|
|
555
|
+
instruction = f"乘坐 {trip or name}".strip()
|
|
556
|
+
if not instruction:
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
"type": "railway",
|
|
561
|
+
"instruction": instruction,
|
|
562
|
+
"line": trip or name,
|
|
563
|
+
"distance": _parse_int(railway.get("distance")),
|
|
564
|
+
"time": _parse_int(railway.get("time")),
|
|
565
|
+
"departure_stop": _extract_stop_name(railway.get("departure_stop")),
|
|
566
|
+
"arrival_stop": _extract_stop_name(railway.get("arrival_stop")),
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _build_transit_taxi_step(taxi: dict[str, Any]) -> dict[str, Any] | None:
|
|
571
|
+
"""Convert a taxi transit segment to a step."""
|
|
572
|
+
distance = _parse_int(taxi.get("distance"))
|
|
573
|
+
duration = _parse_int(taxi.get("duration"))
|
|
574
|
+
cost = _parse_float(taxi.get("cost"))
|
|
575
|
+
if distance is None and duration is None and cost is None:
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
"type": "taxi",
|
|
580
|
+
"instruction": "打车前往",
|
|
581
|
+
"distance": distance,
|
|
582
|
+
"time": duration,
|
|
583
|
+
"cost": cost,
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _extract_stop_name(value: Any) -> str:
|
|
588
|
+
"""Extract a stop name from a nested stop object."""
|
|
589
|
+
if not isinstance(value, dict):
|
|
590
|
+
return ""
|
|
591
|
+
return _string_value(value.get("name"))
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _iter_dict_list(value: Any) -> list[dict[str, Any]]:
|
|
595
|
+
"""Return only dictionary items from a list-like payload field."""
|
|
596
|
+
if not isinstance(value, list):
|
|
597
|
+
return []
|
|
598
|
+
return [item for item in value if isinstance(item, dict)]
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _parse_int(value: Any) -> int | None:
|
|
602
|
+
"""Parse an integer-like response field."""
|
|
603
|
+
if value in (None, ""):
|
|
604
|
+
return None
|
|
605
|
+
try:
|
|
606
|
+
return int(float(str(value)))
|
|
607
|
+
except (TypeError, ValueError):
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _parse_float(value: Any) -> float | None:
|
|
612
|
+
"""Parse a float-like response field."""
|
|
613
|
+
if value in (None, ""):
|
|
614
|
+
return None
|
|
615
|
+
try:
|
|
616
|
+
return float(str(value))
|
|
617
|
+
except (TypeError, ValueError):
|
|
618
|
+
return None
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _string_value(value: Any) -> str:
|
|
622
|
+
"""Convert a response field to a stripped string."""
|
|
623
|
+
if value is None:
|
|
624
|
+
return ""
|
|
625
|
+
return str(value).strip()
|