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
@@ -0,0 +1,1359 @@
1
+ #!/usr/bin/env python3
2
+ """专用构建器:技术社区 / 文档 / AI 搜索"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ import xml.etree.ElementTree as ET
15
+ from datetime import datetime
16
+ from email.utils import parsedate_to_datetime
17
+ from typing import Any
18
+
19
+ from engines_base import (safe_search, _run, _resolve, _get_path, _coerce_field, rank_score,
20
+ _http_get_raw, mcp_error_of as _mcp_error_of, http_open)
21
+ from engine_env import get_env
22
+
23
+
24
+ class _LazyLogger:
25
+ _logger = None
26
+ def __getattr__(self, name):
27
+ if _LazyLogger._logger is None:
28
+ import logging
29
+ _LazyLogger._logger = logging.getLogger("unified_search.engines")
30
+ if not _LazyLogger._logger.handlers:
31
+ _LazyLogger._logger.setLevel(logging.WARNING)
32
+ _LazyLogger._logger.addHandler(logging.StreamHandler(sys.stderr))
33
+ return getattr(_LazyLogger._logger, name)
34
+ logger = _LazyLogger()
35
+
36
+ # ── Exa 专用引擎 ──────────────────────────────────────────────────────────────
37
+
38
+ _EXA_MCP_URL = "https://mcp.exa.ai/mcp"
39
+
40
+ # Exa MCP 文本应答的字段行。Published 的实际标签是 "Published Date",
41
+ # 归一成 published;Highlights 可能折行,由解析器把续行并入上一字段。
42
+ _EXA_BLOCK_LABEL_RE = re.compile(
43
+ r"^(Title|URL|Published(?:\s+Date)?|Author|Highlights?):\s*(.*)$")
44
+
45
+
46
+ def _parse_exa_text_blocks(text: str) -> list[dict[str, str]]:
47
+ """把 Exa MCP web_search_exa 的 text content 解析成字段 dict 列表。
48
+
49
+ 应答文本是逐条目的标签行(Title:/URL:/...),块以新的 Title: 行开始;
50
+ 不属于任何标签行的非空行是上一字段的续行(Highlights 摘要常被折行)。
51
+ """
52
+ blocks: list[dict[str, str]] = []
53
+ cur: dict[str, str] = {}
54
+ last_key = ""
55
+ for line in (text or "").splitlines():
56
+ m = _EXA_BLOCK_LABEL_RE.match(line.strip())
57
+ if m:
58
+ key = m.group(1).split()[0].lower() # "Published Date" → published
59
+ val = m.group(2).strip()
60
+ if key == "title" and cur:
61
+ blocks.append(cur)
62
+ cur = {}
63
+ if val:
64
+ cur[key] = val
65
+ else:
66
+ cur.setdefault(key, "")
67
+ last_key = key
68
+ elif line.strip() and cur and last_key:
69
+ cur[last_key] = (cur[last_key] + " " + line.strip()).strip()
70
+ if cur:
71
+ blocks.append(cur)
72
+ return blocks
73
+
74
+
75
+ def _parse_mcp_wire(raw: str) -> dict | None:
76
+ """把 MCP 应答解析成 JSON-RPC dict;SSE 与纯 JSON 两种形态都支持。
77
+
78
+ MCP 的 HTTP 传输没有强约定:text/event-stream 时应答藏在 data: 行里,
79
+ application/json 时整个 body 就是一个 JSON-RPC 响应。两者都必须吃下,
80
+ 否则同一通道会随服务端 content-type 切换而「时好时坏」。SSE 场景下取
81
+ 第一个含 result/error 的 data 行(MCP 单请求-单应答,不做多事件聚合)。
82
+ """
83
+ text = (raw or "").strip()
84
+ if not text:
85
+ return None
86
+ if text.startswith("{"):
87
+ try:
88
+ data = json.loads(text)
89
+ return data if isinstance(data, dict) else None
90
+ except ValueError:
91
+ return None
92
+ for line in text.splitlines():
93
+ line = line.strip()
94
+ if not line.startswith("data:"):
95
+ continue
96
+ payload = line[len("data:"):].strip()
97
+ if not payload or payload == "[DONE]":
98
+ continue
99
+ try:
100
+ obj = json.loads(payload)
101
+ except ValueError:
102
+ continue
103
+ if isinstance(obj, dict) and ("result" in obj or "error" in obj):
104
+ return obj
105
+ return None
106
+
107
+
108
+ def _exa_mcp_keyless(query: str, n: int, to: float,
109
+ spec: dict[str, Any]) -> list[dict[str, Any]]:
110
+ """exa 免 key 匿名通道:POST Exa 托管 MCP,调 web_search_exa 工具。
111
+
112
+ 任何一层失败(网络 / 非 MCP 应答 / JSON-RPC 错误 / isError)都返回
113
+ 带 error 的记录——匿名通道是免费的兼容路径,坏了必须让调度层看见,
114
+ 静默返回 [] 会和「真没结果」混淆并掩盖通道失效。
115
+ """
116
+ body = json.dumps({
117
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
118
+ "params": {"name": "web_search_exa",
119
+ "arguments": {"query": query, "numResults": min(n, 10)}},
120
+ }).encode("utf-8")
121
+ headers = {"Content-Type": "application/json",
122
+ "Accept": "application/json, text/event-stream",
123
+ # mcp.exa.ai 在 Cloudflare 之后:默认 Python-urllib UA 直接
124
+ # 403(实测 2026-09-26),必须带浏览器 UA
125
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
126
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
127
+ "Chrome/120.0.0.0 Safari/537.36"}
128
+ req = urllib.request.Request(_EXA_MCP_URL, data=body, headers=headers)
129
+ try:
130
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
131
+ raw = resp.read().decode("utf-8", "replace")
132
+ except Exception as e:
133
+ logger.warning(f"exa 匿名 MCP 通道失败: {e}")
134
+ return [{"error": f"exa mcp: {type(e).__name__}: {e}", "source": "exa"}]
135
+ data = _parse_mcp_wire(raw)
136
+ if not isinstance(data, dict):
137
+ return [{"error": "exa mcp: 响应不是可解析的 MCP 应答(SSE/JSON 均失败)",
138
+ "source": "exa"}]
139
+ mcp_err = _mcp_error_of(data)
140
+ if mcp_err:
141
+ logger.warning(f"exa 匿名 MCP 上游错误: {mcp_err}")
142
+ return [{"error": mcp_err, "source": "exa"}]
143
+ result = data.get("result")
144
+ if not isinstance(result, dict):
145
+ return [{"error": "exa mcp: 响应缺少 result 字段(非 tools/call 应答)",
146
+ "source": "exa"}]
147
+ text = "".join(
148
+ str(c.get("text", "")) for c in (result.get("content") or [])
149
+ if isinstance(c, dict))
150
+ results = []
151
+ for i, blk in enumerate(_parse_exa_text_blocks(text)[:max(1, n)]):
152
+ url = blk.get("url", "")
153
+ title = (blk.get("title") or url).strip()
154
+ if not url and not title:
155
+ continue
156
+ r: dict[str, Any] = {
157
+ "title": title[:200],
158
+ "url": url,
159
+ "snippet": (blk.get("highlights") or "").strip()[:400],
160
+ "source": "exa",
161
+ "score": rank_score(0.75, len(results)),
162
+ }
163
+ # 匿名通道无值时给字面 "N/A"(实测 2026-09-26),等于没有,不能
164
+ # 当成真实的发布时间/作者写进结果
165
+ published = blk.get("published", "").strip()
166
+ if published and published.lower() != "n/a":
167
+ r["published_at"] = published
168
+ author = blk.get("author", "").strip()
169
+ if author and author.lower() != "n/a":
170
+ r["metadata"] = {"author": author}
171
+ results.append(r)
172
+ return results
173
+
174
+
175
+ def _build_exa_engine(spec: dict[str, Any]) -> Any:
176
+ """Exa 语义搜索专用引擎(embedding 匹配 + 内容摘要)。
177
+
178
+ 按是否有 key 分两级通道:
179
+ - 有 key(ARGO_EXA_API_KEY / EXA_API_KEY)→ 官方 REST /search(原路径,
180
+ 字段语义不变)。
181
+ - 无 key → Exa 托管 MCP 匿名通道(_exa_mcp_keyless):POST
182
+ https://mcp.exa.ai/mcp 调 web_search_exa,免 key 匿名可用
183
+ (2026-09-26 实测)。通道失效(改版/加鉴权/限流)时表现为带 error
184
+ 的记录而非空结果,调度层据此切换备选源。
185
+ """
186
+ timeout = spec.get("timeout", 15)
187
+
188
+ @safe_search
189
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, depth: str = "fast", **kwargs) -> list[dict[str, Any]]:
190
+ to = _timeout or timeout
191
+ api_key = get_env(["ARGO_EXA_API_KEY", "EXA_API_KEY"])
192
+ if not api_key:
193
+ return _exa_mcp_keyless(query, n, to, spec)
194
+ url = "https://api.exa.ai/search"
195
+ body = json.dumps({
196
+ "query": query,
197
+ "type": "auto",
198
+ "numResults": min(n, 10),
199
+ "contents": {"text": {"maxCharacters": 400}},
200
+ }).encode("utf-8")
201
+ headers = {"x-api-key": api_key, "Content-Type": "application/json"}
202
+ req = urllib.request.Request(url, data=body, headers=headers)
203
+ try:
204
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
205
+ data = json.loads(resp.read().decode("utf-8"))
206
+ results = []
207
+ for r in data.get("results", []):
208
+ snippet = (r.get("text") or r.get("snippet") or "")[:300]
209
+ # 轻量清洗:去掉 YAML front-matter(--- 开头的内容块)与导航噪声
210
+ if snippet.startswith("---"):
211
+ idx = snippet.find("---", 3)
212
+ if idx != -1:
213
+ snippet = snippet[idx + 3:].strip()
214
+ # type:auto 模式不返回 score 字段(恒 0 会被 RRF 埋没),
215
+ # 无 score 时给固定基线分;并叠加位次衰减。
216
+ # 用 None/空串哨兵而不是 `or`:显式 0 分(上游判定不相关)
217
+ # 会经 or 被抬成 0.75,与 2026-09-30 修过的同类
218
+ # 「0 分垫底变中游」逐字同构。
219
+ _raw_score = r.get("score")
220
+ _base_score = 0.75 if _raw_score is None or _raw_score == "" \
221
+ else _raw_score
222
+ results.append({
223
+ "title": r.get("title", ""),
224
+ "url": r.get("url", ""),
225
+ "snippet": snippet,
226
+ "source": "exa",
227
+ "score": rank_score(_base_score, len(results)),
228
+ })
229
+ return results
230
+ except Exception as e:
231
+ logger.warning(f"Exa 引擎失败: {e}")
232
+ return []
233
+ return _engine
234
+
235
+
236
+ # ── anysearch 通用搜索(JSON-RPC / MCP,进程内 builder 替代 subprocess)───────
237
+
238
+ def _build_anysearch_engine(spec: dict[str, Any]) -> Any:
239
+ """anysearch 通用搜索主力:POST JSON-RPC 到 api.anysearch.com/mcp。
240
+
241
+ 替换原 `type: cli` 的 subprocess 调用(每次启动 python3 解释器 ~200-300ms),
242
+ 改为进程内 builder + HttpClient(UA 轮换/重试/退避/Retry-After):
243
+ 省启动开销 + 降低 errors(限流/网络)导致的高失败。2026-08 优化。
244
+ """
245
+ timeout = spec.get("timeout", 8)
246
+ url = "https://api.anysearch.com/mcp"
247
+
248
+ @safe_search
249
+ def _engine(query: str, n: int = 5, _timeout: float | None = None,
250
+ domain: str = "", sub_domain: str = "", **kwargs) -> list[dict[str, Any]]:
251
+ to = _timeout or timeout
252
+ args: dict[str, Any] = {"query": query, "max_results": min(n, 10)}
253
+ if domain:
254
+ args["domain"] = domain
255
+ if sub_domain:
256
+ args["sub_domain"] = sub_domain
257
+ body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
258
+ "params": {"name": "search", "arguments": args}}
259
+ headers = {"Content-Type": "application/json"}
260
+ api_key = get_env(["ARGO_ANYSEARCH_API_KEY", "ANYSEARCH_API_KEY"])
261
+ if api_key:
262
+ headers["Authorization"] = f"Bearer {api_key}"
263
+ try:
264
+ from http_client import HttpClient
265
+ # max_retries=0:**不再做引擎内连接级重试**(2026-09-10 实测修正)。
266
+ # 原因:重试会与调度层的引擎级重试叠乘。此前 max_retries=1 与
267
+ # 外层 retry_count=1 组合出 2×2=4 次尝试 × 8s = 32s 的最坏耗时,
268
+ # 用户侧表现为「查询卡半分钟」,且期间不切备选源。
269
+ # 失败切换的职责在调度层(熔断降权 + hedged 补发备选引擎 +
270
+ # search.py 的每引擎墙钟预算),引擎内重复尝试只会放大延迟。
271
+ client = HttpClient(timeout=to, max_retries=0, jitter=False)
272
+ resp = client.post(url, body=body, extra_headers=headers)
273
+ except ImportError:
274
+ return []
275
+ if resp.get("status", 0) >= 400 or not resp.get("text"):
276
+ return []
277
+ try:
278
+ data = json.loads(resp["text"])
279
+ except (ValueError, TypeError):
280
+ return []
281
+ # ── 上游错误必须显式上报,不得静默返回空 ──────────────────────
282
+ # 实测(2026-09-10)上游返回 HTTP 200 但 isError=true:
283
+ # {"result":{"content":[{"text":"Service temporarily unavailable."}],
284
+ # "isError":true}}
285
+ # 旧实现忽略 isError,而该文本不含配额关键词、也没有 "### N." 结果块,
286
+ # 于是静默返回 [] —— 用户看到「没结果」而非「上游不可用」,
287
+ # 熔断器也拿不到失败信号。这是本仓第 N 次「失败伪装成成功」。
288
+ mcp_err = _mcp_error_of(data)
289
+ if mcp_err:
290
+ logger.warning(f"anysearch 上游错误: {mcp_err}")
291
+ return [{"error": mcp_err, "source": "anysearch"}]
292
+
293
+ content = data.get("result", {}).get("content", []) or []
294
+ records = [
295
+ (i.get("text", "") if isinstance(i, dict) else str(i)) for i in content
296
+ ]
297
+ joined = "".join(records).lower()
298
+ # 配额/限流:仅当无任何结果块(### N.)且文本含配额信号时判定,避免
299
+ # 正常结果正文里出现 'quota/429/rate limit' 等词被误判为配额耗尽。
300
+ #
301
+ # **刻意返回 [](不是 error item)**——与上方 isError 分支处理相反,
302
+ # 理由不同、不可混同:
303
+ # · isError=true 是上游**明确宣示失败**(MCP 协议级信号)→ 必须报错,
304
+ # 让熔断器拿到信号、停止对已坏源的空转调用。
305
+ # · 配额耗尽是**临时**状态(按日/月重置),上游本身健康。
306
+ # 返回 [] 让路由优雅 fallback 到其它源即可;若报 error 会驱动
307
+ # 熔断 open、把「只是暂时没额度」的引擎禁用,反而有害。
308
+ # 既有测试 test_quota_only_response_returns_empty 锁定此契约。
309
+ has_result_blocks = any("### " in t for t in records)
310
+ if (not has_result_blocks) and any(
311
+ k in joined for k in ("quota", "exhausted", "recharge",
312
+ "rate limit", "429", "daily_free_quota")):
313
+ return []
314
+ results = []
315
+ for item in content:
316
+ text = item.get("text", "") if isinstance(item, dict) else str(item)
317
+ # 结果块以行首「### N.」分隔。用 (?m)^ 锚定行首而非 \n 前缀:
318
+ # 首个结果块顶格开头(无前导换行)时,\n 前缀版本会把第一块
319
+ # 并进 blocks[0] 而整块丢失(首个结果静默消失)。
320
+ blocks = re.split(r"(?m)^### \d+\.\s", text)
321
+ for block in blocks[1:]:
322
+ lines = block.strip().split("\n")
323
+ title = lines[0].strip() if lines else ""
324
+ item_url = ""
325
+ snippet_lines = []
326
+ for line in lines[1:]:
327
+ ls = line.strip()
328
+ if ls.startswith("- **URL**: "):
329
+ item_url = ls.replace("- **URL**: ", "")
330
+ elif ls.startswith("**URL**: "):
331
+ item_url = ls.replace("**URL**: ", "")
332
+ else:
333
+ snippet_lines.append(line)
334
+ snippet = "\n".join(snippet_lines).strip()[:500]
335
+ if title:
336
+ results.append({
337
+ "title": title[:200], "url": item_url, "snippet": snippet,
338
+ "source": "anysearch", "score": rank_score(0.7, len(results)),
339
+ })
340
+ return results
341
+ return _engine
342
+
343
+
344
+ # ── 搜狗微信搜索引擎 ─────────────────────────────────────────────────────────
345
+
346
+ # 搜狗中间链 → 微信真实链接(mp.weixin.qq.com)的解析预算与熔断参数:
347
+ # 搜狗 /link、/weixin 跳转链是 SERP 链(SKILL.md 纪律 3:不得作正文来源),
348
+ # 且分钟级过期,--verify 与 article 均无法消费;解析在引擎内就地完成。
349
+ _SOGOU_RESOLVE_TIMEOUT_CAP = 3.5 # 单条解析请求超时封顶(秒)
350
+ _SOGOU_RESOLVE_BUDGET_S = 4.0 # 单轮解析墙钟预算,超预算的余下结果回落中间链
351
+ _SOGOU_RESOLVE_BODY_CAP = 65536 # 反爬 JS 页很小,响应体读取上限
352
+ _SOGOU_RESOLVE_FAIL_LIMIT = 3 # 连续失败达阈值 → 冷却期内跳过解析
353
+ _SOGOU_RESOLVE_COOLDOWN_S = 300.0
354
+ _SOGOU_RESOLVE_MAX_PER_CALL = 5 # 单轮解析条数封顶:n>5 无收益,少发请求降风控暴露
355
+
356
+
357
+ def _sogou_is_intermediate(url: str) -> bool:
358
+ """是否搜狗中间跳转链(/link?url= 或 /weixin?url=),待解析成真实链接。"""
359
+ if "weixin.sogou.com" not in url:
360
+ return False
361
+ return "/link?" in url or "/weixin?" in url
362
+
363
+
364
+ def _resolve_sogou_link(url: str, to: float, engine_name: str,
365
+ cookie: str = "") -> str:
366
+ """单条搜狗中间链 → mp.weixin.qq.com 真实文章链接,失败返回空串。
367
+
368
+ 两条恢复路径:
369
+ a) 正常风控:302 直落真实链,urllib 自动跟随,geturl() 即终址;
370
+ b) 反爬 JS 页(200 + `url += '片段'` 拼接跳转):按序拼回真实地址。
371
+ """
372
+ headers = {
373
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
374
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
375
+ "Chrome/120.0.0.0 Safari/537.36",
376
+ "Referer": "https://weixin.sogou.com/",
377
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
378
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
379
+ }
380
+ if cookie:
381
+ headers["Cookie"] = cookie
382
+ # 搜狗把原始查询词原样塞进 /link 的 query(如 query=AI agent 工作),空格
383
+ # 不编码会被 urllib 拒收(InvalidURL: control characters)
384
+ url = url.replace(" ", "%20")
385
+ req = urllib.request.Request(url, headers=headers)
386
+ with http_open(req, timeout=min(to, _SOGOU_RESOLVE_TIMEOUT_CAP),
387
+ engine=engine_name) as resp:
388
+ final = resp.geturl() if hasattr(resp, "geturl") else ""
389
+ if "mp.weixin.qq.com" in final:
390
+ return final
391
+ body = resp.read(_SOGOU_RESOLVE_BODY_CAP).decode("utf-8", errors="replace")
392
+ candidate = "".join(re.findall(r"\burl\b\s*\+?=\s*['\"]([^'\"]*)['\"]", body))
393
+ if "mp.weixin.qq.com" in candidate:
394
+ return candidate
395
+ return ""
396
+
397
+
398
+ def _build_wechat_sogou_engine(spec: dict[str, Any]) -> Any:
399
+ """搜狗微信搜索引擎(weixin.sogou.com)
400
+
401
+ 抓取搜狗微信搜索结果页,提取公众号文章标题、链接、摘要、公众号名,
402
+ 并将搜狗中间跳转链解析为 mp.weixin.qq.com 真实文章链接
403
+ (url_resolved 标注解析结果,失败回落中间链,连续失败熔断冷却)。
404
+ 无需登录,无需 API key,纯 HTML 解析。
405
+ """
406
+ timeout = spec.get("timeout", 10)
407
+ engine_name = spec.get("_name", "")
408
+ resolve_state = {"fails": 0, "cooldown_until": 0.0}
409
+
410
+ def _resolve_results(results: list[dict[str, Any]], to: float,
411
+ cookie: str) -> None:
412
+ """就地解析结果中的搜狗中间链;降级不丢结果,熔断不白耗请求。"""
413
+ import random
414
+ started = time.monotonic()
415
+ pending = 0
416
+ for r in results:
417
+ if not _sogou_is_intermediate(r.get("url", "")):
418
+ continue
419
+ if (pending >= _SOGOU_RESOLVE_MAX_PER_CALL
420
+ or time.monotonic() - started > _SOGOU_RESOLVE_BUDGET_S
421
+ or time.monotonic() < resolve_state["cooldown_until"]):
422
+ r["url_resolved"] = False
423
+ continue
424
+ pending += 1
425
+ time.sleep(random.uniform(0.05, 0.15)) # 逐条限速,贴近引擎 qps 声明
426
+ try:
427
+ real = _resolve_sogou_link(r["url"], to, engine_name, cookie)
428
+ except Exception as e:
429
+ logger.warning(f"搜狗中间链解析失败: {e}")
430
+ real = ""
431
+ if real:
432
+ resolve_state["fails"] = 0
433
+ r["url"] = real
434
+ r["url_resolved"] = True
435
+ else:
436
+ resolve_state["fails"] += 1
437
+ r["url_resolved"] = False
438
+ if resolve_state["fails"] >= _SOGOU_RESOLVE_FAIL_LIMIT:
439
+ resolve_state["cooldown_until"] = (
440
+ time.monotonic() + _SOGOU_RESOLVE_COOLDOWN_S)
441
+
442
+ @safe_search
443
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
444
+ import urllib.parse as up
445
+ to = _timeout or timeout
446
+ url = f"https://weixin.sogou.com/weixin?type=2&query={up.quote(query)}&ie=utf8"
447
+ headers = {
448
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
449
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
450
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
451
+ }
452
+ req = urllib.request.Request(url, headers=headers)
453
+ try:
454
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
455
+ html = resp.read().decode("utf-8")
456
+ # 复用搜索响应的 Set-Cookie(SNUID/SUV):中间链跳转带上可降风控
457
+ _set_cookies = (resp.headers.get_all("Set-Cookie")
458
+ if hasattr(resp, "headers") else None)
459
+ cookie = "; ".join(c.split(";")[0] for c in _set_cookies) if _set_cookies else ""
460
+ results = []
461
+ li_pattern = re.compile(
462
+ r'<li\s+id="sogou_vr_11002601_box_\d+"[^>]*>(.*?)</li>', re.DOTALL
463
+ )
464
+ for li in li_pattern.findall(html)[:n]:
465
+ title_match = re.search(
466
+ r'<h3[^>]*>.*?<a[^>]*href="([^"]+)"[^>]*>(.*?)</a>', li, re.DOTALL
467
+ )
468
+ if not title_match:
469
+ continue
470
+ href = title_match.group(1).replace("&amp;", "&")
471
+ title = re.sub(r"<[^>]+>", "", title_match.group(2)).strip()
472
+ title = title.replace("<!--red_beg-->", "").replace("<!--red_end-->", "")
473
+
474
+ summary_match = re.search(
475
+ r'<p[^>]*class="txt-info"[^>]*>(.*?)</p>', li, re.DOTALL
476
+ )
477
+ summary = re.sub(r"<[^>]+>", "", summary_match.group(1)).strip() if summary_match else ""
478
+ summary = summary.replace("<!--red_beg-->", "").replace("<!--red_end-->", "")
479
+
480
+ account_match = re.search(
481
+ r'<span[^>]*class="all-time-y2"[^>]*>(.*?)</span>', li, re.DOTALL
482
+ )
483
+ account = re.sub(r"<[^>]+>", "", account_match.group(1)).strip() if account_match else ""
484
+
485
+ # 发布时间:结果条内嵌 script 写入 document.write(timeConvert('10位unix秒'))
486
+ time_match = re.search(r"timeConvert\('?(\d{10})'?\)", li)
487
+ published_at = ""
488
+ if time_match:
489
+ try:
490
+ published_at = datetime.fromtimestamp(
491
+ int(time_match.group(1))
492
+ ).astimezone().isoformat(timespec="seconds")
493
+ except (ValueError, OSError):
494
+ published_at = ""
495
+
496
+ result = {
497
+ "title": title[:80],
498
+ "url": "https://weixin.sogou.com" + href if href.startswith("/") else href,
499
+ "snippet": summary[:200],
500
+ "account": account,
501
+ "source": "wechat_sogou",
502
+ }
503
+ if published_at:
504
+ result["published_at"] = published_at
505
+ results.append(result)
506
+ if results:
507
+ if str(kwargs.get("mode", "auto")) == "fast":
508
+ # fast:省时延跳过解析,统一显式标注未解析
509
+ for r in results:
510
+ if _sogou_is_intermediate(r.get("url", "")):
511
+ r["url_resolved"] = False
512
+ else:
513
+ _resolve_results(results, to, cookie)
514
+ return results
515
+ except Exception as e:
516
+ logger.warning(f"搜狗微信搜索失败: {e}")
517
+ return []
518
+ return _engine
519
+
520
+
521
+ # ── Hacker News 搜索引擎 ──────────────────────────────────────────────────────
522
+
523
+ def _build_hackernews_engine(spec: dict[str, Any]) -> Any:
524
+ """Hacker News 搜索(Algolia API)"""
525
+ timeout = spec.get("timeout", 8)
526
+
527
+ @safe_search
528
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
529
+ import urllib.parse as up
530
+ to = _timeout or timeout
531
+ url = f"https://hn.algolia.com/api/v1/search?query={up.quote(query)}&tags=story&hitsPerPage={min(n, 10)}"
532
+ headers = {"User-Agent": "argo-search/1.0"}
533
+ req = urllib.request.Request(url, headers=headers)
534
+ try:
535
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
536
+ data = json.loads(resp.read())
537
+ results = []
538
+ for h in data.get("hits", []):
539
+ results.append({
540
+ "title": h.get("title", ""),
541
+ "url": h.get("url", f"https://news.ycombinator.com/item?id={h.get('objectID', '')}"),
542
+ "snippet": f"score: {h.get('points', 0)} | comments: {h.get('num_comments', 0)} | by: {h.get('author', '')}",
543
+ "source": "hackernews",
544
+ })
545
+ return results
546
+ except Exception as e:
547
+ logger.warning(f"HackerNews 引擎失败: {e}")
548
+ return []
549
+ return _engine
550
+
551
+
552
+ # ── Stack Overflow 搜索引擎 ───────────────────────────────────────────────────
553
+
554
+ # Stack Exchange 站点族:同一 API 换 site 参数覆盖 180+ 站点,
555
+ # 「site:serverfault nginx 配置」把查询定向到指定站点,其余原样透传
556
+ _SE_SITE_RE = re.compile(r"(?i)^\s*site:([a-z0-9\-]+)\s+")
557
+
558
+
559
+ def _build_stackoverflow_engine(spec: dict[str, Any]) -> Any:
560
+ """Stack Overflow 搜索(Stack Exchange API,site: 前缀切站点族)"""
561
+ timeout = spec.get("timeout", 8)
562
+
563
+ @safe_search
564
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
565
+ import urllib.parse as up
566
+ to = _timeout or timeout
567
+ site = "stackoverflow"
568
+ q = query.strip()
569
+ m = _SE_SITE_RE.match(q)
570
+ if m:
571
+ site = m.group(1).lower()
572
+ q = q[m.end():].strip()
573
+ if not q:
574
+ return []
575
+ url = f"https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q={up.quote(q)}&site={site}&pagesize={min(n, 10)}"
576
+ headers = {"User-Agent": "argo-search/1.0", "Accept-Encoding": "gzip"}
577
+ req = urllib.request.Request(url, headers=headers)
578
+ try:
579
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
580
+ import gzip
581
+ raw = resp.read()
582
+ try:
583
+ data = json.loads(gzip.decompress(raw))
584
+ except Exception:
585
+ data = json.loads(raw)
586
+ results = []
587
+ for item in data.get("items", []):
588
+ tags = ", ".join(item.get("tags", [])[:3])
589
+ prefix = f"[{site}] " if site != "stackoverflow" else ""
590
+ results.append({
591
+ "title": item.get("title", ""),
592
+ "url": item.get("link", ""),
593
+ "snippet": f"{prefix}score: {item.get('score', 0)} | answers: {item.get('answer_count', 0)} | tags: {tags}",
594
+ "source": "stackoverflow",
595
+ })
596
+ return results
597
+ except Exception as e:
598
+ logger.warning(f"StackOverflow 引擎失败: {e}")
599
+ return []
600
+ return _engine
601
+
602
+
603
+ # ── GitHub 搜索引擎(按结构化语法切端点)──────────────────────────────────────
604
+
605
+ # GitHub 搜索与 X 一样支持结构化字段,但不同字段属于不同端点(失配会拿到空结果或噪音):
606
+ # - 仓库搜索: user:/org:/lang:/in:name/in:description/stars:/topic: → /search/repositories
607
+ # - issue/PR: repo:/is:issue/is:pr/label:/author:/assignee:/comments:/created:/in:title/in:body
608
+ # → /search/issues
609
+ # - 代码搜索: in:file/filename:/extension:/path:(需认证) → /search/code
610
+ _GH_REPO_SYNTAX = ("user:", "org:", "lang:", "in:name", "in:description", "stars:", "topic:", "size:", "pushed:")
611
+ _GH_ISSUE_SYNTAX = ("repo:", "is:issue", "is:pr", "is:open", "is:closed", "label:", "milestone:",
612
+ "author:", "assignee:", "comments:", "created:", "updated:", "in:title", "in:body")
613
+ _GH_CODE_SYNTAX = ("in:file", "filename:", "extension:", "path:", "in:readme", "in:path")
614
+
615
+
616
+ def _github_endpoint(query: str, has_token: bool) -> str:
617
+ """按查询中的结构化语法选 GitHub 搜索端点。"""
618
+ if any(s in query for s in _GH_CODE_SYNTAX):
619
+ return "code" if has_token else "issues" # code 需认证;无 token 尽力退到 issues
620
+ if any(s in query for s in _GH_ISSUE_SYNTAX):
621
+ return "issues"
622
+ return "repositories"
623
+
624
+
625
+ def _github_url(endpoint: str, query: str, n: int) -> str:
626
+ q = urllib.parse.quote(query)
627
+ per = min(n, 30)
628
+ base = {
629
+ "repositories": "https://api.github.com/search/repositories",
630
+ "issues": "https://api.github.com/search/issues",
631
+ "code": "https://api.github.com/search/code",
632
+ }[endpoint]
633
+ return f"{base}?q={q}&per_page={per}"
634
+
635
+
636
+ def _gh_repo_result(item: dict) -> dict[str, Any] | None:
637
+ name = item.get("full_name") or item.get("name") or ""
638
+ url = item.get("html_url") or ""
639
+ desc = (item.get("description") or "").strip()
640
+ if not name and not url:
641
+ return None
642
+ stars = item.get("stargazers_count")
643
+ snippet = desc or f"stars: {stars} | language: {item.get('language')}"
644
+ return {
645
+ "title": name or url,
646
+ "url": url,
647
+ "snippet": snippet[:300],
648
+ "source": "github",
649
+ "score": 0.7,
650
+ "published_at": item.get("updated_at"),
651
+ "metadata": {"stars": stars, "language": item.get("language"), "forks": item.get("forks_count")},
652
+ }
653
+
654
+
655
+ def _gh_issue_result(item: dict) -> dict[str, Any] | None:
656
+ title = item.get("title") or ""
657
+ url = item.get("html_url") or ""
658
+ repo_full = (item.get("repository_url") or "").replace("https://api.github.com/repos/", "")
659
+ if not title and not url:
660
+ return None
661
+ state = item.get("state") or ""
662
+ comments = item.get("comments")
663
+ user = (item.get("user") or {}).get("login") or ""
664
+ snippet = f"[{repo_full}] {state} | comments: {comments} | by @{user}" if repo_full else f"{state} | by @{user}"
665
+ return {
666
+ "title": title or url,
667
+ "url": url,
668
+ "snippet": snippet[:300],
669
+ "source": "github",
670
+ "score": 0.7,
671
+ "published_at": item.get("created_at"),
672
+ "metadata": {"repo": repo_full, "state": state, "comments": comments},
673
+ }
674
+
675
+
676
+ def _gh_code_result(item: dict) -> dict[str, Any] | None:
677
+ name = item.get("name") or ""
678
+ url = item.get("html_url") or ""
679
+ repo = (item.get("repository") or {}).get("full_name") or ""
680
+ path = item.get("path") or ""
681
+ if not name and not url:
682
+ return None
683
+ snippets = [(tm.get("fragment") or "").strip() for tm in (item.get("text_matches") or []) if tm.get("fragment")]
684
+ snippet = " / ".join(snippets)[:300] or path
685
+ return {
686
+ "title": f"{repo}:{path or name}",
687
+ "url": url,
688
+ "snippet": snippet,
689
+ "source": "github",
690
+ "score": 0.7,
691
+ "metadata": {"repo": repo, "path": path},
692
+ }
693
+
694
+
695
+ def _build_github_engine(spec: dict[str, Any]) -> Any:
696
+ """GitHub 搜索:按查询结构化语法自动切 repositories / issues / code 端点。
697
+
698
+ 未认证(无 GITHUB_TOKEN)时可用 repositories / issues;code 端点需认证。
699
+ 失配端点会拿到空结果或大段噪音,这里是按语法选对端点的关键修复。
700
+ """
701
+ timeout = spec.get("timeout", 10)
702
+
703
+ @safe_search
704
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
705
+ to = _timeout or timeout
706
+ token = get_env(["ARGO_GITHUB_TOKEN", "GITHUB_TOKEN"]).strip()
707
+ endpoint = _github_endpoint(query, bool(token))
708
+ url = _github_url(endpoint, query, n)
709
+ headers = {
710
+ "User-Agent": "argo-search/1.0 (+github)",
711
+ "Accept": "application/vnd.github+json",
712
+ "X-GitHub-Api-Version": "2022-11-28",
713
+ }
714
+ if token:
715
+ headers["Authorization"] = f"token {token}"
716
+ if endpoint == "code":
717
+ headers["Accept"] = "application/vnd.github.v3.text-match+json"
718
+ req = urllib.request.Request(url, headers=headers)
719
+ try:
720
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
721
+ data = json.loads(resp.read().decode("utf-8"))
722
+ except urllib.error.HTTPError as e:
723
+ logger.warning(f"GitHub {endpoint} 失败 HTTP {e.code}: {e.reason}")
724
+ return []
725
+ except Exception as e:
726
+ logger.warning(f"GitHub {endpoint} 失败: {e}")
727
+ return []
728
+
729
+ results = []
730
+ if endpoint == "repositories":
731
+ for item in data.get("items", [])[:n]:
732
+ r = _gh_repo_result(item)
733
+ if r:
734
+ results.append(r)
735
+ elif endpoint == "issues":
736
+ for item in data.get("items", [])[:n]:
737
+ r = _gh_issue_result(item)
738
+ if r:
739
+ results.append(r)
740
+ else:
741
+ for item in data.get("items", [])[:n]:
742
+ r = _gh_code_result(item)
743
+ if r:
744
+ results.append(r)
745
+ return [dict(r, score=rank_score(r.get("score", 0.7), i)) for i, r in enumerate(results)]
746
+ return _engine
747
+
748
+
749
+ # ── Google Scholar 搜索引擎 ───────────────────────────────────────────────────
750
+
751
+ def _build_google_scholar_engine(spec: dict[str, Any]) -> Any:
752
+ """Google Scholar 搜索(HTTP 页面解析)"""
753
+ timeout = spec.get("timeout", 12)
754
+
755
+ @safe_search
756
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
757
+ import urllib.parse as up
758
+ to = _timeout or timeout
759
+ url = f"https://scholar.google.com/scholar?q={up.quote(query)}&hl=en&as_sdt=0%2C5&num={min(n, 10)}"
760
+ headers = {
761
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
762
+ "Accept": "text/html,application/xhtml+xml",
763
+ }
764
+ req = urllib.request.Request(url, headers=headers)
765
+ try:
766
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
767
+ html = resp.read().decode("utf-8")
768
+ results = []
769
+ titles = re.findall(r'<h3[^>]*class="[^"]*gs_rt[^"]*"[^>]*>(.*?)</h3>', html, re.DOTALL)
770
+ snippets = re.findall(r'<div[^>]*class="[^"]*gs_rs[^"]*"[^>]*>(.*?)</div>', html, re.DOTALL)
771
+ for i, t in enumerate(titles[:n]):
772
+ title = re.sub(r'<[^>]+>', '', t).strip()
773
+ snippet = re.sub(r'<[^>]+>', '', snippets[i]).strip() if i < len(snippets) else ""
774
+ if title:
775
+ results.append({
776
+ "title": title[:100],
777
+ "url": f"https://scholar.google.com/scholar?q={up.quote(title[:50])}",
778
+ "snippet": snippet[:200],
779
+ "source": "google_scholar",
780
+ })
781
+ return results
782
+ except Exception as e:
783
+ logger.warning(f"Google Scholar 引擎失败: {e}")
784
+ return []
785
+ return _engine
786
+
787
+
788
+ # ── V2EX 搜索引擎 ─────────────────────────────────────────────────────────────
789
+
790
+ # 拉丁词最小长度:单字符不做匹配。
791
+ # 实测证据:查询 "a" 会命中 Apple/astar 等任意含该字母的主题。
792
+ # 注意门槛只到 2:词边界(见 _term_hits)已能挡住 "ab"→Avalonia/Wabou
793
+ # 这类组合匹配,若把门槛提到 3 会误伤 "ai" 这种有真实语义的双字符词
794
+ # (实测 "AI" 查询会返回 0 条)。CJK 词不受此门槛限制。
795
+ MIN_LATIN_TERM_LEN = 2
796
+
797
+ _SOV2EX_API = "https://www.sov2ex.com/api/search"
798
+
799
+
800
+ def _sov2ex_search(query: str, n: int, timeout: float,
801
+ engine: str) -> list[dict[str, Any]] | None:
802
+ """第一级来源:sov2ex 社区全文搜索(社区维护的 V2EX 全文索引)。
803
+
804
+ GET /api/search?q=&size=,返回 {total, hits:[{_id, title, content,
805
+ created, _score, highlight?}]}(2026-09-26 实测)。命中产出带真实
806
+ /t/<id> 链接的全文结果;**空结果 / 失败 / 响应不可解析一律返回 None**,
807
+ 由调用方落回官方 API 候选池路径——None 是「此级无产出」的信号,
808
+ 不是最终结果,最终诚实空由池路径给出。
809
+
810
+ 字段读取兼容 _source 包裹:sov2ex 底层是 Elasticsearch,部分部署形态
811
+ 会把字段收进 hits[]._source,两种形态都认,避免索引端调整即失效。
812
+ """
813
+ q = (query or "").strip()
814
+ if not q:
815
+ return None
816
+ u = f"{_SOV2EX_API}?{urllib.parse.urlencode({'q': q, 'size': n})}"
817
+ raw = _http_get_raw(u, {"Accept": "application/json"}, timeout, engine=engine)
818
+ if not raw:
819
+ return None
820
+ try:
821
+ data = json.loads(raw)
822
+ except (json.JSONDecodeError, ValueError):
823
+ return None
824
+ if not isinstance(data, dict):
825
+ return None
826
+ hits = data.get("hits")
827
+ if not isinstance(hits, list) or not hits:
828
+ return None
829
+ results: list[dict[str, Any]] = []
830
+ for i, hit in enumerate(hits[:max(1, n)]):
831
+ if not isinstance(hit, dict):
832
+ continue
833
+ src = hit.get("_source") if isinstance(hit.get("_source"), dict) else hit
834
+ tid = str(hit.get("_id") or src.get("id") or "").strip()
835
+ if not tid:
836
+ continue
837
+ # snippet 优先吃 highlight(查询词命中片段,更相关),缺失再退整段正文
838
+ hl = hit.get("highlight")
839
+ snippet = ""
840
+ if isinstance(hl, dict):
841
+ frag = hl.get("content")
842
+ if isinstance(frag, list) and frag:
843
+ snippet = " … ".join(str(x) for x in frag)
844
+ elif isinstance(frag, str):
845
+ snippet = frag
846
+ if not snippet:
847
+ snippet = str(src.get("content") or "")
848
+ snippet = re.sub(r"</?em>", "", snippet).strip()
849
+ published = ""
850
+ try:
851
+ published = datetime.fromtimestamp(
852
+ int(src.get("created"))
853
+ ).astimezone().isoformat(timespec="seconds")
854
+ except (TypeError, ValueError, OSError):
855
+ published = ""
856
+ r: dict[str, Any] = {
857
+ "title": (str(src.get("title") or "").strip() or tid)[:120],
858
+ "url": f"https://www.v2ex.com/t/{tid}",
859
+ "snippet": snippet[:300],
860
+ "source": "v2ex",
861
+ # sov2ex 的 _score 是 BM25 分,量纲与 argo 的 0-1 分不可比,
862
+ # 不透传;上游相关性顺序用序位衰减编码,原始分进 social_meta
863
+ "score": rank_score(0.85, len(results)),
864
+ "social_meta": {
865
+ "platform": "v2ex",
866
+ "content_type": "topic",
867
+ "url_verifiable": True,
868
+ "retrieval_mode": "sov2ex_fulltext",
869
+ "sov2ex_score": hit.get("_score"),
870
+ },
871
+ }
872
+ if published:
873
+ r["published_at"] = published
874
+ results.append(r)
875
+ return results or None
876
+
877
+
878
+ def _build_v2ex_engine(spec: dict[str, Any]) -> Any:
879
+ """V2EX 社区搜索(sov2ex 全文优先 + 官方 API 候选池降级)
880
+
881
+ 两级来源,按可用性互补:
882
+ 1. sov2ex 社区全文搜索(_sov2ex_search)——能答「全文关键词」问题,
883
+ 命中即返回,不再消耗官方 API 配额;
884
+ 2. 官方开放 API 候选池 + 本地相关性过滤——sov2ex 空结果/失败/超时
885
+ 时的降级路径,语义保持不变。
886
+
887
+ 为什么不是「爬 /search 页」:V2EX 的站内搜索需要登录态,未登录访问
888
+ `/search?q=` 会 302 到 `/go/search`——那是「搜索引擎技术研究」**节点页**,
889
+ 不是搜索结果页。旧实现用 item_title 正则直接抓该页面,于是把节点热帖
890
+ 当成了搜索结果:10 条结果的 url 全部等于查询自身的搜索页地址、
891
+ snippet 恒为硬编码常量,标题则与该节点无关(如查「V2EX 社区」返回
892
+ 「装机 配置 预算」)。coverage 仍报 status=ok/returned=10,
893
+ 失败伪装成成功,破坏 argo「结果可核验」的证据完整链路。
894
+
895
+ 降级路径走官方开放 API(只读、无鉴权、配额 600 次/[窗口]):
896
+ - /api/topics/hot.json 热门主题
897
+ - /api/topics/latest.json 最新主题
898
+ - /api/replies/show.json 主题回复(按 topic_id)
899
+ API 没有搜索端点,因此策略是「拉候选池 → 在本地按查询词做相关性过滤
900
+ → 按命中强度排序」。这样产出的每条结果都带真实 /t/<id> 链接与真正文,
901
+ 可被 fetch 复核。若两级来源都无任何条目与查询相关,**诚实返回空列表**
902
+ (调用方据此标记该引擎无结果),而不是回落到伪造占位结果。
903
+ """
904
+ timeout = spec.get("timeout", 10)
905
+
906
+ @safe_search
907
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
908
+ to = _timeout or timeout
909
+
910
+ # ── 第一级:sov2ex 全文搜索,命中即返回(省官方 API 配额)────
911
+ sov2ex_hits = _sov2ex_search(query, n, to, spec.get("_name", "v2ex"))
912
+ if sov2ex_hits:
913
+ return sov2ex_hits
914
+
915
+ def _fetch_json(path: str, params: dict | None = None):
916
+ u = f"https://www.v2ex.com{path}"
917
+ if params:
918
+ u += "?" + urllib.parse.urlencode(params)
919
+ # 走 argo 统一 GET 出口(HttpClient:UA 轮换 / 重定向跟随 / 429 尊重),
920
+ # 不自造 urllib 请求头——与「新引擎复用 argo HttpClient」纪律一致。
921
+ # engine 必须显式传:默认值 "?" 会把 V2EX 的失败记到伪引擎名下,
922
+ # 聚合层 pop("v2ex") 永远取不到(归因写不进去等于没做)。
923
+ raw = _http_get_raw(u, {"Accept": "application/json"}, to,
924
+ engine=spec.get("_name", "v2ex"))
925
+ if not raw:
926
+ return None
927
+ data = json.loads(raw)
928
+ # 官方 API 会返回 {"status":"error","message":...,"rate_limit":{...}},
929
+ # 例如配额耗尽或参数非法。这类响应不是列表,静默当空会掩盖真实故障。
930
+ if isinstance(data, dict) and data.get("status") == "error":
931
+ rl = data.get("rate_limit") or {}
932
+ logger.warning(
933
+ "V2EX API 返回错误: %s (rate_limit used=%s quota=%s)",
934
+ data.get("message"), rl.get("used"), rl.get("quota"),
935
+ )
936
+ return None
937
+ return data
938
+
939
+ # ── 候选池构建:节点路由优先,hot/latest 保底 ──────────────────
940
+ #
941
+ # 第一批只用 hot+latest(20 条全站热帖),与查询无关,长尾查询
942
+ # 必然落空。现引入节点路由(见 v2ex_nodes):把「全站热帖过滤」
943
+ # 升级为「相关节点内检索」。节点表本地缓存 24h,匹配阶段零 API。
944
+ #
945
+ # 取池策略(成本与召回权衡):
946
+ # 命中节点 → 并发取 Top-K 节点的帖(K=3,配额占用 3/600)
947
+ # 未命中 → 回落 hot+latest,并如实标注 layer=none
948
+ topics: dict[int, dict] = {}
949
+ routed = {"nodes": [], "confidence": 0.0, "layer": "none", "scores": {}}
950
+ try:
951
+ from v2ex_nodes import pick_nodes
952
+ routed = pick_nodes(query, top_k=3, fetcher=lambda u: _http_get_raw(
953
+ u, {"Accept": "application/json"}, to,
954
+ engine=spec.get("_name", "v2ex")))
955
+ except Exception as e:
956
+ logger.debug(f"V2EX 节点路由失败,回落 hot/latest: {e}")
957
+
958
+ for node_name in routed.get("nodes") or []:
959
+ try:
960
+ for t in _fetch_json("/api/topics/show.json",
961
+ {"node_name": node_name, "page": 1}) or []:
962
+ tid = t.get("id")
963
+ if tid and tid not in topics:
964
+ t["_v2ex_node_routed"] = node_name
965
+ topics[tid] = t
966
+ except Exception as e:
967
+ logger.debug(f"V2EX 节点 {node_name} 拉取失败: {e}")
968
+
969
+ # 保底与补充:无论是否命中节点,都并入 hot/latest
970
+ # (节点帖可能与查询无关的部分互补;去重由 topic id 保证)
971
+ for path in ("/api/topics/hot.json", "/api/topics/latest.json"):
972
+ try:
973
+ for t in _fetch_json(path) or []:
974
+ tid = t.get("id")
975
+ if tid and tid not in topics:
976
+ topics[tid] = t
977
+ except Exception as e:
978
+ logger.debug(f"V2EX {path} 拉取失败: {e}")
979
+
980
+ if not topics:
981
+ return []
982
+
983
+ q_norm = (query or "").strip().lower()
984
+ terms = [w for w in re.split(r"[\s,,、/]+", q_norm) if w]
985
+
986
+ def _term_hits(term: str, blob: str) -> bool:
987
+ """词命中判断:拉丁词要求词边界,CJK 走子串。
988
+
989
+ 为什么必须区分:纯子串匹配下 "ab" 会命中 Avalonia/Wabou/Workbuddy
990
+ (实测),"a" 会命中 Apple/astar。对拉丁词加 word boundary 后
991
+ "ai" 仍能精确命中独立的 "ai"/"AI",但不再命中 "Avalonia";
992
+ CJK 没有词边界概念,"程序员" 这类子串匹配是正确的。
993
+ 单字符拉丁词(< MIN_LATIN_TERM_LEN)无边界可依,直接不匹配。
994
+ """
995
+ if not term:
996
+ return False
997
+ if re.search(r"[\u4e00-\u9fff]", term):
998
+ return term in blob # CJK:子串匹配
999
+ if len(term) < MIN_LATIN_TERM_LEN:
1000
+ return False # 单字符拉丁词:放弃
1001
+ return re.search(rf"(?<![a-z0-9]){re.escape(term)}(?![a-z0-9])", blob) is not None
1002
+
1003
+ def _relevance(t: dict) -> float:
1004
+ """命中强度:完整查询命中 > 全部词命中 > 部分词命中。0 表示不相关。
1005
+
1006
+ 只在标题与正文里匹配,**不匹配节点名/节点简介**:节点名多为
1007
+ 「分享发现」「推广」这类通用词,参与匹配会让无关主题靠节点名
1008
+ 蹭进结果(实测查询「V2EX 社区」曾命中一条推广帖)。
1009
+ 部分词命中要求覆盖 ≥50% 且查询至少 2 个词,避免单个通用词
1010
+ (「社区」「工具」)把大量无关主题拉进来。
1011
+ 词命中计算方式见 _term_hits(拉丁词边界 + CJK 子串 + 短词门槛)。
1012
+ """
1013
+ if not q_norm:
1014
+ return 0.0
1015
+ blob = f"{(t.get('title') or '').lower()} {(t.get('content') or '').lower()}"
1016
+ if _term_hits(q_norm, blob):
1017
+ return 3.0
1018
+ if not terms:
1019
+ return 0.0
1020
+ hit = sum(1 for w in terms if _term_hits(w, blob))
1021
+ if hit == 0:
1022
+ return 0.0
1023
+ if hit == len(terms):
1024
+ return 2.0
1025
+ # 部分命中:查询词数 ≥2 且覆盖率 ≥50% 才算相关
1026
+ if len(terms) >= 2 and hit / len(terms) >= 0.5:
1027
+ return 1.0 * (hit / len(terms))
1028
+ return 0.0
1029
+
1030
+ scored = []
1031
+ for t in topics.values():
1032
+ rel = _relevance(t)
1033
+ if rel <= 0:
1034
+ continue
1035
+ scored.append((rel, t))
1036
+
1037
+ # 相关度优先,同级按回复数(社区热度)降序
1038
+ scored.sort(key=lambda x: (-x[0], -(x[1].get("replies") or 0)))
1039
+
1040
+ results = []
1041
+ for rel, t in scored[:n]:
1042
+ node = t.get("node") or {}
1043
+ member = t.get("member") or {}
1044
+ snippet = (t.get("content") or "").strip()
1045
+ if not snippet:
1046
+ snippet = re.sub(r"<[^>]+>", "", t.get("content_rendered") or "").strip()
1047
+ results.append({
1048
+ "title": (t.get("title") or "")[:120],
1049
+ "url": t.get("url") or f"https://www.v2ex.com/t/{t.get('id')}",
1050
+ "snippet": snippet[:300],
1051
+ "source": "v2ex",
1052
+ "published_at": t.get("created"),
1053
+ "social_meta": {
1054
+ "platform": "v2ex",
1055
+ "content_type": "topic",
1056
+ "node": node.get("title") or "",
1057
+ "node_name": node.get("name") or "",
1058
+ "author": member.get("username") or "",
1059
+ "replies": t.get("replies"),
1060
+ "url_verifiable": True,
1061
+ # 计算方式透明:结果来自官方 API 的候选池 + 本地相关性过滤,
1062
+ # 非站内全文搜索。冷门/长尾查询命中率天然偏低,
1063
+ # 命中 0 条是「池内无相关主题」,不等于「V2EX 上没有」。
1064
+ "retrieval_mode": "node_routed_pool" if (
1065
+ routed.get("nodes")) else "candidate_pool_filter",
1066
+ "pool_size": len(topics),
1067
+ # 节点路由可观测:命中哪些节点、置信层、节点路由本身
1068
+ # 的置信度。layer=none 表示无匹配节点,已回落 hot/latest。
1069
+ "routed_nodes": routed.get("nodes") or [],
1070
+ "route_layer": routed.get("layer"),
1071
+ "route_confidence": routed.get("confidence"),
1072
+ "from_routed_node": bool(t.get("_v2ex_node_routed")),
1073
+ },
1074
+ })
1075
+ return results
1076
+ return _engine
1077
+
1078
+
1079
+
1080
+
1081
+ # ── deps.dev 包依赖引擎 ──────────────────────────────────────────────────────
1082
+
1083
+ # 生态别名 → deps.dev system 名。包管理生态的日常叫法远多于正式名,
1084
+ # 词表同时是查询解析器(「npm express」「python requests」均合法)。
1085
+ _DEPS_SYSTEM_ALIASES = {
1086
+ "npm": "npm", "node": "npm", "nodejs": "npm", "js": "npm", "javascript": "npm",
1087
+ "pypi": "pypi", "python": "pypi", "pip": "pypi",
1088
+ "go": "go", "golang": "go",
1089
+ "maven": "maven", "java": "maven", "jvm": "maven",
1090
+ "cargo": "cargo", "rust": "cargo", "crate": "cargo",
1091
+ }
1092
+
1093
+
1094
+ def _build_deps_dev_engine(spec: dict[str, Any]) -> Any:
1095
+ """deps.dev 包依赖引擎(npm/pypi/go/maven/cargo 五生态,免认证)。
1096
+
1097
+ 查询形态:「<生态> <包名>」(npm express / python requests)或裸包名
1098
+ (默认 npm);maven 包名为 group:artifact。每条结果可核验
1099
+ (deps.dev 网页 URL 对应同一数据)。
1100
+
1101
+ 边界说明:包级漏洞聚合端点不存在,逐版本查询漏洞需 N 次请求放大
1102
+ 配额,故本引擎只答「包存在性 / 最新版本 / 弃用状态 / 发布时间」;
1103
+ 漏洞情报由 nvd 引擎按关键词补位,职责分离而非功能缺失。
1104
+ """
1105
+ timeout = spec.get("timeout", 10)
1106
+
1107
+ @safe_search
1108
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1109
+ import urllib.parse as up
1110
+ to = _timeout or timeout
1111
+ parts = (query or "").strip().split()
1112
+ if not parts:
1113
+ return []
1114
+ system = "npm"
1115
+ if parts[0].lower() in _DEPS_SYSTEM_ALIASES:
1116
+ system = _DEPS_SYSTEM_ALIASES[parts[0].lower()]
1117
+ parts = parts[1:]
1118
+ package = " ".join(parts).strip()
1119
+ if not package:
1120
+ return []
1121
+ pkg_encoded = up.quote(package, safe="")
1122
+ url = f"https://api.deps.dev/v3/systems/{system}/packages/{pkg_encoded}"
1123
+ from engines_base import _http_get_raw
1124
+ raw = _http_get_raw(url, {"Accept": "application/json"}, to,
1125
+ engine="deps_dev")
1126
+ if not raw:
1127
+ return []
1128
+ try:
1129
+ data = json.loads(raw)
1130
+ except json.JSONDecodeError:
1131
+ return []
1132
+ versions = data.get("versions") or []
1133
+ if not versions:
1134
+ return []
1135
+ versions.sort(key=lambda v: str(v.get("publishedAt") or ""), reverse=True)
1136
+ latest = next((v for v in versions if v.get("isDefault")), versions[0])
1137
+ dep_count = sum(1 for v in versions if v.get("isDeprecated"))
1138
+ web_url = f"https://deps.dev/{system}/{pkg_encoded}"
1139
+ overview = {
1140
+ "title": f"{package} ({system}) 最新 {latest.get('versionKey', {}).get('version', '?')}",
1141
+ "url": web_url,
1142
+ "snippet": (f"共 {len(versions)} 个版本"
1143
+ f"({dep_count} 个已弃用),"
1144
+ f"最新发布 {str(latest.get('publishedAt') or '')[:10]}"),
1145
+ "source": "deps_dev",
1146
+ "score": 0.95,
1147
+ "published_at": str(latest.get("publishedAt") or "")[:10],
1148
+ }
1149
+ results = [overview]
1150
+ for _rk1, v in enumerate(versions[: max(1, n - 1)]):
1151
+ vname = v.get("versionKey", {}).get("version", "")
1152
+ if vname == latest.get("versionKey", {}).get("version"):
1153
+ continue
1154
+ results.append({
1155
+ "title": f"{package}@{vname}",
1156
+ "url": web_url,
1157
+ "snippet": (f"发布 {str(v.get('publishedAt') or '')[:10]}"
1158
+ + ("(已弃用)" if v.get("isDeprecated") else "")),
1159
+ "source": "deps_dev",
1160
+ "score": rank_score(0.7, _rk1),
1161
+ "published_at": str(v.get("publishedAt") or "")[:10],
1162
+ })
1163
+ return results[:n]
1164
+ return _engine
1165
+
1166
+
1167
+ # ── endoflife.date 产品生命周期引擎 ──────────────────────────────────────────
1168
+
1169
+ _EOL_TAIL_WORDS = ("生命周期", "支持到", "eol", "support", "lifecycle",
1170
+ "什么时候", "停更")
1171
+
1172
+
1173
+ def _build_endoflife_engine(spec: dict[str, Any]) -> Any:
1174
+ """endoflife.date 产品生命周期引擎(200+ 产品,免认证)。
1175
+
1176
+ 查询词 = 产品 slug(python / nodejs / go / kubernetes / ubuntu…)。
1177
+ API 无搜索端点,slug 提取取查询里的第一个 ASCII 词并剥离「生命周期 /
1178
+ EOL / 支持」类尾词;未识别产品 404 → 诚实返回空,slug 模糊匹配交给
1179
+ 通用搜索源。每条结果附 endoflife.date 产品页,可核验。
1180
+ """
1181
+ timeout = spec.get("timeout", 10)
1182
+
1183
+ @safe_search
1184
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1185
+ to = _timeout or timeout
1186
+ slug = ""
1187
+ for tok in re.split(r"[\s,,。/?]+", (query or "")):
1188
+ t = tok.strip().lower()
1189
+ if not t or not re.fullmatch(r"[a-z][a-z0-9.\-_]{1,30}", t):
1190
+ continue
1191
+ if any(w in t for w in _EOL_TAIL_WORDS):
1192
+ continue
1193
+ slug = t
1194
+ break
1195
+ if not slug:
1196
+ return []
1197
+ from engines_base import _http_get_raw
1198
+ raw = _http_get_raw(f"https://endoflife.date/api/{slug}.json",
1199
+ {"Accept": "application/json"}, to,
1200
+ engine="endoflife")
1201
+ if not raw:
1202
+ return []
1203
+ try:
1204
+ data = json.loads(raw)
1205
+ except json.JSONDecodeError:
1206
+ return []
1207
+ if not isinstance(data, list):
1208
+ return []
1209
+ web_url = f"https://endoflife.date/{slug}"
1210
+ results = []
1211
+ for i, cyc in enumerate(data[:n]):
1212
+ label = "(最新)" if i == 0 else ""
1213
+ snippet_parts = []
1214
+ if cyc.get("latest"):
1215
+ snippet_parts.append(f"最新 {cyc['latest']}")
1216
+ if cyc.get("releaseDate"):
1217
+ snippet_parts.append(f"发布 {cyc['releaseDate']}")
1218
+ if cyc.get("support"):
1219
+ snippet_parts.append(f"全支持至 {cyc['support']}")
1220
+ if cyc.get("eol"):
1221
+ snippet_parts.append(f"EOL {cyc['eol']}")
1222
+ results.append({
1223
+ "title": f"{slug} {cyc.get('cycle', '?')}{label}",
1224
+ "url": web_url,
1225
+ "snippet": " · ".join(snippet_parts),
1226
+ "source": "endoflife",
1227
+ "score": 0.9 if i == 0 else 0.7,
1228
+ "published_at": str(cyc.get("latestReleaseDate") or "")[:10],
1229
+ })
1230
+ return results
1231
+ return _engine
1232
+
1233
+
1234
+ # ── TinEye 反向图片搜索(以图搜图,免 key)────────────────────────────────
1235
+
1236
+ def _build_tineye_engine(spec: dict[str, Any]) -> Any:
1237
+ """TinEye 反向图片搜索(公开 JSON 端点,免 key)。"""
1238
+ timeout = spec.get("timeout", 15)
1239
+
1240
+ @safe_search
1241
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1242
+ to = _timeout or timeout
1243
+ q = (query or "").strip()
1244
+ if not re.match(r"(?i)^https?://", q):
1245
+ return [{"error": "tineye: 反搜图引擎要喂图片 URL(http(s):// 开头)"
1246
+ f"作为查询,收到的是关键词「{q[:50]}」",
1247
+ "source": "tineye"}]
1248
+ u = ("https://tineye.com/api/v1/result_json/?url="
1249
+ + urllib.parse.quote(q, safe=""))
1250
+ raw = _http_get_raw(u, {"Accept": "application/json"}, to,
1251
+ engine=spec.get("_name", ""))
1252
+ if not raw:
1253
+ return []
1254
+ try:
1255
+ data = json.loads(raw)
1256
+ except (json.JSONDecodeError, ValueError):
1257
+ return []
1258
+ matches = data.get("matches") if isinstance(data, dict) else None
1259
+ if not isinstance(matches, list):
1260
+ return []
1261
+ results: list[dict[str, Any]] = []
1262
+ seen_pages: set[str] = set()
1263
+ for m in matches:
1264
+ if len(results) >= max(1, n):
1265
+ break
1266
+ if not isinstance(m, dict):
1267
+ continue
1268
+ bl = next((b for b in (m.get("backlinks") or [])
1269
+ if isinstance(b, dict) and (b.get("backlink") or b.get("url"))), None)
1270
+ if not bl:
1271
+ continue
1272
+ page_url = str(bl.get("backlink") or bl.get("url") or "")
1273
+ if not page_url or page_url in seen_pages:
1274
+ continue
1275
+ seen_pages.add(page_url)
1276
+ try:
1277
+ sim = float(m.get("score"))
1278
+ except (TypeError, ValueError):
1279
+ sim = 0.0
1280
+ domain = str(m.get("domain") or "").strip()
1281
+ try:
1282
+ w, h = int(m.get("width") or 0), int(m.get("height") or 0)
1283
+ except (TypeError, ValueError):
1284
+ w = h = 0
1285
+ crawl = str(bl.get("crawl_date") or "").strip()
1286
+ bits = [b for b in (
1287
+ f"相似度 {sim:g}%" if sim > 0 else "",
1288
+ f"来源 {domain}" if domain else "",
1289
+ f"{w}x{h}" if w and h else "",
1290
+ f"收录 {crawl[:10]}" if crawl else "",
1291
+ ) if b]
1292
+ base = min(sim / 100.0, 1.0) if sim > 0 else 0.7
1293
+ results.append({
1294
+ "title": (domain or "TinEye 图片匹配")[:120],
1295
+ "url": page_url,
1296
+ "snippet": " · ".join(bits),
1297
+ "source": "tineye",
1298
+ "score": rank_score(base, len(results)),
1299
+ "metadata": {
1300
+ "image_url": str(m.get("image_url") or ""),
1301
+ "similarity": sim,
1302
+ "domain": domain,
1303
+ "width": w,
1304
+ "height": h,
1305
+ "crawl_date": crawl,
1306
+ },
1307
+ })
1308
+ return results
1309
+ return _engine
1310
+
1311
+
1312
+ # ── Bing RSS 通用网页搜索(免 key,稳定备胎)─────────────────────────────
1313
+
1314
+ def _build_bing_rss_engine(spec: dict[str, Any]) -> Any:
1315
+ """Bing 网页搜索 RSS 出口(免 key、免 HTML 解析的稳定备胎)。"""
1316
+ timeout = spec.get("timeout", 10)
1317
+
1318
+ @safe_search
1319
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1320
+ q = (query or "").strip()
1321
+ if not q:
1322
+ return []
1323
+ to = _timeout or timeout
1324
+ u = (f"https://www.bing.com/search?q={urllib.parse.quote(q)}"
1325
+ f"&format=rss&count={min(max(int(n), 1), 20)}")
1326
+ raw = _http_get_raw(u, {"Accept": "application/rss+xml, application/xml, text/xml"},
1327
+ to, engine=spec.get("_name", ""))
1328
+ if not raw:
1329
+ return []
1330
+ text = re.sub(r"^\s*<\?xml[^>]*\?>", "", raw.strip())
1331
+ try:
1332
+ root = ET.fromstring(text)
1333
+ except ET.ParseError:
1334
+ return []
1335
+ out: list[dict[str, Any]] = []
1336
+ for i, item in enumerate(root.findall(".//item")[:max(1, int(n))]):
1337
+ title = (item.findtext("title") or "").strip()
1338
+ link = (item.findtext("link") or "").strip()
1339
+ if not title or not link:
1340
+ continue
1341
+ desc = re.sub(r"<[^>]+>", "",
1342
+ item.findtext("description") or "").strip()
1343
+ r: dict[str, Any] = {
1344
+ "title": title[:200],
1345
+ "url": link,
1346
+ "snippet": desc[:300],
1347
+ "source": "bing_rss",
1348
+ "score": rank_score(0.7, i),
1349
+ }
1350
+ pub = (item.findtext("pubDate") or "").strip()
1351
+ if pub:
1352
+ try:
1353
+ r["published_at"] = parsedate_to_datetime(
1354
+ pub).astimezone().isoformat(timespec="seconds")
1355
+ except (TypeError, ValueError):
1356
+ r["published_at"] = pub
1357
+ out.append(r)
1358
+ return out
1359
+ return _engine