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,2283 @@
1
+ #!/usr/bin/env python3
2
+ """专用构建器:中文财经 / 热榜 / 百科"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from typing import Any
15
+
16
+ from engine_env import get_env
17
+
18
+ from engines_base import safe_search, _run, _resolve, _get_path, _coerce_field, _http_get_raw, http_open, rank_score
19
+
20
+ logger = logging.getLogger("unified_search.engines")
21
+
22
+ # ── 同花顺热点引擎 ─────────────────────────────────────────────────────────────
23
+
24
+ def _build_ths_hot_engine(spec: dict[str, Any]) -> Any:
25
+ """同花顺当日强势股 + 题材归因(独家能力)
26
+
27
+ 不只告诉你"哪些走强",还告诉你"为什么走强"——同花顺编辑部人工运营的题材标签。
28
+ """
29
+ timeout = spec.get("timeout", 10)
30
+
31
+ @safe_search
32
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
33
+ to = _timeout or timeout
34
+ from datetime import date as _date
35
+ trade_date = _date.today().strftime("%Y-%m-%d")
36
+
37
+ url = (
38
+ f"http://zx.10jqka.com.cn/event/api/getharden/"
39
+ f"date/{trade_date}/orderby/date/orderway/desc/charset/GBK/"
40
+ )
41
+ headers = {
42
+ "User-Agent": (
43
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
44
+ "Chrome/117.0.0.0 Safari/537.36"
45
+ )
46
+ }
47
+ try:
48
+ req = urllib.request.Request(url, headers=headers)
49
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
50
+ data = json.loads(resp.read())
51
+ if data.get("errocode", 0) != 0:
52
+ return []
53
+ rows = data.get("data") or []
54
+ results = []
55
+ for r in rows[:n]:
56
+ results.append({
57
+ "title": f"{r.get('name', '')}({r.get('code', '')}) +{r.get('zhangfu', 0)}%",
58
+ "url": f"https://quote.eastmoney.com/{r.get('code', '')}.html",
59
+ "snippet": f"题材: {r.get('reason', '未知')} | 换手{r.get('huanshou', 0)}% | 成交额{r.get('chengjiaoe', 0)/1e8:.1f}亿",
60
+ "source": "ths_hot",
61
+ })
62
+ return results
63
+ except Exception as e:
64
+ logger.warning(f"同花顺热点引擎失败: {e}")
65
+ return []
66
+ return _engine
67
+
68
+
69
+ # ── 财联社电报引擎 ─────────────────────────────────────────────────────────────
70
+
71
+ # 低信息量触发词:快讯/热榜类引擎的查询词仅作路由触发,不参与内容过滤。
72
+ # 「快讯」「美股」这类查询若当关键词过滤会把全量榜单滤空。
73
+ _TRIGGER_WORDS = frozenset({
74
+ "快讯", "电报", "资讯", "新闻", "热点", "实时", "美股", "行情",
75
+ "财经", "股市", "港股", "A股", "盘面", "速递", "播报", "latest",
76
+ "news", "flash", "market", "stock", "finance",
77
+ # 复合触发短语:各域 pattern 里就是这么写的(jin10_flash / cls_telegraph_search),
78
+ # 不补的话「财经快讯」会被当主题词去过滤,把全量榜单滤成 0 条
79
+ "财经快讯", "市场快讯", "实时快讯", "7x24快讯", "全球快讯",
80
+ })
81
+
82
+
83
+ def _should_filter(query: str) -> bool:
84
+ """查询是否值得做内容关键词过滤(含具体主题才过滤,纯触发词放行全量榜单)。"""
85
+ q = (query or "").strip()
86
+ if not q:
87
+ return False
88
+ tokens = [t for t in re.split(r"[\s,,。、]+|美股|港股|A股", q) if t]
89
+ if not tokens:
90
+ return False
91
+ return not all(t in _TRIGGER_WORDS for t in tokens)
92
+
93
+
94
+ def _build_cls_telegraph_engine(spec: dict[str, Any]) -> Any:
95
+ """财联社电报(全市场实时快讯,v1 API + 本地签名,零 key)"""
96
+ timeout = spec.get("timeout", 10)
97
+
98
+ @safe_search
99
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
100
+ import hashlib
101
+ from datetime import datetime
102
+ to = _timeout or timeout
103
+ # 关键词过滤时放大拉取量(3×),避免小样本过滤后 0 条
104
+ fetch_n = max(int(n) * 3, 10)
105
+ params = {"appName": "CailianpressWeb", "os": "web", "sv": "7.7.5",
106
+ "last_time": "", "refresh_type": "1", "rn": str(fetch_n)}
107
+ qs = "&".join(f"{k}={params[k]}" for k in sorted(params))
108
+ sign = hashlib.md5(hashlib.sha1(qs.encode()).hexdigest().encode()).hexdigest()
109
+ url = f"https://www.cls.cn/v1/roll/get_roll_list?{qs}&sign={sign}"
110
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.cls.cn/"}
111
+ try:
112
+ req = urllib.request.Request(url, headers=headers)
113
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
114
+ d = json.loads(resp.read())
115
+ results = []
116
+ for item in d.get("data", {}).get("roll_data", []) or []:
117
+ ts = item.get("ctime")
118
+ t = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else ""
119
+ title = item.get("title", "") or item.get("brief", "")
120
+ # 关键词过滤:纯触发词(快讯/美股等)放行全量榜单
121
+ if _should_filter(query):
122
+ keywords = query.strip().split()
123
+ if not any(kw.lower() in (title + item.get("content", "")).lower() for kw in keywords):
124
+ continue
125
+ results.append({
126
+ "title": title[:80],
127
+ "url": "https://www.cls.cn/",
128
+ "snippet": f"{t} | {(item.get('content', '') or item.get('brief', ''))[:150]}",
129
+ "source": "cls_telegraph",
130
+ })
131
+ return results[:n]
132
+ except Exception as e:
133
+ logger.warning(f"财联社电报引擎失败: {e}")
134
+ return []
135
+ return _engine
136
+
137
+
138
+ # ── 东财全球资讯引擎 ─────────────────────────────────────────────────────────
139
+
140
+ def _build_em_global_news_engine(spec: dict[str, Any]) -> Any:
141
+ """东财全球财经资讯(7×24 滚动)"""
142
+ timeout = spec.get("timeout", 10)
143
+
144
+ @safe_search
145
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
146
+ import uuid
147
+ to = _timeout or timeout
148
+ url = "https://np-weblist.eastmoney.com/comm/web/getFastNewsList"
149
+ params = {
150
+ "client": "web", "biz": "web_724",
151
+ "fastColumn": "102", "sortEnd": "",
152
+ "pageSize": str(max(int(n) * 3, 10)), # 放大拉取供关键词过滤
153
+ "req_trace": str(uuid.uuid4()),
154
+ }
155
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://kuaixun.eastmoney.com/"}
156
+ try:
157
+ req = urllib.request.Request(url + "?" + "&".join(f"{k}={v}" for k, v in params.items()), headers=headers)
158
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
159
+ d = json.loads(resp.read())
160
+ results = []
161
+ for item in d.get("data", {}).get("fastNewsList", []):
162
+ title = item.get("title", "")
163
+ # 关键词过滤:纯触发词(快讯/美股等)放行全量榜单
164
+ if _should_filter(query):
165
+ keywords = query.strip().split()
166
+ if not any(kw.lower() in (title + item.get("summary", "")).lower() for kw in keywords):
167
+ continue
168
+ results.append({
169
+ "title": title[:80],
170
+ "url": "https://kuaixun.eastmoney.com/",
171
+ "snippet": f"{item.get('showTime', '')} | {(item.get('summary', '') or '')[:150]}",
172
+ "source": "em_global_news",
173
+ })
174
+ return results[:n]
175
+ except Exception as e:
176
+ logger.warning(f"东财全球资讯引擎失败: {e}")
177
+ return []
178
+ return _engine
179
+
180
+
181
+
182
+ # ── 东财妙想搜索(官方权威信源) ─────────────────────────────────────────────
183
+
184
+ def _build_em_miaoxiang_engine(spec: dict[str, Any]) -> Any:
185
+ """东财妙想搜索(mkapi2.dfcfs.com/finskillshub,需 EASTMONEY_APIKEY)。
186
+
187
+ 官方金融信源智能筛选:研报/公告/政策/解读,authorityLevel 权威分级,
188
+ 比公开 search-api-web 接口更适合金融资讯场景。
189
+ """
190
+ timeout = spec.get("timeout", 12)
191
+ url = "https://mkapi2.dfcfs.com/finskillshub/api/claw/news-search"
192
+
193
+ @safe_search
194
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
195
+ import json as _json
196
+ to = _timeout or timeout
197
+ key = get_env(["ARGO_EASTMONEY_APIKEY", "EASTMONEY_APIKEY"])
198
+ if not key:
199
+ logger.warning("妙想搜索缺 EASTMONEY_APIKEY")
200
+ return []
201
+ body = _json.dumps({"query": query, "size": min(n + 3, 15)}).encode("utf-8")
202
+ req = urllib.request.Request(
203
+ url, data=body, method="POST",
204
+ headers={
205
+ "apikey": key,
206
+ "Content-Type": "application/json",
207
+ "User-Agent": "argo-search/2.4 (unified-search@local)",
208
+ },
209
+ )
210
+ try:
211
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
212
+ data = _json.loads(resp.read().decode("utf-8", "replace"))
213
+ except Exception as e:
214
+ logger.warning(f"妙想搜索失败: {e}")
215
+ return []
216
+ items = []
217
+ try:
218
+ items = data["data"]["data"]["llmSearchResponse"]["data"] or []
219
+ except (KeyError, TypeError):
220
+ logger.warning("妙想搜索返回结构异常")
221
+ return []
222
+ results = []
223
+ seen: set[str] = set()
224
+ for _rk, it in enumerate(items):
225
+ title = (it.get("title") or "").strip()
226
+ if not title or title in seen:
227
+ continue
228
+ seen.add(title)
229
+ content = (it.get("content") or "").strip().replace("\n", " ")
230
+ date = it.get("date") or ""
231
+ authority = it.get("authorityLevel") or ""
232
+ info_type = it.get("informationType") or ""
233
+ parts = [p for p in (date, authority, info_type, content[:200]) if p]
234
+ results.append({
235
+ "title": title[:120],
236
+ "url": it.get("jumpUrl") or "https://eastmoney.com",
237
+ "snippet": " | ".join(parts)[:280],
238
+ "source": "em_miaoxiang",
239
+ "score": rank_score(0.85, _rk),
240
+ })
241
+ if len(results) >= n:
242
+ break
243
+ return results
244
+ return _engine
245
+
246
+
247
+ # ── 巨潮资讯公告引擎(官方公告) ──────────────────────────────────────────────
248
+
249
+ def _build_cninfo_engine(spec: dict[str, Any]) -> Any:
250
+ """巨潮资讯网官方公告检索(www.cninfo.com.cn/new/hisAnnouncement/query)。
251
+
252
+ A 股上市公司公告第一官方源,覆盖沪深京三所,免认证。
253
+ 查询词命中公司名/公告标题关键词,返回标题/日期/PDF 原文链接。
254
+ """
255
+ timeout = spec.get("timeout", 12)
256
+ url = "https://www.cninfo.com.cn/new/hisAnnouncement/query"
257
+
258
+ @safe_search
259
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
260
+ import urllib.parse as up
261
+ to = _timeout or timeout
262
+
263
+ def _search_once(keyword: str) -> list[dict[str, Any]]:
264
+ body = up.urlencode({
265
+ "pageNum": "1",
266
+ "pageSize": str(min(n + 2, 15)),
267
+ "column": "szse",
268
+ "tabName": "fulltext",
269
+ "plate": "", "stock": "", "searchkey": keyword,
270
+ "secid": "", "category": "", "trade": "", "seDate": "",
271
+ "sortName": "", "sortType": "", "isHLtitle": "true",
272
+ }).encode("utf-8")
273
+ req = urllib.request.Request(
274
+ url, data=body, method="POST",
275
+ headers={
276
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
277
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
278
+ "X-Requested-With": "XMLHttpRequest",
279
+ "Referer": "https://www.cninfo.com.cn/new/commonUrl/pageOfSearch",
280
+ },
281
+ )
282
+ try:
283
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
284
+ data = json.loads(resp.read().decode("utf-8", "replace"))
285
+ except Exception as e:
286
+ logger.warning(f"巨潮公告搜索失败: {e}")
287
+ return []
288
+ items = data.get("announcements") or []
289
+ if not items:
290
+ return []
291
+ results = []
292
+ seen: set[str] = set()
293
+ for _rk5, it in enumerate(items):
294
+ raw_title = (it.get("announcementTitle") or "").strip()
295
+ if not raw_title or raw_title in seen:
296
+ continue
297
+ seen.add(raw_title)
298
+ title = re.sub(r"<[^>]+>", "", raw_title)[:100]
299
+ sec = re.sub(r"<[^>]+>", "", it.get("secName") or "").strip()
300
+ ts = it.get("announcementTime")
301
+ date = time.strftime("%Y-%m-%d", time.localtime(ts / 1000)) if ts else ""
302
+ adjunct = it.get("adjunctUrl") or ""
303
+ pdf_url = "https://static.cninfo.com.cn/" + adjunct if adjunct else "https://www.cninfo.com.cn/"
304
+ results.append({
305
+ "title": f"{sec} {title}" if sec and sec not in title else title,
306
+ "url": pdf_url,
307
+ "snippet": " | ".join(p for p in (date, "公告原文 PDF", "巨潮资讯网官方") if p)[:280],
308
+ "source": "cninfo",
309
+ "score": rank_score(0.9, _rk5),
310
+ })
311
+ if len(results) >= n:
312
+ break
313
+ return results
314
+
315
+ # 候选词干:super_search 的查询改写会把原句拼上「贵州茅台 600519 白酒 股票行情」
316
+ # 等扩展词,整句全文搜索命中率反而低。先试整句,0 结果时按分词去 STOP 词重试。
317
+ _STOP = ("公告", "披露", "查询", "什么", "怎么样", "多少", "怎么", "了", "吗",
318
+ "今日", "最新", "股票", "股价", "行情", "走势", "报价", "分红方案",
319
+ "利润分配", "每股", "多少钱")
320
+ cands = [query]
321
+ for token in re.split(r"[\s,,、/]+", query):
322
+ t = token.strip()
323
+ if not t:
324
+ continue
325
+ for stop in _STOP:
326
+ t = t.replace(stop, "")
327
+ t = t.strip()
328
+ if 2 <= len(t) <= 20 and t not in cands:
329
+ cands.append(t)
330
+ for c in cands:
331
+ res = _search_once(c)
332
+ if res:
333
+ return res
334
+ return []
335
+ return _engine
336
+
337
+
338
+ # ── 新浪行情引擎(实时行情快照) ─────────────────────────────────────────────
339
+
340
+ def _build_sina_quote_engine(spec: dict[str, Any]) -> Any:
341
+ """新浪实时行情快照(hq.sinajs.cn + suggest3 代码解析)。
342
+
343
+ 免认证直连:suggest3.sinajs.cn 把中文名/拼音/代码解析为证券代码,
344
+ 再拉 hq.sinajs.cn 实时快照(现价/涨跌/开高低/成交量),适合"茅台股价"类查询。
345
+ """
346
+ timeout = spec.get("timeout", 8)
347
+ suggest_url = "https://suggest3.sinajs.cn/suggest/type=11,12,15,21,31,41&key="
348
+ quote_url = "https://hq.sinajs.cn/list="
349
+
350
+ _MARKET_PREFIX = {
351
+ "sh": "沪", "sz": "深", "bj": "北",
352
+ "hk": "港", "us": "美", "hf": "期货",
353
+ "gb_": "外盘", "rt_hk": "港", "znb_": "银行",
354
+ }
355
+
356
+ @safe_search
357
+ def _engine(query: str, n: int = 3, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
358
+ import urllib.parse as up
359
+ to = _timeout or timeout
360
+ headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
361
+ "Referer": "https://finance.sina.com.cn/"}
362
+ code = _resolve_code(query, to, headers)
363
+ if not code:
364
+ return []
365
+ symbol = code.split(",")[0] if "," in code else code
366
+ try:
367
+ req = urllib.request.Request(quote_url + symbol, headers=headers)
368
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
369
+ text = resp.read().decode("gbk", "replace").strip()
370
+ except Exception as e:
371
+ logger.warning(f"新浪行情失败: {e}")
372
+ return []
373
+ if not text or "=" not in text:
374
+ return []
375
+ var_part = text.split("=", 1)[1].strip().strip('"')
376
+ fields = var_part.split(",")
377
+ if not fields or len(fields) < 4:
378
+ return []
379
+ name = fields[0]
380
+ # A 股字段:0名称 1今开 2昨收 3现价 4最高 5最低 6买一 7卖一 8成交量 9成交额
381
+ if len(fields) >= 10:
382
+ cur, prev = fields[3], fields[2]
383
+ try:
384
+ chg = float(cur) - float(prev) if cur and prev else 0.0
385
+ pct = chg / float(prev) * 100 if prev and float(prev) else 0.0
386
+ except ValueError:
387
+ chg, pct = 0.0, 0.0
388
+ arrow = "↑" if pct > 0 else ("↓" if pct < 0 else "→")
389
+ title = f"{name} {cur} {arrow}{pct:+.2f}%" if cur else f"{name} 行情"
390
+ snip_parts = [
391
+ f"现价 {cur}", f"涨跌 {chg:+.2f} ({pct:+.2f}%)" if chg else "",
392
+ f"今开 {fields[1]}", f"昨收 {prev}",
393
+ f"最高 {fields[4]}", f"最低 {fields[5]}",
394
+ f"成交量 {_fmt_vol(fields[8])}" if fields[8] else "",
395
+ ]
396
+ else:
397
+ title = f"{name} 行情"
398
+ snip_parts = [f"数据 {var_part[:60]}"]
399
+ market = symbol[:2]
400
+ prefix = _MARKET_PREFIX.get(market, "")
401
+ quote_page = "https://finance.sina.com.cn/realstock/company/" + symbol + "/nc.shtml"
402
+ return [{
403
+ "title": title[:80],
404
+ "url": quote_page,
405
+ "snippet": " | ".join(p for p in snip_parts if p)[:200],
406
+ "source": "sina_quote",
407
+ "score": 0.9,
408
+ }]
409
+
410
+ def _resolve_code(q: str, to: float, headers: dict) -> str:
411
+ import urllib.parse as up
412
+ # 候选词干:先整句,再逐词试;去掉常见行情后缀词
413
+ _STOP = ("股价", "行情", "股票", "价格", "走势", "最新", "今日", "报价", "查询",
414
+ "怎么样", "多少", "怎么", "了", "吗", "的", "a股", "港股", "美股")
415
+ cands = [q]
416
+ for token in re.split(r"[\s,,、/]+", q):
417
+ t = token.strip()
418
+ if not t:
419
+ continue
420
+ for stop in _STOP:
421
+ t = t.replace(stop, "")
422
+ t = t.strip()
423
+ if 2 <= len(t) <= 8 and t not in cands:
424
+ cands.append(t)
425
+ for c in cands:
426
+ try:
427
+ req = urllib.request.Request(suggest_url + up.quote(c), headers=headers)
428
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
429
+ text = resp.read().decode("gbk", "replace")
430
+ except Exception as e:
431
+ logger.warning(f"新浪代码解析失败: {e}")
432
+ continue
433
+ if "suggestvalue=" not in text:
434
+ continue
435
+ val = text.split("suggestvalue=", 1)[1].strip().strip('"')
436
+ # 格式: 名称,类型,代码,符号,拼音,... 取第一个符号
437
+ parts = val.split(",")
438
+ if len(parts) >= 4 and parts[3]:
439
+ return parts[3]
440
+ return ""
441
+
442
+ def _fmt_vol(v: str) -> str:
443
+ try:
444
+ f = float(v)
445
+ except ValueError:
446
+ return v
447
+ if f >= 1e8:
448
+ return f"{f / 1e8:.2f}亿手"
449
+ if f >= 1e4:
450
+ return f"{f / 1e4:.2f}万手"
451
+ return v
452
+ return _engine
453
+
454
+
455
+ # ── 腾讯行情引擎(实时行情快照,含换手率/市盈率/五档) ───────────────────────
456
+
457
+ def _build_tencent_quote_engine(spec: dict[str, Any]) -> Any:
458
+ """腾讯实时行情快照(qt.gtimg.cn + smartbox 代码解析)。
459
+
460
+ 免费直连 GBK 接口,比新浪多换手率/市盈率/市净率/总市值等字段,
461
+ 与 sina_quote 互为交叉验证,适合「茅台股价」「上证指数」类查询。
462
+ """
463
+ timeout = spec.get("timeout", 8)
464
+ suggest_url = "https://smartbox.gtimg.cn/s3/?v=2&t=all&q="
465
+ quote_url = "https://qt.gtimg.cn/q="
466
+
467
+ @safe_search
468
+ def _engine(query: str, n: int = 3, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
469
+ import urllib.parse as up
470
+ to = _timeout or timeout
471
+ headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
472
+ "Referer": "https://gu.qq.com/"}
473
+ symbol = _resolve_symbol(query, to, headers)
474
+ if not symbol:
475
+ return []
476
+ try:
477
+ req = urllib.request.Request(quote_url + symbol, headers=headers)
478
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
479
+ text = resp.read().decode("gbk", "replace").strip()
480
+ except Exception as e:
481
+ logger.warning(f"腾讯行情失败: {e}")
482
+ return []
483
+ if "=" not in text:
484
+ return []
485
+ var_part = text.split("=", 1)[1].strip().strip('"')
486
+ fields = var_part.split("~")
487
+ if len(fields) < 35:
488
+ return []
489
+ name, code = fields[1], fields[2]
490
+ cur, prev, opn = fields[3], fields[4], fields[5]
491
+ try:
492
+ chg = float(fields[31]) if fields[31] else 0.0
493
+ pct = float(fields[32]) if fields[32] else 0.0
494
+ except ValueError:
495
+ chg, pct = 0.0, 0.0
496
+ arrow = "↑" if pct > 0 else ("↓" if pct < 0 else "→")
497
+ title = f"{name} {cur} {arrow}{pct:+.2f}%" if cur else f"{name} 行情"
498
+ parts = [
499
+ f"现价 {cur}", f"涨跌 {chg:+.2f} ({pct:+.2f}%)",
500
+ f"今开 {opn}", f"昨收 {prev}",
501
+ f"最高 {fields[33]}", f"最低 {fields[34]}",
502
+ f"成交量 {fields[36]}手" if len(fields) > 36 and fields[36] else "",
503
+ f"成交额 {float(fields[37]) / 1e4:.2f}亿" if len(fields) > 37 and fields[37] else "",
504
+ f"换手率 {fields[38]}%" if len(fields) > 38 and fields[38] else "",
505
+ f"市盈率(动) {fields[39]}" if len(fields) > 39 and fields[39] else "",
506
+ ]
507
+ return [{
508
+ "title": title[:80],
509
+ "url": f"https://gu.qq.com/{symbol}/gp",
510
+ "snippet": " | ".join(p for p in parts if p)[:220],
511
+ "source": "tencent_quote",
512
+ "score": 0.9,
513
+ }]
514
+
515
+ def _resolve_symbol(q: str, to: float, headers: dict) -> str:
516
+ import urllib.parse as up
517
+ _STOP = ("股价", "行情", "股票", "价格", "走势", "最新", "今日", "报价", "查询",
518
+ "怎么样", "多少", "怎么", "了", "吗", "的", "a股", "港股", "美股")
519
+ cands = [q]
520
+ for token in re.split(r"[\s,,、/]+", q):
521
+ t = token.strip()
522
+ if not t:
523
+ continue
524
+ for stop in _STOP:
525
+ t = t.replace(stop, "")
526
+ t = t.strip()
527
+ if 2 <= len(t) <= 8 and t not in cands:
528
+ cands.append(t)
529
+ for c in cands:
530
+ try:
531
+ req = urllib.request.Request(suggest_url + up.quote(c), headers=headers)
532
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
533
+ text = resp.read().decode("gbk", "replace")
534
+ except Exception as e:
535
+ logger.warning(f"腾讯代码解析失败: {e}")
536
+ continue
537
+ # v_hint="sh~600519~贵州茅台~600519~gp~A股~贵州茅台~GP-A"
538
+ for m in re.finditer(r'v_hint="([^"]+)"', text):
539
+ parts = m.group(1).split("~")
540
+ if len(parts) >= 3 and parts[2]:
541
+ return parts[0] + parts[1]
542
+ return ""
543
+ return _engine
544
+
545
+
546
+ # ── 东财资金流引擎(个股主力资金流/北向资金/板块资金流) ─────────────────────
547
+
548
+ def _build_em_flow_engine(spec: dict[str, Any]) -> Any:
549
+ """东方财富资金流向(push2delay.eastmoney.com,免认证直连)。
550
+
551
+ 三类数据:个股主力资金流(fflow/kline)、北向资金(kamt/get)、
552
+ 板块资金流排行(clist/get)。「资金流/主力/北向」类查询的答案源。
553
+ """
554
+ timeout = spec.get("timeout", 8)
555
+ smartbox_url = "https://smartbox.gtimg.cn/s3/?v=2&t=all&q="
556
+ fflow_url = "https://push2delay.eastmoney.com/api/qt/stock/fflow/kline/get"
557
+ kamt_url = "https://push2delay.eastmoney.com/api/qt/kamt/get"
558
+ clist_url = "https://push2delay.eastmoney.com/api/qt/clist/get"
559
+
560
+ @safe_search
561
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
562
+ import urllib.parse as up
563
+ to = _timeout or timeout
564
+ headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
565
+ "Referer": "https://data.eastmoney.com/"}
566
+ q = query.lower()
567
+ if "北向" in q or "沪深港通" in q or ("外资" in q and "流入" in q):
568
+ res = _northbound(to, headers)
569
+ if res:
570
+ return res
571
+ if "板块" in q and ("资金" in q or "流入" in q or "净额" in q):
572
+ res = _sector_flow(to, headers, n)
573
+ if res:
574
+ return res
575
+ symbol = _resolve_symbol(query, to, headers)
576
+ if symbol:
577
+ res = _stock_flow(symbol, to, headers)
578
+ if res:
579
+ return res
580
+ return _sector_flow(to, headers, n)
581
+
582
+ def _northbound(to: float, headers: dict) -> list[dict[str, Any]]:
583
+ data = None
584
+ for _a in range(2):
585
+ try:
586
+ req = urllib.request.Request(
587
+ kamt_url + "?fields1=f1,f3&fields2=f51,f52,f53,f54,f55,f56",
588
+ headers=headers)
589
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
590
+ data = json.loads(resp.read().decode("utf-8", "replace"))
591
+ break
592
+ except Exception as e:
593
+ logger.warning(f"北向资金失败(重试): {e}")
594
+ time.sleep(0.3)
595
+ if data is None:
596
+ return []
597
+ try:
598
+ hk2sh = data["data"]["hk2sh"]
599
+ hk2sz = data["data"]["hk2sz"]
600
+ except (KeyError, TypeError):
601
+ return []
602
+ rows = []
603
+ for name, leg in (("沪股通", hk2sh), ("深股通", hk2sz)):
604
+ amt = leg.get("dayNetAmtIn")
605
+ date = leg.get("date2") or leg.get("date") or ""
606
+ rows.append((name, amt, date))
607
+ results = []
608
+ total = 0.0
609
+ for _rk1, name, amt, date in enumerate(rows):
610
+ try:
611
+ f = float(amt) / 1e8
612
+ except (TypeError, ValueError):
613
+ continue
614
+ total += f
615
+ results.append({
616
+ "title": f"北向资金-{name} 当日净流入 {f:+.2f} 亿元",
617
+ "url": "https://data.eastmoney.com/hsgt/index.html",
618
+ "snippet": f"日期 {date} | 沪深港通北向资金 | 东方财富数据中心".strip(),
619
+ "source": "em_flow",
620
+ "score": rank_score(0.9, _rk1),
621
+ })
622
+ if total:
623
+ results.append({
624
+ "title": f"北向资金合计 当日净流入 {total:+.2f} 亿元",
625
+ "url": "https://data.eastmoney.com/hsgt/index.html",
626
+ "snippet": "沪股通 + 深股通合计 | 东方财富数据中心",
627
+ "source": "em_flow",
628
+ "score": 0.85,
629
+ })
630
+ return results
631
+
632
+ def _sector_flow(to: float, headers: dict, n: int) -> list[dict[str, Any]]:
633
+ url = (clist_url + "?pn=1&pz=%d&po=1&np=1&fltt=2&invt=2&fid=f62"
634
+ "&fs=m:90+t:2&fields=f12,f14,f2,f3,f62,f184" % min(max(n, 3), 10))
635
+ data = None
636
+ for _a in range(2):
637
+ try:
638
+ req = urllib.request.Request(url, headers=headers)
639
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
640
+ data = json.loads(resp.read().decode("utf-8", "replace"))
641
+ break
642
+ except Exception as e:
643
+ logger.warning(f"板块资金流失败(重试): {e}")
644
+ time.sleep(0.3)
645
+ if data is None:
646
+ return []
647
+ try:
648
+ diff = data["data"]["diff"] or []
649
+ except (KeyError, TypeError):
650
+ return []
651
+ results = []
652
+ for _rk2, it in enumerate(diff):
653
+ name = it.get("f14", "")
654
+ chg = it.get("f3")
655
+ flow = it.get("f62")
656
+ pct = it.get("f184")
657
+ if not name:
658
+ continue
659
+ try:
660
+ flow_yi = float(flow) / 1e8
661
+ except (TypeError, ValueError):
662
+ flow_yi = 0.0
663
+ try:
664
+ chg_s = f"{float(chg):+.2f}%" if chg is not None else ""
665
+ except (TypeError, ValueError):
666
+ chg_s = ""
667
+ try:
668
+ pct_s = f"主力净占比 {float(pct):.2f}%"
669
+ except (TypeError, ValueError):
670
+ pct_s = ""
671
+ results.append({
672
+ "title": f"板块 {name} 主力净流入 {flow_yi:+.2f} 亿元",
673
+ "url": "https://data.eastmoney.com/bkzj/hy.html",
674
+ "snippet": " | ".join(x for x in (chg_s, pct_s, "东方财富板块资金流") if x)[:200],
675
+ "source": "em_flow",
676
+ "score": rank_score(0.9, _rk2),
677
+ })
678
+ return results
679
+
680
+ def _stock_flow(symbol: str, to: float, headers: dict) -> list[dict[str, Any]]:
681
+ # symbol 形如 sh600519 / sz000858,secid 沪市=1.xxx 深市=0.xxx
682
+ market = "1" if symbol.startswith("sh") else "0"
683
+ code = symbol[2:]
684
+ url = (fflow_url + "?lmt=0&klt=101&fields1=f1,f2,f3,f7"
685
+ "&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63"
686
+ "&secid=%s.%s" % (market, code))
687
+ data = None
688
+ for _a in range(2):
689
+ try:
690
+ req = urllib.request.Request(url, headers=headers)
691
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
692
+ data = json.loads(resp.read().decode("utf-8", "replace"))
693
+ break
694
+ except Exception as e:
695
+ logger.warning(f"个股资金流失败(重试): {e}")
696
+ time.sleep(0.3)
697
+ if data is None:
698
+ return []
699
+ try:
700
+ klines = data["data"]["klines"] or []
701
+ name = data["data"]["name"] or code
702
+ except (KeyError, TypeError):
703
+ return []
704
+ if not klines:
705
+ return []
706
+ last = klines[-1].split(",")
707
+ if len(last) < 6:
708
+ return []
709
+ # push2delay 返回 6 字段: 0日期 1主力 2小单 3中单 4大单 5超大单
710
+ # push2 完整 13 字段: 6-10 净占比 11收盘 12涨跌幅(延迟源仅 6 字段)
711
+ date, main, small, mid, big, xbig = last[0], last[1], last[2], last[3], last[4], last[5]
712
+ try:
713
+ main_yi = float(main) / 1e8
714
+ except (TypeError, ValueError):
715
+ main_yi = 0.0
716
+ main_ratio, chg = 0.0, 0.0
717
+ if len(last) >= 13:
718
+ try:
719
+ main_ratio = float(last[6]) if last[6] else 0.0
720
+ chg = float(last[12]) if last[12] else 0.0
721
+ except (TypeError, ValueError):
722
+ pass
723
+ try:
724
+ detail = (f"超大单 {float(xbig) / 1e8:+.2f}亿 大单 {float(big) / 1e8:+.2f}亿"
725
+ f" 中单 {float(mid) / 1e8:+.2f}亿 小单 {float(small) / 1e8:+.2f}亿")
726
+ except (TypeError, ValueError):
727
+ detail = ""
728
+ extra = " | ".join(x for x in (
729
+ f"主力净占比 {main_ratio:+.2f}%" if main_ratio else "",
730
+ f"涨跌幅 {chg:+.2f}%" if chg else "",
731
+ ) if x)
732
+ snippet = " | ".join(x for x in (f"日期 {date}", extra, detail, "东方财富资金流向") if x)
733
+ return [{
734
+ "title": f"{name} 主力资金净流入 {main_yi:+.2f} 亿元",
735
+ "url": f"https://data.eastmoney.com/zjlx/{code}.html",
736
+ "snippet": snippet[:220],
737
+ "source": "em_flow",
738
+ "score": 0.9,
739
+ }]
740
+
741
+ def _resolve_symbol(q: str, to: float, headers: dict) -> str:
742
+ import urllib.parse as up
743
+ _STOP = ("股价", "行情", "股票", "价格", "走势", "最新", "今日", "资金流", "资金",
744
+ "主力", "净流入", "净流出", "查询", "怎么样", "多少", "怎么", "了", "吗",
745
+ "的", "a股", "港股", "美股")
746
+ cands = [q]
747
+ for token in re.split(r"[\s,,、/]+", q):
748
+ t = token.strip()
749
+ if not t:
750
+ continue
751
+ for stop in _STOP:
752
+ t = t.replace(stop, "")
753
+ t = t.strip()
754
+ if 2 <= len(t) <= 8 and t not in cands:
755
+ cands.append(t)
756
+ for c in cands:
757
+ try:
758
+ req = urllib.request.Request(smartbox_url + up.quote(c), headers=headers)
759
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
760
+ text = resp.read().decode("gbk", "replace")
761
+ except Exception as e:
762
+ logger.warning(f"东财代码解析失败: {e}")
763
+ continue
764
+ for m in re.finditer(r'v_hint="([^"]+)"', text):
765
+ parts = m.group(1).split("~")
766
+ if len(parts) >= 3 and parts[2]:
767
+ return parts[0] + parts[1]
768
+ return ""
769
+ return _engine
770
+
771
+
772
+ # ── 东财财经搜索引擎 ─────────────────────────────────────────────────────────
773
+
774
+ def _build_eastmoney_engine(spec: dict[str, Any]) -> Any:
775
+ """东财经搜搜索(纯 HTTP API,零外部依赖)
776
+
777
+ 160→ 支持:
778
+ - 个股新闻搜索(按股票代码或关键词)
779
+ - 东财全球资讯(7×24 财经快讯)
780
+ """
781
+ timeout = spec.get("timeout", 12)
782
+
783
+ @safe_search
784
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
785
+ import urllib.parse as up
786
+ import re
787
+ to = _timeout or timeout
788
+
789
+ # 判断是股票代码(6位数字)还是关键词
790
+ is_stock_code = re.match(r'^\d{6}$', query.strip())
791
+
792
+ if is_stock_code:
793
+ # 按股票代码搜新闻
794
+ return _eastmoney_stock_news(query.strip(), n, to)
795
+ else:
796
+ # 按关键词搜全球资讯
797
+ return _eastmoney_keyword_news(query, n, to)
798
+
799
+ def _eastmoney_stock_news(code: str, n: int, to: float) -> list[dict[str, Any]]:
800
+ """按股票代码搜新闻"""
801
+ import json as _json
802
+ import urllib.parse as up
803
+ cb = "jQuery_news"
804
+ url = "https://search-api-web.eastmoney.com/search/jsonp"
805
+ inner_params = _json.dumps({
806
+ "uid": "", "keyword": code, "type": ["cmsArticleWebOld"],
807
+ "client": "web", "clientType": "web", "clientVersion": "curr",
808
+ "param": {"cmsArticleWebOld": {"searchScope": "default", "sort": "default",
809
+ "pageIndex": 1, "pageSize": n, "preTag": "", "postTag": ""}},
810
+ }, separators=(',', ':'))
811
+ params = {"cb": cb, "param": inner_params}
812
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://so.eastmoney.com/"}
813
+ try:
814
+ full_url = url + "?" + "&".join(f"{k}={up.quote(str(v))}" for k, v in params.items())
815
+ req = urllib.request.Request(full_url, headers=headers)
816
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
817
+ text = resp.read().decode("utf-8")
818
+ json_str = text[text.index("(") + 1:text.rindex(")")]
819
+ d = _json.loads(json_str)
820
+ articles = d.get("result", {}).get("cmsArticleWebOld", []) or []
821
+ results = []
822
+ for a in articles[:n]:
823
+ results.append({
824
+ "title": re.sub(r'<[^>]+>', '', a.get("title", ""))[:80],
825
+ "url": a.get("url", ""),
826
+ "snippet": re.sub(r'<[^>]+>', '', a.get("content", ""))[:200],
827
+ "source": "eastmoney",
828
+ })
829
+ return results
830
+ except Exception as e:
831
+ logger.warning(f"东财个股新闻搜索失败: {e}")
832
+ return []
833
+
834
+ def _eastmoney_keyword_news(query: str, n: int, to: float) -> list[dict[str, Any]]:
835
+ """按关键词搜东财资讯(search-api-web 检索接口,按词真正检索)"""
836
+ import urllib.parse as up
837
+ cb = "jQuery_news"
838
+ url = "https://search-api-web.eastmoney.com/search/jsonp"
839
+ inner_params = json.dumps({
840
+ "uid": "", "keyword": query, "type": ["cmsArticleWebOld"],
841
+ "client": "web", "clientType": "web", "clientVersion": "curr",
842
+ "param": {"cmsArticleWebOld": {"searchScope": "default", "sort": "default",
843
+ "pageIndex": 1, "pageSize": n, "preTag": "", "postTag": ""}},
844
+ }, separators=(',', ':'))
845
+ params = {"cb": cb, "param": inner_params}
846
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://so.eastmoney.com/"}
847
+ try:
848
+ full_url = url + "?" + "&".join(f"{k}={up.quote(str(v))}" for k, v in params.items())
849
+ req = urllib.request.Request(full_url, headers=headers)
850
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
851
+ text = resp.read().decode("utf-8")
852
+ json_str = text[text.index("(") + 1:text.rindex(")")]
853
+ d = json.loads(json_str)
854
+ articles = d.get("result", {}).get("cmsArticleWebOld", []) or []
855
+ results = []
856
+ for a in articles[:n]:
857
+ results.append({
858
+ "title": re.sub(r'<[^>]+>', '', a.get("title", ""))[:80],
859
+ "url": a.get("url", "") or "https://so.eastmoney.com/",
860
+ "snippet": re.sub(r'<[^>]+>', '', a.get("content", ""))[:200],
861
+ "source": "eastmoney",
862
+ })
863
+ return results
864
+ except Exception as e:
865
+ logger.warning(f"东财关键词搜索失败: {e}")
866
+ return []
867
+
868
+ return _engine
869
+
870
+
871
+ # ── itotii 梗百科引擎 ────────────────────────────────────────────────────────
872
+
873
+ def _build_itotii_engine(spec: dict[str, Any]) -> Any:
874
+ """itotii 梗百科(中文流行语/网络梗词条,WordPress REST API,免认证)"""
875
+ timeout = spec.get("timeout", 10)
876
+
877
+ @safe_search
878
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
879
+ import urllib.parse as up
880
+ to = _timeout or timeout
881
+ url = (
882
+ "https://geng.itotii.com/wp-json/wp/v2/posts?search="
883
+ + up.quote(query) + f"&per_page={min(n, 10)}"
884
+ )
885
+ headers = {"User-Agent": "Mozilla/5.0"}
886
+ req = urllib.request.Request(url, headers=headers)
887
+ try:
888
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
889
+ data = json.loads(resp.read().decode("utf-8", "replace"))
890
+ results = []
891
+ for p in data:
892
+ title = p.get("title", {})
893
+ title = title.get("rendered", "") if isinstance(title, dict) else str(title)
894
+ content = p.get("content", {})
895
+ content = content.get("rendered", "") if isinstance(content, dict) else str(content)
896
+ title = re.sub(r"<[^>]+>", "", title).strip()
897
+ content = re.sub(r"<[^>]+>", "", content).strip()
898
+ if not title:
899
+ continue
900
+ results.append({
901
+ "title": title[:80],
902
+ "url": p.get("link", ""),
903
+ "snippet": f"{p.get('date', '')[:10]} | {content[:200]}",
904
+ "source": "itotii",
905
+ "score": max(1.0 - len(results) * 0.1, 0.1),
906
+ })
907
+ return results[:n]
908
+ except Exception as e:
909
+ logger.warning(f"itotii 梗百科失败: {e}")
910
+ return []
911
+ return _engine
912
+
913
+
914
+ # ── 百度热搜引擎 ─────────────────────────────────────────────────────────────
915
+
916
+ def _build_baidu_hot_engine(spec: dict[str, Any]) -> Any:
917
+ """百度热搜(top.baidu.com 实时热搜榜,HTML 解析)"""
918
+ timeout = spec.get("timeout", 10)
919
+
920
+ @safe_search
921
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
922
+ import urllib.parse as up
923
+ to = _timeout or timeout
924
+ url = "https://top.baidu.com/board?tab=realtime"
925
+ headers = {"User-Agent": "Mozilla/5.0"}
926
+ req = urllib.request.Request(url, headers=headers)
927
+ try:
928
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
929
+ page = resp.read().decode("utf-8", "replace")
930
+ words = re.findall(r'word":"([^"]+)"', page)
931
+ results, seen = [], set()
932
+ for w in words:
933
+ if w in seen:
934
+ continue
935
+ seen.add(w)
936
+ results.append({
937
+ "title": w[:60],
938
+ "url": "https://www.baidu.com/s?wd=" + up.quote(w),
939
+ "snippet": "百度热搜",
940
+ "source": "baidu_hot",
941
+ "score": max(1.0 - len(results) * 0.05, 0.1),
942
+ })
943
+ if len(results) >= n:
944
+ break
945
+ return results
946
+ except Exception as e:
947
+ logger.warning(f"百度热搜失败: {e}")
948
+ return []
949
+ return _engine
950
+
951
+
952
+ # ── 今日头条热榜引擎 ─────────────────────────────────────────────────────────
953
+
954
+ def _build_toutiao_hot_engine(spec: dict[str, Any]) -> Any:
955
+ """今日头条热榜(hot-board JSON,免认证)"""
956
+ timeout = spec.get("timeout", 10)
957
+
958
+ @safe_search
959
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
960
+ to = _timeout or timeout
961
+ url = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc"
962
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.toutiao.com/"}
963
+ req = urllib.request.Request(url, headers=headers)
964
+ try:
965
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
966
+ data = json.loads(resp.read().decode("utf-8", "replace"))
967
+ results = []
968
+ for i, item in enumerate(data.get("data", [])[:n]):
969
+ hot = item.get("HotValue", "")
970
+ results.append({
971
+ "title": item.get("Title", "")[:60],
972
+ "url": item.get("Url", ""),
973
+ "snippet": f"热度 {hot}" if hot else "今日头条热榜",
974
+ "source": "toutiao_hot",
975
+ "score": max(1.0 - i * 0.05, 0.1),
976
+ })
977
+ return results
978
+ except Exception as e:
979
+ logger.warning(f"今日头条热榜失败: {e}")
980
+ return []
981
+ return _engine
982
+
983
+
984
+ # ── B站热搜引擎 ──────────────────────────────────────────────────────────────
985
+
986
+ def _build_bilibili_hot_engine(spec: dict[str, Any]) -> Any:
987
+ """B站热搜(search/square 热搜词,免认证)"""
988
+ timeout = spec.get("timeout", 10)
989
+
990
+ @safe_search
991
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
992
+ import urllib.parse as up
993
+ to = _timeout or timeout
994
+ url = "https://api.bilibili.com/x/web-interface/search/square?limit=20"
995
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.bilibili.com"}
996
+ req = urllib.request.Request(url, headers=headers)
997
+ try:
998
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
999
+ data = json.loads(resp.read().decode("utf-8", "replace"))
1000
+ items = data.get("data", {}).get("trending", {}).get("list", [])
1001
+ results = []
1002
+ for i, item in enumerate(items[:n]):
1003
+ kw = item.get("keyword", "")
1004
+ if not kw:
1005
+ continue
1006
+ results.append({
1007
+ "title": kw[:60],
1008
+ "url": "https://search.bilibili.com/all?keyword=" + up.quote(kw),
1009
+ "snippet": "B站热搜",
1010
+ "source": "bilibili_hot",
1011
+ "score": max(1.0 - i * 0.05, 0.1),
1012
+ })
1013
+ return results
1014
+ except Exception as e:
1015
+ logger.warning(f"B站热搜失败: {e}")
1016
+ return []
1017
+ return _engine
1018
+
1019
+
1020
+ # ── 知乎全网搜索(global_search,需 ZHIHU_ACCESS_SECRET)─────────────────────
1021
+
1022
+ # site:/host: 站点限定语法 → Filter host=="..." 表达式。
1023
+ # 取值吸收到首个空白或中文字符(值只能是域名,纯中文值视为无效 host)。
1024
+ _SITE_FILTER_RE = re.compile(r"(?:site|host)\s*[::]\s*([^\s\u4e00-\u9fff]+)")
1025
+ # 残留的 site:/host: 词法片段(无有效 host 值也剥离,避免把语法词当搜索词)
1026
+ _SITE_TOKEN_RE = re.compile(r"(?:site|host)\s*[::]")
1027
+
1028
+
1029
+ def _parse_site_filter(query: str) -> tuple[str, str]:
1030
+ """解析 site:/host: 站点限定语法 → (Filter 表达式, 剔除后的查询词)。
1031
+
1032
+ 只取第一处匹配;host 兼容裸域名 / 完整 URL / 带端口三种写法,
1033
+ 统一剥离 scheme、路径、端口为裸域名(global_search 的 Filter host==
1034
+ 要求裸域名,`site:https://blog.csdn.net/x` 若整串传入会取到 https 当 host)。
1035
+
1036
+ Returns:
1037
+ (filter_expr, cleaned_query);无有效 host 时 filter_expr 为空串,
1038
+ 但残留的 site:/host: 词法片段仍会被剥离。
1039
+ """
1040
+ m = _SITE_FILTER_RE.search(query)
1041
+ if not m:
1042
+ t = _SITE_TOKEN_RE.search(query)
1043
+ if not t:
1044
+ return "", query.strip()
1045
+ cleaned = (query[: t.start()] + query[t.end():]).strip()
1046
+ return "", re.sub(r"\s{2,}", " ", cleaned)
1047
+ raw = m.group(1).strip().rstrip(".,;:,。;")
1048
+ host = re.sub(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", "", raw) # 去 scheme
1049
+ host = host.split("/", 1)[0].split(":", 1)[0].strip().lower() # 去路径与端口
1050
+ cleaned = (query[: m.start()] + query[m.end():]).strip()
1051
+ cleaned = re.sub(r"\s{2,}", " ", cleaned)
1052
+ return (f'host=="{host}"' if host else ""), cleaned
1053
+
1054
+
1055
+ def _build_zhihu_global_engine(spec: dict[str, Any]) -> Any:
1056
+ """知乎开放平台全网搜索(developer.zhihu.com global_search)。
1057
+
1058
+ 第一性:zhihu_global 是「真全网搜索」——默认 SearchDB=all 搜全网索引,
1059
+ 且支持 Filter host== 精确限定站点(byted/bocha 做不到的精确能力)。
1060
+ 响应含 AuthorityLevel(1-4 权威等级)、VoteUpCount、EditTime 等结构化信号,
1061
+ 全部提取进统一 schema,供 evidence 消费。
1062
+
1063
+ 查询语法:
1064
+ - `site:zhuanlan.zhihu.com 关键词` → Filter: host=="zhuanlan.zhihu.com"
1065
+ - `host:blog.csdn.net 关键词` → 同上(兼容写法)
1066
+ - 普通查询 → SearchDB=all 全网
1067
+ """
1068
+ timeout = spec.get("timeout", 10)
1069
+
1070
+ @safe_search
1071
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1072
+ import urllib.parse as up
1073
+ to = _timeout or timeout
1074
+ secret = get_env(["ARGO_ZHIHU_ACCESS_SECRET", "ZHIHU_ACCESS_SECRET"])
1075
+ if not secret:
1076
+ return []
1077
+
1078
+ # 解析 site:/host: 站点限定语法 → Filter: host=="..."
1079
+ filter_expr, search_query = _parse_site_filter(query)
1080
+
1081
+ params: dict[str, Any] = {
1082
+ "Query": search_query or query,
1083
+ "Count": str(min(n, 20)),
1084
+ "SearchDB": "all",
1085
+ }
1086
+ if filter_expr:
1087
+ params["Filter"] = filter_expr
1088
+ url = "https://developer.zhihu.com/api/v1/content/global_search?" + up.urlencode(params)
1089
+ headers = {
1090
+ "Authorization": f"Bearer {secret}",
1091
+ "X-Request-Timestamp": str(int(time.time())),
1092
+ "Content-Type": "application/json",
1093
+ "User-Agent": "argo-search/2.6 (unified-search@local)",
1094
+ }
1095
+ try:
1096
+ with http_open(urllib.request.Request(url, headers=headers), timeout=to, engine=spec.get("_name", "")) as resp:
1097
+ data = json.loads(resp.read().decode("utf-8", "replace"))
1098
+ except urllib.error.HTTPError as e:
1099
+ # 401/403 等必须暴露为 error item 而非静默空——调用侧把
1100
+ # 「没配置」「鉴权失败」「没结果」区分开才可行动(保持一致
1101
+ # social_engines/zhihu_engine.py 同接口的计算方式)
1102
+ return [{"error": f"zhihu_global API HTTP {e.code}", "source": "zhihu_global"}]
1103
+ except Exception as e:
1104
+ logger.warning(f"zhihu_global 失败: {e}")
1105
+ return [{"error": f"zhihu_global {type(e).__name__}: {e}", "source": "zhihu_global"}]
1106
+ if data.get("Code") not in (0, None):
1107
+ # 30001=频率限制 30002=配额限制,显式暴露供配额状态机归类
1108
+ logger.warning(f"zhihu_global 返回码异常: {data.get('Code')} {data.get('Message')}")
1109
+ return [{"error": f"zhihu_global Code={data.get('Code')} "
1110
+ f"{str(data.get('Message') or '')[:100]}",
1111
+ "source": "zhihu_global"}]
1112
+ items = (data.get("Data") or {}).get("Items") or []
1113
+ results = []
1114
+ for _rk3, item in enumerate(items[:n]):
1115
+ title = (item.get("Title") or "").strip()
1116
+ # API 标题统一带「 - 知乎」尾巴:截断前剥掉(先剥再切,尾巴不占正文)
1117
+ if title.endswith(" - 知乎"):
1118
+ title = title[: -len(" - 知乎")].rstrip()
1119
+ url_ = item.get("Url") or ""
1120
+ snippet = item.get("ContentText") or ""
1121
+ # 去 <em> 高亮标签
1122
+ snippet = re.sub(r"<[^>]+>", "", snippet).strip()
1123
+ if not title and not url_:
1124
+ continue
1125
+ # 结构化信号:权威等级 / 互动 / 时效
1126
+ social_meta = {
1127
+ "author": item.get("AuthorName") or "",
1128
+ "content_type": item.get("ContentType") or "",
1129
+ "vote_up": item.get("VoteUpCount") or 0,
1130
+ "comment_count": item.get("CommentCount") or 0,
1131
+ "authority_level": item.get("AuthorityLevel") or "",
1132
+ "edit_time": item.get("EditTime") or 0,
1133
+ }
1134
+ results.append({
1135
+ "title": title[:200],
1136
+ "url": url_,
1137
+ "snippet": snippet[:300],
1138
+ "source": "zhihu_global",
1139
+ "score": rank_score(0.7, _rk3),
1140
+ "authority_level": social_meta["authority_level"],
1141
+ "social_meta": social_meta,
1142
+ })
1143
+ return results
1144
+ return _engine
1145
+
1146
+
1147
+ # ── 博查 Web Search 引擎(专用解析 + 动态时效)──────────────────────────────────
1148
+
1149
+ _BOCHA_FRESH_RE = re.compile(
1150
+ r"(本周|本月|最近一周|近一周|近一个月|recent|past\s*(week|month)|last\s*(week|month))",
1151
+ re.I,
1152
+ )
1153
+
1154
+
1155
+ def _bocha_freshness(query: str) -> str:
1156
+ """按查询时效敏感度动态选择 freshness 参数。
1157
+
1158
+ 周/月级窗口词(本周/本月/近一周等)→ oneWeek;
1159
+ 日/时级时效词(今日/实时/最新/盘中/快讯等,复用缓存层的敏感检测)→ oneDay;
1160
+ 其余放宽为 noLimit(全量)。
1161
+ """
1162
+ if re.search(r"(本周|本月|近一周|近一个月|recent|past\s*(week|month)|last\s*(week|month)|this\s*week)", query or "", re.I):
1163
+ return "oneWeek"
1164
+ try:
1165
+ from cache import is_freshness_sensitive_query
1166
+ if is_freshness_sensitive_query(query or ""):
1167
+ return "oneDay"
1168
+ except Exception:
1169
+ pass
1170
+ return "noLimit"
1171
+
1172
+
1173
+ # ── zhihu_user:知乎个人数据(本人创作/收藏/关注)─────────────────────────
1174
+ # 站内搜索/全网搜覆盖「找内容」;本引擎覆盖「看自己」——个人创作运营
1175
+ # (哪些回答点赞高、最近发了什么)与素材回溯(收藏夹)。查本人数据用
1176
+ # Access Secret 直调(官方文档:不传 X-OAuth-Token 即本人),OAuth 仅在
1177
+ # 查其他用户时才需要。/user/* 端点共享 user_data 日额度(10000/天)。
1178
+
1179
+ _ZHIHU_USER_API = "https://developer.zhihu.com/api/v1/user"
1180
+
1181
+ # 子意图 → 端点与参数(按序匹配,命中即停)
1182
+ _ZHIHU_USER_INTENTS: list[tuple[str, str, dict[str, str]]] = [
1183
+ # (pattern, endpoint, extra query params)
1184
+ (r"我的收藏夹|收藏夹(列表|有哪些)", "favlists", {}),
1185
+ (r"我的收藏|收藏的内容", "collections", {}),
1186
+ (r"我关注(的)?(人|列表|谁)?(?!.*动态)", "followees", {}),
1187
+ (r"我的(回答|答主)", "contents", {"ContentType": "answer"}),
1188
+ (r"我的(文章|专栏)", "contents", {"ContentType": "article"}),
1189
+ (r"我的视频", "contents", {"ContentType": "zvideo"}),
1190
+ (r"我的(想法|动态|短内容)", "contents", {"ContentType": "pin"}),
1191
+ (r"我的(提问|问题)", "contents", {"ContentType": "question"}),
1192
+ ]
1193
+
1194
+
1195
+ def _parse_zhihu_user_intent(query: str) -> tuple[str, dict[str, str], str]:
1196
+ """解析子意图,返回 (endpoint, extra_params, 剩余关键词)。"""
1197
+ q = (query or "").strip()
1198
+ for pat, endpoint, extra in _ZHIHU_USER_INTENTS:
1199
+ if re.search(pat, q):
1200
+ leftover = re.sub(pat, " ", q, count=1)
1201
+ return endpoint, extra, leftover
1202
+ # 默认:全部内容(时间倒序)
1203
+ leftover = re.sub(r"(我的知乎|知乎我的|我的(全部)?内容|个人(主页|数据)|我的创作)", " ", q)
1204
+ return "contents", {"ContentType": "all"}, leftover
1205
+
1206
+
1207
+ def _build_zhihu_user_engine(spec: dict[str, Any]) -> Any:
1208
+ timeout = spec.get("timeout", 10)
1209
+
1210
+ @safe_search
1211
+ def _engine(query: str, n: int = 5, _timeout: float | None = None,
1212
+ **kwargs) -> list[dict[str, Any]]:
1213
+ import urllib.parse as up
1214
+ to = _timeout or timeout
1215
+ secret = get_env(["ARGO_ZHIHU_ACCESS_SECRET", "ZHIHU_ACCESS_SECRET"])
1216
+ if not secret:
1217
+ return []
1218
+
1219
+ endpoint, extra, leftover = _parse_zhihu_user_intent(query)
1220
+ params: dict[str, Any] = {"Limit": str(min(int(n), 50)), **extra}
1221
+ if endpoint == "contents":
1222
+ # 创作分析语义:点赞最多/最受欢迎 → 按赞排序(默认最新)
1223
+ if re.search(r"点赞(最)?(多|高)|最受欢迎|高赞|表现(最)?好", query):
1224
+ params["SortField"] = "like_count"
1225
+ url = f"{_ZHIHU_USER_API}/{endpoint}?" + up.urlencode(params)
1226
+ headers = {
1227
+ "Authorization": f"Bearer {secret}",
1228
+ "X-Request-Timestamp": str(int(time.time())),
1229
+ "Content-Type": "application/json",
1230
+ "User-Agent": "argo-search/2.6 (unified-search@local)",
1231
+ }
1232
+ try:
1233
+ with http_open(urllib.request.Request(url, headers=headers), timeout=to, engine=spec.get("_name", "")) as resp:
1234
+ data = json.loads(resp.read().decode("utf-8", "replace"))
1235
+ except urllib.error.HTTPError as e:
1236
+ return [{"error": f"zhihu_user API HTTP {e.code}", "source": "zhihu_user"}]
1237
+ except Exception as e:
1238
+ logger.warning(f"zhihu_user 失败: {e}")
1239
+ return [{"error": f"zhihu_user {type(e).__name__}: {e}", "source": "zhihu_user"}]
1240
+ if data.get("Code") not in (0, None):
1241
+ logger.warning(f"zhihu_user 返回码异常: {data.get('Code')} {data.get('Message')}")
1242
+ return [{"error": f"zhihu_user Code={data.get('Code')} "
1243
+ f"{str(data.get('Message') or '')[:100]}",
1244
+ "source": "zhihu_user"}]
1245
+
1246
+ items = (data.get("Data") or {}).get("Items") or []
1247
+ # 剩余关键词做标题/摘要本地过滤(「我的收藏 大模型」→ 只留含大模型的)
1248
+ leftover_kw = re.sub(r"[\s,,。??的]+", " ", leftover).strip()
1249
+ keywords = [w for w in leftover_kw.split() if len(w) >= 2]
1250
+
1251
+ results: list[dict[str, Any]] = []
1252
+ for item in items[:n]:
1253
+ if not isinstance(item, dict):
1254
+ continue
1255
+ title = str(item.get("Title") or "").strip()
1256
+ url_ = item.get("Url") or ""
1257
+ if endpoint == "followees":
1258
+ name = str(item.get("Fullname") or "").strip()
1259
+ if not name:
1260
+ continue
1261
+ results.append({
1262
+ "title": name[:100],
1263
+ "url": item.get("Url") or "",
1264
+ "snippet": str(item.get("Headline") or "")[:300],
1265
+ "source": "zhihu_user",
1266
+ "score": max(1.0 - len(results) * 0.1, 0.1),
1267
+ "social_meta": {"platform": "zhihu_user",
1268
+ "content_type": "followee",
1269
+ "followers": item.get("FollowerCount") or 0},
1270
+ })
1271
+ continue
1272
+ # 收藏夹列表:Title/Description 为收藏夹本身
1273
+ summary = str(item.get("Summary") or item.get("Description") or "")[:300]
1274
+ if keywords and not any(
1275
+ k in title or k in summary for k in keywords):
1276
+ continue
1277
+ created = item.get("CreatedAt") or 0
1278
+ results.append({
1279
+ "title": (title[:100] + ("..." if len(title) > 100 else "")) if title else "(无标题)",
1280
+ "url": url_,
1281
+ "snippet": summary,
1282
+ "source": "zhihu_user",
1283
+ "score": max(1.0 - len(results) * 0.1, 0.1),
1284
+ "published_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(created)) if created else None,
1285
+ "social_meta": {
1286
+ "platform": "zhihu_user",
1287
+ "content_type": str(item.get("ContentType") or endpoint),
1288
+ "likes": item.get("LikeCount") or 0,
1289
+ "comments": item.get("CommentCount") or 0,
1290
+ "favorites": item.get("FavoriteCount") or 0,
1291
+ "fav_time": item.get("FavTime") or None,
1292
+ },
1293
+ })
1294
+ return results
1295
+
1296
+ return _engine
1297
+
1298
+
1299
+ def _bocha_http_error(exc: Exception, source: str) -> list[dict[str, Any]]:
1300
+ """把博查的 HTTP 失败转成 error 记录(错误体一并带上)。
1301
+
1302
+ 为什么不能直接抛给 safe_search:safe_search 吞掉异常返回空列表,
1303
+ 「接口 403」与「这个词真没结果」在下游看起来一模一样。博查 AI Search
1304
+ 端点要单独套餐,本机 key 只有 web-search 权限,实测
1305
+ `HTTP 403 {"message":"You do not have enough money or package quota"}`——
1306
+ 此前这条被吃成空结果,引擎一直显示 ready,结构化卡静默降级成普通网页结果。
1307
+ 这里把状态码与错误体交出去,search.py 的 `_QUOTA_ERROR_KEYWORDS`(含
1308
+ "quota")就能判成 quota-exhausted,配额状态机据此停用该源。
1309
+ """
1310
+ detail = ""
1311
+ if isinstance(exc, urllib.error.HTTPError):
1312
+ try:
1313
+ detail = exc.read().decode("utf-8", errors="replace")[:200]
1314
+ except Exception:
1315
+ detail = ""
1316
+ return [{"error": f"HTTP {exc.code}: {detail}" if detail else f"HTTP {exc.code}",
1317
+ "source": source}]
1318
+ return [{"error": f"{type(exc).__name__}: {str(exc)[:160]}", "source": source}]
1319
+
1320
+
1321
+ def _bocha_key() -> str:
1322
+ return get_env(["ARGO_BOCHA_API_KEY", "BOCHA_API_KEY"])
1323
+
1324
+
1325
+ def _bocha_web_item(item: dict[str, Any]) -> dict[str, Any]:
1326
+ """webPages.value 单条 → 统一结果项(name/summary/datePublished/siteName 语义映射)。"""
1327
+ return {
1328
+ "title": str(item.get("name") or item.get("title") or "")[:200],
1329
+ "url": str(item.get("url") or ""),
1330
+ "snippet": str(item.get("summary") or item.get("snippet") or item.get("description") or "")[:300],
1331
+ "source": "bocha",
1332
+ "score": 0.7,
1333
+ "date": item.get("datePublished") or "",
1334
+ "site_name": item.get("siteName") or "",
1335
+ }
1336
+
1337
+
1338
+ def _build_bocha_engine(spec: dict[str, Any]) -> Any:
1339
+ """博查 Web Search(中文全网搜索,AI 友好摘要)。
1340
+
1341
+ 专用解析修复通用 parser 对 `data.webPages.value` 嵌套路径的漏检;
1342
+ freshness 按查询时效敏感度动态化(替代静态 oneYear)。
1343
+ """
1344
+ timeout = spec.get("timeout", 8)
1345
+
1346
+ @safe_search
1347
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1348
+ to = _timeout or timeout
1349
+ key = _bocha_key()
1350
+ if not key:
1351
+ return [{"error": "BOCHA_API_KEY 未设置", "source": "bocha"}]
1352
+ body = {
1353
+ "query": query or "",
1354
+ "summary": True,
1355
+ "freshness": _bocha_freshness(query),
1356
+ "count": max(1, min(int(n or 5), 50)),
1357
+ }
1358
+ req = urllib.request.Request(
1359
+ "https://api.bochaai.com/v1/web-search",
1360
+ data=json.dumps(body).encode("utf-8"),
1361
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
1362
+ )
1363
+ try:
1364
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
1365
+ data = json.loads(resp.read().decode("utf-8"))
1366
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e:
1367
+ return _bocha_http_error(e, "bocha")
1368
+ pages = (data.get("data") or {}).get("webPages") or {}
1369
+ return [_bocha_web_item(i) for i in (pages.get("value") or [])]
1370
+ return _engine
1371
+
1372
+
1373
+ # ── 博查 AI Search 引擎 ─────────────────────────────────────────────────────
1374
+
1375
+ def _build_bocha_ai_engine(spec: dict[str, Any]) -> Any:
1376
+ """博查 AI Search:网页结果直连(ai-search 端点的 webpage 通道)。
1377
+
1378
+ 历史版本还解析该端点的垂直结构化模态卡(天气/股票/汇率等,url 为空 +
1379
+ card_type 标注)——2026-09-28 移除:博查配额 403 期间该功能本就不可达,
1380
+ 且无 url 的卡行会被 SERP 过滤闸整体吞掉(is_serp_or_jump_url 对空 url
1381
+ 返回 True),形成「产出来就被扔」的静默浪费。要接回时参考 git 历史
1382
+ (本函数旧版 + search_rank 仍保留的 card_type 感知键),并先解决
1383
+ 「无 url 卡行如何过 SERP 过滤」的豁免判据。
1384
+ """
1385
+ timeout = spec.get("timeout", 12)
1386
+
1387
+ @safe_search
1388
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1389
+ to = _timeout or timeout
1390
+ key = _bocha_key()
1391
+ if not key:
1392
+ return [{"error": "BOCHA_API_KEY 未设置", "source": "bocha_ai"}]
1393
+ body = {
1394
+ "query": query or "",
1395
+ "freshness": _bocha_freshness(query),
1396
+ "count": max(1, min(int(n or 5), 50)),
1397
+ "answer": False,
1398
+ "stream": False,
1399
+ }
1400
+ req = urllib.request.Request(
1401
+ "https://api.bochaai.com/v1/ai-search",
1402
+ data=json.dumps(body).encode("utf-8"),
1403
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
1404
+ )
1405
+ try:
1406
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
1407
+ data = json.loads(resp.read().decode("utf-8"))
1408
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e:
1409
+ return _bocha_http_error(e, "bocha_ai")
1410
+
1411
+ results: list[dict[str, Any]] = []
1412
+ for _rk4, message in enumerate(data.get("messages") or []):
1413
+ ct = message.get("content_type") or ""
1414
+ raw = message.get("content") or "{}"
1415
+ try:
1416
+ parsed = json.loads(raw) if isinstance(raw, str) else (raw or {})
1417
+ except (json.JSONDecodeError, ValueError):
1418
+ parsed = {}
1419
+ if ct == "webpage":
1420
+ for item in parsed.get("value") or []:
1421
+ if not isinstance(item, dict):
1422
+ continue
1423
+ it = _bocha_web_item(item)
1424
+ it["source"] = "bocha_ai"
1425
+ results.append(it)
1426
+ else:
1427
+ # 非 webpage 消息一律跳过(模态卡特化已移除,见 docstring)
1428
+ continue
1429
+ return results
1430
+ return _engine
1431
+
1432
+
1433
+
1434
+
1435
+
1436
+ # ── 批次八:数据源扩展(2026-09-12)──────────────────────────────────────────
1437
+
1438
+ # std.samr 的 C_C_NAME 与政府类接口正文常带高亮标记,进结果前一律剥掉
1439
+ _SACINFO_TAG_RE = re.compile(r"</?sacinfo\s*>")
1440
+ _HL_TAG_RE = re.compile(r"</?em[^>]*>")
1441
+ _TAG_RE = re.compile(r"<[^>]+>")
1442
+
1443
+
1444
+ def _strip_hl(text: str) -> str:
1445
+ return re.sub(r"\s+", " ", _HL_TAG_RE.sub("", str(text or ""))).strip()
1446
+
1447
+
1448
+ def _build_std_samr_engine(spec: dict[str, Any]) -> Any:
1449
+ """全国标准信息公共服务平台(国标检索,免认证)。
1450
+
1451
+ C_C_NAME 带 <sacinfo> 高亮标签;id 即 openstd 的 hcno,直通全文预览页。
1452
+ """
1453
+ timeout = spec.get("timeout", 12)
1454
+
1455
+ @safe_search
1456
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1457
+ q = query.strip()
1458
+ if not q:
1459
+ return []
1460
+ to = _timeout or timeout
1461
+ url = ("https://std.samr.gov.cn/gb/search/gbQueryPage"
1462
+ f"?searchText={urllib.parse.quote(q)}&pageNumber=1&pageSize={min(max(int(n), 1), 50)}")
1463
+ raw = _http_get_raw(url, {"User-Agent": "argo-search/1.0 (+std_samr)", "Accept": "application/json"},
1464
+ to, engine=spec.get("_name", "std_samr"))
1465
+ if raw is None:
1466
+ return []
1467
+ try:
1468
+ rows = (json.loads(raw) or {}).get("rows") or []
1469
+ except (json.JSONDecodeError, ValueError):
1470
+ return []
1471
+ out = []
1472
+ for r in rows:
1473
+ # C_STD_CODE 同样带 <sacinfo> 高亮:旧实现只剥了 C_C_NAME,
1474
+ # 把 XML 标签直接印进了结果标题(实测
1475
+ # '<sacinfo>GB</sacinfo>/<sacinfo>T</sacinfo> <sacinfo>45577</sacinfo>-2025 …')。
1476
+ code = _SACINFO_TAG_RE.sub("", str(r.get("C_STD_CODE") or "")).strip()
1477
+ name = _SACINFO_TAG_RE.sub("", str(r.get("C_C_NAME") or "")).strip()
1478
+ if not code and not name:
1479
+ continue
1480
+ bits = [str(r.get(k) or "").strip() for k in ("STD_NATURE", "STATE")]
1481
+ if r.get("ISSUE_DATE"):
1482
+ bits.append(f"发布 {r['ISSUE_DATE']}")
1483
+ if r.get("ACT_DATE"):
1484
+ bits.append(f"实施 {r['ACT_DATE']}")
1485
+ hcno = str(r.get("id") or "").strip()
1486
+ out.append({
1487
+ "title": f"{code} {name}".strip(),
1488
+ "url": f"https://openstd.samr.gov.cn/bzgk/gb/newGbInfo?hcno={hcno}" if hcno else "",
1489
+ "snippet": " · ".join(b for b in bits if b),
1490
+ "source": "std_samr",
1491
+ })
1492
+ return out[:max(int(n), 1)]
1493
+ return _engine
1494
+
1495
+
1496
+ _STD_TAIL_WORDS = ("全文", "标准", "最新", "下载")
1497
+
1498
+
1499
+ def _build_openstd_engine(spec: dict[str, Any]) -> Any:
1500
+ """国家标准全文公开系统(GB 全文预览入口,HTML 行解析,免认证)。
1501
+
1502
+ 列表为服务端渲染表格,详情键在 onclick="showInfo('hcno')";列位置随
1503
+ 行型浮动,按内容特征定位(标准号模式 / 日期模式 / 状态词表),不按序号。
1504
+ """
1505
+ timeout = spec.get("timeout", 15)
1506
+ _std_code_re = re.compile(r"^[A-Z]{2,4}(?:/[A-Z]{1,2})?\s?\d+[-—]\d{4}")
1507
+ _date_re = re.compile(r"^\d{4}-\d{2}-\d{2}")
1508
+ _known_words = {"推标", "强标", "推荐性", "强制性", "现行", "即将实施", "废止", "作废", "被代替", "查看详细"}
1509
+
1510
+ @safe_search
1511
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1512
+ q = query.strip()
1513
+ for w in _STD_TAIL_WORDS:
1514
+ q = re.sub(rf"{w}$", "", q).strip()
1515
+ if not q:
1516
+ return []
1517
+ to = _timeout or timeout
1518
+ url = ("https://openstd.samr.gov.cn/bzgk/gb/std_list"
1519
+ f"?p.p1=0&p.p2={urllib.parse.quote(q)}&p.p90=circulation_date&p.p91=desc")
1520
+ raw = _http_get_raw(url, {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
1521
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"},
1522
+ to, engine=spec.get("_name", "openstd"))
1523
+ if raw is None:
1524
+ return []
1525
+ out = []
1526
+ for tr in re.findall(r"<tr[^>]*>(.*?)</tr>", raw, re.S):
1527
+ m = re.search(r"showInfo\('([0-9A-Fa-f]+)'\)", tr)
1528
+ if not m:
1529
+ continue
1530
+ tds = [re.sub(r"\s+", " ", _TAG_RE.sub("", t)).strip()
1531
+ for t in re.findall(r"<td[^>]*>(.*?)</td>", tr, re.S)]
1532
+ code_i = next((i for i, t in enumerate(tds) if _std_code_re.match(t)), None)
1533
+ if code_i is None:
1534
+ continue
1535
+ code = tds[code_i]
1536
+ name = next((t for t in tds[code_i + 1:]
1537
+ if len(t) > 3 and not _date_re.match(t) and t not in _known_words), "")
1538
+ dates = [t for t in tds if _date_re.match(t)]
1539
+ words = [t for t in tds[code_i + 1:] if t in _known_words and t != "查看详细"]
1540
+ bits = words + [f"发布 {dates[0]}" for _ in [0] if dates]
1541
+ if len(dates) > 1:
1542
+ bits.append(f"实施 {dates[1]}")
1543
+ out.append({
1544
+ "title": f"{code} {name}".strip(),
1545
+ "url": f"https://openstd.samr.gov.cn/bzgk/gb/newGbInfo?hcno={m.group(1)}",
1546
+ "snippet": " · ".join(bits),
1547
+ "source": "openstd",
1548
+ })
1549
+ if len(out) >= max(int(n), 1):
1550
+ break
1551
+ return out
1552
+ return _engine
1553
+
1554
+
1555
+ def _build_bangumi_engine(spec: dict[str, Any]) -> Any:
1556
+ """Bangumi 番剧仓库(动画/漫画/游戏条目元数据,官方开放 API,POST JSON)。
1557
+
1558
+ name_cn 常缺省 → 回落 name;rating.score 可能为 0/null。
1559
+ """
1560
+ timeout = spec.get("timeout", 12)
1561
+
1562
+ @safe_search
1563
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1564
+ q = query.strip()
1565
+ if not q:
1566
+ return []
1567
+ to = _timeout or timeout
1568
+ req = urllib.request.Request(
1569
+ "https://api.bgm.tv/v0/search/subjects",
1570
+ data=json.dumps({"keyword": q}).encode("utf-8"),
1571
+ headers={
1572
+ "Content-Type": "application/json",
1573
+ "User-Agent": "taxue/argo-search (+bangumi engine)",
1574
+ "Accept": "application/json",
1575
+ },
1576
+ method="POST",
1577
+ )
1578
+ try:
1579
+ with http_open(req, timeout=to, engine=spec.get("_name", "bangumi")) as resp:
1580
+ data = json.loads(resp.read().decode("utf-8"))
1581
+ except Exception as e:
1582
+ logger.warning(f"Bangumi 引擎失败: {e}")
1583
+ return []
1584
+ out = []
1585
+ for it in (data.get("data") or [])[:max(int(n), 1)]:
1586
+ title = str(it.get("name_cn") or it.get("name") or "").strip()
1587
+ if not title:
1588
+ continue
1589
+ bits = [str(it.get(k) or "").strip() for k in ("platform", "date")]
1590
+ rating = it.get("rating") if isinstance(it.get("rating"), dict) else {}
1591
+ if rating and rating.get("score"):
1592
+ bits.append(f"评分 {rating.get('score')}")
1593
+ summary = re.sub(r"\s+", " ", str(it.get("summary") or "")).strip()
1594
+ head = " · ".join(b for b in bits if b)
1595
+ out.append({
1596
+ "title": title,
1597
+ "url": f"https://bgm.tv/subject/{it.get('id')}",
1598
+ "snippet": (f"{head} {summary}"[:300] if head else summary[:300]),
1599
+ "source": "bangumi",
1600
+ })
1601
+ return out
1602
+ return _engine
1603
+
1604
+
1605
+ _DOUBAN_TYPE_CN = {"movie": "电影", "tv": "剧集"}
1606
+
1607
+
1608
+ def _build_douban_movie_engine(spec: dict[str, Any]) -> Any:
1609
+ """豆瓣电影 suggest 接口(中文片名/年份/类型,免认证)。
1610
+
1611
+ suggest 无评分字段;type 为 movie/tv。豆瓣对无 cookie 请求逐步收紧,
1612
+ 失败诚实空。
1613
+ """
1614
+ timeout = spec.get("timeout", 10)
1615
+
1616
+ @safe_search
1617
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1618
+ q = query.strip()
1619
+ if not q:
1620
+ return []
1621
+ to = _timeout or timeout
1622
+ url = f"https://movie.douban.com/j/subject_suggest?q={urllib.parse.quote(q)}"
1623
+ raw = _http_get_raw(url, {
1624
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
1625
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
1626
+ "Referer": "https://movie.douban.com/",
1627
+ "Accept": "application/json",
1628
+ }, to, engine=spec.get("_name", "douban_movie"))
1629
+ if raw is None:
1630
+ return []
1631
+ try:
1632
+ items = json.loads(raw)
1633
+ except (json.JSONDecodeError, ValueError):
1634
+ return []
1635
+ if not isinstance(items, list):
1636
+ return []
1637
+ out = []
1638
+ for it in items[:max(int(n), 1)]:
1639
+ title = str(it.get("title") or "").strip()
1640
+ if not title:
1641
+ continue
1642
+ year = str(it.get("year") or "").strip()
1643
+ t = _DOUBAN_TYPE_CN.get(str(it.get("type") or ""), str(it.get("type") or ""))
1644
+ out.append({
1645
+ "title": title,
1646
+ "url": str(it.get("url") or "").strip(),
1647
+ "snippet": " · ".join(b for b in (t, f"{year}年" if year else "") if b),
1648
+ "source": "douban_movie",
1649
+ })
1650
+ return out
1651
+ return _engine
1652
+
1653
+
1654
+ def _build_zdic_engine(spec: dict[str, Any]) -> Any:
1655
+ """汉典(中文字词典,词条页 /hans/{词} 直达)。
1656
+
1657
+ 取「详细解释」前几条释义拼摘要;查无此字 404 → _http_get_raw None → 诚实空。
1658
+ """
1659
+ timeout = spec.get("timeout", 12)
1660
+
1661
+ @safe_search
1662
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1663
+ q = query.strip()
1664
+ if not q or len(q) > 12:
1665
+ return []
1666
+ to = _timeout or timeout
1667
+ url = f"https://www.zdic.net/hans/{urllib.parse.quote(q)}"
1668
+ raw = _http_get_raw(url, {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
1669
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"},
1670
+ to, engine=spec.get("_name", "zdic"))
1671
+ if raw is None:
1672
+ return []
1673
+ defs = [_TAG_RE.sub("", d) for d in
1674
+ re.findall(r'<div class="xxjs-item__def">(.*?)</div>', raw, re.S)]
1675
+ defs = [re.sub(r"\s+", " ", d).strip() for d in defs]
1676
+ defs = [d for d in defs if d]
1677
+ if not defs:
1678
+ return []
1679
+ title_m = re.search(r"<title>([^<]+)</title>", raw)
1680
+ title = title_m.group(1).split(" - ")[0].strip() if title_m else q
1681
+ return [{
1682
+ "title": title,
1683
+ "url": url,
1684
+ "snippet": "释义:" + ";".join(defs[:3])[:280],
1685
+ "source": "zdic",
1686
+ }]
1687
+ return _engine
1688
+
1689
+
1690
+ # ── iPlant 植物智(中文植物名 → 学名 + 分类)──────────────────────────────────
1691
+
1692
+ _IPLANT_HOST = "https://www.iplant.cn"
1693
+ _IPLANT_HEADERS = {
1694
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
1695
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
1696
+ "Referer": "https://www.iplant.cn/",
1697
+ }
1698
+
1699
+ # 服务端渲染的页面变量。可靠——与「物种保护」「分类信息」那些 AJAX 填充块不同。
1700
+ _IPLANT_VAR_RE = re.compile(r"var\s+(spno|spcname|latin2|systype)\s*=\s*\"([^\"]*)\"")
1701
+ # 「您是否要找」消歧块:别名/泛称页在此给出接受名(中文名 + 学名 + 带内部 id 的链接)
1702
+ _IPLANT_SUGGEST_RE = re.compile(r"您是否要找[::](.*?)</div>", re.S)
1703
+ _IPLANT_ANCHOR_RE = re.compile(r"<a\s+href='([^']+)'[^>]*>([^<]+)</a>")
1704
+ # 俗名块:<div>俗名:<a href='/info/苞米'>苞米</a>、…</div>
1705
+ # 不取「异名」:那是拉丁→拉丁的映射,gbif 本来就管得好,不属于本源承诺的
1706
+ # 「中文名/俗名 → 学名 + 分类」(它的块结构也不同,异名:后紧跟 </div>)
1707
+ _IPLANT_VERN_RE = re.compile(r"俗名:(.{0,400}?)</div>", re.S)
1708
+ # 用户常把「玉米 学名」这类整体丢进来,去掉修饰词后才是可查的名字
1709
+ _IPLANT_NOISE_RE = re.compile(
1710
+ r"(学名|拉丁名|拉丁学名|俗名|别名|别称|植物|物种|分类|是什么|有哪些|查询|搜索)"
1711
+ )
1712
+
1713
+
1714
+ def _build_iplant_engine(spec: dict[str, Any]) -> Any:
1715
+ """iPlant 植物智——中文植物名(含俗名)→ 学名 + 分类(HTML 解析,免认证)。
1716
+
1717
+ 补的是 argo 一条硬盲区:此前没有任何从中文名进入生物数据库的路。gbif 对纯
1718
+ 中文查询返回的是无关属种(实测「玉米」首条是 Frithia 属;补 qField=VERNACULAR
1719
+ 仍是垃圾),本仓 gbif builder 也因此在中文查询上直接短路。iPlant 的 /info/{名}
1720
+ 直接吃中文名、学名、属名、科名,实测时延 0.29–0.41s。
1721
+
1722
+ **三级判据,少一级就错**(每级的实测依据见下):
1723
+ 1. `spno` 为空 → 未收录 → 诚实空。**不能用 HTTP 状态码**:不存在的名字同样
1724
+ 返回 200(只是页面小 1.8KB);**也不能用 latin2**:未收录时它把查询词本身
1725
+ 填回去(查 zzzz → latin2=zzzznotexist),看着像命中。
1726
+ 2. `spno` 非空且 `latin2` 非空 → 直接命中(玉米 / 牡丹 / 银杏)。
1727
+ 3. `spno` 非空但 `latin2` 为空 → 别名或泛称,接受名在「您是否要找」块里。
1728
+ **这是俗名查询的主路径而非边缘情况**:玉米页列出的 5 个俗名(包谷 / 苞米 /
1729
+ 玉蜀黍 / 珍珠米 / 麻蜀棒子)逐个反查,全部走这一级——只做前两级等于对
1730
+ 中文俗名基本失效。
1731
+
1732
+ `systype` 过滤非植物:该站带「名称校对」功能,查「霸王龙」返回恐龙条目、
1733
+ 「蘑菇」返回菌物条目,对植物源是噪声。实测 1=植物 2=动物 3=菌物,只放行 1;
1734
+ 字段缺失时不拦(判不出类群就不拦截,宁放过不误杀)。
1735
+
1736
+ 分类链来自 `/ashx/getspinfos.ashx?spid=&type=classsys`(免认证,实测可直调),
1737
+ 返回 8 套分类系统的 HTML 片段,取第一套——站点的默认展示(种>属>科>目>纲)。
1738
+ 植物志正文(frps/foc)、分布、标本都是 AJAX 填充且内部接口不可直调(参数空间
1739
+ 已试开,除本接口外一律空返回),不做承诺。
1740
+ """
1741
+ timeout = spec.get("timeout", 12)
1742
+ engine_name = spec.get("_name", "iplant")
1743
+
1744
+ def _fetch(path: str, to: float) -> str | None:
1745
+ return _http_get_raw(f"{_IPLANT_HOST}{path}", _IPLANT_HEADERS, to, engine=engine_name)
1746
+
1747
+ def _entry(path: str, to: float) -> dict[str, Any] | None:
1748
+ """取一个物种条目。未收录、非植物、取不到都返回 None。"""
1749
+ html = _fetch(path, to)
1750
+ if not html:
1751
+ return None
1752
+ v = dict(_IPLANT_VAR_RE.findall(html))
1753
+ if not v.get("spno"):
1754
+ return None
1755
+ if v.get("systype") and v["systype"] != "1":
1756
+ return None
1757
+ return {"spno": v["spno"], "cname": v.get("spcname", ""),
1758
+ "latin": v.get("latin2", ""), "html": html}
1759
+
1760
+ def _classsys(spno: str, to: float) -> list[str]:
1761
+ """分类链(第一套系统),从种到门;取不到返回空列表(分类是增益不是前提)。"""
1762
+ raw = _fetch(f"/ashx/getspinfos.ashx?spid={spno}&type=classsys", to)
1763
+ if not raw:
1764
+ return []
1765
+ try:
1766
+ chains = (json.loads(raw) or {}).get("classsys") or []
1767
+ except (json.JSONDecodeError, ValueError):
1768
+ return []
1769
+ if not isinstance(chains, list) or not chains:
1770
+ return []
1771
+ return re.findall(r"<a[^>]*>([^<]*)</a>", str(chains[0]))
1772
+
1773
+ def _suggest(html: str) -> list[dict[str, str]]:
1774
+ """「您是否要找」里的接受名。href 带内部 id,需再取一次才有 spno 与分类。"""
1775
+ blk = _IPLANT_SUGGEST_RE.search(html)
1776
+ if not blk:
1777
+ return []
1778
+ out = []
1779
+ for href, text in _IPLANT_ANCHOR_RE.findall(blk.group(1)):
1780
+ label = re.sub(r"\s+", " ", text).strip()
1781
+ m = re.match(r"^(\S+)\s+([A-Z][A-Za-z.\s×x]*)$", label)
1782
+ out.append({"href": href,
1783
+ "cname": m.group(1) if m else label,
1784
+ "latin": m.group(2).strip() if m else ""})
1785
+ return out
1786
+
1787
+ def _rel_path(href: str) -> str:
1788
+ """页面里的相对 href → 可直接 GET 的路径。名字部分要转义,?id= 原样保留。"""
1789
+ path, _, qs = href.partition("?")
1790
+ return f"{urllib.parse.quote(path)}?{qs}" if qs else urllib.parse.quote(path)
1791
+
1792
+ def _names(html: str, pat: re.Pattern) -> list[str]:
1793
+ """从「俗名:」「异名:」块取锚文本。"""
1794
+ blk = pat.search(html or "")
1795
+ if not blk:
1796
+ return []
1797
+ return [re.sub(r"\s+", " ", t).strip()
1798
+ for t in re.findall(r"<a[^>]*>([^<]+)</a>", blk.group(1)) if t.strip()]
1799
+
1800
+ def _result(e: dict[str, Any], ranks: list[str], q: str, idx: int) -> dict[str, Any]:
1801
+ cname = e.get("cname") or q
1802
+ latin = e.get("latin") or ""
1803
+ html = e.get("html") or ""
1804
+ bits = [f"学名 {latin}"] if latin else []
1805
+ # 分类链首段是种本身,取其后三段当属/科/目
1806
+ bits += [f"{lab} {val}" for lab, val in zip(("属", "科", "目"), ranks[1:4])]
1807
+ vern = _names(html, _IPLANT_VERN_RE)
1808
+ if vern:
1809
+ bits.append("俗名 " + "、".join(vern[:6]))
1810
+ return {
1811
+ "title": f"{cname} {latin}".strip(),
1812
+ "url": f"{_IPLANT_HOST}/info/{urllib.parse.quote(cname)}",
1813
+ "snippet": " | ".join(bits)[:300],
1814
+ "source": engine_name,
1815
+ "score": rank_score(0.95, idx),
1816
+ }
1817
+
1818
+ @safe_search
1819
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1820
+ q = _IPLANT_NOISE_RE.sub("", query).strip()
1821
+ if not q or len(q) > 40:
1822
+ return []
1823
+ to = _timeout or timeout
1824
+ head = _entry(f"/info/{urllib.parse.quote(q)}", to)
1825
+ if head is None:
1826
+ return []
1827
+ if head["latin"]:
1828
+ return [_result(head, _classsys(head["spno"], to), q, 0)]
1829
+ # 第三级:别名/泛称 → 取「您是否要找」给的接受名
1830
+ out: list[dict[str, Any]] = []
1831
+ for s in _suggest(head["html"])[: max(int(n), 1)]:
1832
+ acc = _entry(_rel_path(s["href"]), to)
1833
+ if acc is None:
1834
+ # 接受名页取不到(改版/超时)时退一步用提示里的名字对,仍比空手强
1835
+ if not s["latin"]:
1836
+ continue
1837
+ acc = {"spno": "", "cname": s["cname"], "latin": s["latin"], "html": ""}
1838
+ ranks = _classsys(acc["spno"], to) if acc["spno"] else []
1839
+ out.append(_result(acc, ranks, q, len(out)))
1840
+ return out
1841
+
1842
+ return _engine
1843
+
1844
+
1845
+ def _build_people_daily_engine(spec: dict[str, Any]) -> Any:
1846
+ """人民网搜索(权威综合中文新闻,官方接口,免认证)。
1847
+
1848
+ 走 http 而非 https(https 301 丢 body);标题/正文带 <em> 高亮标签;
1849
+ displayTime 为毫秒时间戳。
1850
+ """
1851
+ timeout = spec.get("timeout", 12)
1852
+
1853
+ @safe_search
1854
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1855
+ q = query.strip()
1856
+ if not q:
1857
+ return []
1858
+ to = _timeout or timeout
1859
+ req = urllib.request.Request(
1860
+ "http://search.people.cn/search-platform/front/search",
1861
+ data=json.dumps({
1862
+ "key": q, "page": 1, "limit": min(max(int(n), 1), 20),
1863
+ "hasTitle": True, "hasContent": True, "isFuzzy": True,
1864
+ "type": 0, "sortType": 2, "startTime": 0, "endTime": 0,
1865
+ }).encode("utf-8"),
1866
+ headers={"Content-Type": "application/json", "User-Agent": "argo-search/1.0 (+people_daily)"},
1867
+ method="POST",
1868
+ )
1869
+ try:
1870
+ with http_open(req, timeout=to, engine=spec.get("_name", "people_daily")) as resp:
1871
+ data = json.loads(resp.read().decode("utf-8"))
1872
+ except Exception as e:
1873
+ logger.warning(f"人民网引擎失败: {e}")
1874
+ return []
1875
+ records = ((data.get("data") or {}).get("records")) or []
1876
+ out = []
1877
+ for r in records[:max(int(n), 1)]:
1878
+ title = _strip_hl(r.get("title"))
1879
+ if not title:
1880
+ continue
1881
+ ts = str(r.get("displayTime") or "").strip()
1882
+ published = ""
1883
+ if ts.isdigit() and ts.isascii(): # isascii:'²'.isdigit()=True 但 int 崩
1884
+ published = time.strftime("%Y-%m-%d", time.localtime(int(ts) / 1000))
1885
+ content = _strip_hl(r.get("content"))[:280]
1886
+ out.append({
1887
+ "title": title,
1888
+ "url": str(r.get("url") or "").strip(),
1889
+ "snippet": content,
1890
+ "source": "people_daily",
1891
+ "published_at": published,
1892
+ })
1893
+ return out
1894
+ return _engine
1895
+
1896
+
1897
+ # flk 时效性枚举(官网 enumData/前端常量):1 已废止 / 2 已修改 / 3 有效 / 4 尚未生效
1898
+ _FLK_SXX = {1: "已废止", 2: "已修改", 3: "有效", 4: "尚未生效"}
1899
+
1900
+ # flk 的模糊检索对「第 N 条」内部带空格的写法返回 0 条,压掉空格后返回
1901
+ # 8-10 条(实测 2026-09-12:「民法典 第 1062 条」→「民法典 第1062条」)。
1902
+ # 只压「第…条」内部的空白,不动查询里其余空格——法名与并列检索词之间的
1903
+ # 空格是有语义的分隔(「个人信息保护 数据安全」必须原样透传)。
1904
+ _FLK_ARTICLE_SPACE_RE = re.compile(r"第\s*(\d{1,7})\s*条")
1905
+
1906
+
1907
+ def _build_flk_law_engine(spec: dict[str, Any]) -> Any:
1908
+ """国家法律法规数据库(法律/行政法规/司法解释/地方性法规,权威法条源)。
1909
+
1910
+ 接口为 flk SPA 前端逆向:searchRange 1=标题 2=正文;searchType 1=精确
1911
+ 2=模糊;orderByParam 必须是 {order, sort} 对象(扁平字符串后端 500)。
1912
+ 无验证码无签名;失败诚实空,不做绕过。
1913
+ """
1914
+ timeout = spec.get("timeout", 25)
1915
+ try:
1916
+ from http_client import register_spec_limit
1917
+ register_spec_limit(spec.get("_name", "flk_law"), None, 2500)
1918
+ except ImportError:
1919
+ pass
1920
+
1921
+ @safe_search
1922
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1923
+ q = _FLK_ARTICLE_SPACE_RE.sub(r"第\1条", query.strip())
1924
+ if not q:
1925
+ return []
1926
+ to = _timeout or timeout
1927
+ req = urllib.request.Request(
1928
+ "https://flk.npc.gov.cn/law-search/search/list",
1929
+ data=json.dumps({
1930
+ "searchRange": 1, "sxrq": [], "gbrq": [], "searchType": 2,
1931
+ "sxx": [], "gbrqYear": [], "flfgCodeId": [], "zdjgCodeId": [],
1932
+ "searchContent": q,
1933
+ "orderByParam": {"order": "-1", "sort": ""},
1934
+ "pageNum": 1, "pageSize": min(max(int(n), 1), 20),
1935
+ }).encode("utf-8"),
1936
+ headers={
1937
+ "Content-Type": "application/json;charset=utf-8",
1938
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
1939
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
1940
+ "Referer": "https://flk.npc.gov.cn/",
1941
+ "Accept": "application/json",
1942
+ },
1943
+ method="POST",
1944
+ )
1945
+ # 服务端间歇性掐断 TLS 握手(实测约半数首连失败),单次退避重试消化波动
1946
+ data = None
1947
+ for attempt in range(2):
1948
+ try:
1949
+ with http_open(req, timeout=to, engine=spec.get("_name", "flk_law")) as resp:
1950
+ data = json.loads(resp.read().decode("utf-8"))
1951
+ break
1952
+ except Exception as e:
1953
+ if attempt == 0:
1954
+ time.sleep(4)
1955
+ continue
1956
+ logger.warning(f"flk 引擎失败: {e}")
1957
+ return []
1958
+ if data is None:
1959
+ return []
1960
+ if not isinstance(data, dict) or data.get("code") != 200:
1961
+ return []
1962
+ out = []
1963
+ for r in (data.get("rows") or [])[:max(int(n), 1)]:
1964
+ title = _strip_hl(r.get("title"))
1965
+ if not title:
1966
+ continue
1967
+ sxx = _FLK_SXX.get(r.get("sxx"), "")
1968
+ bits = [str(r.get("flxz") or "").strip(), sxx,
1969
+ str(r.get("zdjgName") or "").strip()]
1970
+ if r.get("gbrq"):
1971
+ bits.append(f"公布 {r['gbrq']}")
1972
+ if r.get("sxrq"):
1973
+ bits.append(f"施行 {r['sxrq']}")
1974
+ bbbs = str(r.get("bbbs") or "").strip()
1975
+ out.append({
1976
+ "title": title,
1977
+ "url": f"https://flk.npc.gov.cn/detail?id={bbbs}" if bbbs else "",
1978
+ "snippet": " · ".join(b for b in bits if b),
1979
+ "source": "flk_law",
1980
+ "published_at": str(r.get("gbrq") or ""),
1981
+ })
1982
+ return out
1983
+ return _engine
1984
+
1985
+
1986
+ def _build_wikisource_engine(spec: dict[str, Any]) -> Any:
1987
+ """维基文库(古文/公版文献全文,MediaWiki API,免认证)。
1988
+
1989
+ 标题含空格需编码后拼 wiki 路径——声明式 url_template 不做编码,
1990
+ 故走 builder。snippet 带 <span class="searchmatch"> 高亮,剥离保文本。
1991
+ """
1992
+ timeout = spec.get("timeout", 10)
1993
+
1994
+ @safe_search
1995
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1996
+ q = query.strip()
1997
+ if not q:
1998
+ return []
1999
+ to = _timeout or timeout
2000
+ url = ("https://zh.wikisource.org/w/api.php?action=query&format=json&list=search"
2001
+ f"&srsearch={urllib.parse.quote(q)}&srlimit={min(max(int(n), 1), 20)}")
2002
+ raw = _http_get_raw(url, {"User-Agent": "argo-search/1.0 (+wikisource)", "Accept": "application/json"},
2003
+ to, engine=spec.get("_name", "wikisource"))
2004
+ if raw is None:
2005
+ return []
2006
+ try:
2007
+ items = ((json.loads(raw) or {}).get("query") or {}).get("search") or []
2008
+ except (json.JSONDecodeError, ValueError):
2009
+ return []
2010
+ out = []
2011
+ for it in items[:max(int(n), 1)]:
2012
+ title = str(it.get("title") or "").strip()
2013
+ if not title:
2014
+ continue
2015
+ snippet = re.sub(r"\s+", " ", _TAG_RE.sub("", str(it.get("snippet") or ""))).strip()
2016
+ out.append({
2017
+ "title": title,
2018
+ "url": "https://zh.wikisource.org/wiki/" + urllib.parse.quote(title.replace(" ", "_")),
2019
+ "snippet": snippet[:300],
2020
+ "source": "wikisource",
2021
+ "published_at": str(it.get("timestamp") or ""),
2022
+ })
2023
+ return out
2024
+ return _engine
2025
+
2026
+
2027
+ # ── 微博热搜榜引擎 ─────────────────────────────────────────────────────────────
2028
+
2029
+ def _build_weibo_hot_engine(spec: dict[str, Any]) -> Any:
2030
+ """微博热搜榜(weibo.com/ajax/side/hotSearch,免登录)
2031
+
2032
+ 榜单型源:查询词只作路由触发,不参与内容过滤(见 config 的 relevance_check: false)。
2033
+ """
2034
+ timeout = spec.get("timeout", 10)
2035
+
2036
+ @safe_search
2037
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
2038
+ to = _timeout or timeout
2039
+ url = "https://weibo.com/ajax/side/hotSearch"
2040
+ headers = {
2041
+ "User-Agent": "Mozilla/5.0",
2042
+ # 缺 Referer 时上游直接返回 {"error":"Forbidden"}(实测)
2043
+ "Referer": "https://weibo.com/",
2044
+ }
2045
+ req = urllib.request.Request(url, headers=headers)
2046
+ try:
2047
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
2048
+ d = json.loads(resp.read().decode("utf-8", "replace"))
2049
+ rows = (d.get("data") or {}).get("realtime") or []
2050
+ results = []
2051
+ for i, it in enumerate(rows):
2052
+ if len(results) >= n:
2053
+ break
2054
+ word = str(it.get("word") or "").strip()
2055
+ if not word:
2056
+ continue
2057
+ rank = it.get("realpos") or (i + 1)
2058
+ label = str(it.get("label_name") or "").strip()
2059
+ num = it.get("num") or 0
2060
+ results.append({
2061
+ "title": word[:80],
2062
+ "url": "https://s.weibo.com/weibo?q=" + urllib.parse.quote("#" + word + "#"),
2063
+ "snippet": f"微博热搜 第{rank}位 · 热度 {num}" + (f" · {label}" if label else ""),
2064
+ "source": "weibo_hot",
2065
+ "score": max(1.0 - len(results) * 0.02, 0.2),
2066
+ })
2067
+ return results
2068
+ except Exception as e:
2069
+ logger.warning(f"微博热搜榜失败: {e}")
2070
+ return []
2071
+ return _engine
2072
+
2073
+
2074
+ # ── 抖音热榜引擎 ─────────────────────────────────────────────────────────────
2075
+
2076
+ def _build_douyin_hot_engine(spec: dict[str, Any]) -> Any:
2077
+ """抖音热榜(iesdouyin web api,免认证,榜单型)"""
2078
+ timeout = spec.get("timeout", 10)
2079
+
2080
+ @safe_search
2081
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
2082
+ to = _timeout or timeout
2083
+ url = "https://www.iesdouyin.com/web/api/v2/hotsearch/billboard/word/"
2084
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.douyin.com/"}
2085
+ req = urllib.request.Request(url, headers=headers)
2086
+ try:
2087
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
2088
+ d = json.loads(resp.read().decode("utf-8", "replace"))
2089
+ rows = d.get("word_list") or []
2090
+ results = []
2091
+ for i, it in enumerate(rows):
2092
+ if len(results) >= n:
2093
+ break
2094
+ word = str(it.get("word") or "").strip()
2095
+ if not word:
2096
+ continue
2097
+ hot = it.get("hot_value") or 0
2098
+ results.append({
2099
+ "title": word[:80],
2100
+ "url": "https://www.douyin.com/search/" + urllib.parse.quote(word),
2101
+ "snippet": f"抖音热榜 第{i + 1}位 · 热度 {hot}",
2102
+ "source": "douyin_hot",
2103
+ "score": max(1.0 - len(results) * 0.02, 0.2),
2104
+ })
2105
+ return results
2106
+ except Exception as e:
2107
+ logger.warning(f"抖音热榜失败: {e}")
2108
+ return []
2109
+ return _engine
2110
+
2111
+
2112
+ # ── CSDN 搜索 ────────────────────────────────────────────────────────────────
2113
+
2114
+ _EM_TAG_RE = re.compile(r"</?em>")
2115
+
2116
+
2117
+ def _build_csdn_engine(spec: dict[str, Any]) -> Any:
2118
+ """CSDN 搜索(so.csdn.net v3 API,免认证)
2119
+
2120
+ 标题与摘要是带 <em> 高亮的片段,URL 带 utm/request_id 追踪参数——两者都在这里清掉,
2121
+ 否则召回分会被标签噪声拉低、URL 也无法直接复用。
2122
+ """
2123
+ timeout = spec.get("timeout", 12)
2124
+
2125
+ @safe_search
2126
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
2127
+ to = _timeout or timeout
2128
+ params = urllib.parse.urlencode({"q": query, "t": "blog", "p": 1})
2129
+ url = f"https://so.csdn.net/api/v3/search?{params}"
2130
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://so.csdn.net/"}
2131
+ req = urllib.request.Request(url, headers=headers)
2132
+ try:
2133
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
2134
+ d = json.loads(resp.read().decode("utf-8", "replace"))
2135
+ rows = d.get("result_vos") or []
2136
+ results = []
2137
+ for it in rows:
2138
+ if len(results) >= n:
2139
+ break
2140
+ title = _EM_TAG_RE.sub("", str(it.get("title") or "")).strip()
2141
+ link = str(it.get("url") or "").split("?")[0]
2142
+ if not title or not link:
2143
+ continue
2144
+ desc = _EM_TAG_RE.sub(
2145
+ "", str(it.get("description") or it.get("digest") or "")
2146
+ ).strip()
2147
+ results.append({
2148
+ "title": title[:120],
2149
+ "url": link,
2150
+ "snippet": re.sub(r"\s+", " ", desc)[:220],
2151
+ "source": "csdn",
2152
+ "author": str(it.get("author") or ""),
2153
+ "published_at": str(it.get("created_at") or it.get("create_time_str") or ""),
2154
+ })
2155
+ return results
2156
+ except Exception as e:
2157
+ logger.warning(f"CSDN 搜索失败: {e}")
2158
+ return []
2159
+ return _engine
2160
+
2161
+
2162
+ # ── 华尔街见闻快讯引擎 ─────────────────────────────────────────────────────────
2163
+
2164
+ def _build_wallstreetcn_engine(spec: dict[str, Any]) -> Any:
2165
+ """华尔街见闻快讯(lives 直播流,免认证)
2166
+
2167
+ 与财联社电报同为「全量快讯流 + 本地关键词过滤」形态:带具体主题的查询做过滤,
2168
+ 纯触发词(快讯/财经等)放行全量榜单,避免过滤后空结果。
2169
+ """
2170
+ timeout = spec.get("timeout", 10)
2171
+
2172
+ @safe_search
2173
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
2174
+ from datetime import datetime
2175
+ to = _timeout or timeout
2176
+ # 关键词过滤时放大拉取量:上游 limit 上限就是 100,过滤窗口开满才不至于空手
2177
+ fetch_n = min(max(int(n) * 5, 60), 100)
2178
+ url = (
2179
+ "https://api-one.wallstcn.com/apiv1/content/lives"
2180
+ f"?channel=global-channel&limit={fetch_n}"
2181
+ )
2182
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://wallstreetcn.com/"}
2183
+ req = urllib.request.Request(url, headers=headers)
2184
+ try:
2185
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
2186
+ d = json.loads(resp.read().decode("utf-8", "replace"))
2187
+ rows = (d.get("data") or {}).get("items") or []
2188
+ keywords = query.strip().split() if _should_filter(query) else []
2189
+ results = []
2190
+ for it in rows:
2191
+ if len(results) >= n:
2192
+ break
2193
+ title = str(it.get("title") or "").strip()
2194
+ content = str(it.get("content_text") or "")
2195
+ if keywords and not any(
2196
+ k.lower() in (title + content).lower() for k in keywords
2197
+ ):
2198
+ continue
2199
+ ts = it.get("display_time")
2200
+ t = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
2201
+ results.append({
2202
+ "title": (title or content[:60]).strip()[:100],
2203
+ "url": str(it.get("uri") or "https://wallstreetcn.com/live/global"),
2204
+ "snippet": (f"{t} | {content}" if t else content)[:220],
2205
+ "source": "wallstreetcn",
2206
+ "published_at": t,
2207
+ })
2208
+ return results
2209
+ except Exception as e:
2210
+ logger.warning(f"华尔街见闻快讯失败: {e}")
2211
+ return []
2212
+ return _engine
2213
+
2214
+
2215
+ # ── 中国天气网引擎 ─────────────────────────────────────────────────────────────
2216
+
2217
+ # 查询里的自然语言噪声:城市名之外的部分全部剥掉("上海今天天气怎么样" → "上海")
2218
+ # 「今日/明日/昨日」是口语高频形态(与「今天」等价),漏掉会让联想接口拿
2219
+ # 「今日北京」查不到 cityid → 引擎静默返回 0 条。
2220
+ _WEATHER_CN_NOISE_RE = re.compile(
2221
+ r"(今[天日晚]|明[天日晚]|后[天日]|昨[天日]|现在|实时|当前|查询|怎么样|如何|多少度|"
2222
+ r"气温|温度|天气|预报|阴晴|下雨|的|了|呢|吗|\?|?|,|,|。|\s)+"
2223
+ )
2224
+
2225
+
2226
+ def _build_weather_cn_engine(spec: dict[str, Any]) -> Any:
2227
+ """中国天气网(城市联想 → 实况,免认证,两步)
2228
+
2229
+ 第一步 toy1.weather.com.cn/search 把城市名换成 cityid,第二步 data/sk/<id>.html 取实况。
2230
+ 联想接口返回 JSONP 外壳(`([...])` 或 `callback([...])`),这里把外层剥掉再解析。
2231
+ """
2232
+ timeout = spec.get("timeout", 10)
2233
+
2234
+ @safe_search
2235
+ def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
2236
+ to = _timeout or timeout
2237
+ city = _WEATHER_CN_NOISE_RE.sub("", query or "").strip() or (query or "").strip()
2238
+ if not city:
2239
+ return []
2240
+ headers = {
2241
+ "User-Agent": "Mozilla/5.0",
2242
+ "Referer": "https://www.weather.com.cn/",
2243
+ }
2244
+ try:
2245
+ u1 = "https://toy1.weather.com.cn/search?cityname=" + urllib.parse.quote(city)
2246
+ with http_open(urllib.request.Request(u1, headers=headers),
2247
+ timeout=to, engine=spec.get("_name", "")) as resp:
2248
+ raw = resp.read().decode("utf-8", "replace").strip()
2249
+ if not raw.startswith("["):
2250
+ lo, hi = raw.find("("), raw.rfind(")")
2251
+ raw = raw[lo + 1:hi] if lo >= 0 and hi > lo else raw
2252
+ cand = json.loads(raw) or []
2253
+ if not cand:
2254
+ return []
2255
+ parts = str(cand[0].get("ref") or "").split("~")
2256
+ if not parts or not parts[0]:
2257
+ return []
2258
+ cid = parts[0]
2259
+ name = parts[2] if len(parts) > 2 else city
2260
+
2261
+ # 注意:老的 /data/sk/<id>.html 已 301 到 HTML 页(不再吐 JSON),
2262
+ # 现役实况端点是 d1 的 sk_2d,响应为 JS 赋值 `var dataSK={...}`。
2263
+ u2 = f"https://d1.weather.com.cn/sk_2d/{cid}.html"
2264
+ with http_open(urllib.request.Request(u2, headers=headers),
2265
+ timeout=to, engine=spec.get("_name", "")) as resp:
2266
+ body = resp.read().decode("utf-8", "replace").strip()
2267
+ brace = body.find("{")
2268
+ sk = json.loads(body[brace:]) if brace >= 0 else {}
2269
+ if not sk:
2270
+ return []
2271
+ return [{
2272
+ "title": (f"{sk.get('cityname') or name} 实况:{sk.get('temp', '')}℃ "
2273
+ f"{sk.get('weather', '')} {sk.get('WD', '')}{sk.get('WS', '')}"),
2274
+ "url": f"https://www.weather.com.cn/weather/{cid}.shtml",
2275
+ "snippet": (f"湿度 {sk.get('SD', '')} · 气压 {sk.get('qy', '')}hPa · "
2276
+ f"AQI {sk.get('aqi', '')} · 能见度 {sk.get('njd', '')} · "
2277
+ f"观测 {sk.get('time', '')} · {sk.get('date', '')}"),
2278
+ "source": "weather_cn",
2279
+ }]
2280
+ except Exception as e:
2281
+ logger.warning(f"中国天气网失败: {e}")
2282
+ return []
2283
+ return _engine