serpapi_client.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. """
  2. 这个 Client 只负责:
  3. Pydantic查询参数
  4. → SerpApi HTTP参数
  5. → 发送请求
  6. → 返回原始JSON
  7. """
  8. from __future__ import annotations
  9. from typing import Any
  10. import httpx
  11. from app.schemas.flight import FlightSearchQuery
  12. from app.schemas.hotel import HotelSearchQuery
  13. SERPAPI_BASE_URL = "https://serpapi.com/search.json"
  14. FLIGHT_TYPE_MAP = {
  15. "round_trip":1,
  16. "one_way":2,
  17. }
  18. TRAVEL_CLASS_MAP = {
  19. "economy":1,
  20. "business":3,
  21. "first":4,
  22. "premium_economy":2,
  23. }
  24. FLIGHT_SORT_MAP = {
  25. "top":1,
  26. "price":2,
  27. "departure_time":3,
  28. "arrival_time":4,
  29. "duration":5,
  30. "emissions":6,
  31. }
  32. STOPS_MAP = {
  33. "any":0,
  34. "nonstop":1,
  35. "one_or_fewer":2,
  36. "two_or_fewer":3,
  37. }
  38. HOTEL_SORT_MAP = {
  39. "lowest_price":3,
  40. "highest_rating":8,
  41. "most_reviewed":13,
  42. }
  43. HOTEL_RATING_MAP = {
  44. "3.5+":7,
  45. "4.0+":8,
  46. "4.5+":9,
  47. }
  48. class SerpApiError(RuntimeError):
  49. """SerApi调用错误。"""
  50. class SerpApiClient:
  51. """SerpApi异步HTTP客户端"""
  52. def __init__(self,api_key:str,timeout_seconds:int | float=30.0)->None:
  53. """初始化SerpApiClient,验证API Key并提供可选的自定义base_url。"""
  54. if not api_key.strip():
  55. raise ValueError("SerpApi API Key不能为空。")
  56. self.api_key = api_key
  57. self.timeout_seconds = timeout_seconds
  58. self._client:httpx.AsyncClient | None = None
  59. async def __aenter__(self)->"SerpApiClient":
  60. """异步上下文管理器协议,支持async with用法。"""
  61. self._ensure_client()
  62. return self
  63. async def __aexit__(self, exc_type:object, exc_value:object, traceback:object)->None:
  64. """异步上下文管理器协议,支持async with用法。"""
  65. await self.aclose()
  66. def _ensure_client(self)->httpx.AsyncClient:
  67. """惰性创建并缓存httpx.AsyncClient实例。"""
  68. if self._client is None:
  69. timeout = httpx.Timeout(
  70. timeout=self.timeout_seconds,
  71. connect=min(10.0,self.timeout_seconds),
  72. )
  73. self._client=httpx.AsyncClient(
  74. timeout=timeout,
  75. follow_redirects=True
  76. )
  77. return self._client
  78. async def aclose(self)->None:
  79. """关闭底层httpx连接,释放资源。"""
  80. if self._client is not None:
  81. await self._client.aclose()
  82. self._client = None
  83. async def _request(self,params:dict[str,Any],)->dict[str,Any]:
  84. """
  85. 执行SerpApi请求并统一处理错误。
  86. 错误消息中不会打印完整请求URL,
  87. 避免API Key通过查询参数泄漏。
  88. """
  89. client = self._ensure_client()
  90. request_params = {
  91. **params,
  92. "api_key":self.api_key,
  93. }
  94. try:
  95. response = await client.get(
  96. SERPAPI_BASE_URL,
  97. params=request_params
  98. )
  99. except httpx.TimeoutException as e:
  100. raise SerpApiError(
  101. f"SerpApi请求超时,超时时间为{self.timeout_seconds}秒"
  102. ) from e
  103. except httpx.RequestError as e:
  104. raise SerpApiError(
  105. f"SerpApi网络请求失败:{type(e).__name__}"
  106. ) from e
  107. if response.status_code >= 400:
  108. error_text = response.text[:500]
  109. raise SerpApiError(f"SerpApi返回HTTP错误:{response.status_code},响应内容:{error_text}")
  110. try:
  111. data:dict[str, Any] = response.json()
  112. except ValueError as e:
  113. raise SerpApiError("SerpApi返回内容不是合法JSON。") from e
  114. if error :=data.get("error"):
  115. raise SerpApiError(f"SerpApi查询失败:{error}")
  116. metadata = data.get("search_metadata",{})
  117. status = metadata.get("status")
  118. if status is not None and str(status).lower() != "success":
  119. raise SerpApiError(f"SerpApi返回异常状态:{status}")
  120. return data
  121. def _build_flight_params(
  122. self,
  123. query: FlightSearchQuery,
  124. ) -> dict[str, Any]:
  125. """把内部航班查询模型转换为 SerpApi 参数。"""
  126. params: dict[str, Any] = {
  127. "engine": "google_flights",
  128. "departure_id": ",".join(
  129. query.departure_airports
  130. ),
  131. "arrival_id": ",".join(
  132. query.arrival_airports
  133. ),
  134. "outbound_date": query.outbound_date.isoformat(),
  135. "type": FLIGHT_TYPE_MAP[query.flight_type],
  136. "adults": query.adults,
  137. "children": query.children,
  138. "travel_class": TRAVEL_CLASS_MAP[
  139. query.travel_class
  140. ],
  141. "currency": query.currency,
  142. "hl": query.language,
  143. "gl": query.country,
  144. }
  145. if query.return_date is not None:
  146. params["return_date"] = (
  147. query.return_date.isoformat()
  148. )
  149. if query.sort_by is not None:
  150. params["sort_by"] = FLIGHT_SORT_MAP[
  151. query.sort_by
  152. ]
  153. if query.stops is not None:
  154. params["stops"] = STOPS_MAP[query.stops]
  155. if query.max_price is not None:
  156. params["max_price"] = query.max_price
  157. if query.outbound_times is not None:
  158. params["outbound_times"] = (
  159. query.outbound_times
  160. )
  161. if query.show_hidden:
  162. params["show_hidden"] = True
  163. if query.deep_search:
  164. params["deep_search"] = True
  165. if query.no_cache:
  166. params["no_cache"] = True
  167. return params
  168. async def search_flights_raw(
  169. self,
  170. query: FlightSearchQuery,
  171. ) -> dict[str, Any]:
  172. """查询首次航班结果。
  173. 单程搜索返回完整单程候选。
  174. 往返搜索首先返回去程候选。
  175. """
  176. params = self._build_flight_params(query)
  177. return await self._request(params)
  178. async def search_return_flights_raw(
  179. self,
  180. query: FlightSearchQuery,
  181. departure_token: str,
  182. ) -> dict[str, Any]:
  183. """根据选定去程查询对应的返程航班。"""
  184. if query.flight_type != "round_trip":
  185. raise ValueError(
  186. "只有 round_trip 查询才能获取返程航班。"
  187. )
  188. token = departure_token.strip()
  189. if not token:
  190. raise ValueError(
  191. "departure_token 不能为空。"
  192. )
  193. params = self._build_flight_params(query)
  194. params["departure_token"] = token
  195. return await self._request(params)
  196. async def search_hotels_raw(self, query: HotelSearchQuery) -> dict[str, Any]:
  197. """查询酒店并返回SerpApi原始JSON。"""
  198. params:dict[str,Any] = {
  199. "engine": "google_hotels",
  200. "q": query.query,
  201. "check_in_date": query.check_in_date.isoformat(),
  202. "check_out_date": query.check_out_date.isoformat(),
  203. "adults": query.adults,
  204. "children": len(query.children_ages),
  205. "currency": query.currency,
  206. "hl": query.language,
  207. "gl": query.country,
  208. }
  209. if query.children_ages:
  210. params["children_ages"] = ",".join(
  211. str(age) for age in query.children_ages
  212. )
  213. if query.min_price is not None:
  214. params["min_price"] = query.min_price
  215. if query.max_price is not None:
  216. params["max_price"] = query.max_price
  217. if query.rating_filter is not None:
  218. params["rating"] = HOTEL_RATING_MAP[query.rating_filter]
  219. if query.hotel_class:
  220. params["hotel_class"] = ",".join(
  221. str(hotel_class) for hotel_class in query.hotel_class
  222. )
  223. if query.free_cancellation is not None:
  224. params["free_cancellation"] = query.free_cancellation
  225. if query.sort is not None:
  226. params["sort"] = HOTEL_SORT_MAP[query.sort]
  227. if query.no_cache:
  228. params["no_cache"] = True
  229. return await self._request(params)
  230. async def search_airports_raw(
  231. self,
  232. query: str,
  233. language: str = "zh-cn",
  234. ) -> dict[str, Any]:
  235. """根据城市或机场关键词查询机场信息。"""
  236. keyword = query.strip()
  237. if not keyword:
  238. raise ValueError("机场搜索关键词不能为空。")
  239. params: dict[str, Any] = {
  240. "engine": "google_flights_autocomplete",
  241. "q": keyword,
  242. "hl": language,
  243. }
  244. return await self._request(params)