argo-search 1.0.1 → 2.9.8

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.
Files changed (472) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +735 -0
  3. package/README.es.md +777 -0
  4. package/README.ja.md +777 -0
  5. package/README.ko.md +777 -0
  6. package/README.md +778 -320
  7. package/SKILL.md +135 -0
  8. package/assets/readme/hero.svg +121 -0
  9. package/assets/readme/made-with-beautify.svg +10 -0
  10. package/assets/readme/proof-routes.svg +73 -0
  11. package/assets/readme/why-better.svg +104 -0
  12. package/assets/readme/workflow.svg +83 -0
  13. package/backends/domain_profiles.json +2356 -96
  14. package/backends/engine_registry.yaml +3428 -503
  15. package/backends/query_synonyms_cn.json +35 -0
  16. package/backends/quota_profiles.json +2488 -160
  17. package/backends/source_types_cn.json +109 -0
  18. package/backends/text_lexicon.json +44 -0
  19. package/bin/argo +772 -0
  20. package/bin/argo.js +102 -39
  21. package/config.yaml +5139 -367
  22. package/cordis.patch.yml +21 -0
  23. package/data/countries.json +40 -0
  24. package/data/lang_matrix/lang_capability.json +842 -0
  25. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  26. package/data/regions_cn.json +1 -0
  27. package/docs/ADDING_NEW_ENGINE.md +184 -0
  28. package/docs/ARGO_INTRO.md +124 -0
  29. package/docs/DATA_SOURCE_GAPS.md +81 -0
  30. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  31. package/docs/ENGINE_CATALOG.md +575 -0
  32. package/docs/MCP_SETUP.md +108 -0
  33. package/docs/PLATFORM_PATHS.md +111 -0
  34. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  35. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  36. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  37. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  38. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  39. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  40. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  41. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  42. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  43. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  44. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  45. package/docs/SEARCH_ARCHIVE.md +115 -0
  46. package/docs/assets/architecture.svg +104 -0
  47. package/docs/assets/hero.svg +98 -0
  48. package/docs/engines/bangumi.md +27 -0
  49. package/docs/engines/biorxiv.md +27 -0
  50. package/docs/engines/clawhub.md +27 -0
  51. package/docs/engines/crt_sh.md +27 -0
  52. package/docs/engines/datacite.md +27 -0
  53. package/docs/engines/deps_dev.md +27 -0
  54. package/docs/engines/douban_movie.md +27 -0
  55. package/docs/engines/endoflife.md +27 -0
  56. package/docs/engines/flk_law.md +27 -0
  57. package/docs/engines/fxtwitter.md +27 -0
  58. package/docs/engines/google_news.md +27 -0
  59. package/docs/engines/gov_regulations.md +27 -0
  60. package/docs/engines/guqian.md +27 -0
  61. package/docs/engines/hackernews.md +27 -0
  62. package/docs/engines/iplant.md +27 -0
  63. package/docs/engines/keenable.md +27 -0
  64. package/docs/engines/met_museum.md +27 -0
  65. package/docs/engines/nasa_images.md +27 -0
  66. package/docs/engines/netease_music.md +27 -0
  67. package/docs/engines/npm.md +27 -0
  68. package/docs/engines/nvd.md +27 -0
  69. package/docs/engines/openreview.md +27 -0
  70. package/docs/engines/openstd.md +27 -0
  71. package/docs/engines/parallel_free.md +27 -0
  72. package/docs/engines/people_daily.md +27 -0
  73. package/docs/engines/realtime_index.md +27 -0
  74. package/docs/engines/sec_edgar.md +27 -0
  75. package/docs/engines/seltz.md +27 -0
  76. package/docs/engines/skillsmp.md +27 -0
  77. package/docs/engines/sspai.md +27 -0
  78. package/docs/engines/std_samr.md +27 -0
  79. package/docs/engines/train.md +27 -0
  80. package/docs/engines/un_comtrade.md +27 -0
  81. package/docs/engines/wikisource.md +27 -0
  82. package/docs/engines/zdic.md +27 -0
  83. package/docs/engines/zenodo.md +27 -0
  84. package/docs/engines/zhihu_hot_app.md +27 -0
  85. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  86. package/engines/README.md +10 -0
  87. package/engines/_template_cli.yaml +39 -0
  88. package/engines/_template_http.yaml +37 -0
  89. package/engines/plugins/_template_plugin.py +50 -0
  90. package/engines/specs/_cisa_kev.yaml +54 -0
  91. package/engines/specs/_osv.yaml +63 -0
  92. package/engines/specs/aljazeera.yaml +43 -0
  93. package/engines/specs/antara.yaml +43 -0
  94. package/engines/specs/artic.yaml +20 -0
  95. package/engines/specs/aviation_weather.yaml +39 -0
  96. package/engines/specs/bangumi.yaml +21 -0
  97. package/engines/specs/biorxiv.yaml +24 -0
  98. package/engines/specs/carbon_intensity.yaml +32 -0
  99. package/engines/specs/cfets.yaml +41 -0
  100. package/engines/specs/cinii.yaml +44 -0
  101. package/engines/specs/clawhub.yaml +49 -0
  102. package/engines/specs/cleveland.yaml +19 -0
  103. package/engines/specs/cn_ai_news.yaml +43 -0
  104. package/engines/specs/core.yaml +35 -0
  105. package/engines/specs/crt_sh.yaml +38 -0
  106. package/engines/specs/ct24.yaml +43 -0
  107. package/engines/specs/datacite.yaml +27 -0
  108. package/engines/specs/datagov_uk.yaml +41 -0
  109. package/engines/specs/ddgs_images.yaml +45 -0
  110. package/engines/specs/deepseek_search.yaml +74 -0
  111. package/engines/specs/deezer.yaml +20 -0
  112. package/engines/specs/defillama.yaml +38 -0
  113. package/engines/specs/deps_dev.yaml +21 -0
  114. package/engines/specs/digup.yaml +36 -0
  115. package/engines/specs/doi.yaml +39 -0
  116. package/engines/specs/douban_movie.yaml +22 -0
  117. package/engines/specs/dw_news.yaml +47 -0
  118. package/engines/specs/egov_law.yaml +21 -0
  119. package/engines/specs/elpais.yaml +43 -0
  120. package/engines/specs/endoflife.yaml +21 -0
  121. package/engines/specs/energy_charts.yaml +21 -0
  122. package/engines/specs/factcheck_org.yaml +52 -0
  123. package/engines/specs/faz.yaml +43 -0
  124. package/engines/specs/federal_register.yaml +57 -0
  125. package/engines/specs/firecrawl.yaml +53 -0
  126. package/engines/specs/flk_law.yaml +24 -0
  127. package/engines/specs/folha.yaml +43 -0
  128. package/engines/specs/france24.yaml +44 -0
  129. package/engines/specs/full_fact.yaml +47 -0
  130. package/engines/specs/fxtwitter.yaml +36 -0
  131. package/engines/specs/gbfs_nyc.yaml +32 -0
  132. package/engines/specs/gdacs.yaml +21 -0
  133. package/engines/specs/gdelt.yaml +64 -0
  134. package/engines/specs/gleif.yaml +55 -0
  135. package/engines/specs/google_news.yaml +22 -0
  136. package/engines/specs/gov_regulations.yaml +38 -0
  137. package/engines/specs/guardian_rss.yaml +59 -0
  138. package/engines/specs/hkex.yaml +40 -0
  139. package/engines/specs/hurriyet.yaml +43 -0
  140. package/engines/specs/iplant.yaml +25 -0
  141. package/engines/specs/jikan.yaml +24 -0
  142. package/engines/specs/jolpica.yaml +20 -0
  143. package/engines/specs/k10plus.yaml +20 -0
  144. package/engines/specs/keenable.yaml +44 -0
  145. package/engines/specs/keenable_free.yaml +36 -0
  146. package/engines/specs/lefigaro.yaml +43 -0
  147. package/engines/specs/listenbrainz.yaml +19 -0
  148. package/engines/specs/matichon.yaml +43 -0
  149. package/engines/specs/met_museum.yaml +21 -0
  150. package/engines/specs/nasa_images.yaml +45 -0
  151. package/engines/specs/netease_music.yaml +38 -0
  152. package/engines/specs/nhk.yaml +43 -0
  153. package/engines/specs/nhtsa_vpic.yaml +20 -0
  154. package/engines/specs/noaa_swpc.yaml +20 -0
  155. package/engines/specs/nvd.yaml +38 -0
  156. package/engines/specs/obis.yaml +20 -0
  157. package/engines/specs/opencitations.yaml +52 -0
  158. package/engines/specs/openf1.yaml +21 -0
  159. package/engines/specs/openfoodfacts.yaml +45 -0
  160. package/engines/specs/openligadb.yaml +20 -0
  161. package/engines/specs/openreview.yaml +37 -0
  162. package/engines/specs/openstd.yaml +21 -0
  163. package/engines/specs/people_daily.yaml +22 -0
  164. package/engines/specs/realtime_index.yaml +34 -0
  165. package/engines/specs/ror.yaml +20 -0
  166. package/engines/specs/satnogs.yaml +20 -0
  167. package/engines/specs/sec_edgar.yaml +46 -0
  168. package/engines/specs/shfe.yaml +39 -0
  169. package/engines/specs/skillsmp.yaml +50 -0
  170. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  171. package/engines/specs/soilgrids.yaml +25 -0
  172. package/engines/specs/sspai.yaml +36 -0
  173. package/engines/specs/stackexchange.yaml +46 -0
  174. package/engines/specs/std_samr.yaml +21 -0
  175. package/engines/specs/tass.yaml +43 -0
  176. package/engines/specs/tavily_free.yaml +56 -0
  177. package/engines/specs/tle_mirror.yaml +21 -0
  178. package/engines/specs/train.yaml +37 -0
  179. package/engines/specs/tvmaze.yaml +20 -0
  180. package/engines/specs/twitter_syndication.yaml +31 -0
  181. package/engines/specs/un_comtrade.yaml +23 -0
  182. package/engines/specs/unpaywall.yaml +54 -0
  183. package/engines/specs/vnpress.yaml +43 -0
  184. package/engines/specs/weather.yaml +32 -0
  185. package/engines/specs/who_don.yaml +21 -0
  186. package/engines/specs/who_gho.yaml +20 -0
  187. package/engines/specs/wikimedia_commons.yaml +34 -0
  188. package/engines/specs/wikisource.yaml +22 -0
  189. package/engines/specs/worms.yaml +19 -0
  190. package/engines/specs/x_trending.yaml +34 -0
  191. package/engines/specs/yna.yaml +43 -0
  192. package/engines/specs/ynet.yaml +43 -0
  193. package/engines/specs/yuanbao.yaml +93 -0
  194. package/engines/specs/zbmath.yaml +40 -0
  195. package/engines/specs/zdic.yaml +22 -0
  196. package/engines/specs/zenodo.yaml +27 -0
  197. package/engines/specs/zhihu_hot_app.yaml +35 -0
  198. package/mcp/clients.yaml +106 -0
  199. package/package.json +55 -12
  200. package/packages/dsh-plugin/README.md +152 -0
  201. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  202. package/packages/dsh-plugin/dsh/client.js +1533 -0
  203. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  204. package/packages/dsh-plugin/dsh/index.js +527 -0
  205. package/packages/dsh-plugin/dsh/native-tools.mjs +749 -0
  206. package/packages/dsh-plugin/dsh/runner.js +666 -0
  207. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  208. package/packages/dsh-plugin/package.json +38 -0
  209. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  210. package/packages/dsh-plugin/test/client.test.js +406 -0
  211. package/packages/dsh-plugin/test/config.test.js +30 -0
  212. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  213. package/packages/dsh-plugin/test/index.test.js +1382 -0
  214. package/references/query-templates.md +96 -0
  215. package/references/query-templates.yaml +118 -0
  216. package/scripts/ab_eval.py +219 -0
  217. package/scripts/ab_eval_p0p1.py +266 -0
  218. package/scripts/adaptive.py +294 -70
  219. package/scripts/answer.py +154 -0
  220. package/scripts/archive_run.py +591 -0
  221. package/scripts/argo_engine_registry.py +172 -0
  222. package/scripts/argo_paths.py +728 -0
  223. package/scripts/article.py +136 -0
  224. package/scripts/article_meta.py +131 -0
  225. package/scripts/arxiv_source.py +201 -0
  226. package/scripts/badcase_ledger.py +252 -0
  227. package/scripts/batch_probe.py +241 -0
  228. package/scripts/benchmark.py +19 -0
  229. package/scripts/bounded_run.py +144 -0
  230. package/scripts/browser_auth.py +323 -0
  231. package/scripts/budget.py +247 -0
  232. package/scripts/budget_cli.py +93 -0
  233. package/scripts/build_release_assets.sh +65 -0
  234. package/scripts/burstiness_detector.py +147 -0
  235. package/scripts/cache.py +1066 -67
  236. package/scripts/cache_guard.py +271 -0
  237. package/scripts/cache_key_vdom.py +56 -0
  238. package/scripts/cache_similarity.py +170 -0
  239. package/scripts/caliber_isolation.py +195 -0
  240. package/scripts/calibrate_lowquality.py +210 -0
  241. package/scripts/candidate_envelope.py +366 -0
  242. package/scripts/chrome_cdp.py +871 -0
  243. package/scripts/ci_drift_gate.py +90 -0
  244. package/scripts/circuit_breaker.py +389 -0
  245. package/scripts/citations.py +334 -0
  246. package/scripts/cite_cli.py +59 -0
  247. package/scripts/cjk_tokens.py +95 -0
  248. package/scripts/clarify.py +47 -24
  249. package/scripts/cli_io.py +240 -0
  250. package/scripts/config.py +683 -45
  251. package/scripts/config_cache.py +402 -0
  252. package/scripts/conn_pool.py +220 -0
  253. package/scripts/content_boundary.py +46 -0
  254. package/scripts/content_security.py +685 -0
  255. package/scripts/content_signals.py +751 -0
  256. package/scripts/crawl.py +74 -35
  257. package/scripts/cross_lingual_trace.py +293 -0
  258. package/scripts/daemon.py +866 -0
  259. package/scripts/digup_bridge.py +214 -0
  260. package/scripts/dispatch_result.py +98 -0
  261. package/scripts/engine_admission.py +366 -0
  262. package/scripts/engine_dispatch.py +982 -0
  263. package/scripts/engine_env.py +539 -0
  264. package/scripts/engine_failure.py +411 -0
  265. package/scripts/engine_families.py +738 -0
  266. package/scripts/engine_policy.py +218 -0
  267. package/scripts/engine_requires.py +190 -0
  268. package/scripts/engine_status.py +322 -0
  269. package/scripts/engine_validate.py +638 -0
  270. package/scripts/engines.py +799 -294
  271. package/scripts/engines_base.py +1521 -0
  272. package/scripts/engines_builders.py +310 -0
  273. package/scripts/engines_builders_batch10.py +204 -0
  274. package/scripts/engines_builders_batch11.py +174 -0
  275. package/scripts/engines_builders_batch12.py +103 -0
  276. package/scripts/engines_builders_batch13.py +340 -0
  277. package/scripts/engines_builders_batch14.py +656 -0
  278. package/scripts/engines_builders_batch15.py +249 -0
  279. package/scripts/engines_builders_batch9.py +1468 -0
  280. package/scripts/engines_builders_cn.py +2283 -0
  281. package/scripts/engines_builders_data.py +2923 -0
  282. package/scripts/engines_builders_data_macro.py +775 -0
  283. package/scripts/engines_builders_feeds.py +255 -0
  284. package/scripts/engines_builders_intl.py +1239 -0
  285. package/scripts/engines_builders_keyless.py +111 -0
  286. package/scripts/engines_builders_search.py +440 -0
  287. package/scripts/engines_builders_tech.py +1359 -0
  288. package/scripts/evidence.py +620 -166
  289. package/scripts/evidence_loop.py +624 -0
  290. package/scripts/evidence_tier.py +298 -0
  291. package/scripts/except_sets.py +25 -0
  292. package/scripts/extract.py +55 -16
  293. package/scripts/fact_align.py +207 -0
  294. package/scripts/fact_crosscheck.py +230 -0
  295. package/scripts/fetch.py +25 -12
  296. package/scripts/fetch_browser.py +87 -0
  297. package/scripts/fetch_jsrun.py +122 -0
  298. package/scripts/fetch_quality.py +225 -0
  299. package/scripts/fetch_render_tinyfish.py +117 -0
  300. package/scripts/fetch_v3.py +1804 -0
  301. package/scripts/focus_extract.py +350 -0
  302. package/scripts/fulltext_store.py +169 -0
  303. package/scripts/fxtwitter_api.py +325 -0
  304. package/scripts/gen_engine_catalog.py +364 -0
  305. package/scripts/gen_native_tools.py +91 -0
  306. package/scripts/gen_routing_snapshot.py +195 -0
  307. package/scripts/health_check.py +140 -402
  308. package/scripts/health_probe.py +23 -8
  309. package/scripts/hot_state.py +134 -0
  310. package/scripts/http_client.py +1053 -0
  311. package/scripts/image_ops.py +430 -0
  312. package/scripts/image_vision/vision_probe.swift +126 -0
  313. package/scripts/install.ps1 +166 -0
  314. package/scripts/install.sh +221 -0
  315. package/scripts/job.py +1186 -0
  316. package/scripts/jump_resolver.py +235 -0
  317. package/scripts/lang_capability.py +150 -0
  318. package/scripts/lang_detect.py +289 -0
  319. package/scripts/lang_matrix_probe.py +329 -0
  320. package/scripts/lang_pref.py +311 -0
  321. package/scripts/link_source.py +340 -0
  322. package/scripts/local_image.py +970 -0
  323. package/scripts/local_seek.py +402 -0
  324. package/scripts/macro_countries.py +97 -0
  325. package/scripts/matrix_search_eval.py +1187 -0
  326. package/scripts/mcp_compact.py +234 -0
  327. package/scripts/mcp_diag.py +8 -3
  328. package/scripts/mcp_handlers.py +492 -0
  329. package/scripts/mcp_handlers_surface.py +157 -0
  330. package/scripts/mcp_launch.sh +124 -0
  331. package/scripts/mcp_local.py +241 -0
  332. package/scripts/mcp_server.py +125 -419
  333. package/scripts/mcp_setup.py +431 -0
  334. package/scripts/mcp_tools.py +331 -0
  335. package/scripts/mcp_transport.py +191 -0
  336. package/scripts/mcp_warm.py +127 -0
  337. package/scripts/min_version_scan.py +335 -0
  338. package/scripts/net_proxy.py +258 -0
  339. package/scripts/network_aware.py +152 -0
  340. package/scripts/output_transparency.py +200 -0
  341. package/scripts/paid_policy.py +98 -0
  342. package/scripts/paper.py +274 -0
  343. package/scripts/pdf_extract.py +518 -0
  344. package/scripts/phrase_filter.py +66 -0
  345. package/scripts/plan.py +422 -0
  346. package/scripts/postprocess_signals.py +150 -0
  347. package/scripts/pre-commit +48 -0
  348. package/scripts/query_enhance.py +157 -0
  349. package/scripts/query_operators.py +196 -0
  350. package/scripts/query_rewriter.py +116 -7
  351. package/scripts/query_signals.py +300 -0
  352. package/scripts/query_templates.py +167 -0
  353. package/scripts/query_understanding.py +411 -0
  354. package/scripts/query_variants.py +298 -0
  355. package/scripts/quota.py +633 -76
  356. package/scripts/rank_signals.py +775 -0
  357. package/scripts/ranking_eval.py +319 -0
  358. package/scripts/readability_extract.py +433 -0
  359. package/scripts/recompute.py +415 -0
  360. package/scripts/recovery.py +616 -0
  361. package/scripts/recovery_l0.py +160 -0
  362. package/scripts/recovery_runner.py +150 -0
  363. package/scripts/redskill/redskill_engine.py +332 -0
  364. package/scripts/regression_p0p1.py +318 -0
  365. package/scripts/replay_eval.py +658 -0
  366. package/scripts/rerank_tfidf.py +225 -0
  367. package/scripts/research.py +607 -403
  368. package/scripts/research_cli.py +361 -0
  369. package/scripts/research_dossier.py +492 -0
  370. package/scripts/research_expand.py +169 -0
  371. package/scripts/research_gates.py +272 -0
  372. package/scripts/research_report.py +209 -0
  373. package/scripts/research_strategy.py +37 -0
  374. package/scripts/research_work_packages.py +195 -0
  375. package/scripts/result_lang.py +311 -0
  376. package/scripts/retry_policy.py +76 -0
  377. package/scripts/robots_guard.py +259 -0
  378. package/scripts/route.py +921 -254
  379. package/scripts/route_bangs.py +40 -0
  380. package/scripts/route_cache.py +243 -0
  381. package/scripts/route_combo.py +917 -0
  382. package/scripts/route_domains.py +341 -0
  383. package/scripts/route_lang.py +605 -0
  384. package/scripts/route_log.py +63 -0
  385. package/scripts/route_policy.py +250 -0
  386. package/scripts/rrf_classic.py +66 -0
  387. package/scripts/screenshot.py +73 -0
  388. package/scripts/search.py +886 -406
  389. package/scripts/search_attribution.py +185 -0
  390. package/scripts/search_benchmark.py +407 -0
  391. package/scripts/search_cli.py +640 -0
  392. package/scripts/search_entry.py +395 -0
  393. package/scripts/search_output.py +690 -0
  394. package/scripts/search_pipeline.py +926 -0
  395. package/scripts/search_rank.py +991 -0
  396. package/scripts/search_types.py +129 -16
  397. package/scripts/seek_locator.py +68 -0
  398. package/scripts/semantic_evidence.py +391 -0
  399. package/scripts/serp_guard.py +336 -0
  400. package/scripts/serp_kill_log.py +74 -0
  401. package/scripts/single_flight.py +120 -0
  402. package/scripts/social_engines/__init__.py +10 -0
  403. package/scripts/social_engines/bilibili_engine.py +131 -0
  404. package/scripts/social_engines/hackernews_engine.py +113 -0
  405. package/scripts/social_engines/reddit_engine.py +172 -0
  406. package/scripts/social_engines/twitter_engine.py +397 -0
  407. package/scripts/social_engines/v2ex_engine.py +101 -0
  408. package/scripts/social_engines/weibo_engine.py +128 -0
  409. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  410. package/scripts/social_engines/zhihu_engine.py +139 -0
  411. package/scripts/social_research.py +201 -0
  412. package/scripts/sogou_weixin_browser.py +318 -0
  413. package/scripts/source_ledger.py +249 -0
  414. package/scripts/stage_timing.py +58 -0
  415. package/scripts/stats_cli.py +107 -0
  416. package/scripts/straggler_policy.py +60 -0
  417. package/scripts/stylometry_detector.py +231 -0
  418. package/scripts/sync_backends.py +353 -0
  419. package/scripts/sync_installs.py +29 -0
  420. package/scripts/template_cli.py +127 -0
  421. package/scripts/text_lexicon.py +72 -0
  422. package/scripts/tfidf_router.py +109 -37
  423. package/scripts/time_utils.py +233 -0
  424. package/scripts/topic_research_profiles.py +643 -0
  425. package/scripts/train.py +737 -0
  426. package/scripts/tweet.py +596 -0
  427. package/scripts/url_canon.py +217 -0
  428. package/scripts/url_optimize.py +56 -0
  429. package/scripts/url_safety.py +236 -0
  430. package/scripts/usage_log.py +116 -0
  431. package/scripts/usage_log_clean.py +113 -0
  432. package/scripts/v2ex_nodes.py +311 -0
  433. package/scripts/variant_recall.py +188 -0
  434. package/scripts/watch.py +205 -0
  435. package/scripts/wechat_article.py +258 -0
  436. package/scripts/wide_research.py +447 -0
  437. package/scripts/wx.py +349 -0
  438. package/scripts/x_article_fetch.py +141 -0
  439. package/scripts/x_search.py +234 -0
  440. package/scripts/x_trending.py +189 -0
  441. package/scripts/yaml_load.py +67 -0
  442. package/scripts/zhihu_quota.py +139 -0
  443. package/sub-skills/ego-search/SKILL.md +253 -0
  444. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  445. package/sub-skills/ego-search/scripts/merge.py +226 -0
  446. package/sub-skills/ego-search/scripts/quality.py +64 -0
  447. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  448. package/sub-skills/ego-search/scripts/safety.py +109 -0
  449. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  450. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  451. package/sub-skills/js-run/SKILL.md +67 -0
  452. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  453. package/sub-skills/js-run/scripts/shim.js +188 -0
  454. package/sub-skills/local-search/SKILL.md +50 -53
  455. package/sub-skills/local-search/bm25.py +186 -0
  456. package/sub-skills/local-search/cli_runner.py +331 -0
  457. package/sub-skills/local-search/config.yaml +152 -55
  458. package/sub-skills/local-search/engine_registry.py +91 -26
  459. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  460. package/sub-skills/local-search/local_search_adapter.py +13 -2
  461. package/sub-skills/local-search/parse_maps.yaml +40 -42
  462. package/sub-skills/local-search/parsers.py +328 -0
  463. package/sub-skills/local-search/search_v3.py +716 -242
  464. package/sub-skills/local-search/smart_router.py +47 -7
  465. package/sub-skills/local-seek/SKILL.md +110 -0
  466. package/sub-skills/local-seek/config/domains.yaml +20 -0
  467. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  468. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  469. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  470. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  471. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  472. package/sub-skills/local-seek/scripts/seek_rank.py +197 -0
@@ -1,24 +1,23 @@
1
1
  #!/usr/bin/env python3
2
- """engines.py — Unified Search v2 引擎适配层(精简版,< 400 行)
2
+ """engines.py — Unified Search v2 引擎适配层(门面)
3
3
 
4
- 配置驱动 + 声明式 output_map 字段提取 + 通用 parser 兜底。
5
- 支持 cli / http(GET/POST) 类型,所有异常吞没返回 []。
4
+ 配置驱动 + 声明式 output_map 字段提取 + 通用 parser 保底。
5
+ 实现拆分:
6
+ - engines_base.py 公共工具 / cli / http / html / 通用解析
7
+ - engines_builders.py 专用引擎构建器
8
+ 本文件仅负责 BUILDERS 注册表与 search() 入口。
6
9
  """
7
10
 
8
11
  from __future__ import annotations
9
12
 
10
- import functools
13
+ import contextlib
14
+ import inspect
11
15
  import json
12
- import logging
13
- import os
14
16
  import re
15
- import subprocess
16
17
  import sys
17
18
  import time
18
- import urllib.error
19
- import urllib.request
20
19
  from pathlib import Path
21
- from typing import Any, Callable
20
+ from typing import Any
22
21
 
23
22
  try:
24
23
  from config import load_config, get_engines
@@ -26,344 +25,850 @@ except ImportError:
26
25
  sys.path.insert(0, str(Path(__file__).parent))
27
26
  from config import load_config, get_engines
28
27
 
29
- logger = logging.getLogger("unified_search.engines")
30
- if not logger.handlers:
31
- logger.setLevel(logging.WARNING)
32
- logger.addHandler(logging.StreamHandler(sys.stderr))
33
-
34
-
35
- def safe_search(fn: Callable) -> Callable:
36
- """统一错误处理装饰器 — 所有异常返回 []。"""
37
- @functools.wraps(fn)
38
- def wrapper(*args, **kwargs) -> list[dict[str, Any]]:
39
- name = fn.__name__.replace("_engine", "").strip("_")
40
- try:
41
- return fn(*args, **kwargs)
42
- except subprocess.TimeoutExpired:
43
- logger.warning(f"引擎 {name} 超时")
44
- except FileNotFoundError as e:
45
- logger.warning(f"引擎 {name} 命令不存在: {e}")
46
- except Exception as e:
47
- logger.error(f"引擎 {name} 异常: {type(e).__name__}: {e}", exc_info=True)
28
+ try:
29
+ from single_flight import engine_coalescer
30
+ except ImportError: # pragma: no cover
31
+ def engine_coalescer(): # type: ignore
32
+ class _Noop:
33
+ def run(self, key, fn, **kw):
34
+ return fn()
35
+ return _Noop()
36
+
37
+ from engines_base import (
38
+ safe_search,
39
+ _build_cli_engine,
40
+ _build_http_engine,
41
+ _build_html_engine,
42
+ _parse_generic,
43
+ _parse_text_output,
44
+ _parse_xml,
45
+ _parse_uapi,
46
+ _parse_semantic_scholar,
47
+ _ensure_engine_source,
48
+ _CUSTOM_JSON_PARSERS,
49
+ )
50
+ from recovery import strip_structured
51
+ from cli_io import dumps
52
+
53
+ # 对外/测试兼容:专用解析器与 source 纠正
54
+ __all__ = [
55
+ "search",
56
+ "available_engines",
57
+ "get_registry",
58
+ "safe_search",
59
+ "_parse_uapi",
60
+ "_parse_semantic_scholar",
61
+ "_ensure_engine_source",
62
+ ]
63
+ from engines_builders_keyless import _build_keenable_free_engine
64
+ from engines_builders import (
65
+ _build_who_don_engine,
66
+ _build_who_gho_engine,
67
+ _build_pubmed_engine,
68
+ _build_core_engine,
69
+ _build_gdacs_engine,
70
+ _build_obis_engine,
71
+ _build_worms_engine,
72
+ _build_energy_charts_engine,
73
+ _build_jolpica_engine,
74
+ _build_openf1_engine,
75
+ _build_openligadb_engine,
76
+ _build_artic_engine,
77
+ _build_cleveland_engine,
78
+ _build_tvmaze_engine,
79
+ _build_jikan_engine,
80
+ _build_deezer_engine,
81
+ _build_listenbrainz_engine,
82
+ _build_egov_law_engine,
83
+ _build_k10plus_engine,
84
+ _build_ror_engine,
85
+ _build_soilgrids_engine,
86
+ _build_noaa_swpc_engine,
87
+ _build_satnogs_engine,
88
+ _build_tle_mirror_engine,
89
+ _build_nhtsa_vpic_engine,
90
+ _build_exa_engine,
91
+ _build_anysearch_engine,
92
+ _build_parallel_engine,
93
+ _build_parallel_free_engine,
94
+ _build_seltz_engine,
95
+ _build_you_engine,
96
+ _build_em_miaoxiang_engine,
97
+ _build_cninfo_engine,
98
+ _build_sina_quote_engine,
99
+ _build_tencent_quote_engine,
100
+ _build_em_flow_engine,
101
+ _build_wechat_sogou_engine,
102
+ _build_hackernews_engine,
103
+ _build_stackoverflow_engine,
104
+ _build_google_scholar_engine,
105
+ _build_v2ex_engine,
106
+ _build_tineye_engine,
107
+ _build_bing_rss_engine,
108
+ _build_ths_hot_engine,
109
+ _build_cls_telegraph_engine,
110
+ _build_em_global_news_engine,
111
+ _build_eastmoney_engine,
112
+ _build_itotii_engine,
113
+ _build_baidu_hot_engine,
114
+ _build_toutiao_hot_engine,
115
+ _build_bilibili_hot_engine,
116
+ _build_zhihu_global_engine,
117
+ _build_zhihu_user_engine,
118
+ _build_bocha_engine,
119
+ _build_bocha_ai_engine,
120
+ _build_std_samr_engine,
121
+ _build_openstd_engine,
122
+ _build_bangumi_engine,
123
+ _build_so_engine,
124
+ _build_shenma_engine,
125
+ _build_qwant_engine,
126
+ _build_ecosia_engine,
127
+ _build_douban_movie_engine,
128
+ _build_zdic_engine,
129
+ _build_iplant_engine,
130
+ _build_people_daily_engine,
131
+ _build_flk_law_engine,
132
+ _build_wikisource_engine,
133
+ _build_weibo_hot_engine,
134
+ _build_douyin_hot_engine,
135
+ _build_csdn_engine,
136
+ _build_wallstreetcn_engine,
137
+ _build_weather_cn_engine,
138
+ _build_google_news_engine,
139
+ _build_met_museum_engine, _build_open_library_engine,
140
+ _build_weread_engine,
141
+ _build_douban_book_engine,
142
+ _build_fred_engine,
143
+ _build_fx_rate_engine,
144
+ _build_worldbank_engine,
145
+ _build_nbs_stats_engine,
146
+ _build_pubchem_engine,
147
+ _build_eurostat_engine,
148
+ _build_gbif_engine,
149
+ _build_rfc_editor_engine,
150
+ _build_twitter_syndication_engine,
151
+ _build_deps_dev_engine,
152
+ _build_endoflife_engine,
153
+ _build_biorxiv_engine,
154
+ _build_un_comtrade_engine,
155
+ _build_uniprot_engine,
156
+ _build_rcsb_pdb_engine,
157
+ _build_courtlistener_engine,
158
+ _build_gutenberg_engine,
159
+ _build_wayback_cdx_engine,
160
+ _build_usgs_engine,
161
+ _build_nasa_cmr_engine,
162
+ _build_free_dictionary_engine,
163
+ _build_baidu_baike_engine,
164
+ _build_pypi_engine,
165
+ _build_clinicaltrials_engine,
166
+ _build_openfda_engine,
167
+ _build_juejin_engine,
168
+ _build_models_dev_engine,
169
+ _build_finviz_engine,
170
+ _build_yahoo_finance_engine,
171
+ _build_sec_companyfacts_engine,
172
+ _build_frankfurter_engine,
173
+ _build_hkex_engine,
174
+ _build_shfe_engine,
175
+ _build_cfets_engine,
176
+ _build_defillama_engine,
177
+ _build_yuanbao_engine,
178
+ _build_seeking_alpha_engine,
179
+ _build_qweather_engine,
180
+ _build_wenshu_engine,
181
+ _build_jin10_engine,
182
+ _build_octen_engine,
183
+ _build_imdb_engine,
184
+ _build_thesportsdb_engine,
185
+ _build_itunes_engine,
186
+ _build_opencorporates_engine,
187
+ _build_google_patents_engine,
188
+ _build_marginalia_engine,
189
+ _build_wiby_engine,
190
+ _build_cnii_engine,
191
+ _build_ndl_engine,
192
+ _build_kor_law_engine,
193
+ _build_hatena_bookmark_engine,
194
+ _build_dnb_engine,
195
+ _build_doaj_engine,
196
+ _build_europeana_engine,
197
+ _build_hal_engine,
198
+ _build_eu_opendata_engine,
199
+ _build_open_meteo_engine,
200
+ _build_searchmysite_engine,
201
+ _build_lieu_engine,
202
+ _build_opensky_engine,
203
+ _build_electricity_maps_engine,
204
+ _build_usda_engine,
205
+ _build_tatoeba_engine,
206
+ _build_figshare_engine,
207
+ _build_tencent_kline_engine,
208
+ _build_qq_music_engine,
209
+ _build_github_engine,
210
+ _build_rss_feed_engine,
211
+ )
212
+ # 批次十(engines_builders_batch10):engines_builders.py 聚合层暂未转出该模块,
213
+ # 先直连其源模块——engines_builders.py 补转出后可并入上方聚合 import。
214
+ # zhihu_global 用别名:与上方 engines_builders 转出的 cn 旧版同名,F811 门禁
215
+ # 禁止静默重定义;别名让「batch10 取代 cn 版」在注册表处显式可见。
216
+ from engines_builders_batch10 import ( # noqa: F401
217
+ _build_zhihu_global_engine as _build_zhihu_global_engine_v2,
218
+ )
219
+ # 批次十一(engines_builders_batch11):图源补强。Wikimedia Commons 的
220
+ # query.pages 是 pageid 字典,声明式 output_map 取不到字段,必须有 builder。
221
+ from engines_builders_batch11 import ( # noqa: F401
222
+ _build_wikimedia_commons_engine,
223
+ )
224
+
225
+
226
+
227
+ class _LazyLogger:
228
+ """延迟创建 logger,避免 import logging 的重量级 import 链(traceback→dataclasses→inspect)。"""
229
+ _logger = None
230
+
231
+ def __getattr__(self, name: str):
232
+ if _LazyLogger._logger is None:
233
+ import logging
234
+ _LazyLogger._logger = logging.getLogger("unified_search.engines")
235
+ if not _LazyLogger._logger.handlers:
236
+ _LazyLogger._logger.setLevel(logging.WARNING)
237
+ _LazyLogger._logger.addHandler(logging.StreamHandler(sys.stderr))
238
+ return getattr(_LazyLogger._logger, name)
239
+
240
+
241
+ logger = _LazyLogger()
242
+
243
+
244
+ # 「未知引擎」警告按名字每进程只报一次。
245
+ # 有界(2026-09-29):键可能来自用户/路由输入,长驻 MCP server 收到大量
246
+ # 非常规引擎名时,无界集合会随进程寿命线性增长。超限即整体清空——去重是
247
+ # best-effort,丢一轮去重只多几行重复警告,远比无界增长便宜。
248
+ _UNKNOWN_ENGINE_WARNED_MAX = 256
249
+ _unknown_engine_warned: set[str] = set()
250
+
251
+
252
+ def _warn_unknown_engine(engine: str) -> None:
253
+ """未注册引擎的日志警告(同名每进程一次)。
254
+
255
+ why 去重:同一次调用里,主 combo、次域补充、恢复链会各试一遍同一个名字,
256
+ 实测同一条警告重复 3–4 行。重复并不增加信息,只会把真信号淹掉。
257
+ 机器可读的那一份不在这里——它走 outcome 的 `status=unknown-engine` + detail,
258
+ 最终进响应的 `errors`(见 engine_dispatch 的前置拦截)。
259
+ """
260
+ if engine in _unknown_engine_warned:
261
+ return
262
+ if len(_unknown_engine_warned) >= _UNKNOWN_ENGINE_WARNED_MAX:
263
+ _unknown_engine_warned.clear()
264
+ _unknown_engine_warned.add(engine)
265
+ logger.warning(
266
+ "未知引擎: %s(registry 查无此名:拼写错误,或该源未随本版发布)", engine)
267
+
268
+
269
+ def is_registered(engine: str) -> bool:
270
+ """该名字在本版 registry 里是否存在。
271
+
272
+ 唯一判据就是 `get_registry()`,与 `run_engine` 内部的 `registry.get()` 同源:
273
+ 调用方(engine_dispatch 的前置拦截)必须与执行层看到同一个注册表,各写一份
274
+ `in` 判断迟早漂移。
275
+ """
276
+ return engine in get_registry()
277
+
278
+
279
+ def unknown_requested_engines(engine_override: str) -> list[str]:
280
+ """显式点名的引擎里,本版 registry 查无的那些(按输入顺序,去重)。
281
+
282
+ why:`--engine nope` 此前产出
283
+ `status=completed / errors=[] / engines_used=['nope'] / count=0`——调用方
284
+ (Agent)据此判定「网上没有这个信息」并停止追问,而真相是这个名字在本版
285
+ registry 里根本不存在(拼写错、或该源未随本版发布)。它与「缺 API Key 的
286
+ 静默 no-results」是同一根因,处置也应一致:变成可行动的 error。
287
+
288
+ **为什么判在请求层而不是执行层**:执行层收到的引擎名可以是测试替身
289
+ (test_serial_hedge 的 quick_primary、test_race_fast_budget 的 fast_garbage
290
+ 都从 dispatch 入口走),在那里按 registry 判「未注册」会误伤替身——实测
291
+ 11 个 dispatch 用例集体变红。而「用户点了个不存在的名字」是**请求级**的
292
+ 事实,判在请求层才既不误伤、又落在正确的抽象层。
293
+
294
+ 住在 engines.py 而不是 search.py:判据本体(is_registered)在这里,而
295
+ search.py 已压着 1000 行硬上限(本函数曾在那边,被体积门禁拦下)。这里只做
296
+ 「请求串 → 查无的名字」这一层薄封装,不重复 `in` 判断。
297
+ """
298
+ if not engine_override or engine_override == "auto":
48
299
  return []
49
- return wrapper
300
+ unknown: list[str] = []
301
+ for raw in str(engine_override).split(","):
302
+ name = raw.strip()
303
+ if name and name not in unknown and not is_registered(name):
304
+ unknown.append(name)
305
+ return unknown
306
+
307
+
308
+ @contextlib.contextmanager
309
+ def _sys_path_tmp(path: str):
310
+ """临时把 path 加入 sys.path,退出时移除——只移除自己添加的那一次。
311
+
312
+ append 而非 insert(0):sub-skills 顶层有与 scripts 同名的模块
313
+ (health_check 等),插到前面会劫持 scripts 下同名模块的解析。
314
+ """
315
+ added = path not in sys.path
316
+ if added:
317
+ sys.path.append(path)
318
+ try:
319
+ yield
320
+ finally:
321
+ if added:
322
+ sys.path.remove(path)
50
323
 
51
324
 
52
- def _run(cmd: list[str], timeout: float = 8, engine_name: str = "?") -> str:
53
- """执行命令,超时/异常不抛。"""
54
- try:
55
- r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
56
- if r.returncode == 0:
57
- return r.stdout
58
- tail = (r.stderr or "").strip()[:200]
59
- logger.warning(f"引擎 {engine_name} 失败 (rc={r.returncode}): {tail}")
60
- return r.stdout if r.stdout.strip() else ""
61
- except subprocess.TimeoutExpired:
62
- logger.warning(f"引擎 {engine_name} 超时 (>{timeout}s)")
63
- except FileNotFoundError as e:
64
- logger.error(f"引擎 {engine_name} CLI 缺失: {e}")
65
- except Exception as e:
66
- logger.error(f"引擎 {engine_name} 异常: {type(e).__name__}: {e}")
67
- return ""
68
-
69
-
70
- def _resolve(template: list[str] | str, query: str, n: int, **extra: Any) -> list[str] | str:
71
- """替换模板占位符。"""
72
- if isinstance(template, list):
73
- return [_resolve(item, query, n, **extra) for item in template]
74
- s = template.replace("{query}", query).replace("{n}", str(n))
75
- s = s.replace("{TIMESTAMP}", str(int(time.time())))
76
- for key, val in extra.items():
77
- s = s.replace(f"{{{key}}}", str(val))
78
- if s.startswith("~"):
79
- s = str(Path.home() / s[1:])
80
- return re.sub(r"\{([A-Z_][A-Z0-9_]*)\}", lambda m: os.environ.get(m.group(1), m.group(0)), s)
81
-
82
-
83
- def _extract_items(data: Any, path: str) -> list[dict]:
84
- """从嵌套 dict 按路径提取列表。"""
85
- obj = data
86
- for part in path.split("."):
87
- if isinstance(obj, dict):
88
- obj = obj.get(part, [])
89
- else:
90
- return []
91
- return obj if isinstance(obj, list) else []
325
+ def _build_local_search_engine(spec: dict[str, Any]) -> Any:
326
+ """进程内调用 local-search 子技能,避免 subprocess 冷启动(~300-500ms/次)。
92
327
 
328
+ 直接 import search_v3.search_engines,复用其智能路由/健康过滤/批量并行,
329
+ 输出与 unified-search 一致的 schema。安全降级:import 失败时回退
330
+ 原 subprocess 调用,保证功能不丢。
331
+ """
332
+ import os as _os
333
+ import subprocess as _subprocess
93
334
 
94
- def _make_field_parser(path: str, fields: dict[str, str]) -> Callable:
95
- """构造声明式 parser。"""
96
- def parser(data: Any) -> list[dict[str, Any]]:
97
- items = _extract_items(data, path) if isinstance(data, dict) else []
98
- results = []
99
- for item in items:
100
- if not isinstance(item, dict):
101
- continue
102
- r = {ok: iv[:300] if ok == "snippet" and isinstance(iv, str) else iv
103
- for ok, ik in fields.items() if (iv := item.get(ik, ""))}
104
- if r.get("title") or r.get("url"):
105
- results.append(r)
106
- return results[:10]
107
- return parser
108
-
109
-
110
- def _build_cli_engine(spec: dict[str, Any]) -> Any:
111
335
  cmd_template = spec.get("cmd", [])
112
336
  search_args = spec.get("search_args", [])
113
- env_overrides = spec.get("env", {})
114
337
 
115
338
  @safe_search
116
339
  def _engine(query: str, n: int = 5, timeout: float = 8, mode: str = "fast", **kwargs) -> list[dict[str, Any]]:
117
- cmd = _resolve(cmd_template, query, n, mode=mode)
118
- args = _resolve(search_args, query, n, mode=mode)
340
+ # 回退路径无条件依赖 _resolve:必须放 try 外。放 try 首行时,import 失败
341
+ # 会被下面的 except Exception 吞掉,回退路径读 _resolve_tpl 直接
342
+ # UnboundLocalError——「进程内失败回退 subprocess」的语义被架空
343
+ # (ef4e509 同类:分支内 import 污染同函数后续读取,静态门禁
344
+ # test_local_import_shadows_global 依赖此处不报)。
345
+ from engines_base import _resolve as _resolve_tpl
346
+ # 进程内优先(省 subprocess 冷启动)
347
+ try:
348
+ sub_dir = Path(__file__).resolve().parent.parent / "sub-skills" / "local-search"
349
+ # 临时加路径 import,完事自动清理(助手语义见定义处)
350
+ with _sys_path_tmp(str(sub_dir)):
351
+ import search_v3
352
+ res = search_v3.search_engines(
353
+ query, engines=None, n=n, timeout=float(timeout),
354
+ # None = 读子技能 config 的 max_parallel_engines(此前硬编码 5,
355
+ # config 调并发对主链路径不生效);预算=单引擎超时,local-first
356
+ # 是「快」语义,墙钟不吃 30s 历史默认。
357
+ max_parallel=None, total_budget=float(timeout),
358
+ skip_cache=bool(kwargs.get("skip_cache", False)),
359
+ mode=mode,
360
+ since=kwargs.get("since"), until=kwargs.get("until"),
361
+ sort=kwargs.get("sort"),
362
+ )
363
+ results = res.get("results") or []
364
+ # 与子进程路径一致:每条带 _engine 标记,source 保持子引擎名
365
+ for r in results:
366
+ if isinstance(r, dict) and "error" not in r:
367
+ r.setdefault("_engine", r.get("source") or "local_search")
368
+ if results:
369
+ return results
370
+ except Exception:
371
+ pass # 进程内失败回退 subprocess
372
+ # 回退:subprocess 调用(原行为)
373
+ cmd = _resolve_tpl(cmd_template, query, n, mode=mode)
374
+ args = _resolve_tpl(search_args, query, n, mode=mode)
119
375
  if not cmd:
120
376
  return []
121
- env = os.environ.copy()
122
- env.update(env_overrides)
123
- return _parse_text_output(_run(cmd + args, timeout=timeout, engine_name=spec.get("_name", "cli")),
124
- spec.get("_name", "cli"))
125
- return _engine
126
-
127
-
128
- def _build_http_engine(spec: dict[str, Any]) -> Any:
129
- """统一 HTTP 引擎构造(GET/POST)。"""
130
- url_template = spec.get("url", "")
131
- headers = spec.get("headers", {"Content-Type": "application/json"})
132
- query_param = spec.get("query_param", "q")
133
- fmt = spec.get("format", "")
134
- timeout = spec.get("timeout", 8)
135
- extra_params = spec.get("extra_params", {})
136
- output_map = spec.get("output_map", {})
137
- is_get = spec.get("method", "GET") == "GET"
138
- body_template = spec.get("body", {})
139
-
140
- @safe_search
141
- def _engine(query: str, n: int = 5, _timeout: float | None = None, depth: str = "fast", **kwargs) -> list[dict[str, Any]]:
142
- to = _timeout or timeout
143
- import urllib.parse as up
144
-
145
- if is_get:
146
- resolved_url = _resolve(url_template, query, n)
147
- separator = "&" if "?" in resolved_url else "?"
148
- full_url = f"{resolved_url}{separator}{query_param}={up.quote(query)}"
149
- if fmt:
150
- full_url += f"&format={fmt}"
151
- for k, v in extra_params.items():
152
- full_url += f"&{k}={up.quote(_resolve(str(v), query, n))}"
153
- req = urllib.request.Request(full_url, headers={k: _resolve(v, query, n) for k, v in headers.items()})
154
- else:
155
- body: dict[str, Any] = {}
156
- for k, v in body_template.items():
157
- resolved = _resolve(str(v), query, n)
158
- if k == "search_depth":
159
- body[k] = depth
160
- elif resolved.lower() == "true":
161
- body[k] = True
162
- elif resolved.lower() == "false":
163
- body[k] = False
164
- else:
165
- try:
166
- body[k] = int(resolved)
167
- except ValueError:
168
- try:
169
- body[k] = float(resolved)
170
- except ValueError:
171
- body[k] = resolved
172
- req = urllib.request.Request(url_template, data=json.dumps(body).encode("utf-8"),
173
- headers={k: _resolve(v, query, n) for k, v in headers.items()})
174
-
377
+ env = _os.environ.copy()
378
+ env.update(spec.get("env", {}) or {})
379
+ proc = _subprocess.run(cmd + args, capture_output=True, text=True,
380
+ encoding="utf-8", errors="replace",
381
+ timeout=timeout, env=env)
382
+ if proc.returncode != 0:
383
+ return []
175
384
  try:
176
- with urllib.request.urlopen(req, timeout=to) as resp:
177
- raw = resp.read().decode("utf-8")
178
- if fmt == "xml":
179
- return _parse_xml(raw, spec.get("_name", ""))
180
- data = json.loads(raw)
181
- if output_map:
182
- return _make_field_parser(output_map.get("items", ""), {
183
- "title": output_map.get("item_title", "title"),
184
- "url": output_map.get("item_url", "url"),
185
- "snippet": output_map.get("item_summary", "snippet"),
186
- "source": output_map.get("item_source", "source"),
187
- })(data)
188
- return _parse_generic(data, spec.get("_name", ""))
189
- except (urllib.error.URLError, urllib.error.HTTPError, Exception) as e:
190
- logger.warning(f"HTTP 引擎失败: {e}")
385
+ data = json.loads(proc.stdout)
386
+ except (json.JSONDecodeError, ValueError):
191
387
  return []
388
+ results = data.get("results") or []
389
+ for r in results:
390
+ if isinstance(r, dict) and "error" not in r:
391
+ r.setdefault("_engine", r.get("source") or "local_search")
392
+ return results
192
393
  return _engine
193
394
 
194
395
 
195
- _BUILDERS = {"cli": _build_cli_engine, "http": _build_http_engine}
196
-
197
-
198
- # ── 通用解析器 ─────────────────────────────────────────────────────────────────
199
-
200
- def _parse_text_output(text: str, engine_name: str) -> list[dict[str, Any]]:
201
- """通用 CLI 文本解析:优先 JSON,其次结构化文本。"""
202
- try:
203
- data = json.loads(text.strip())
204
- if isinstance(data, list):
205
- return [{"title": i.get("title", ""), "url": i.get("url", ""),
206
- "snippet": i.get("snippet", i.get("content", ""))[:300],
207
- "source": engine_name} for i in data if isinstance(i, dict)]
208
- if isinstance(data, dict):
209
- items = data.get("results", data.get("items", data.get("data", [])))
210
- if isinstance(items, list):
211
- return [{"title": i.get("title", ""), "url": i.get("url", ""),
212
- "snippet": i.get("snippet", i.get("content", ""))[:300],
213
- "source": engine_name} for i in items if isinstance(i, dict)]
214
- except (json.JSONDecodeError, ValueError):
215
- pass
216
-
217
- results, cur = [], {}
218
- seen_url = False
219
- for line in text.split("\n"):
220
- s = line.strip()
221
- if s.startswith("### "):
222
- if cur:
223
- results.append(cur)
224
- cur = {"title": re.sub(r'^\d+\.\s*', '', s[4:].strip()), "source": engine_name,
225
- "score": max(1.0 - len(results) * 0.1, 0.1)}
226
- seen_url = False
227
- elif s.startswith("- **URL**: ") and cur:
228
- cur["url"] = s[11:].strip()
229
- seen_url = True
230
- elif s.startswith("- ") and not s.startswith("- **") and seen_url and cur:
231
- cur["snippet"] = " ".join(s[2:].strip().split())[:300]
232
- seen_url = False
233
- if cur:
234
- results.append(cur)
235
- return results[:10]
236
-
237
-
238
- def _parse_xml(text: str, engine_name: str) -> list[dict[str, Any]]:
239
- """解析 Atom XML(arXiv 等)。"""
240
- import xml.etree.ElementTree as ET
241
- results = []
242
- try:
243
- root = ET.fromstring(text)
244
- ns = {"atom": "http://www.w3.org/2005/Atom"}
245
- entries = root.findall(".//atom:entry", ns) or root.findall(".//{http://www.w3.org/2005/Atom}entry")
246
- for entry in entries:
247
- title = entry.findtext("atom:title", "", ns).strip().replace("\n", " ")[:200]
248
- summary = entry.findtext("atom:summary", "", ns).strip().replace("\n", " ")[:300]
249
- entry_id = entry.findtext("atom:id", "", ns)
250
- url = entry_id
251
- for link in entry.findall("atom:link", ns):
252
- if link.get("title") == "pdf":
253
- url = link.get("href", url)
254
- break
255
- if title:
256
- results.append({"title": title, "url": url, "snippet": summary, "source": engine_name})
257
- except ET.ParseError:
258
- pass
259
- return results
260
-
261
-
262
- def _parse_generic(data: dict[str, Any], engine_name: str = "?") -> list[dict[str, Any]]:
263
- """通用 JSON 解析:自动探测常见字段。"""
264
- items = None
265
- for key in ["results", "items", "data", "works", "message.items"]:
266
- if "." in key:
267
- parts = key.split(".")
268
- obj = data
269
- for p in parts:
270
- obj = obj.get(p, {}) if isinstance(obj, dict) else {}
271
- if isinstance(obj, list):
272
- items = obj
396
+ _BUILDERS = {
397
+ "cli": _build_cli_engine,
398
+ "http": _build_http_engine,
399
+ "html": _build_html_engine,
400
+ "local_search": _build_local_search_engine,
401
+ "exa": _build_exa_engine,
402
+ "keenable_free": _build_keenable_free_engine,
403
+ "anysearch": _build_anysearch_engine,
404
+ "em_miaoxiang": _build_em_miaoxiang_engine,
405
+ "cninfo": _build_cninfo_engine,
406
+ "sina_quote": _build_sina_quote_engine,
407
+ "tencent_quote": _build_tencent_quote_engine,
408
+ "em_flow": _build_em_flow_engine,
409
+ "wechat_sogou": _build_wechat_sogou_engine,
410
+ "hackernews": _build_hackernews_engine,
411
+ "stackoverflow": _build_stackoverflow_engine,
412
+ "std_samr": _build_std_samr_engine,
413
+ "openstd": _build_openstd_engine,
414
+ "bangumi": _build_bangumi_engine,
415
+ "douban_movie": _build_douban_movie_engine,
416
+ "zdic": _build_zdic_engine,
417
+ "iplant": _build_iplant_engine,
418
+ "people_daily": _build_people_daily_engine,
419
+ "flk_law": _build_flk_law_engine,
420
+ "wikisource": _build_wikisource_engine,
421
+ "weibo_hot": _build_weibo_hot_engine,
422
+ "douyin_hot": _build_douyin_hot_engine,
423
+ "csdn": _build_csdn_engine,
424
+ "wallstreetcn": _build_wallstreetcn_engine,
425
+ "weather_cn": _build_weather_cn_engine,
426
+ "google_news": _build_google_news_engine,
427
+ "met_museum": _build_met_museum_engine,
428
+ "github": _build_github_engine,
429
+ "google_scholar": _build_google_scholar_engine,
430
+ "v2ex": _build_v2ex_engine,
431
+ "tineye": _build_tineye_engine,
432
+ "bing_rss": _build_bing_rss_engine,
433
+ "ths_hot": _build_ths_hot_engine,
434
+ "cls_telegraph": _build_cls_telegraph_engine,
435
+ "twitter_syndication": _build_twitter_syndication_engine,
436
+ "deps_dev": _build_deps_dev_engine,
437
+ "endoflife": _build_endoflife_engine,
438
+ "biorxiv": _build_biorxiv_engine,
439
+ "un_comtrade": _build_un_comtrade_engine,
440
+ "em_global_news": _build_em_global_news_engine,
441
+ "eastmoney": _build_eastmoney_engine,
442
+ "itotii": _build_itotii_engine,
443
+ "baidu_hot": _build_baidu_hot_engine,
444
+ "toutiao_hot": _build_toutiao_hot_engine,
445
+ "bilibili_hot": _build_bilibili_hot_engine,
446
+ "open_library": _build_open_library_engine,
447
+ "weread": _build_weread_engine,
448
+ "douban_book": _build_douban_book_engine,
449
+ "zhihu_global": _build_zhihu_global_engine_v2, # 补强版在 batch10(候选池取满 + 错误行动提示;cn 版被取代,cn 冻结在行数门禁上限)
450
+ "zhihu_user": _build_zhihu_user_engine,
451
+ "fred": _build_fred_engine,
452
+ "so": _build_so_engine,
453
+ "shenma": _build_shenma_engine,
454
+ "qwant": _build_qwant_engine,
455
+ "ecosia": _build_ecosia_engine,
456
+ "fx_rate": _build_fx_rate_engine,
457
+ "worldbank": _build_worldbank_engine,
458
+ "nbs_stats": _build_nbs_stats_engine,
459
+ "pubchem": _build_pubchem_engine,
460
+ "eurostat": _build_eurostat_engine,
461
+ "gbif": _build_gbif_engine,
462
+ "rfc_editor": _build_rfc_editor_engine,
463
+ "uniprot": _build_uniprot_engine,
464
+ "rcsb_pdb": _build_rcsb_pdb_engine,
465
+ "courtlistener": _build_courtlistener_engine,
466
+ "gutenberg": _build_gutenberg_engine,
467
+ "wayback_cdx": _build_wayback_cdx_engine,
468
+ "usgs": _build_usgs_engine,
469
+ "nasa_cmr": _build_nasa_cmr_engine,
470
+ "free_dictionary": _build_free_dictionary_engine,
471
+ "baidu_baike": _build_baidu_baike_engine,
472
+ "pypi": _build_pypi_engine,
473
+ "clinicaltrials": _build_clinicaltrials_engine,
474
+ "openfda": _build_openfda_engine,
475
+ "juejin": _build_juejin_engine,
476
+ "models_dev": _build_models_dev_engine,
477
+ "finviz": _build_finviz_engine,
478
+ "yahoo_finance": _build_yahoo_finance_engine,
479
+ "sec_companyfacts": _build_sec_companyfacts_engine,
480
+ "frankfurter": _build_frankfurter_engine,
481
+ "hkex": _build_hkex_engine,
482
+ "shfe": _build_shfe_engine,
483
+ "cfets": _build_cfets_engine,
484
+ "defillama": _build_defillama_engine,
485
+ "yuanbao": _build_yuanbao_engine,
486
+ "seeking_alpha": _build_seeking_alpha_engine,
487
+ "qweather": _build_qweather_engine,
488
+ "wenshu": _build_wenshu_engine,
489
+ "jin10": _build_jin10_engine,
490
+ "octen": _build_octen_engine,
491
+ "bocha": _build_bocha_engine,
492
+ "parallel": _build_parallel_engine,
493
+ "parallel_free": _build_parallel_free_engine,
494
+ "seltz": _build_seltz_engine,
495
+ "you": _build_you_engine,
496
+ "bocha_ai": _build_bocha_ai_engine,
497
+ "imdb": _build_imdb_engine,
498
+ "thesportsdb": _build_thesportsdb_engine,
499
+ "itunes": _build_itunes_engine,
500
+ "opencorporates": _build_opencorporates_engine,
501
+ "google_patents": _build_google_patents_engine,
502
+ "marginalia": _build_marginalia_engine,
503
+ "wiby": _build_wiby_engine,
504
+ "rss_feed": _build_rss_feed_engine,
505
+ "cnii": _build_cnii_engine,
506
+ "ndl": _build_ndl_engine,
507
+ "kor_law": _build_kor_law_engine,
508
+ "hatena_bookmark": _build_hatena_bookmark_engine,
509
+ "dnb": _build_dnb_engine,
510
+ "doaj": _build_doaj_engine,
511
+ "europeana": _build_europeana_engine,
512
+ "hal": _build_hal_engine,
513
+ "eu_opendata": _build_eu_opendata_engine,
514
+ "open_meteo": _build_open_meteo_engine,
515
+ "searchmysite": _build_searchmysite_engine,
516
+ "lieu": _build_lieu_engine,
517
+ "opensky": _build_opensky_engine,
518
+ "electricity_maps": _build_electricity_maps_engine,
519
+ "usda": _build_usda_engine,
520
+ "tatoeba": _build_tatoeba_engine,
521
+ "figshare": _build_figshare_engine,
522
+ "tencent_kline": _build_tencent_kline_engine,
523
+ "qq_music": _build_qq_music_engine,
524
+ "who_don": _build_who_don_engine,
525
+ "who_gho": _build_who_gho_engine,
526
+ "pubmed": _build_pubmed_engine,
527
+ "core": _build_core_engine,
528
+ "gdacs": _build_gdacs_engine,
529
+ "obis": _build_obis_engine,
530
+ "worms": _build_worms_engine,
531
+ "energy_charts": _build_energy_charts_engine,
532
+ "jolpica": _build_jolpica_engine,
533
+ "openf1": _build_openf1_engine,
534
+ "openligadb": _build_openligadb_engine,
535
+ "artic": _build_artic_engine,
536
+ "cleveland": _build_cleveland_engine,
537
+ "wikimedia_commons": _build_wikimedia_commons_engine,
538
+ "tvmaze": _build_tvmaze_engine,
539
+ "jikan": _build_jikan_engine,
540
+ "deezer": _build_deezer_engine,
541
+ "listenbrainz": _build_listenbrainz_engine,
542
+ "egov_law": _build_egov_law_engine,
543
+ "k10plus": _build_k10plus_engine,
544
+ "ror": _build_ror_engine,
545
+ "soilgrids": _build_soilgrids_engine,
546
+ "noaa_swpc": _build_noaa_swpc_engine,
547
+ "satnogs": _build_satnogs_engine,
548
+ "tle_mirror": _build_tle_mirror_engine,
549
+ "nhtsa_vpic": _build_nhtsa_vpic_engine,
550
+ }
551
+
552
+ # 语义型引擎:把 query 当自然语言语义检索,不识别平台原生结构化语法
553
+ # (from:/repo:/site:/until: 等)。对其剥掉字段只留核心词,避免污染相关性。
554
+ # 透传型/垂直源(local_*、social/academic/code、github 等)保持原 query,让平台语法生效。
555
+ # local_search 是聚合器(子引擎 local_bing 等透传平台语法),不在语义集内。
556
+ _SEMANTIC_ENGINES = frozenset({
557
+ "byted", "bocha", "anysearch", "tavily", "exa", "octen",
558
+ "uapi", "searxng", "parallel", "you", "bocha_ai",
559
+ })
560
+
561
+ # ── 实体型引擎的查询规范化(2026-09-06 live 金标教训)─────────────────────
562
+ # 自然语言句直达实体搜索接口会全军覆没:「NASA founding year」wikidata 0 条
563
+ # (裸「NASA」7 条)、「where is Eiffel Tower」同型且拖满 11s 超时、
564
+ # 「Cristiano Ronaldo club」thesportsdb 抖动放大。接口只吃实体名——分发层
565
+ # 统一剥疑问前缀与属性词,保留核心实体。只作用于实体型引擎;通用 web 引擎
566
+ # 不动(属性词对全文检索是有用信号)。
567
+ _ENTITY_QUERY_ENGINES = frozenset({"wikidata", "thesportsdb", "local_openstreetmap"})
568
+ _ENTITY_Q_PREFIX_RE = re.compile(
569
+ r"(?i)^\s*(where\s+(is|are|was|were)|where'?s|who\s+(is|are|was)|who'?s"
570
+ r"|what\s+(is|are)|when\s+(was|is|did)|which\s+\w+\s+(is|was)"
571
+ r"|哪里|在哪[里儿]?|是谁?|是什么|什么时候|哪一[个国年])\s*",
572
+ )
573
+ # 英文属性词带 \b(空格分词下词边界天然成立);中文属性词不进此表——
574
+ # CJK 字符全是 \w,「周杰伦专辑」「周杰伦的专辑」这类无空格连写永远撞不上
575
+ # 词边界(2026-09-06 审查实锤漏剥),改由下方尾部循环剥离。
576
+ _ENTITY_ATTR_RE = re.compile(
577
+ r"(?i)\b(founding year|founded|established|headquarters?|located( in)?"
578
+ r"|population|capital|address|club|team|stadium|league|album|discography"
579
+ r"|song|movie|film|director|cast)\b",
580
+ )
581
+ # 中文属性词(含繁体):只在查询尾部剥离——中文自然语序是实体名在前、
582
+ # 属性词在后;不碰串中,「电影频道」「歌曲排行榜」这类实体名内含属性词,
583
+ # 串中剥离会误伤。长词优先,避免「成立年份」被「成立」截断残留「年份」。
584
+ _ENTITY_ATTR_TAILS = (
585
+ "成立年份", "成立", "创立", "創立", "总部", "總部", "位置", "球队", "球隊",
586
+ "俱乐部", "俱樂部", "职能", "職能", "职责", "職責", "专辑", "專輯",
587
+ "歌曲", "电影", "電影", "导演", "導演", "主演",
588
+ )
589
+
590
+
591
+ def _entity_core_query(query: str) -> str:
592
+ """实体搜索接口的查询规范化:剥疑问前缀+属性词,保留核心实体名。"""
593
+ raw = (query or "").strip()
594
+ q = _ENTITY_Q_PREFIX_RE.sub(" ", raw)
595
+ # 中文疑问词多为尾部后置(「清华大学 在哪里」),与英文前缀形态分开剥
596
+ q = re.sub(r"(?i)(在哪里|在哪|哪儿|哪里|是什么|是谁|什么时候|有哪些)\s*[??]?\s*$",
597
+ " ", q)
598
+ q = _ENTITY_ATTR_RE.sub(" ", q)
599
+ # 空白归一化必须在尾部循环前:前缀/属性词剥除用空格替换,尾空格会让
600
+ # endswith 判空(「清华大学总部 」漏剥总部)
601
+ q = re.sub(r"\s+", " ", q).strip()
602
+ # 中文属性词尾部循环剥 + 「的」后缀收尾:「周杰伦的专辑」→「周杰伦的」
603
+ # →「周杰伦」。剩单字不剥(「美的」是实体,剥成「美」即误伤)
604
+ changed = True
605
+ while changed:
606
+ changed = False
607
+ for tail in _ENTITY_ATTR_TAILS:
608
+ if q.endswith(tail) and len(q) > len(tail) + 1:
609
+ q = q[:-len(tail)].strip()
610
+ changed = True
273
611
  break
274
- elif isinstance(data, dict) and key in data and isinstance(data[key], list):
275
- items = data[key]
276
- break
277
-
278
- if items is None and isinstance(data, dict):
279
- for v in data.values():
280
- if isinstance(v, dict):
281
- for key in ["results", "items", "value"]:
282
- if key in v and isinstance(v[key], list):
283
- items = v[key]
284
- break
285
- if items:
286
- break
287
-
288
- if not items or not isinstance(items, list):
289
- return []
612
+ if not changed and q.endswith("的") and len(q) > 3:
613
+ q = q[:-1].strip()
614
+ changed = True
615
+ q = re.sub(r"[??::,,]+", " ", q)
616
+ q = re.sub(r"\s+", " ", q).strip()
617
+ return q or raw
290
618
 
291
- results = []
292
- for i in items:
293
- if not isinstance(i, dict):
294
- continue
295
- title = i.get("title", "")
296
- if isinstance(title, list):
297
- title = title[0] if title else ""
298
- url = i.get("url", i.get("URL", i.get("html_url", "")))
299
- snippet = (i.get("snippet", i.get("content", i.get("summary", i.get("description", "")))))[:300]
300
- score = i.get("score", i.get("relevance_score", 0.5))
301
- results.append({"title": str(title)[:200], "url": str(url),
302
- "snippet": str(snippet), "score": score, "source": engine_name})
303
- return results[:10]
304
-
305
-
306
- # ── 引擎注册表 ─────────────────────────────────────────────────────────────────
307
619
 
308
620
  _engine_registry: dict[str, Any] = {}
621
+ _engine_specs: dict[str, dict[str, Any]] = {}
309
622
  _engine_registry_loaded = False
623
+ _registry_stamp: float | None = None
310
624
 
311
625
 
312
626
  def _load_registry():
313
- global _engine_registry, _engine_registry_loaded
627
+ global _engine_registry, _engine_specs, _engine_registry_loaded, _registry_stamp
314
628
  if _engine_registry_loaded:
315
629
  return
316
630
  cfg = load_config()
317
631
  engines = get_engines(cfg)
632
+ # 本地个性化引擎插件(不随公开仓库提交)
633
+ try:
634
+ from local_engines import register as _register_local
635
+ _register_local(_BUILDERS, engines)
636
+ except ImportError:
637
+ pass
318
638
  registry = {}
319
639
  for name, spec in engines.items():
320
640
  spec = dict(spec)
321
641
  spec["_name"] = name
642
+ # local_search 走进程内 builder(config 里 type=cli,这里显式路由到专用实现)
643
+ if name == "local_search":
644
+ spec["type"] = "local_search"
645
+ # anysearch:type 已在 config.yaml 显式声明(引擎声明来源),
646
+ # 不再运行时硬覆盖;若旧配置缺 type 字段,保底路由到进程内 builder。
647
+ if name == "anysearch" and spec.get("type", "cli") not in _BUILDERS:
648
+ spec["type"] = "anysearch"
322
649
  builder = _BUILDERS.get(spec.get("type", "cli"))
323
650
  if builder:
324
651
  registry[name] = builder(spec)
325
652
  else:
326
653
  logger.warning(f"未知引擎类型: {spec.get('type')} (引擎 {name})")
327
654
  _engine_registry = registry
655
+ # spec 侧表:engine_env 缺 env 检测等需要原始声明(registry 值是闭包)
656
+ global _engine_specs
657
+ _engine_specs = engines
328
658
  _engine_registry_loaded = True
659
+ try:
660
+ from config import config_stamp
661
+ _registry_stamp = config_stamp()
662
+ except ImportError:
663
+ _registry_stamp = None
329
664
 
330
665
 
331
666
  def get_registry() -> dict[str, Any]:
667
+ """引擎注册表。config 变更(综合 mtime 指纹)时自动重建——
668
+ 增删引擎/改 qps/换声明等配置无需重启进程。"""
669
+ global _engine_registry, _engine_specs, _engine_registry_loaded, _registry_stamp
670
+ try:
671
+ from config import config_stamp
672
+ stamp = config_stamp()
673
+ except ImportError:
674
+ stamp = _registry_stamp
675
+ if _engine_registry_loaded and _registry_stamp is not None and stamp != _registry_stamp:
676
+ logger.info("config 变更 → 重建引擎注册表")
677
+ _engine_registry = {}
678
+ _engine_specs = {}
679
+ _engine_registry_loaded = False
332
680
  _load_registry()
333
681
  return _engine_registry
334
682
 
335
683
 
336
- def available_engines() -> list[str]:
337
- return sorted(get_registry().keys())
684
+ def get_engine_spec(name: str) -> dict[str, Any] | None:
685
+ """返回引擎原始声明(spec),无此引擎返回 None。
686
+
687
+ registry 值是构建后的闭包,原始 spec(url/headers/required_env 等)
688
+ 存侧表供 engine_env 缺 env 检测等调用方使用。
689
+ """
690
+ get_registry() # 触发热重建检查,保持侧表与注册表同步
691
+ return _engine_specs.get(name)
338
692
 
339
693
 
340
- def search(query: str, engine: str, n: int = 5, timeout: float = 8, depth: str = "fast", mode: str = "fast") -> list[dict[str, Any]]:
341
- """统一引擎调用入口;失败返回空 list,不抛异常。"""
694
+ def available_engines(routable_only: bool = False) -> list[str]:
695
+ """可实例化的引擎名(有 builder)。
696
+
697
+ routable_only=True 时再按「真能用」过滤(密钥齐全、后端依赖就位、
698
+ 未被熔断/禁用)——这是与 --list-engines --detail 的 routable 同一判定。
699
+
700
+ routable_only 此前没被实现(本函数不收参数),调用侧传进来会抛
701
+ TypeError 并被 except TypeError 吞掉回退到全量——`--list-engines
702
+ --routable-only` 因此静默返回全部引擎,用户以为筛过了。
703
+ """
704
+ names = sorted(get_registry().keys())
705
+ if not routable_only:
706
+ return names
707
+ try:
708
+ from engine_status import list_routable_engine_ids
709
+ routable = set(list_routable_engine_ids())
710
+ return [n for n in names if n in routable]
711
+ except Exception:
712
+ return names
713
+
714
+
715
+ # ── 单飞合并 + 免费引擎结果数桶化 ──
716
+ # 场景:MCP 并发请求 / 多轨道同查询 → 同一引擎调用重复打上游。
717
+ # 桶化只对免费引擎(cost_factor >= 0.85,与 route 免费判定一致):n 向上
718
+ # snap 到 10/20/50/100,让同查询不同请求数共享一次执行与缓存;付费引擎
719
+ # 保持精确 n(按结果计费不得放大)。
720
+ _NUM_BUCKETS = (10, 20, 50, 100)
721
+
722
+
723
+ def _free_engine(engine: str) -> bool:
724
+ """是否「免费档」——只有免费档才做 n 桶化。
725
+
726
+ 此前判据是 cost_factor >= 0.85,而成本表没有 api 档的分支(fallthrough
727
+ 到 1.0),于是 api 档(exa/octen/you/parallel/zhihu_global/tavily 这类
728
+ 按量计费的源)被当成免费:要 5 条被放大到 10 条,按结果计费的接口直接
729
+ 双倍计费。本函数注释一直写着「付费引擎保持精确 n(按结果计费不得放大)」,
730
+ 实现却漏了这一档——现在按声明的档位判,不再依赖魔法阈值。
731
+ """
732
+ try:
733
+ from config import cost_tier_of
734
+ return cost_tier_of(engine) == "free"
735
+ except Exception:
736
+ return False
737
+
738
+
739
+ def bucket_n(engine: str, n: int) -> int:
740
+ """免费引擎:n 向上 snap 到缓存友好桶(≤100);付费引擎原样。"""
741
+ n = max(1, min(int(n), 100))
742
+ if not _free_engine(engine):
743
+ return n
744
+ for b in _NUM_BUCKETS:
745
+ if n <= b:
746
+ return b
747
+ return 100
748
+
749
+
750
+ def _call_key(query: str, engine: str, n: int, kwargs: dict[str, Any],
751
+ *, timeout: float | None = None, depth: str = "fast",
752
+ mode: str = "fast") -> str:
753
+ """单飞 key:同 query+engine+参数指纹才合并(时间窗/域等影响结果参数
754
+ 必须进 key,否则不同检索被错误合并)。
755
+
756
+ 2026-10-15 补齐:depth / mode / timeout 同样决定结果,此前只经闭包进
757
+ `_execute`、没进 key —— 跨档位并发被错误合并:deep 请求可能拿到 fast
758
+ 的结果集(反向亦然,且 follower 还会继承 leader 的 timeout)。三者入 key
759
+ 是治本,不是给合并器打补丁。
760
+ """
761
+ import json as _json
762
+ try:
763
+ params = _json.dumps(kwargs, sort_keys=True, ensure_ascii=False,
764
+ default=str)
765
+ except Exception:
766
+ params = str(sorted(kwargs.items()))
767
+ # timeout 归一化:8 与 8.0 是同一次执行,不该分裂成两个 key
768
+ if timeout is None or isinstance(timeout, bool):
769
+ to_seg = str(timeout)
770
+ elif isinstance(timeout, (int, float)):
771
+ to_seg = f"{float(timeout):g}"
772
+ else:
773
+ to_seg = str(timeout)
774
+ return f"{engine}|{n}|{query}|{params}|{depth}|{mode}|{to_seg}"
775
+
776
+
777
+ def search(query: str, engine: str, n: int = 5, timeout: float = 8, depth: str = "fast", mode: str = "fast", **kwargs) -> list[dict[str, Any]]:
778
+ """统一引擎调用入口;失败返回空 list,不抛异常。kwargs 透传到引擎 builder(如 since/until 时间窗)。
779
+
780
+ 并发同调用(同 query+engine+参数)经进程内单飞合并为一次上游执行;
781
+ 免费引擎 n 桶化(见 bucket_n),leader 按桶内最大 n 执行,调用方输出
782
+ 层截断到请求数。
783
+ """
342
784
  registry = get_registry()
343
785
  fn = registry.get(engine)
344
786
  if not fn:
345
- logger.warning(f"未知引擎: {engine}")
787
+ _warn_unknown_engine(engine)
346
788
  return []
347
- t0 = time.time()
348
- try:
349
- results = fn(query, n, timeout, depth=depth, mode=mode)
350
- except TypeError:
789
+ # 引擎可用性由熔断器统一裁决(dispatch 的 breaker.allow / get_negative)。
790
+ # 这里曾有一份 `neg:{engine}` 负缓存,但两处调用(读/写)与 SearchCache
791
+ # 的签名都不匹配,异常被 except 吞掉,从未生效过(2026-10-18 复核)。
792
+ # 不修它:单次失败就全局压制该引擎 45s,比熔断器「连续两次才打开」更激进,
793
+ # 会在一次网络抖动后误杀健康源。保持单一真源,避免第二套状态。
794
+ # 语义型引擎不识别平台结构化语法,剥掉字段只留核心词;透传型保持原 query。
795
+ if engine in _SEMANTIC_ENGINES:
796
+ query = strip_structured(query)
797
+ elif engine in _ENTITY_QUERY_ENGINES:
798
+ # 实体型引擎:自然语言句(疑问前缀/属性词)会让实体搜索接口空结果
799
+ query = _entity_core_query(query)
800
+ eff_n = bucket_n(engine, n)
801
+
802
+ # 引擎自声明 timeout 是该源的硬上限(此前被调用方 timeout 位置传参整体
803
+ # 覆盖成死配置——OSM timeout:6 实测跑出 10s+5s curl 守卫 11.3s)。
804
+ # deep 模式保持原语义:研究场景宁可等,不受 spec 短超时约束。
805
+ spec_to = (_engine_specs or {}).get(engine) or {}
806
+ st = spec_to.get("timeout") if isinstance(spec_to, dict) else None
807
+ call_to = timeout
808
+ if mode != "deep" and isinstance(st, (int, float)) and 0 < st < timeout:
809
+ call_to = float(st)
810
+
811
+ # key 用 call_to(实际执行时的超时)而非调用方声明的 timeout:被 spec
812
+ # 夹到同一上限的两次调用是同一执行,应共享单飞而不是白拆两份。
813
+ key = _call_key(query, engine, eff_n, kwargs,
814
+ timeout=call_to, depth=depth, mode=mode)
815
+
816
+ def _execute() -> list[dict[str, Any]]:
817
+ t0 = time.time()
351
818
  try:
352
- results = fn(query, n, timeout)
819
+ results = fn(query, eff_n, call_to, depth=depth, mode=mode, **kwargs)
353
820
  except Exception as e:
354
- logger.error(f"引擎 {engine} 异常: {type(e).__name__}: {e}")
355
- results = []
356
- except Exception as e:
357
- logger.error(f"引擎 {engine} 异常: {type(e).__name__}: {e}")
358
- results = []
359
- elapsed = time.time() - t0
360
- if results and isinstance(results, list):
361
- for r in results:
362
- if isinstance(r, dict) and "error" not in r:
363
- r["_engine"] = engine
364
- r["_elapsed"] = round(elapsed, 3)
365
- return results if isinstance(results, list) else []
821
+ # TypeError 可能来自引擎内部逻辑错误而非签名不匹配。
822
+ # 用 inspect.signature 确认引擎是否接受 depth/mode 参数,
823
+ # 减少误判:只有引擎函数签名明确不接受这些参数时才回退。
824
+ _retry_simple = False
825
+ if isinstance(e, TypeError):
826
+ try:
827
+ sig = inspect.signature(fn)
828
+ params = list(sig.parameters.keys())
829
+ _retry_simple = not (
830
+ "depth" in params or "mode" in params or "kwargs" in params
831
+ or any(
832
+ p.kind == inspect.Parameter.VAR_KEYWORD
833
+ for _, p in sig.parameters.items()
834
+ )
835
+ )
836
+ except (ValueError, TypeError):
837
+ _retry_simple = False
838
+ if _retry_simple:
839
+ try:
840
+ results = fn(query, eff_n, timeout)
841
+ except Exception as e2:
842
+ logger.error(f"引擎 {engine} 回退失败: {type(e2).__name__}: {e2}")
843
+ results = []
844
+ else:
845
+ logger.error(f"引擎 {engine} 异常: {type(e).__name__}: {e}")
846
+ results = []
847
+ elapsed = time.time() - t0
848
+ if results and isinstance(results, list):
849
+ for r in results:
850
+ if isinstance(r, dict) and "error" not in r:
851
+ r["_engine"] = engine
852
+ r["_elapsed"] = round(elapsed, 3)
853
+ return results if isinstance(results, list) else []
366
854
 
855
+ try:
856
+ results = engine_coalescer().run(key, _execute)
857
+ except Exception:
858
+ results = []
859
+ results = results or []
860
+ # SERP 相关度守卫统一在引擎执行出口判定。放在 builder 里会漏掉
861
+ # people_daily 这类模糊搜索 API:它匹配不到时回整页默认 feed,形态是
862
+ # 正常结果,只有按 query 覆盖率才判得出来(issue #18 复现 A)。
863
+ # builder 已经判过垃圾的 local_* 这里拿到空列表,按早退语义不重复记账。
864
+ try:
865
+ from serp_kill_log import serp_guard_apply
866
+ results = serp_guard_apply(engine, query, results)
867
+ except ImportError:
868
+ pass
869
+ # 桶化后可能多于请求数:调用方按请求 n 截断语义由 execute_search 融合层负责;
870
+ # 此处保留全量(缓存键按 eff_n 存,命中时同样可截)。
871
+ return results
367
872
 
368
873
  def _cli():
369
874
  import argparse
@@ -375,11 +880,11 @@ def _cli():
375
880
  parser.add_argument("--list", action="store_true")
376
881
  args = parser.parse_args()
377
882
  if args.list:
378
- print(json.dumps(available_engines(), ensure_ascii=False, indent=2))
883
+ print(dumps(available_engines()))
379
884
  return
380
885
  if not args.query:
381
886
  parser.error("必须提供 query")
382
- print(json.dumps(search(args.query, args.engine, args.n, args.timeout), ensure_ascii=False, indent=2))
887
+ print(dumps(search(args.query, args.engine, args.n, args.timeout)))
383
888
 
384
889
 
385
890
  if __name__ == "__main__":