web_search.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. from __future__ import annotations
  2. import base64
  3. import json
  4. import random
  5. import time
  6. from io import BytesIO
  7. from pathlib import Path
  8. from typing import Any
  9. from urllib.parse import quote_plus
  10. from fastapi import HTTPException
  11. from PIL import Image
  12. from ... import ai_service, settings_service, windows_automation
  13. from ..context import WorkflowContext
  14. from ..registry import control_ports, field_def, register_node
  15. SEARCH_ENGINES = {
  16. "google": "https://www.google.com/search?q={query}",
  17. "bing": "https://www.bing.com/search?q={query}",
  18. }
  19. def _number(value: Any, default: float, minimum: float, maximum: float) -> float:
  20. try:
  21. number = float(value)
  22. except (TypeError, ValueError):
  23. number = default
  24. return max(minimum, min(maximum, number))
  25. def _integer(value: Any, default: int, minimum: int, maximum: int) -> int:
  26. return int(_number(value, default, minimum, maximum))
  27. def _percent(value: Any) -> float | None:
  28. try:
  29. number = float(value)
  30. except (TypeError, ValueError):
  31. return None
  32. if 0 <= number <= 1:
  33. number *= 100
  34. elif number > 100:
  35. # 部分小模型会丢失小数点,把 67.6 输出为 676;此时按千分比还原为百分比。
  36. number = number / 10
  37. return max(0.0, min(100.0, number))
  38. def _screen_point(x_percent: Any, y_percent: Any, width: Any, height: Any) -> tuple[int | None, int | None]:
  39. x = _percent(x_percent)
  40. y = _percent(y_percent)
  41. try:
  42. screen_width = int(width)
  43. screen_height = int(height)
  44. except (TypeError, ValueError):
  45. return None, None
  46. if x is None or y is None or screen_width <= 0 or screen_height <= 0:
  47. return None, None
  48. # 模型可能返回 100%,直接换算会得到屏幕外坐标并触发 PyAutoGUI 角点保护。
  49. safe_x = max(1, min(screen_width - 2, round(screen_width * x / 100)))
  50. safe_y = max(1, min(screen_height - 2, round(screen_height * y / 100)))
  51. return safe_x, safe_y
  52. def normalize_search_result(item: Any, scroll_page: int, width: Any, height: Any) -> dict[str, Any] | None:
  53. """规范化视觉模型返回的搜索结果,并换算标题点击坐标。"""
  54. if not isinstance(item, dict):
  55. return None
  56. title = str(item.get("title") or "").strip()
  57. url = str(item.get("url") or "").strip()
  58. if not title and not url:
  59. return None
  60. x_percent = _percent(item.get("title_center_x_percent"))
  61. y_percent = _percent(item.get("title_center_y_percent"))
  62. x, y = _screen_point(x_percent, y_percent, width, height)
  63. return {
  64. "title": title,
  65. "url": url,
  66. "snippet": str(item.get("snippet") or "").strip(),
  67. "position": item.get("position") if isinstance(item.get("position"), (int, float)) else None,
  68. "scroll_page": scroll_page,
  69. "title_center_x_percent": x_percent,
  70. "title_center_y_percent": y_percent,
  71. "title_center_x": x,
  72. "title_center_y": y,
  73. }
  74. def result_identity(item: dict[str, Any]) -> str:
  75. """优先按 URL 去重;视觉模型未识别 URL 时退回标题。"""
  76. return str(item.get("url") or item.get("title") or "").strip().lower()
  77. def screenshot_difference(left: dict[str, Any], right: dict[str, Any]) -> float:
  78. """用低分辨率灰度图估算两张截图差异,返回 0 到 1 的平均像素差。"""
  79. try:
  80. left_image = Image.open(BytesIO(base64.b64decode(str(left["image_base64"])))).convert("L").resize((96, 54))
  81. right_image = Image.open(BytesIO(base64.b64decode(str(right["image_base64"])))).convert("L").resize((96, 54))
  82. except Exception:
  83. return 1.0
  84. left_pixels = list(left_image.getdata())
  85. right_pixels = list(right_image.getdata())
  86. if not left_pixels or len(left_pixels) != len(right_pixels):
  87. return 1.0
  88. return sum(abs(a - b) for a, b in zip(left_pixels, right_pixels)) / (255 * len(left_pixels))
  89. class WebSearchRunner:
  90. """使用真实浏览器、屏幕截图和多模态模型完成网页搜索研究。"""
  91. def __init__(self, context: WorkflowContext, params: dict[str, Any]) -> None:
  92. if not context.provider_id or not context.model_id:
  93. raise HTTPException(status_code=400, detail="网页搜索节点需要配置默认 AI 服务商和模型")
  94. self.context = context
  95. self.params = params
  96. self.query = str(params.get("query") or "").strip()
  97. if not self.query:
  98. raise HTTPException(status_code=400, detail="网页搜索关键词不能为空")
  99. self.page_wait = _number(params.get("page_load_wait_seconds"), 8, 0, 120)
  100. self.action_wait = _number(params.get("action_wait_seconds"), 1, 0, 30)
  101. self.max_search_pages = _integer(params.get("max_search_pages"), 4, 1, 20)
  102. self.result_count = _integer(params.get("result_count"), 3, 1, 10)
  103. self.detail_max_pages = _integer(params.get("detail_max_pages"), 4, 1, 20)
  104. self.click_attempts = _integer(params.get("click_attempts"), 2, 1, 5)
  105. self.maximize_browser = bool(params.get("maximize_browser", True))
  106. self.wait_jitter_min = _number(params.get("wait_jitter_min_seconds"), 0, 0, 30)
  107. self.wait_jitter_max = _number(params.get("wait_jitter_max_seconds"), 0, 0, 30)
  108. if self.wait_jitter_max < self.wait_jitter_min:
  109. self.wait_jitter_min, self.wait_jitter_max = self.wait_jitter_max, self.wait_jitter_min
  110. self.focus_change_threshold = _number(params.get("focus_change_threshold"), 0.12, 0, 1)
  111. self.scroll_change_threshold = _number(params.get("scroll_change_threshold"), 0.01, 0, 1)
  112. self.analyses: list[dict[str, Any]] = []
  113. def _sleep(self, seconds: float) -> None:
  114. """在固定等待上增加可配置随机抖动,默认不抖动。"""
  115. jitter = random.uniform(self.wait_jitter_min, self.wait_jitter_max)
  116. time.sleep(max(0.0, seconds) + jitter)
  117. def run(self) -> dict[str, Any]:
  118. browser = str(self.params.get("browser") or "edge")
  119. engine = str(self.params.get("search_engine") or "google").lower()
  120. template = SEARCH_ENGINES.get(engine, SEARCH_ENGINES["google"])
  121. search_url = template.format(query=quote_plus(self.query))
  122. opened = windows_automation.open_url(search_url, browser=browser, new_window=True)
  123. self.context.remember_pid(opened.get("pid"))
  124. if self.maximize_browser:
  125. self._sleep(self.action_wait)
  126. opened["maximize"] = windows_automation.maximize_active_window()
  127. self._sleep(self.page_wait)
  128. try:
  129. results = self._collect_results(engine)
  130. ranked = self._rank_results(results)
  131. details = self._research_results(ranked)
  132. final_summary = self._summarize(details, ranked)
  133. report_path = self._write_report(results, ranked, details, final_summary)
  134. output = {
  135. "query": self.query,
  136. "search_url": search_url,
  137. "result_count": len(results),
  138. "researched_count": len(details),
  139. "results": results,
  140. "ranked_results": ranked,
  141. "researched_details": details,
  142. "summary": str(final_summary.get("summary") or ""),
  143. "key_points": final_summary.get("key_points") or [],
  144. "conclusion": str(final_summary.get("conclusion") or ""),
  145. "report_path": report_path,
  146. "next_port": "success" if results else "no_results",
  147. }
  148. if bool(self.params.get("include_debug_analyses", False)):
  149. output["analyses"] = self.analyses
  150. return output
  151. finally:
  152. if bool(self.params.get("close_browser", True)):
  153. try:
  154. windows_automation.keyboard_action("hotkey", keys=["alt", "f4"])
  155. self._sleep(self.action_wait)
  156. except Exception:
  157. # 清理浏览器失败不应覆盖已经得到的搜索结果或原始异常。
  158. pass
  159. def _capture(self) -> dict[str, Any]:
  160. return windows_automation.take_screenshot(None, include_base64=True)
  161. def _vision_json(self, prompt: str, screenshot: dict[str, Any]) -> dict[str, Any]:
  162. result = ai_service.chat_with_images(
  163. int(self.context.provider_id),
  164. int(self.context.model_id),
  165. prompt,
  166. [{"base64": screenshot["image_base64"], "mime_type": screenshot.get("mime_type", "image/png")}],
  167. self.context.temperature,
  168. )
  169. try:
  170. parsed = json.loads(ai_service.extract_json_text(result["content"]))
  171. except (json.JSONDecodeError, ValueError, TypeError) as exc:
  172. raise HTTPException(status_code=502, detail=f"网页视觉模型未返回有效 JSON: {exc}") from exc
  173. if not isinstance(parsed, dict):
  174. raise HTTPException(status_code=502, detail="网页视觉模型返回值必须是 JSON 对象")
  175. return parsed
  176. def _text_json(self, prompt: str, stage: str) -> dict[str, Any]:
  177. result = ai_service.chat(
  178. int(self.context.provider_id),
  179. int(self.context.model_id),
  180. prompt,
  181. self.context.temperature,
  182. )
  183. content = str(result.get("content") or "")
  184. extracted = ai_service.extract_json_text(content)
  185. try:
  186. parsed = json.loads(extracted)
  187. except (json.JSONDecodeError, ValueError, TypeError) as exc:
  188. # 失败时保留阶段和原始片段,方便从异步任务详情直接定位是哪次模型输出坏了。
  189. raw_excerpt = extracted[:1500]
  190. raise HTTPException(
  191. status_code=502,
  192. detail={
  193. "message": f"网页搜索模型未返回有效 JSON: {exc}",
  194. "stage": stage,
  195. "raw_excerpt": raw_excerpt,
  196. "raw_length": len(extracted),
  197. "content_excerpt": content[:1500],
  198. },
  199. ) from exc
  200. if not isinstance(parsed, dict):
  201. raise HTTPException(
  202. status_code=502,
  203. detail={"message": "网页搜索模型返回值必须是 JSON 对象", "stage": stage},
  204. )
  205. return parsed
  206. def _collect_results(self, engine: str) -> list[dict[str, Any]]:
  207. results: list[dict[str, Any]] = []
  208. seen: set[str] = set()
  209. for scroll_page in range(self.max_search_pages):
  210. screenshot = self._capture()
  211. prompt = f"""请分析真实 Windows 浏览器中的搜索结果截图。当前搜索引擎:{engine},查询词:{self.query}。
  212. 任务:
  213. 1. 判断当前页面是否为搜索结果页、验证码/阻止页或其他页面。
  214. 2. 提取可见的自然搜索结果,忽略广告、导航、相关搜索和重复项。
  215. 3. 估算每个结果标题中心点相对整张截图的百分比坐标。
  216. 4. 判断是否已经到当前搜索结果页底部。
  217. 5. 严格只输出 JSON:
  218. {{
  219. "is_bottom": boolean,
  220. "page_state": "search_results|blocked|captcha|consent|other",
  221. "results": [{{
  222. "title": string,
  223. "url": string,
  224. "snippet": string,
  225. "position": number|null,
  226. "title_center_x_percent": number|null,
  227. "title_center_y_percent": number|null
  228. }}],
  229. "notes": string
  230. }}"""
  231. analysis = self._vision_json(prompt, screenshot)
  232. analysis["scroll_page"] = scroll_page
  233. self.analyses.append({"type": "search_page", **analysis})
  234. if analysis.get("page_state") not in {None, "search_results"}:
  235. break
  236. for raw_item in analysis.get("results") or []:
  237. item = normalize_search_result(raw_item, scroll_page, screenshot.get("width"), screenshot.get("height"))
  238. if not item:
  239. continue
  240. identity = result_identity(item)
  241. if not identity or identity in seen:
  242. continue
  243. seen.add(identity)
  244. results.append(item)
  245. if bool(analysis.get("is_bottom")):
  246. break
  247. windows_automation.keyboard_action("press", key="pagedown")
  248. self._sleep(self.action_wait)
  249. return results
  250. def _rank_results(self, results: list[dict[str, Any]]) -> list[dict[str, Any]]:
  251. if not results:
  252. return []
  253. indexed = [{"original_index": index, **item} for index, item in enumerate(results)]
  254. prompt = f"""请对网页搜索结果去重并按与查询词的相关性排序。
  255. 查询词:{self.query}
  256. 最多选择:{self.result_count}
  257. 严格只输出 JSON:
  258. {{
  259. "ranked_results": [{{
  260. "original_index": number,
  261. "relevance_score": number,
  262. "dedupe_reason": string,
  263. "why_relevant": string
  264. }}],
  265. "notes": string
  266. }}
  267. 搜索结果:
  268. {json.dumps(indexed, ensure_ascii=False, indent=2)}"""
  269. ranking = self._text_json(prompt, "rank_results")
  270. self.analyses.append({"type": "ranking", **ranking})
  271. ranked: list[dict[str, Any]] = []
  272. used: set[int] = set()
  273. for rank_item in ranking.get("ranked_results") or []:
  274. if not isinstance(rank_item, dict):
  275. continue
  276. try:
  277. index = int(rank_item.get("original_index"))
  278. except (TypeError, ValueError):
  279. continue
  280. if index in used or index < 0 or index >= len(results):
  281. continue
  282. used.add(index)
  283. ranked.append({**results[index], **rank_item, "original_index": index})
  284. if len(ranked) >= self.result_count:
  285. break
  286. if not ranked:
  287. ranked = [{**item, "original_index": index} for index, item in enumerate(results[: self.result_count])]
  288. return ranked
  289. def _research_results(self, ranked: list[dict[str, Any]]) -> list[dict[str, Any]]:
  290. details: list[dict[str, Any]] = []
  291. for rank, result in enumerate(ranked[: self.result_count], start=1):
  292. classification = self._open_result(result)
  293. if not classification.get("opened_detail_page"):
  294. details.append({"rank": rank, "result": result, "opened_detail_page": False, "error": classification.get("notes")})
  295. self._restore_search_page_if_needed(classification)
  296. continue
  297. visited_url = self._current_url()
  298. self._focus_page_content(f"detail_before_extract:{result.get('title') or ''}")
  299. chunks = self._extract_detail(result)
  300. cleaned = self._clean_detail(result, visited_url, chunks)
  301. details.append({
  302. "rank": rank,
  303. "result": result,
  304. "visited_url": visited_url,
  305. "opened_detail_page": True,
  306. "chunks": chunks,
  307. "cleaned": cleaned,
  308. })
  309. windows_automation.keyboard_action("hotkey", keys=["alt", "left"])
  310. self._sleep(self.page_wait)
  311. return details
  312. def _go_to_scroll_page(self, scroll_page: int) -> None:
  313. windows_automation.keyboard_action("press", key="home")
  314. self._sleep(self.action_wait)
  315. for _ in range(max(0, scroll_page)):
  316. windows_automation.keyboard_action("press", key="pagedown")
  317. self._sleep(self.action_wait)
  318. def _open_result(self, result: dict[str, Any]) -> dict[str, Any]:
  319. title = str(result.get("title") or "")
  320. scroll_page = _integer(result.get("scroll_page"), 0, 0, self.max_search_pages)
  321. last: dict[str, Any] = {
  322. "opened_detail_page": False,
  323. "is_search_results_page": True,
  324. "notes": "未执行点击",
  325. }
  326. for attempt in range(1, self.click_attempts + 1):
  327. self._go_to_scroll_page(scroll_page)
  328. x = result.get("title_center_x") if attempt == 1 else None
  329. y = result.get("title_center_y") if attempt == 1 else None
  330. if x is None or y is None:
  331. screenshot = self._capture()
  332. prompt = f"""请在搜索结果截图中定位与目标标题最匹配的可点击标题。
  333. 目标标题:{title}
  334. 严格只输出 JSON:
  335. {{"found": boolean, "center_x_percent": number|null, "center_y_percent": number|null, "confidence": number, "notes": string}}"""
  336. location = self._vision_json(prompt, screenshot)
  337. self.analyses.append({"type": "result_location", "title": title, **location})
  338. if not location.get("found"):
  339. last = {"opened_detail_page": False, "is_search_results_page": True, **location}
  340. continue
  341. x, y = _screen_point(
  342. location.get("center_x_percent"),
  343. location.get("center_y_percent"),
  344. screenshot.get("width"),
  345. screenshot.get("height"),
  346. )
  347. if x is None or y is None:
  348. last = {
  349. "opened_detail_page": False,
  350. "is_search_results_page": True,
  351. "notes": "模型未返回可用点击坐标",
  352. }
  353. continue
  354. try:
  355. windows_automation.mouse_action("click", x=int(x), y=int(y))
  356. except HTTPException as exc:
  357. if isinstance(exc.detail, dict):
  358. exc.detail["target_result"] = {
  359. "title": title,
  360. "scroll_page": scroll_page,
  361. "x": int(x),
  362. "y": int(y),
  363. }
  364. raise
  365. self._sleep(self.page_wait)
  366. screenshot = self._capture()
  367. prompt = f"""请判断点击搜索结果后当前浏览器页面的类型。
  368. 预期标题:{title}
  369. 严格只输出 JSON:
  370. {{
  371. "is_search_results_page": boolean,
  372. "is_article_or_detail_page": boolean,
  373. "page_state": "search_results|article_or_detail|captcha|blocked|other",
  374. "confidence": number,
  375. "notes": string
  376. }}"""
  377. classification = self._vision_json(prompt, screenshot)
  378. classification["attempt"] = attempt
  379. self.analyses.append({"type": "clicked_page", "title": title, **classification})
  380. if classification.get("is_article_or_detail_page") and not classification.get("is_search_results_page"):
  381. return {"opened_detail_page": True, **classification}
  382. last = {"opened_detail_page": False, **classification}
  383. if not classification.get("is_search_results_page"):
  384. break
  385. return last
  386. def _restore_search_page_if_needed(self, classification: dict[str, Any]) -> None:
  387. if classification.get("is_search_results_page"):
  388. return
  389. windows_automation.keyboard_action("hotkey", keys=["alt", "left"])
  390. self._sleep(self.page_wait)
  391. def _current_url(self) -> str:
  392. try:
  393. import pyperclip
  394. except ImportError as exc:
  395. raise HTTPException(status_code=500, detail="pyperclip is not installed") from exc
  396. windows_automation.keyboard_action("hotkey", keys=["alt", "d"])
  397. self._sleep(self.action_wait)
  398. windows_automation.keyboard_action("hotkey", keys=["ctrl", "c"])
  399. self._sleep(self.action_wait)
  400. url = str(pyperclip.paste() or "").strip()
  401. windows_automation.keyboard_action("press", key="escape")
  402. self._sleep(self.action_wait)
  403. return url
  404. def _focus_page_content(self, reason: str) -> dict[str, Any]:
  405. """点击活动浏览器窗口正文区域以恢复页面焦点;若误触导致页面变化则回退。"""
  406. before = self._capture()
  407. try:
  408. bounds = windows_automation.active_window_bounds()
  409. except HTTPException as exc:
  410. self.analyses.append({"type": "focus_page_content", "reason": reason, "focused": False, "error": exc.detail})
  411. return {"focused": False, "error": exc.detail}
  412. width = max(1, int(bounds.get("width") or 1))
  413. height = max(1, int(bounds.get("height") or 1))
  414. left = int(bounds.get("left") or 0)
  415. top = int(bounds.get("top") or 0)
  416. # 避开浏览器顶部工具栏、底部边缘和右侧滚动条,降低误点链接或浏览器控件的概率。
  417. x = left + max(80, min(width - 120, round(width * 0.55)))
  418. y = top + max(140, min(height - 160, round(height * 0.48)))
  419. windows_automation.mouse_action("click", x=x, y=y)
  420. self._sleep(self.action_wait)
  421. after = self._capture()
  422. diff = screenshot_difference(before, after)
  423. focused = diff <= self.focus_change_threshold
  424. if not focused:
  425. windows_automation.keyboard_action("hotkey", keys=["alt", "left"])
  426. self._sleep(self.page_wait)
  427. result = {
  428. "type": "focus_page_content",
  429. "reason": reason,
  430. "focused": focused,
  431. "x": x,
  432. "y": y,
  433. "screenshot_difference": diff,
  434. "window": bounds,
  435. "rolled_back": not focused,
  436. }
  437. self.analyses.append(result)
  438. return result
  439. def _scroll_detail_page(self, before: dict[str, Any], title: str, detail_page: int) -> None:
  440. """详情页优先用 PageDown 翻页;若截图几乎不变,则用鼠标滚轮兜底。"""
  441. self._focus_page_content(f"detail_scroll:{title}:{detail_page}")
  442. windows_automation.keyboard_action("press", key="pagedown")
  443. self._sleep(self.action_wait)
  444. after_key = self._capture()
  445. key_diff = screenshot_difference(before, after_key)
  446. used_fallback = key_diff < self.scroll_change_threshold
  447. wheel_diff: float | None = None
  448. if used_fallback:
  449. windows_automation.mouse_action("scroll", amount=-6)
  450. self._sleep(self.action_wait)
  451. after_wheel = self._capture()
  452. wheel_diff = screenshot_difference(before, after_wheel)
  453. self.analyses.append(
  454. {
  455. "type": "detail_scroll",
  456. "title": title,
  457. "detail_page": detail_page,
  458. "pagedown_difference": key_diff,
  459. "used_wheel_fallback": used_fallback,
  460. "wheel_difference": wheel_diff,
  461. }
  462. )
  463. def _extract_detail(self, result: dict[str, Any]) -> list[dict[str, Any]]:
  464. chunks: list[dict[str, Any]] = []
  465. title = str(result.get("title") or "")
  466. for detail_page in range(self.detail_max_pages):
  467. screenshot = self._capture()
  468. prompt = f"""请提取文章、文档或详情页截图中与研究问题相关的可见信息。
  469. 研究问题:{self.query}
  470. 原搜索结果标题:{title}
  471. 忽略广告、导航、Cookie 提示和重复页眉页脚。
  472. 严格只输出 JSON:
  473. {{
  474. "is_bottom": boolean,
  475. "page_state": "article_or_detail|blocked|captcha|other",
  476. "visible_information": string,
  477. "confidence": number,
  478. "notes": string
  479. }}"""
  480. extraction = self._vision_json(prompt, screenshot)
  481. extraction["detail_page"] = detail_page
  482. chunks.append(extraction)
  483. self.analyses.append({"type": "detail_extraction", "title": title, **extraction})
  484. if extraction.get("is_bottom") or extraction.get("page_state") in {"blocked", "captcha"}:
  485. break
  486. self._scroll_detail_page(screenshot, title, detail_page)
  487. return chunks
  488. def _clean_detail(self, result: dict[str, Any], visited_url: str, chunks: list[dict[str, Any]]) -> dict[str, Any]:
  489. prompt = f"""请清理、去重并组织一个网页搜索结果中提取的信息。
  490. 研究问题:{self.query}
  491. 搜索结果:{json.dumps({**result, 'visited_url': visited_url}, ensure_ascii=False)}
  492. 提取片段:{json.dumps(chunks, ensure_ascii=False)}
  493. 严格只输出 JSON:
  494. {{"clean_title": string, "clean_text": string, "key_points": [string], "notes": string}}"""
  495. cleaned = self._text_json(prompt, "clean_detail")
  496. self.analyses.append({"type": "clean_detail", "title": result.get("title"), **cleaned})
  497. return cleaned
  498. def _summarize(self, details: list[dict[str, Any]], ranked: list[dict[str, Any]]) -> dict[str, Any]:
  499. if not details:
  500. return {"summary": "未获取到可研究的网页详情。", "key_points": [], "conclusion": "", "notes": ""}
  501. prompt = f"""请根据网页搜索研究结果生成事实清晰、避免重复的中文总结。
  502. 研究问题:{self.query}
  503. 排序结果:{json.dumps(ranked, ensure_ascii=False)}
  504. 详情:{json.dumps(details, ensure_ascii=False)}
  505. 严格只输出 JSON:
  506. {{"summary": string, "key_points": [string], "conclusion": string, "notes": string}}"""
  507. summary = self._text_json(prompt, "summarize")
  508. self.analyses.append({"type": "final_summary", **summary})
  509. return summary
  510. def _write_report(
  511. self,
  512. results: list[dict[str, Any]],
  513. ranked: list[dict[str, Any]],
  514. details: list[dict[str, Any]],
  515. summary: dict[str, Any],
  516. ) -> str:
  517. report_dir = settings_service.resolve_data_path("automation_runtime_path", "automation/runtime") / "web_search"
  518. report_dir.mkdir(parents=True, exist_ok=True)
  519. path = report_dir / f"search_{int(time.time() * 1000)}.json"
  520. payload = {
  521. "query": self.query,
  522. "results": results,
  523. "ranked_results": ranked,
  524. "researched_details": details,
  525. "final_summary": summary,
  526. }
  527. path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  528. return str(path)
  529. def web_search_node(node: dict[str, Any], inputs: dict[str, Any], context: WorkflowContext) -> dict[str, Any]:
  530. params = {**(node.get("params") or {}), **inputs}
  531. return WebSearchRunner(context, params).run()
  532. register_node(
  533. {
  534. "type": "browser.web_search",
  535. "category": "browser",
  536. "label": "网页搜索研究",
  537. "params": {
  538. "query": field_def("text", "搜索关键词", required=True),
  539. "search_engine": field_def("select", "搜索引擎", "google", options=["google", "bing"]),
  540. "browser": field_def("select", "浏览器", "edge", options=["default", "edge"]),
  541. "max_search_pages": field_def("number", "最多搜索页屏", 4, minimum=1, maximum=20),
  542. "result_count": field_def("number", "研究结果数", 3, minimum=1, maximum=10),
  543. "detail_max_pages": field_def("number", "每页最多滚动", 4, minimum=1, maximum=20),
  544. "click_attempts": field_def("number", "标题点击重试", 2, minimum=1, maximum=5),
  545. "maximize_browser": field_def("boolean", "打开后最大化浏览器", True),
  546. "page_load_wait_seconds": field_def("number", "页面加载等待秒数", 8, minimum=0, maximum=120),
  547. "action_wait_seconds": field_def("number", "操作等待秒数", 1, minimum=0, maximum=30),
  548. "wait_jitter_min_seconds": field_def("number", "等待抖动最小秒数", 0, minimum=0, maximum=30),
  549. "wait_jitter_max_seconds": field_def("number", "等待抖动最大秒数", 0, minimum=0, maximum=30),
  550. "close_browser": field_def("boolean", "完成后关闭浏览器", True),
  551. "include_debug_analyses": field_def("boolean", "返回调试分析", False),
  552. },
  553. "inputs": {
  554. "query": field_def("string", "搜索关键词"),
  555. "search_engine": field_def("string", "搜索引擎"),
  556. "browser": field_def("string", "浏览器"),
  557. },
  558. "outputs": {
  559. "query": {"type": "string", "label": "搜索关键词"},
  560. "results": {"type": "array", "label": "搜索结果"},
  561. "ranked_results": {"type": "array", "label": "排序结果"},
  562. "researched_details": {"type": "array", "label": "详情研究结果"},
  563. "summary": {"type": "string", "label": "总结"},
  564. "key_points": {"type": "array", "label": "要点"},
  565. "conclusion": {"type": "string", "label": "结论"},
  566. "report_path": {"type": "string", "label": "结果文件"},
  567. },
  568. "control_ports": control_ports(["success", "no_results", "failure"]),
  569. },
  570. web_search_node,
  571. )