test_serpapi.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. from __future__ import annotations
  2. """直接调用SerpApi REST接口,验证原始JSON返回结构。"""
  3. import asyncio
  4. import sys
  5. from pathlib import Path
  6. from typing import Any
  7. import httpx
  8. from rich.console import Console
  9. from rich.table import Table
  10. # 直接运行本文件时,Python 只把 scripts/ 加入 sys.path,找不到项目根的 app 包。
  11. # 这里把项目根目录(本文件的上级目录)注入 sys.path,保证直接运行文件也能 import app。
  12. # sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
  13. # 使用 `python -m scripts.test_amap_mcp` 运行时则无需此处理,项目根会自动加入 sys.path。
  14. from app.config import get_settings
  15. SERPAPI_URL = "https://serpapi.com/search.json"
  16. console = Console()
  17. async def serpapi_request(
  18. client:httpx.AsyncClient,
  19. params:dict[str,Any],
  20. )->dict[str,Any]:
  21. """调用SerpAPI,并进行统一错误处理。"""
  22. response = await client.get(SERPAPI_URL,params=params)
  23. response.raise_for_status()
  24. data:dict[str,Any] = response.json()
  25. if error := data.get("error"):
  26. raise RuntimeError(f"SerpApi 查询失败:{error}")
  27. status = data.get("search_metadata",{}).get("status")
  28. # SerpApi 返回的 status 形如 "Success"(首字母大写),这里做大小写不敏感比较
  29. if status is not None and str(status).lower() != "success":
  30. raise RuntimeError(f"SerpApi 返回异常状态:{status}")
  31. return data
  32. def format_flight_route(flight_option:dict[str,Any])->str:
  33. """把航班列表转换成可读路线"""
  34. segments = flight_option.get("flights",[])
  35. if not segments:
  36. return "未知路线"
  37. airport_ids:list[str] = []
  38. for index,segment in enumerate(segments):
  39. departure = segment.get("departure_airport",{}).get("id","?")
  40. arrival = segment.get("arrival_airport",{}).get("id","?")
  41. if index == 0:
  42. airport_ids.append(departure)
  43. airport_ids.append(arrival)
  44. return " -> ".join(airport_ids)
  45. def format_duration(minutes:Any)->str:
  46. """把分钟数转成可读时长,如 200分钟 -> 3小时20分。"""
  47. if not isinstance(minutes,(int,float)):
  48. return "?"
  49. total = int(minutes)
  50. hours = total // 60
  51. mins = total % 60
  52. if hours == 0:
  53. return f"{mins}分钟"
  54. if mins == 0:
  55. return f"{hours}小时"
  56. return f"{hours}小时{mins}分"
  57. async def test_flights(client:httpx.AsyncClient,api_key:str)->None:
  58. """
  59. 测试上海到成都的真实航班查询。
  60. 第一版使用单程搜索。
  61. 正式项目中,去成和返程各调用一次,
  62. 比使用departure_token 的往返联动流程更容易维护。
  63. """
  64. console.rule("[bold]1.SerpApi 航班查询")
  65. params = {
  66. "engine":"google_flights",
  67. "api_key":api_key,
  68. "departure_id":"SHA,PVG",
  69. "arrival_id":"CTU,TFU",
  70. "outbound_date":"2026-08-10",
  71. "type":2, # 单程
  72. "travel_class":1,
  73. "currency":"CNY",
  74. "gl":"cn",
  75. "hl":"zh-cn",
  76. }
  77. data = await serpapi_request(client,params)
  78. options = [
  79. *data.get("best_flights",[]),
  80. *data.get("other_flights",[]),
  81. ]
  82. if not options:
  83. console.print("[yellow]查询成功,但没有返回航班信息。[/yellow]")
  84. console.print("返回字段:",list(data.keys()))
  85. return
  86. table = Table(title="上海->成都航班候选")
  87. table.add_column("路线")
  88. table.add_column("总时长")
  89. table.add_column("价格")
  90. table.add_column("航班号")
  91. table.add_column("出发时间")
  92. table.add_column("到达时间")
  93. for option in options:
  94. segments = option.get("flights",[])
  95. first_segment = segments[0] if segments else {}
  96. last_segment = segments[-1] if segments else {}
  97. # 预先取值,避免 f-string 嵌套双引号(Python 3.9 不支持)
  98. duration = option.get("total_duration","?")
  99. price = option.get("price","?")
  100. flight_numbers = ", ".join(
  101. seg.get("flight_number","?") for seg in segments
  102. ) or "?"
  103. table.add_row(
  104. format_flight_route(option),
  105. format_duration(duration),
  106. f"¥{price}",
  107. flight_numbers,
  108. str(first_segment.get("departure_airport",{}).get("time","?")),
  109. str(last_segment.get("arrival_airport",{}).get("time","?")),
  110. )
  111. console.print(table)
  112. metadata = data.get("search_metadata",{})
  113. console.print(
  114. {
  115. "provider":"serpapi_google_flights",
  116. "status":metadata.get("status"),
  117. "search_id":metadata.get("id"),
  118. "result_count":len(options),
  119. }
  120. )
  121. async def test_hotels(client:httpx.AsyncClient,api_key:str)->None:
  122. """测试成都酒店真实查询。"""
  123. params = {
  124. "engine":"google_hotels",
  125. "api_key":api_key,
  126. "q":"成都 地铁附近 酒店",
  127. "check_in_date":"2026-08-10",
  128. "check_out_date":"2026-08-15",
  129. "adults":2,
  130. "children":0,
  131. "currency":"CNY",
  132. "gl":"cn",
  133. "hl":"zh-cn",
  134. }
  135. data = await serpapi_request(client,params)
  136. properties = data.get("properties",[])
  137. if not properties:
  138. console.print("[yellow]查询成功,但没有返回酒店候选。[/yellow]")
  139. console.print("返回字段",list(data.keys()))
  140. return
  141. table = Table(title="成都酒店候选")
  142. table.add_column("酒店")
  143. table.add_column("星级")
  144. table.add_column("评分")
  145. table.add_column("每晚最低价")
  146. table.add_column("附近地标")
  147. for hotel in properties[:20]:
  148. rate = hotel.get("rate_per_night",{})
  149. # 价格:用 extracted_lowest(纯数字)统一加 ¥,避免和 SerpApi 自带的 ¥ 重复
  150. extracted = rate.get("extracted_lowest")
  151. price_text = f"¥{extracted}" if extracted is not None else "?"
  152. # 附近地标:取 nearby_places 第一项的名称 + 交通耗时
  153. nearby = hotel.get("nearby_places") or []
  154. landmark_text = "?"
  155. if nearby:
  156. first = nearby[0]
  157. landmark_name = first.get("name","?")
  158. transports = first.get("transportations") or []
  159. duration = transports[0].get("duration","") if transports else ""
  160. landmark_text = f"{landmark_name}({duration})" if duration else str(landmark_name)
  161. table.add_row(
  162. str(hotel.get("name","?")),
  163. str(hotel.get("hotel_class","?")),
  164. str(hotel.get("overall_rating","?")),
  165. price_text,
  166. landmark_text,
  167. )
  168. console.print(table)
  169. metadata = data.get("search_metadata",{})
  170. console.print(
  171. {
  172. "provider":"serpapi_google_hotels",
  173. "status":metadata.get("status"),
  174. "search_id":metadata.get("id"),
  175. "result_count":len(properties),
  176. }
  177. )
  178. async def main()->None:
  179. settings = get_settings()
  180. api_key = settings.require("serpapi_api_key")
  181. time_out = httpx.Timeout(
  182. timeout = settings.request_timeout_seconds,
  183. connect = 10.0,
  184. )
  185. async with httpx.AsyncClient(timeout = time_out, follow_redirects = True) as client:
  186. await test_flights(client,api_key)
  187. await test_hotels(client,api_key)
  188. if __name__=="__main__":
  189. try:
  190. asyncio.run(main())
  191. except httpx.HTTPStatusError as e:
  192. console.print(
  193. f"[red]HTTP错误:{e.response.status_code} {e.response.text[:500]}[/red]"
  194. )
  195. raise SystemExit(1) from e
  196. except (httpx.RequestError, RuntimeError) as e:
  197. console.print(f"[red]测试实效:{e}[/red]")
  198. raise SystemExit(1) from e