travel_search_service.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  1. from __future__ import annotations
  2. """旅行搜索服务:封装SerpApiClient,把原始搜索响应规范化为Pydantic结果模型,提供统一的航班/酒店/机场搜索入口。"""
  3. import hashlib
  4. from datetime import datetime
  5. from typing import Any
  6. from app.clients.serpapi_client import SerpApiClient
  7. from app.schemas.common import SourceMetadata
  8. from app.schemas.flight import (
  9. CarbonEmissions,
  10. FlightOption,
  11. FlightSearchQuery,
  12. FlightSearchResult,
  13. FlightSegment,
  14. FlightSourceGroup,
  15. Layover,
  16. )
  17. from app.schemas.hotel import (
  18. GeoPoint,
  19. HotelOption,
  20. HotelPriceOffer,
  21. HotelSearchQuery,
  22. HotelSearchResult,
  23. )
  24. class TravelSearchService:
  25. """
  26. 旅行资源查询服务
  27. 职责:
  28. 1.调用SerpApiClient 获取原始数据
  29. 2.合并不同结果分组
  30. 3.标准化字段
  31. 4.去除完成重复的候选
  32. 5.返回项目内部统一数据模型
  33. 不负责:
  34. 1.根据用户偏好筛选
  35. 2.对候选进行个性化排序
  36. 3.生成推荐理由
  37. 4.阶段候选数量。
  38. """
  39. def __init__(self,client:SerpApiClient)->None:
  40. """初始化搜索服务,传入配置好的SerpApiClient实例。"""
  41. self._client = client
  42. async def search_flights(
  43. self,
  44. query: FlightSearchQuery,
  45. ) -> FlightSearchResult:
  46. """查询航班(单程或去程)。
  47. 单程搜索返回完整单程候选。
  48. 往返搜索首先返回去程候选。
  49. """
  50. raw_data = await self._client.search_flights_raw(query)
  51. return self.normalize_flights_response(
  52. query=query,
  53. raw_data=raw_data,
  54. )
  55. async def search_return_flights(
  56. self,
  57. query: FlightSearchQuery,
  58. departure_token: str,
  59. ) -> FlightSearchResult:
  60. """查询与选定去程匹配的返程候选。"""
  61. raw_data = (
  62. await self._client.search_return_flights_raw(
  63. query=query,
  64. departure_token=departure_token,
  65. )
  66. )
  67. return self.normalize_flights_response(
  68. query=query,
  69. raw_data=raw_data,
  70. )
  71. async def search_hotels(self, query: HotelSearchQuery) -> HotelSearchResult:
  72. """按查询条件搜索酒店并对结果做规范化处理。"""
  73. raw_data = await self._client.search_hotels_raw(query)
  74. return self.normalize_hotels_response(query=query, raw_data=raw_data)
  75. @classmethod
  76. def normalize_flights_response(cls,query:FlightSearchQuery,raw_data:dict[str,Any])->FlightSearchResult:
  77. """
  78. 把SerpApi航班原始响应转换成统一结构。
  79. best_flights和other_flights会全部进入标准化流程。
  80. 这里只去除完全重复的候选,不限制返回数量
  81. """
  82. normalized_flights:list[FlightOption]=[]
  83. seen_option_ids:set[str]=set()
  84. warnings:list[str]=[]
  85. groups: tuple[
  86. tuple[FlightSourceGroup,list[dict[str,Any]]],
  87. ...,
  88. ] = (
  89. (
  90. "best_flights",
  91. raw_data.get("best_flights",[]),
  92. ),
  93. (
  94. "other_flights",
  95. raw_data.get("other_flights",[]),
  96. )
  97. )
  98. for source_group, raw_options in groups:
  99. if not isinstance(raw_options,list):
  100. warnings.append(f"{source_group}不是列表,已忽略。")
  101. continue
  102. for provider_rank,raw_option in enumerate(
  103. raw_options,
  104. start=1
  105. ):
  106. if not isinstance(raw_option,dict):
  107. warnings.append(f"{source_group}第{provider_rank}条。不是有效对象,已忽略")
  108. continue
  109. try:
  110. option = cls._normalize_flight_option(
  111. raw_option = raw_option,
  112. source_group = source_group,
  113. provider_rank = provider_rank,
  114. currency = query.currency,
  115. adults = query.adults,
  116. )
  117. except (KeyError,TypeError,ValueError) as e:
  118. warnings.append(f"{source_group}第{provider_rank}条。航班解析失败:{e}")
  119. continue
  120. if option.option_id in seen_option_ids:
  121. continue
  122. seen_option_ids.add(option.option_id)
  123. normalized_flights.append(option)
  124. metadata = raw_data.get("search_metadata",{})
  125. warnings.append(
  126. "price 字段已按单人(每成人)票价标准化,原始 SerpApi 总价已除以 adults 人数"
  127. )
  128. return FlightSearchResult(
  129. query=query,
  130. flights=normalized_flights,
  131. total_count=len(normalized_flights),
  132. source=SourceMetadata(
  133. provider="serpapi_google_flights",
  134. search_id=metadata.get("id"),
  135. status=metadata.get("status","Success"),
  136. currency=query.currency,
  137. is_cached=None
  138. ),
  139. price_insights=raw_data.get("price_insights"),
  140. warnings=warnings
  141. )
  142. @classmethod
  143. def _normalize_flight_option(
  144. cls,
  145. raw_option:dict[str,Any],
  146. source_group:FlightSourceGroup,
  147. provider_rank:int,
  148. currency:str,
  149. adults:int = 1,
  150. )->FlightOption:
  151. """把单条航班原始dict规范化为Pydantic FlightOption。"""
  152. raw_segments = raw_option.get("flights")
  153. if not isinstance(raw_segments,list) or not raw_segments:
  154. raise ValueError("缺少flights航段信息。")
  155. segments = [
  156. cls._normalize_flight_segment(raw_segment)
  157. for raw_segment in raw_segments
  158. ]
  159. raw_layovers = raw_option.get("layovers", [])
  160. layovers = [
  161. cls._normalize_layover(raw_layover)
  162. for raw_layover in raw_layovers
  163. if isinstance(raw_layover, dict)
  164. ]
  165. first_segment = segments[0]
  166. last_segment = segments[-1]
  167. total_duration = cls._to_int(
  168. raw_option.get("total_duration")
  169. )
  170. if total_duration is None:
  171. total_duration = sum(
  172. segment.duration_minutes or 0
  173. for segment in segments
  174. ) + sum(
  175. layover.duration_minutes or 0
  176. for layover in layovers
  177. )
  178. airlines = cls._unique_strings(
  179. segment.airline
  180. for segment in segments
  181. if segment.airline
  182. )
  183. price = cls._normalize_price_per_adult(
  184. raw_option.get("price"),
  185. adults=adults,
  186. )
  187. option_id = cls._build_flight_option_id(
  188. segments=segments,
  189. price=price,
  190. )
  191. raw_carbon = raw_option.get("carbon_emissions")
  192. carbon_emissions = None
  193. if isinstance(raw_carbon, dict):
  194. carbon_emissions = CarbonEmissions(
  195. this_flight_grams=cls._to_int(
  196. raw_carbon.get("this_flight")
  197. ),
  198. typical_for_route_grams=cls._to_int(
  199. raw_carbon.get(
  200. "typical_for_this_route"
  201. )
  202. ),
  203. difference_percent=cls._to_int(
  204. raw_carbon.get(
  205. "difference_percent"
  206. )
  207. ),
  208. )
  209. return FlightOption(
  210. option_id=option_id,
  211. source_group=source_group,
  212. provider_rank=provider_rank,
  213. segments=segments,
  214. layovers=layovers,
  215. departure_airport_code=(
  216. first_segment.departure_airport_code
  217. ),
  218. final_arrival_airport_code=(
  219. last_segment.arrival_airport_code
  220. ),
  221. departure_time=first_segment.departure_time,
  222. arrival_time=last_segment.arrival_time,
  223. total_duration_minutes=total_duration,
  224. stop_count=max(len(segments) - 1, 0),
  225. price=price,
  226. currency=currency,
  227. flight_type=raw_option.get("type"),
  228. airlines=airlines,
  229. is_overnight=(
  230. any(segment.overnight for segment in segments)
  231. or any(layover.overnight for layover in layovers)
  232. ),
  233. carbon_emissions=carbon_emissions,
  234. departure_token=raw_option.get(
  235. "departure_token"
  236. ),
  237. booking_token=raw_option.get(
  238. "booking_token"
  239. ),
  240. )
  241. @classmethod
  242. def _normalize_flight_segment(
  243. cls,
  244. raw_segment: dict[str, Any],
  245. ) -> FlightSegment:
  246. """规范化单个航段的起降信息。"""
  247. if not isinstance(raw_segment, dict):
  248. raise TypeError("航段不是有效对象。")
  249. departure = raw_segment.get(
  250. "departure_airport",
  251. {},
  252. )
  253. arrival = raw_segment.get(
  254. "arrival_airport",
  255. {},
  256. )
  257. departure_code = str(
  258. departure.get("id", "")
  259. ).strip().upper()
  260. arrival_code = str(
  261. arrival.get("id", "")
  262. ).strip().upper()
  263. if not departure_code or not arrival_code:
  264. raise ValueError("航段缺少机场代码。")
  265. departure_time = cls._parse_local_datetime(
  266. departure.get("time")
  267. )
  268. arrival_time = cls._parse_local_datetime(
  269. arrival.get("time")
  270. )
  271. return FlightSegment(
  272. flight_number=raw_segment.get(
  273. "flight_number"
  274. ),
  275. airline=raw_segment.get("airline"),
  276. departure_airport_code=departure_code,
  277. departure_airport_name=departure.get("name"),
  278. departure_time=departure_time,
  279. arrival_airport_code=arrival_code,
  280. arrival_airport_name=arrival.get("name"),
  281. arrival_time=arrival_time,
  282. duration_minutes=cls._to_int(
  283. raw_segment.get("duration")
  284. ),
  285. airplane=raw_segment.get("airplane"),
  286. travel_class=raw_segment.get(
  287. "travel_class"
  288. ),
  289. legroom=raw_segment.get("legroom"),
  290. overnight=bool(
  291. raw_segment.get("overnight", False)
  292. ),
  293. often_delayed_by_over_30_min=bool(
  294. raw_segment.get(
  295. "often_delayed_by_over_30_min",
  296. False,
  297. )
  298. ),
  299. plane_and_crew_by=raw_segment.get(
  300. "plane_and_crew_by"
  301. ),
  302. extensions=cls._string_list(
  303. raw_segment.get("extensions")
  304. ),
  305. ticket_also_sold_by=cls._string_list(
  306. raw_segment.get(
  307. "ticket_also_sold_by"
  308. )
  309. ),
  310. )
  311. @classmethod
  312. def _normalize_layover(
  313. cls,
  314. raw_layover: dict[str, Any],
  315. ) -> Layover:
  316. """规范化中转停留信息。"""
  317. airport_code = str(
  318. raw_layover.get("id", "")
  319. ).strip().upper()
  320. if not airport_code:
  321. raise ValueError("经停信息缺少机场代码。")
  322. return Layover(
  323. airport_code=airport_code,
  324. airport_name=raw_layover.get("name"),
  325. duration_minutes=cls._to_int(
  326. raw_layover.get("duration")
  327. ),
  328. overnight=bool(
  329. raw_layover.get("overnight", False)
  330. ),
  331. )
  332. @classmethod
  333. def normalize_hotels_response(
  334. cls,
  335. query: HotelSearchQuery,
  336. raw_data: dict[str, Any],
  337. ) -> HotelSearchResult:
  338. """把酒店原始响应转换为统一结构。"""
  339. raw_properties = raw_data.get("properties", [])
  340. normalized_hotels: list[HotelOption] = []
  341. seen_hotel_ids: set[str] = set()
  342. warnings: list[str] = []
  343. if not isinstance(raw_properties, list):
  344. raw_properties = []
  345. warnings.append(
  346. "properties 不是列表,已按空结果处理。"
  347. )
  348. for provider_rank, raw_hotel in enumerate(
  349. raw_properties,
  350. start=1,
  351. ):
  352. if not isinstance(raw_hotel, dict):
  353. warnings.append(
  354. f"第 {provider_rank} 条酒店不是有效对象,"
  355. "已忽略。"
  356. )
  357. continue
  358. try:
  359. hotel = cls._normalize_hotel_option(
  360. raw_hotel=raw_hotel,
  361. provider_rank=provider_rank,
  362. currency=query.currency,
  363. )
  364. except (KeyError, TypeError, ValueError) as exc:
  365. warnings.append(
  366. f"第 {provider_rank} 条酒店解析失败:"
  367. f"{exc}"
  368. )
  369. continue
  370. if hotel.hotel_id in seen_hotel_ids:
  371. continue
  372. seen_hotel_ids.add(hotel.hotel_id)
  373. normalized_hotels.append(hotel)
  374. metadata = raw_data.get("search_metadata", {})
  375. pagination = raw_data.get(
  376. "serpapi_pagination",
  377. {},
  378. )
  379. return HotelSearchResult(
  380. query=query,
  381. hotels=normalized_hotels,
  382. total_count=len(normalized_hotels),
  383. source=SourceMetadata(
  384. provider="serpapi_google_hotels",
  385. search_id=metadata.get("id"),
  386. status=metadata.get("status", "Success"),
  387. currency=query.currency,
  388. is_cached=None,
  389. ),
  390. next_page_token=(
  391. pagination.get("next_page_token")
  392. or raw_data.get("next_page_token")
  393. ),
  394. warnings=warnings,
  395. )
  396. @classmethod
  397. def _normalize_hotel_option(
  398. cls,
  399. raw_hotel: dict[str, Any],
  400. provider_rank: int,
  401. currency: str,
  402. ) -> HotelOption:
  403. """把单条酒店原始dict规范化为Pydantic HotelOption。"""
  404. name = str(raw_hotel.get("name", "")).strip()
  405. if not name:
  406. raise ValueError("酒店名称为空。")
  407. raw_coordinates = raw_hotel.get(
  408. "gps_coordinates",
  409. {},
  410. )
  411. coordinates = None
  412. if isinstance(raw_coordinates, dict):
  413. latitude = cls._to_float(
  414. raw_coordinates.get("latitude")
  415. )
  416. longitude = cls._to_float(
  417. raw_coordinates.get("longitude")
  418. )
  419. if latitude is not None and longitude is not None:
  420. coordinates = GeoPoint(
  421. latitude=latitude,
  422. longitude=longitude,
  423. )
  424. rate_per_night = raw_hotel.get(
  425. "rate_per_night",
  426. {},
  427. )
  428. total_rate = raw_hotel.get(
  429. "total_rate",
  430. {},
  431. )
  432. price_per_night = None
  433. total_price = None
  434. if isinstance(rate_per_night, dict):
  435. price_per_night = cls._to_float(
  436. rate_per_night.get(
  437. "extracted_lowest"
  438. )
  439. )
  440. if isinstance(total_rate, dict):
  441. total_price = cls._to_float(
  442. total_rate.get(
  443. "extracted_lowest"
  444. )
  445. )
  446. price_offers = cls._normalize_hotel_prices(
  447. raw_hotel.get("prices", [])
  448. )
  449. property_token = raw_hotel.get(
  450. "property_token"
  451. )
  452. hotel_id = (
  453. str(property_token)
  454. if property_token
  455. else cls._stable_id(
  456. prefix="hotel",
  457. values=[
  458. name,
  459. coordinates.latitude
  460. if coordinates
  461. else "",
  462. coordinates.longitude
  463. if coordinates
  464. else "",
  465. ],
  466. )
  467. )
  468. images = raw_hotel.get("images", [])
  469. thumbnail = raw_hotel.get("thumbnail")
  470. if (
  471. thumbnail is None
  472. and isinstance(images, list)
  473. and images
  474. and isinstance(images[0], dict)
  475. ):
  476. thumbnail = images[0].get("thumbnail")
  477. free_cancellation_raw = raw_hotel.get(
  478. "free_cancellation"
  479. )
  480. free_cancellation = (
  481. free_cancellation_raw
  482. if isinstance(
  483. free_cancellation_raw,
  484. bool,
  485. )
  486. else None
  487. )
  488. return HotelOption(
  489. hotel_id=hotel_id,
  490. provider_rank=provider_rank,
  491. name=name,
  492. property_type=raw_hotel.get("type"),
  493. description=raw_hotel.get("description"),
  494. coordinates=coordinates,
  495. check_in_time=raw_hotel.get(
  496. "check_in_time"
  497. ),
  498. check_out_time=raw_hotel.get(
  499. "check_out_time"
  500. ),
  501. hotel_class=cls._to_int(
  502. raw_hotel.get(
  503. "extracted_hotel_class"
  504. )
  505. ),
  506. overall_rating=cls._to_float(
  507. raw_hotel.get("overall_rating")
  508. ),
  509. review_count=cls._to_int(
  510. raw_hotel.get("reviews")
  511. ),
  512. location_rating=cls._to_float(
  513. raw_hotel.get("location_rating")
  514. ),
  515. price_per_night=price_per_night,
  516. total_price=total_price,
  517. currency=currency,
  518. amenities=cls._string_list(
  519. raw_hotel.get("amenities")
  520. ),
  521. excluded_amenities=cls._string_list(
  522. raw_hotel.get(
  523. "excluded_amenities"
  524. )
  525. ),
  526. free_cancellation=free_cancellation,
  527. sponsored=bool(
  528. raw_hotel.get("sponsored", False)
  529. ),
  530. eco_certified=bool(
  531. raw_hotel.get(
  532. "eco_certified",
  533. False,
  534. )
  535. ),
  536. property_token=property_token,
  537. thumbnail_url=thumbnail,
  538. price_offers=price_offers,
  539. deal=raw_hotel.get("deal"),
  540. deal_description=raw_hotel.get(
  541. "deal_description"
  542. ),
  543. )
  544. @classmethod
  545. def _normalize_hotel_prices(
  546. cls,
  547. raw_prices: Any,
  548. ) -> list[HotelPriceOffer]:
  549. """规范化酒店价格列表。"""
  550. if not isinstance(raw_prices, list):
  551. return []
  552. offers: list[HotelPriceOffer] = []
  553. for raw_price in raw_prices:
  554. if not isinstance(raw_price, dict):
  555. continue
  556. raw_nightly = raw_price.get(
  557. "rate_per_night",
  558. {},
  559. )
  560. raw_total = raw_price.get(
  561. "total_rate",
  562. {},
  563. )
  564. nightly_price = None
  565. total_price = None
  566. if isinstance(raw_nightly, dict):
  567. nightly_price = cls._to_float(
  568. raw_nightly.get(
  569. "extracted_lowest"
  570. )
  571. )
  572. if isinstance(raw_total, dict):
  573. total_price = cls._to_float(
  574. raw_total.get(
  575. "extracted_lowest"
  576. )
  577. )
  578. free_cancellation_raw = raw_price.get(
  579. "free_cancellation"
  580. )
  581. offers.append(
  582. HotelPriceOffer(
  583. source=raw_price.get("source"),
  584. price_per_night=nightly_price,
  585. total_price=total_price,
  586. free_cancellation=(
  587. free_cancellation_raw
  588. if isinstance(
  589. free_cancellation_raw,
  590. bool,
  591. )
  592. else None
  593. ),
  594. )
  595. )
  596. return offers
  597. @staticmethod
  598. def _build_flight_option_id(
  599. segments: list[FlightSegment],
  600. price: int | None,
  601. ) -> str:
  602. """根据关键字段生成稳定的航班选项ID。"""
  603. values: list[Any] = [price]
  604. for segment in segments:
  605. values.extend(
  606. [
  607. segment.flight_number,
  608. segment.departure_airport_code,
  609. segment.departure_time.isoformat(),
  610. segment.arrival_airport_code,
  611. segment.arrival_time.isoformat(),
  612. ]
  613. )
  614. return TravelSearchService._stable_id(
  615. prefix="flight",
  616. values=values,
  617. )
  618. @staticmethod
  619. def _stable_id(
  620. prefix: str,
  621. values: list[Any],
  622. ) -> str:
  623. """生成SHA256派生的稳定字符串ID。"""
  624. raw_value = "|".join(
  625. str(value)
  626. for value in values
  627. )
  628. digest = hashlib.sha256(
  629. raw_value.encode("utf-8")
  630. ).hexdigest()[:20]
  631. return f"{prefix}_{digest}"
  632. @staticmethod
  633. def _parse_local_datetime(
  634. value: Any,
  635. ) -> datetime:
  636. """解析第三方接口返回的机场当地时间。
  637. SerpApi 返回值没有明确时区偏移,
  638. 因此这里保留为无时区 datetime。
  639. """
  640. if not isinstance(value, str) or not value.strip():
  641. raise ValueError("航班时间为空。")
  642. try:
  643. return datetime.fromisoformat(value.strip())
  644. except ValueError as exc:
  645. raise ValueError(
  646. f"无法解析航班时间:{value}"
  647. ) from exc
  648. @staticmethod
  649. def _to_int(value: Any) -> int | None:
  650. """安全转换为int,不可转换返回None。"""
  651. if value is None or isinstance(value, bool):
  652. return None
  653. try:
  654. return int(value)
  655. except (TypeError, ValueError):
  656. return None
  657. @staticmethod
  658. def _normalize_price_per_adult(
  659. raw_price: Any,
  660. adults: int,
  661. ) -> int | None:
  662. """将 SerpApi 原始总价除以成人数,返回单人票价。
  663. SerpApi / Google Flights 返回的 price 是所选乘客的
  664. 订单总价(含所有成人),这里统一折算为每成人单价,
  665. 避免前端误解为「一个人」或「全部人」的价格。
  666. """
  667. if raw_price is None or isinstance(raw_price, bool):
  668. return None
  669. if adults < 1:
  670. adults = 1
  671. try:
  672. total = float(raw_price)
  673. except (TypeError, ValueError):
  674. return None
  675. return round(total / adults)
  676. @staticmethod
  677. def _to_float(value: Any) -> float | None:
  678. """安全转换为float,不可转换返回None。"""
  679. if value is None or isinstance(value, bool):
  680. return None
  681. try:
  682. return float(value)
  683. except (TypeError, ValueError):
  684. return None
  685. @staticmethod
  686. def _string_list(value: Any) -> list[str]:
  687. """把字符串或列表统一转换为字符串列表。"""
  688. if not isinstance(value, list):
  689. return []
  690. return [
  691. str(item).strip()
  692. for item in value
  693. if str(item).strip()
  694. ]
  695. @staticmethod
  696. def _unique_strings(
  697. values: Any,
  698. ) -> list[str]:
  699. """字符串列表去重并保持顺序。"""
  700. result: list[str] = []
  701. for value in values:
  702. text = str(value).strip()
  703. if text and text not in result:
  704. result.append(text)
  705. return result
  706. async def search_airports(
  707. self,
  708. query: str,
  709. ) -> dict[str, Any]:
  710. """查询并标准化城市对应的机场信息。"""
  711. raw_data = await self._client.search_airports_raw(
  712. query=query,
  713. )
  714. raw_suggestions = raw_data.get(
  715. "suggestions",
  716. [],
  717. )
  718. suggestions: list[dict[str, Any]] = []
  719. if not isinstance(raw_suggestions, list):
  720. raw_suggestions = []
  721. for raw_suggestion in raw_suggestions:
  722. if not isinstance(raw_suggestion, dict):
  723. continue
  724. raw_airports = raw_suggestion.get(
  725. "airports",
  726. [],
  727. )
  728. airports: list[dict[str, Any]] = []
  729. if isinstance(raw_airports, list):
  730. for raw_airport in raw_airports:
  731. if not isinstance(
  732. raw_airport,
  733. dict,
  734. ):
  735. continue
  736. airport_code = str(
  737. raw_airport.get("id", "")
  738. ).strip().upper()
  739. if not airport_code:
  740. continue
  741. airports.append(
  742. {
  743. "code": airport_code,
  744. "name": raw_airport.get(
  745. "name"
  746. ),
  747. "city": raw_airport.get(
  748. "city"
  749. ),
  750. "city_id": raw_airport.get(
  751. "city_id"
  752. ),
  753. "distance": raw_airport.get(
  754. "distance"
  755. ),
  756. }
  757. )
  758. suggestions.append(
  759. {
  760. "position": raw_suggestion.get(
  761. "position"
  762. ),
  763. "name": raw_suggestion.get("name"),
  764. "type": raw_suggestion.get("type"),
  765. "description": (
  766. raw_suggestion.get(
  767. "description"
  768. )
  769. ),
  770. "location_id": raw_suggestion.get(
  771. "id"
  772. ),
  773. "airports": airports,
  774. }
  775. )
  776. metadata = raw_data.get(
  777. "search_metadata",
  778. {},
  779. )
  780. return {
  781. "query": query,
  782. "total_count": len(suggestions),
  783. "suggestions": suggestions,
  784. "source": {
  785. "provider": (
  786. "serpapi_google_flights_autocomplete"
  787. ),
  788. "search_id": metadata.get("id"),
  789. "status": metadata.get(
  790. "status",
  791. "Success",
  792. ),
  793. },
  794. }