argo-search 1.0.1 → 2.9.7

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 (463) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +822 -0
  3. package/README.es.md +864 -0
  4. package/README.ja.md +864 -0
  5. package/README.ko.md +864 -0
  6. package/README.md +867 -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/settings-keys.png +0 -0
  12. package/assets/readme/settings-tools.png +0 -0
  13. package/assets/readme/why-better.svg +104 -0
  14. package/assets/readme/workflow.svg +83 -0
  15. package/backends/domain_profiles.json +2308 -96
  16. package/backends/engine_registry.yaml +3314 -503
  17. package/backends/query_synonyms_cn.json +35 -0
  18. package/backends/quota_profiles.json +2409 -161
  19. package/backends/source_types_cn.json +83 -0
  20. package/backends/text_lexicon.json +44 -0
  21. package/bin/argo +703 -0
  22. package/bin/argo.js +71 -39
  23. package/config.yaml +4976 -363
  24. package/cordis.patch.yml +21 -0
  25. package/data/countries.json +40 -0
  26. package/data/lang_matrix/lang_capability.json +842 -0
  27. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  28. package/data/regions_cn.json +1 -0
  29. package/docs/ADDING_NEW_ENGINE.md +184 -0
  30. package/docs/ARGO_INTRO.md +124 -0
  31. package/docs/DATA_SOURCE_GAPS.md +81 -0
  32. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  33. package/docs/ENGINE_CATALOG.md +564 -0
  34. package/docs/MCP_SETUP.md +106 -0
  35. package/docs/PLATFORM_PATHS.md +111 -0
  36. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  37. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  38. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  39. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  40. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  41. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  42. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  43. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  44. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  45. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  46. package/docs/SEARCH_ARCHIVE.md +115 -0
  47. package/docs/assets/architecture.svg +104 -0
  48. package/docs/assets/hero.svg +98 -0
  49. package/docs/engines/bangumi.md +27 -0
  50. package/docs/engines/biorxiv.md +27 -0
  51. package/docs/engines/clawhub.md +27 -0
  52. package/docs/engines/crt_sh.md +27 -0
  53. package/docs/engines/datacite.md +27 -0
  54. package/docs/engines/deps_dev.md +27 -0
  55. package/docs/engines/douban_movie.md +27 -0
  56. package/docs/engines/endoflife.md +27 -0
  57. package/docs/engines/flk_law.md +27 -0
  58. package/docs/engines/fxtwitter.md +27 -0
  59. package/docs/engines/google_news.md +27 -0
  60. package/docs/engines/gov_regulations.md +27 -0
  61. package/docs/engines/guqian.md +27 -0
  62. package/docs/engines/hackernews.md +27 -0
  63. package/docs/engines/iplant.md +27 -0
  64. package/docs/engines/keenable.md +27 -0
  65. package/docs/engines/met_museum.md +27 -0
  66. package/docs/engines/nasa_images.md +27 -0
  67. package/docs/engines/netease_music.md +27 -0
  68. package/docs/engines/npm.md +27 -0
  69. package/docs/engines/nvd.md +27 -0
  70. package/docs/engines/openreview.md +27 -0
  71. package/docs/engines/openstd.md +27 -0
  72. package/docs/engines/parallel_free.md +27 -0
  73. package/docs/engines/people_daily.md +27 -0
  74. package/docs/engines/realtime_index.md +27 -0
  75. package/docs/engines/sec_edgar.md +27 -0
  76. package/docs/engines/seltz.md +27 -0
  77. package/docs/engines/skillsmp.md +27 -0
  78. package/docs/engines/sspai.md +27 -0
  79. package/docs/engines/std_samr.md +27 -0
  80. package/docs/engines/train.md +27 -0
  81. package/docs/engines/un_comtrade.md +27 -0
  82. package/docs/engines/wikisource.md +27 -0
  83. package/docs/engines/zdic.md +27 -0
  84. package/docs/engines/zenodo.md +27 -0
  85. package/docs/engines/zhihu_hot_app.md +27 -0
  86. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  87. package/engines/README.md +10 -0
  88. package/engines/_template_cli.yaml +39 -0
  89. package/engines/_template_http.yaml +37 -0
  90. package/engines/plugins/_template_plugin.py +50 -0
  91. package/engines/specs/_cisa_kev.yaml +54 -0
  92. package/engines/specs/_osv.yaml +63 -0
  93. package/engines/specs/aljazeera.yaml +43 -0
  94. package/engines/specs/antara.yaml +43 -0
  95. package/engines/specs/artic.yaml +20 -0
  96. package/engines/specs/aviation_weather.yaml +39 -0
  97. package/engines/specs/bangumi.yaml +21 -0
  98. package/engines/specs/biorxiv.yaml +24 -0
  99. package/engines/specs/carbon_intensity.yaml +32 -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/deps_dev.yaml +21 -0
  113. package/engines/specs/digup.yaml +36 -0
  114. package/engines/specs/doi.yaml +39 -0
  115. package/engines/specs/douban_movie.yaml +22 -0
  116. package/engines/specs/dw_news.yaml +47 -0
  117. package/engines/specs/egov_law.yaml +21 -0
  118. package/engines/specs/elpais.yaml +43 -0
  119. package/engines/specs/endoflife.yaml +21 -0
  120. package/engines/specs/energy_charts.yaml +21 -0
  121. package/engines/specs/factcheck_org.yaml +52 -0
  122. package/engines/specs/faz.yaml +43 -0
  123. package/engines/specs/federal_register.yaml +57 -0
  124. package/engines/specs/firecrawl.yaml +53 -0
  125. package/engines/specs/flk_law.yaml +24 -0
  126. package/engines/specs/folha.yaml +43 -0
  127. package/engines/specs/france24.yaml +44 -0
  128. package/engines/specs/full_fact.yaml +47 -0
  129. package/engines/specs/fxtwitter.yaml +31 -0
  130. package/engines/specs/gbfs_nyc.yaml +32 -0
  131. package/engines/specs/gdacs.yaml +21 -0
  132. package/engines/specs/gdelt.yaml +64 -0
  133. package/engines/specs/google_news.yaml +22 -0
  134. package/engines/specs/gov_regulations.yaml +38 -0
  135. package/engines/specs/guardian_rss.yaml +59 -0
  136. package/engines/specs/hurriyet.yaml +43 -0
  137. package/engines/specs/iplant.yaml +25 -0
  138. package/engines/specs/jikan.yaml +24 -0
  139. package/engines/specs/jolpica.yaml +20 -0
  140. package/engines/specs/k10plus.yaml +20 -0
  141. package/engines/specs/keenable.yaml +44 -0
  142. package/engines/specs/lefigaro.yaml +43 -0
  143. package/engines/specs/listenbrainz.yaml +19 -0
  144. package/engines/specs/matichon.yaml +43 -0
  145. package/engines/specs/met_museum.yaml +21 -0
  146. package/engines/specs/nasa_images.yaml +45 -0
  147. package/engines/specs/netease_music.yaml +38 -0
  148. package/engines/specs/nhk.yaml +43 -0
  149. package/engines/specs/nhtsa_vpic.yaml +20 -0
  150. package/engines/specs/noaa_swpc.yaml +20 -0
  151. package/engines/specs/nvd.yaml +38 -0
  152. package/engines/specs/obis.yaml +20 -0
  153. package/engines/specs/opencitations.yaml +52 -0
  154. package/engines/specs/openf1.yaml +21 -0
  155. package/engines/specs/openfoodfacts.yaml +45 -0
  156. package/engines/specs/openligadb.yaml +20 -0
  157. package/engines/specs/openreview.yaml +37 -0
  158. package/engines/specs/openstd.yaml +21 -0
  159. package/engines/specs/people_daily.yaml +22 -0
  160. package/engines/specs/realtime_index.yaml +34 -0
  161. package/engines/specs/ror.yaml +20 -0
  162. package/engines/specs/satnogs.yaml +20 -0
  163. package/engines/specs/sec_edgar.yaml +46 -0
  164. package/engines/specs/skillsmp.yaml +50 -0
  165. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  166. package/engines/specs/soilgrids.yaml +25 -0
  167. package/engines/specs/sspai.yaml +36 -0
  168. package/engines/specs/stackexchange.yaml +46 -0
  169. package/engines/specs/std_samr.yaml +21 -0
  170. package/engines/specs/tass.yaml +43 -0
  171. package/engines/specs/tle_mirror.yaml +21 -0
  172. package/engines/specs/train.yaml +37 -0
  173. package/engines/specs/tvmaze.yaml +20 -0
  174. package/engines/specs/twitter_syndication.yaml +31 -0
  175. package/engines/specs/un_comtrade.yaml +23 -0
  176. package/engines/specs/unpaywall.yaml +54 -0
  177. package/engines/specs/vnpress.yaml +43 -0
  178. package/engines/specs/weather.yaml +32 -0
  179. package/engines/specs/who_don.yaml +21 -0
  180. package/engines/specs/who_gho.yaml +20 -0
  181. package/engines/specs/wikimedia_commons.yaml +34 -0
  182. package/engines/specs/wikisource.yaml +22 -0
  183. package/engines/specs/worms.yaml +19 -0
  184. package/engines/specs/x_trending.yaml +34 -0
  185. package/engines/specs/yna.yaml +43 -0
  186. package/engines/specs/ynet.yaml +43 -0
  187. package/engines/specs/zbmath.yaml +40 -0
  188. package/engines/specs/zdic.yaml +22 -0
  189. package/engines/specs/zenodo.yaml +27 -0
  190. package/engines/specs/zhihu_hot_app.yaml +35 -0
  191. package/mcp/clients.yaml +74 -0
  192. package/package.json +51 -12
  193. package/packages/dsh-plugin/README.md +134 -0
  194. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  195. package/packages/dsh-plugin/dsh/client.js +1244 -0
  196. package/packages/dsh-plugin/dsh/index.js +2290 -0
  197. package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
  198. package/packages/dsh-plugin/package.json +38 -0
  199. package/packages/dsh-plugin/test/client.test.js +232 -0
  200. package/packages/dsh-plugin/test/config.test.js +30 -0
  201. package/packages/dsh-plugin/test/index.test.js +1338 -0
  202. package/references/query-templates.md +96 -0
  203. package/references/query-templates.yaml +118 -0
  204. package/scripts/ab_eval.py +219 -0
  205. package/scripts/ab_eval_p0p1.py +266 -0
  206. package/scripts/adaptive.py +271 -70
  207. package/scripts/answer.py +154 -0
  208. package/scripts/archive_run.py +591 -0
  209. package/scripts/argo_engine_registry.py +172 -0
  210. package/scripts/argo_paths.py +728 -0
  211. package/scripts/article.py +136 -0
  212. package/scripts/arxiv_source.py +201 -0
  213. package/scripts/badcase_ledger.py +252 -0
  214. package/scripts/batch_probe.py +241 -0
  215. package/scripts/benchmark.py +19 -0
  216. package/scripts/bounded_run.py +144 -0
  217. package/scripts/browser_auth.py +323 -0
  218. package/scripts/burstiness_detector.py +147 -0
  219. package/scripts/cache.py +1078 -67
  220. package/scripts/cache_guard.py +271 -0
  221. package/scripts/cache_key_vdom.py +56 -0
  222. package/scripts/cache_similarity.py +170 -0
  223. package/scripts/caliber_isolation.py +195 -0
  224. package/scripts/calibrate_lowquality.py +210 -0
  225. package/scripts/candidate_envelope.py +366 -0
  226. package/scripts/chrome_cdp.py +871 -0
  227. package/scripts/circuit_breaker.py +389 -0
  228. package/scripts/citations.py +334 -0
  229. package/scripts/cite_cli.py +59 -0
  230. package/scripts/cjk_tokens.py +95 -0
  231. package/scripts/clarify.py +47 -24
  232. package/scripts/cli_io.py +240 -0
  233. package/scripts/config.py +683 -45
  234. package/scripts/config_cache.py +402 -0
  235. package/scripts/conn_pool.py +220 -0
  236. package/scripts/content_security.py +685 -0
  237. package/scripts/content_signals.py +730 -0
  238. package/scripts/crawl.py +74 -35
  239. package/scripts/daemon.py +866 -0
  240. package/scripts/digup_bridge.py +214 -0
  241. package/scripts/engine_admission.py +366 -0
  242. package/scripts/engine_dispatch.py +1019 -0
  243. package/scripts/engine_env.py +534 -0
  244. package/scripts/engine_failure.py +411 -0
  245. package/scripts/engine_families.py +732 -0
  246. package/scripts/engine_policy.py +218 -0
  247. package/scripts/engine_requires.py +190 -0
  248. package/scripts/engine_status.py +322 -0
  249. package/scripts/engine_validate.py +638 -0
  250. package/scripts/engines.py +778 -294
  251. package/scripts/engines_base.py +1521 -0
  252. package/scripts/engines_builders.py +298 -0
  253. package/scripts/engines_builders_batch10.py +159 -0
  254. package/scripts/engines_builders_batch11.py +174 -0
  255. package/scripts/engines_builders_batch12.py +103 -0
  256. package/scripts/engines_builders_batch13.py +340 -0
  257. package/scripts/engines_builders_batch9.py +1468 -0
  258. package/scripts/engines_builders_cn.py +2283 -0
  259. package/scripts/engines_builders_data.py +2923 -0
  260. package/scripts/engines_builders_data_macro.py +775 -0
  261. package/scripts/engines_builders_feeds.py +255 -0
  262. package/scripts/engines_builders_intl.py +1239 -0
  263. package/scripts/engines_builders_search.py +440 -0
  264. package/scripts/engines_builders_tech.py +1360 -0
  265. package/scripts/evidence.py +588 -162
  266. package/scripts/evidence_loop.py +592 -0
  267. package/scripts/evidence_tier.py +298 -0
  268. package/scripts/except_sets.py +25 -0
  269. package/scripts/extract.py +55 -16
  270. package/scripts/fact_align.py +207 -0
  271. package/scripts/fact_crosscheck.py +230 -0
  272. package/scripts/fetch.py +25 -12
  273. package/scripts/fetch_browser.py +87 -0
  274. package/scripts/fetch_jsrun.py +122 -0
  275. package/scripts/fetch_quality.py +225 -0
  276. package/scripts/fetch_render_tinyfish.py +117 -0
  277. package/scripts/fetch_v3.py +1779 -0
  278. package/scripts/focus_extract.py +350 -0
  279. package/scripts/fulltext_store.py +169 -0
  280. package/scripts/fxtwitter_api.py +325 -0
  281. package/scripts/gen_engine_catalog.py +364 -0
  282. package/scripts/gen_native_tools.py +91 -0
  283. package/scripts/gen_routing_snapshot.py +184 -0
  284. package/scripts/health_check.py +140 -402
  285. package/scripts/health_probe.py +23 -8
  286. package/scripts/hot_state.py +134 -0
  287. package/scripts/http_client.py +1053 -0
  288. package/scripts/image_ops.py +430 -0
  289. package/scripts/image_vision/vision_probe +0 -0
  290. package/scripts/image_vision/vision_probe.swift +126 -0
  291. package/scripts/install.ps1 +148 -0
  292. package/scripts/install.sh +206 -0
  293. package/scripts/job.py +1186 -0
  294. package/scripts/jump_resolver.py +235 -0
  295. package/scripts/lang_capability.py +150 -0
  296. package/scripts/lang_detect.py +289 -0
  297. package/scripts/lang_matrix_probe.py +329 -0
  298. package/scripts/lang_pref.py +311 -0
  299. package/scripts/link_source.py +340 -0
  300. package/scripts/local_engines.py +321 -0
  301. package/scripts/local_image.py +970 -0
  302. package/scripts/local_seek.py +366 -0
  303. package/scripts/macro_countries.py +82 -0
  304. package/scripts/matrix_search_eval.py +1105 -0
  305. package/scripts/mcp_diag.py +8 -3
  306. package/scripts/mcp_handlers.py +1074 -0
  307. package/scripts/mcp_handlers_surface.py +157 -0
  308. package/scripts/mcp_launch.sh +122 -0
  309. package/scripts/mcp_server.py +118 -417
  310. package/scripts/mcp_setup.py +407 -0
  311. package/scripts/mcp_tools.py +330 -0
  312. package/scripts/mcp_transport.py +191 -0
  313. package/scripts/min_version_scan.py +335 -0
  314. package/scripts/net_proxy.py +258 -0
  315. package/scripts/network_aware.py +152 -0
  316. package/scripts/paper.py +274 -0
  317. package/scripts/pdf_extract.py +518 -0
  318. package/scripts/phrase_filter.py +66 -0
  319. package/scripts/plan.py +422 -0
  320. package/scripts/postprocess_signals.py +150 -0
  321. package/scripts/pre-commit +48 -0
  322. package/scripts/query_enhance.py +157 -0
  323. package/scripts/query_operators.py +196 -0
  324. package/scripts/query_rewriter.py +116 -7
  325. package/scripts/query_signals.py +300 -0
  326. package/scripts/query_templates.py +167 -0
  327. package/scripts/query_understanding.py +411 -0
  328. package/scripts/query_variants.py +298 -0
  329. package/scripts/quota.py +488 -76
  330. package/scripts/rank_signals.py +775 -0
  331. package/scripts/ranking_eval.py +319 -0
  332. package/scripts/readability_extract.py +433 -0
  333. package/scripts/recompute.py +415 -0
  334. package/scripts/recovery.py +616 -0
  335. package/scripts/recovery_l0.py +160 -0
  336. package/scripts/redskill/redskill_engine.py +332 -0
  337. package/scripts/regression_p0p1.py +318 -0
  338. package/scripts/replay_eval.py +640 -0
  339. package/scripts/rerank_tfidf.py +225 -0
  340. package/scripts/research.py +591 -403
  341. package/scripts/research_cli.py +361 -0
  342. package/scripts/research_dossier.py +492 -0
  343. package/scripts/research_expand.py +146 -0
  344. package/scripts/research_gates.py +272 -0
  345. package/scripts/research_report.py +209 -0
  346. package/scripts/research_strategy.py +37 -0
  347. package/scripts/research_work_packages.py +195 -0
  348. package/scripts/result_lang.py +311 -0
  349. package/scripts/robots_guard.py +259 -0
  350. package/scripts/route.py +921 -254
  351. package/scripts/route_bangs.py +40 -0
  352. package/scripts/route_cache.py +225 -0
  353. package/scripts/route_combo.py +910 -0
  354. package/scripts/route_domains.py +328 -0
  355. package/scripts/route_lang.py +605 -0
  356. package/scripts/route_log.py +63 -0
  357. package/scripts/route_policy.py +250 -0
  358. package/scripts/rrf_classic.py +66 -0
  359. package/scripts/screenshot.py +73 -0
  360. package/scripts/search.py +897 -405
  361. package/scripts/search_attribution.py +88 -0
  362. package/scripts/search_benchmark.py +407 -0
  363. package/scripts/search_cli.py +627 -0
  364. package/scripts/search_entry.py +389 -0
  365. package/scripts/search_output.py +606 -0
  366. package/scripts/search_pipeline.py +1077 -0
  367. package/scripts/search_rank.py +991 -0
  368. package/scripts/search_types.py +27 -12
  369. package/scripts/seek_locator.py +68 -0
  370. package/scripts/semantic_evidence.py +215 -0
  371. package/scripts/serp_guard.py +228 -0
  372. package/scripts/serp_kill_log.py +51 -0
  373. package/scripts/single_flight.py +120 -0
  374. package/scripts/social_engines/__init__.py +10 -0
  375. package/scripts/social_engines/bilibili_engine.py +131 -0
  376. package/scripts/social_engines/hackernews_engine.py +113 -0
  377. package/scripts/social_engines/reddit_engine.py +172 -0
  378. package/scripts/social_engines/twitter_engine.py +397 -0
  379. package/scripts/social_engines/v2ex_engine.py +101 -0
  380. package/scripts/social_engines/weibo_engine.py +128 -0
  381. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  382. package/scripts/social_engines/zhihu_engine.py +139 -0
  383. package/scripts/social_research.py +201 -0
  384. package/scripts/sogou_weixin_browser.py +318 -0
  385. package/scripts/source_ledger.py +249 -0
  386. package/scripts/stage_timing.py +58 -0
  387. package/scripts/stats_cli.py +95 -0
  388. package/scripts/straggler_policy.py +60 -0
  389. package/scripts/stylometry_detector.py +231 -0
  390. package/scripts/sync_backends.py +353 -0
  391. package/scripts/sync_installs.py +29 -0
  392. package/scripts/template_cli.py +127 -0
  393. package/scripts/text_lexicon.py +72 -0
  394. package/scripts/tfidf_router.py +109 -37
  395. package/scripts/time_utils.py +233 -0
  396. package/scripts/topic_research_profiles.py +643 -0
  397. package/scripts/train.py +737 -0
  398. package/scripts/tweet.py +596 -0
  399. package/scripts/url_canon.py +217 -0
  400. package/scripts/url_optimize.py +56 -0
  401. package/scripts/url_safety.py +236 -0
  402. package/scripts/usage_log.py +116 -0
  403. package/scripts/usage_log_clean.py +113 -0
  404. package/scripts/v2ex_nodes.py +311 -0
  405. package/scripts/variant_recall.py +188 -0
  406. package/scripts/watch.py +205 -0
  407. package/scripts/wechat_article.py +258 -0
  408. package/scripts/wide_research.py +402 -0
  409. package/scripts/wx.py +349 -0
  410. package/scripts/x_article_fetch.py +141 -0
  411. package/scripts/x_search.py +234 -0
  412. package/scripts/x_trending.py +189 -0
  413. package/scripts/yaml_load.py +67 -0
  414. package/sub-skills/ego-search/SKILL.md +253 -0
  415. package/sub-skills/ego-search/references/browser-runtime.md +43 -0
  416. package/sub-skills/ego-search/references/install.md +67 -0
  417. package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
  418. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  419. package/sub-skills/ego-search/scripts/merge.py +226 -0
  420. package/sub-skills/ego-search/scripts/quality.py +64 -0
  421. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  422. package/sub-skills/ego-search/scripts/safety.py +109 -0
  423. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  424. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  425. package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
  426. package/sub-skills/js-run/SKILL.md +67 -0
  427. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  428. package/sub-skills/js-run/scripts/shim.js +188 -0
  429. package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
  430. package/sub-skills/js-run/tests/test_jsrun.py +165 -0
  431. package/sub-skills/local-search/SKILL.md +50 -53
  432. package/sub-skills/local-search/bm25.py +162 -0
  433. package/sub-skills/local-search/config.yaml +152 -55
  434. package/sub-skills/local-search/engine_registry.py +91 -26
  435. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  436. package/sub-skills/local-search/local_search_adapter.py +13 -2
  437. package/sub-skills/local-search/parse_maps.yaml +40 -42
  438. package/sub-skills/local-search/search_v3.py +1182 -65
  439. package/sub-skills/local-search/smart_router.py +47 -7
  440. package/sub-skills/local-seek/SKILL.md +110 -0
  441. package/sub-skills/local-seek/config/domains.yaml +20 -0
  442. package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
  443. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
  444. package/sub-skills/local-seek/references/git-integration.md +54 -0
  445. package/sub-skills/local-seek/references/remote-handoff.md +46 -0
  446. package/sub-skills/local-seek/references/rg-recipes.md +96 -0
  447. package/sub-skills/local-seek/references/strategies.md +52 -0
  448. package/sub-skills/local-seek/references/structural-search.md +59 -0
  449. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  450. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  451. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  452. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  453. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  454. package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
  455. package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
  456. package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
  457. package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
  458. package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
  459. package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
  460. package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
  461. package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
  462. package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
  463. package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
@@ -0,0 +1,1239 @@
1
+ #!/usr/bin/env python3
2
+ """多语言与开放数据专用构建器:日/韩/欧/全球开放源。
3
+
4
+ 覆盖:
5
+ cnii CiNii Research(日本国立情报学研究所学术总库,JSON-LD)
6
+ ndl NDL Search(国立国会图书馆书目,OpenSearch RSS)
7
+ kor_law law.go.kr 韩国法令/判例(官方公开 demo 账号 OC=test,韩文 XML)
8
+ hatena_bookmark Hatena Bookmark(日本技术圈书签,RDF RSS)
9
+ dnb DNB 德国国家图书馆(SRU/RDF XML)
10
+ doaj DOAJ 开放获取期刊(JSON,bibjson 嵌套)
11
+ europeana Europeana 欧洲文化遗产(JSON,title 为数组)
12
+ hal HAL 法国科研开放仓储(Solr JSON,字段为数组)
13
+ eu_opendata EU Open Data Portal(JSON,title 嵌套 label)
14
+ open_meteo 全球天气(geocoding + forecast,免认证)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import re
22
+ import urllib.parse
23
+ import urllib.request
24
+ import xml.etree.ElementTree as ET
25
+ from typing import Any
26
+
27
+ from engines_base import safe_search, _http_get_raw, http_open, rank_score
28
+ from bounded_run import run_bounded
29
+
30
+ logger = logging.getLogger("unified_search.engines")
31
+
32
+ _UA = "argo-search/2.4 (unified-search@local)"
33
+
34
+ # 结构字符保留集:既有 %XX 编码、协议/路径/参数分隔符不重复转义
35
+ _URL_SAFE = "/:?&=%,._~#+-"
36
+
37
+
38
+ def _encode_url(url: str) -> str:
39
+ """URL 非 ASCII 安全编码:中文查询(如 open_meteo geocode name=北京)
40
+ 直接拼接会让 urllib 报 ascii 编码错误;已有 %XX 与结构字符不动。"""
41
+ return urllib.parse.quote(url, safe=_URL_SAFE)
42
+
43
+
44
+ # 地理编码类查询的自然语言噪声:open_meteo 的 geocoding 只吃地名,
45
+ # 「今日北京天气」「北京今天多少度」这类完整问句会让 name= 带上时间词与
46
+ # 疑问词,上游 0 候选 → 引擎静默返回空(wx.py / weather_cn 各有一份同型
47
+ # 正则,漏掉任一处该查询在对应源就全军覆没)。
48
+ # 只剥「查询表达」不碰地名本身:坐标串(lat,lon)、纯地名原样通过。
49
+ _GEO_COORD_RE = re.compile(r"-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?")
50
+ _GEO_NOISE_RE = re.compile(
51
+ r"(天气|气温|温度|多少度|预报|怎么样|如何|下雨|降水|雪|晴|阴|"
52
+ r"今[天日晚]|明[天日晚]|后[天日]|昨[天日]|现在|实时|当前|查询|"
53
+ r"的|了|呢|吗|\?|?|,|,|。|\s)+",
54
+ )
55
+
56
+
57
+ def _geo_core(query: str) -> str:
58
+ """剥掉天气问句的时间/疑问词,留地名;空则回退原查询(不猜)。"""
59
+ raw = (query or "").strip()
60
+ # 坐标串(lat,lon)不是问句,原样透传——剥了逗号反而毁掉它
61
+ if _GEO_COORD_RE.fullmatch(raw):
62
+ return raw
63
+ q = _GEO_NOISE_RE.sub(" ", raw).strip()
64
+ return q or raw
65
+
66
+
67
+ def _http_json(url: str, timeout: float, engine: str = "") -> Any:
68
+ req = urllib.request.Request(_encode_url(url), headers={"User-Agent": _UA, "Accept-Encoding": "gzip"})
69
+ with http_open(req, timeout=timeout, engine=engine) as resp:
70
+ raw = resp.read()
71
+ if resp.headers.get("Content-Encoding") == "gzip":
72
+ import gzip
73
+ raw = gzip.decompress(raw)
74
+ return json.loads(raw.decode("utf-8", "replace"))
75
+
76
+
77
+ def _http_text(url: str, timeout: float, engine: str = "") -> str:
78
+ req = urllib.request.Request(_encode_url(url), headers={"User-Agent": _UA})
79
+ with http_open(req, timeout=timeout, engine=engine) as resp:
80
+ return resp.read().decode("utf-8", "replace")
81
+
82
+
83
+ # ── CiNii Research ────────────────────────────────────────────────────────────
84
+
85
+ def _build_cnii_engine(spec: dict[str, Any]) -> Any:
86
+ """CiNii Research 学术总库(cir.nii.ac.jp,OpenSearch JSON-LD,免认证)。"""
87
+ timeout = spec.get("timeout", 12)
88
+
89
+ @safe_search
90
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
91
+ to = _timeout or timeout
92
+ url = f"https://cir.nii.ac.jp/opensearch/all?q={urllib.parse.quote(query)}&format=json&count={min(n, 10)}"
93
+ try:
94
+ data = _http_json(url, to, engine=spec.get("_name", ""))
95
+ except Exception as e:
96
+ logger.warning(f"CiNii 失败: {e}")
97
+ return []
98
+ results = []
99
+ for _rk, it in enumerate((data.get("items") or [])[:n]):
100
+ if not isinstance(it, dict):
101
+ continue
102
+ title = it.get("title", "")
103
+ link = it.get("link")
104
+ url_ = link.get("@id", "") if isinstance(link, dict) else str(link or "")
105
+ creators = it.get("dc:creator") or []
106
+ if isinstance(creators, str):
107
+ creators = [creators]
108
+ year = it.get("prism:publicationDate", "")
109
+ parts = [p for p in (", ".join(str(c) for c in creators[:3]), str(year)) if p]
110
+ if not title and not url_:
111
+ continue
112
+ results.append({
113
+ "title": str(title)[:200],
114
+ "url": url_,
115
+ "snippet": (" · ".join(parts) + " · " + str(it.get("description", "")))[:300],
116
+ "source": "cnii",
117
+ "score": rank_score(0.7, _rk),
118
+ })
119
+ return results
120
+ return _engine
121
+
122
+
123
+ # ── NDL Search(OpenSearch RSS)───────────────────────────────────────────────
124
+
125
+ def _build_ndl_engine(spec: dict[str, Any]) -> Any:
126
+ """NDL Search 国立国会图书馆书目(ndlsearch.ndl.go.jp OpenSearch RSS)。"""
127
+ timeout = spec.get("timeout", 12)
128
+
129
+ @safe_search
130
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
131
+ to = _timeout or timeout
132
+ url = f"https://ndlsearch.ndl.go.jp/api/opensearch?title={urllib.parse.quote(query)}&cnt={min(n, 10)}"
133
+ try:
134
+ raw = _http_text(url, to, engine=spec.get("_name", ""))
135
+ except Exception as e:
136
+ logger.warning(f"NDL 失败: {e}")
137
+ return []
138
+ try:
139
+ root = ET.fromstring(raw)
140
+ except ET.ParseError as e:
141
+ logger.warning(f"NDL XML 解析失败: {e}")
142
+ return []
143
+ results = []
144
+ RSS = "http://purl.org/rss/1.0/"
145
+ DC = "http://purl.org/dc/elements/1.1/"
146
+ # NDL 的 <item> 无默认 namespace(rss 根只声明了带前缀的 xmlns)
147
+ for _rk1, item in enumerate(root.iter("item")):
148
+ def _txt(ns: str, tag: str) -> str:
149
+ el = item.find(f"{{{ns}}}{tag}") if ns else item.find(tag)
150
+ return el.text.strip() if el is not None and el.text else ""
151
+ title = _txt("", "title")
152
+ link = _txt("", "link")
153
+ description = _txt(DC, "description")
154
+ if not title and not link:
155
+ continue
156
+ results.append({
157
+ "title": title[:200],
158
+ "url": link,
159
+ "snippet": description[:300],
160
+ "source": "ndl",
161
+ "score": rank_score(0.7, _rk1),
162
+ })
163
+ if len(results) >= n:
164
+ break
165
+ return results
166
+ return _engine
167
+
168
+
169
+ # ── law.go.kr 韩国法令/判例 ───────────────────────────────────────────────────
170
+
171
+ def _build_kor_law_engine(spec: dict[str, Any]) -> Any:
172
+ """law.go.kr 国家法令信息中心(官方公开 demo 账号 OC=test,韩文 XML)。
173
+
174
+ target=prec 判例(总库最扎实,实测「계약」3004 条);查询词命中判例要点。
175
+ """
176
+ timeout = spec.get("timeout", 12)
177
+ target = spec.get("extra_params", {}).get("target", "prec")
178
+
179
+ @safe_search
180
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
181
+ to = _timeout or timeout
182
+ url = (f"https://law.go.kr/DRF/lawSearch.do?OC=test&target={target}&type=XML"
183
+ f"&query={urllib.parse.quote(query)}&display={min(n, 10)}")
184
+ try:
185
+ raw = _http_text(url, to, engine=spec.get("_name", ""))
186
+ except Exception as e:
187
+ logger.warning(f"law.go.kr 失败: {e}")
188
+ return []
189
+ try:
190
+ root = ET.fromstring(raw)
191
+ except ET.ParseError as e:
192
+ logger.warning(f"law.go.kr XML 解析失败: {e}")
193
+ return []
194
+ # 判例条目标签 <prec>,法令条目 <law>
195
+ results = []
196
+ for tag in ("prec", "law"):
197
+ for item in root.iter(tag):
198
+ def _field(name: str) -> str:
199
+ el = item.find(name)
200
+ return el.text.strip() if el is not None and el.text else ""
201
+ title = _field("사건명") or _field("법령명한글")
202
+ link = _field("판례상세링크") or _field("법령상세링크")
203
+ if link and link.startswith("/"):
204
+ link = "https://law.go.kr" + link
205
+ meta = " · ".join(p for p in (
206
+ _field("사건번호") or _field("법령일련번호"),
207
+ _field("선고일자"),
208
+ _field("사건종류명"),
209
+ ) if p)
210
+ if not title and not link:
211
+ continue
212
+ results.append({
213
+ "title": title[:200],
214
+ "url": link,
215
+ "snippet": meta[:300],
216
+ "source": "kor_law",
217
+ "score": rank_score(0.7, len(results)),
218
+ })
219
+ if len(results) >= n:
220
+ return results
221
+ return results
222
+ return _engine
223
+
224
+
225
+ # ── Hatena Bookmark(RDF RSS)─────────────────────────────────────────────────
226
+
227
+ def _build_hatena_bookmark_engine(spec: dict[str, Any]) -> Any:
228
+ """Hatena Bookmark 书签搜索(b.hatena.ne.jp RDF RSS,带收藏热度)。"""
229
+ timeout = spec.get("timeout", 12)
230
+
231
+ @safe_search
232
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
233
+ to = _timeout or timeout
234
+ url = f"https://b.hatena.ne.jp/search/text?q={urllib.parse.quote(query)}&mode=rss"
235
+ try:
236
+ raw = _http_text(url, to, engine=spec.get("_name", ""))
237
+ except Exception as e:
238
+ logger.warning(f"Hatena 失败: {e}")
239
+ return []
240
+ try:
241
+ root = ET.fromstring(raw)
242
+ except ET.ParseError as e:
243
+ logger.warning(f"Hatena XML 解析失败: {e}")
244
+ return []
245
+ results = []
246
+ RSS = "http://purl.org/rss/1.0/"
247
+ DC = "http://purl.org/dc/elements/1.1/"
248
+ for _rk3, item in enumerate(root.iter(f"{{{RSS}}}item")):
249
+ def _txt(ns: str, tag: str) -> str:
250
+ el = item.find(f"{{{ns}}}{tag}")
251
+ return el.text.strip() if el is not None and el.text else ""
252
+ title = _txt(RSS, "title")
253
+ link = _txt(RSS, "link")
254
+ date = _txt(DC, "date")
255
+ if not title and not link:
256
+ continue
257
+ results.append({
258
+ "title": title[:200],
259
+ "url": link,
260
+ "snippet": (f"bookmarked: {date[:10]}" if date else "")[:300],
261
+ "source": "hatena_bookmark",
262
+ "score": rank_score(0.7, _rk3),
263
+ })
264
+ if len(results) >= n:
265
+ break
266
+ return results
267
+ return _engine
268
+
269
+
270
+ # ── DNB 德国国家图书馆(SRU)─────────────────────────────────────────────────
271
+
272
+ def _build_dnb_engine(spec: dict[str, Any]) -> Any:
273
+ """DNB 德国国家图书馆书目(services.dnb.de SRU,RDF XML 记录)。"""
274
+ timeout = spec.get("timeout", 12)
275
+
276
+ @safe_search
277
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
278
+ to = _timeout or timeout
279
+ url = ("https://services.dnb.de/sru/dnb?operation=searchRetrieve&version=1.1"
280
+ f"&query={urllib.parse.quote(query)}&maximumRecords={min(n, 10)}")
281
+ try:
282
+ raw = _http_text(url, to, engine=spec.get("_name", ""))
283
+ except Exception as e:
284
+ logger.warning(f"DNB 失败: {e}")
285
+ return []
286
+ try:
287
+ root = ET.fromstring(raw)
288
+ except ET.ParseError as e:
289
+ logger.warning(f"DNB XML 解析失败: {e}")
290
+ return []
291
+ results = []
292
+ SRW = "http://www.loc.gov/zing/srw/"
293
+ for _rk4, rec in enumerate(root.iter(f"{{{SRW}}}record")):
294
+ title = ""
295
+ link = ""
296
+ # 记录内 RDF/DC 元数据:取 dc:title / dc:identifier 或链接
297
+ for el in rec.iter():
298
+ tag = el.tag.rsplit("}", 1)[-1]
299
+ if not el.text or not el.text.strip():
300
+ continue
301
+ if tag in ("title", "Title") and not title:
302
+ title = el.text.strip()
303
+ elif tag in ("identifier", "Identifier") and (
304
+ "d-nb.info" in el.text or "dnb.de" in el.text):
305
+ link = el.text.strip()
306
+ if not title:
307
+ continue
308
+ results.append({
309
+ "title": title[:200],
310
+ "url": link or f"https://portal.dnb.de/opac/simpleSearch?query={urllib.parse.quote(title)}",
311
+ "snippet": "",
312
+ "source": "dnb",
313
+ "score": rank_score(0.7, _rk4),
314
+ })
315
+ if len(results) >= n:
316
+ break
317
+ return results
318
+ return _engine
319
+
320
+
321
+ # ── DOAJ 开放获取期刊 ─────────────────────────────────────────────────────────
322
+
323
+ def _build_doaj_engine(spec: dict[str, Any]) -> Any:
324
+ """DOAJ 开放获取期刊全球总库(doaj.org/api,bibjson 嵌套结构)。"""
325
+ timeout = spec.get("timeout", 12)
326
+
327
+ @safe_search
328
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
329
+ to = _timeout or timeout
330
+ url = f"https://doaj.org/api/search/articles/{urllib.parse.quote(query)}?pageSize={min(n, 10)}"
331
+ try:
332
+ data = _http_json(url, to, engine=spec.get("_name", ""))
333
+ except Exception as e:
334
+ logger.warning(f"DOAJ 失败: {e}")
335
+ return []
336
+ results = []
337
+ for _rk5, r in enumerate((data.get("results") or [])[:n]):
338
+ bib = r.get("bibjson") or {}
339
+ title = bib.get("title", "")
340
+ # link 是 [{url, type}] 数组,取 fulltext/doi 优先
341
+ link = ""
342
+ for lk in (bib.get("link") or []):
343
+ if isinstance(lk, dict):
344
+ u = lk.get("url", "")
345
+ if u and (lk.get("type") in ("fulltext", "doi") or not link):
346
+ link = u
347
+ authors = bib.get("author") or []
348
+ if isinstance(authors, list):
349
+ names = [a.get("name", "") for a in authors[:3] if isinstance(a, dict)]
350
+ else:
351
+ names = []
352
+ year = bib.get("year", "")
353
+ parts = [p for p in (", ".join(names), str(year), bib.get("journal", {}).get("title", "") if isinstance(bib.get("journal"), dict) else "") if p]
354
+ if not title and not link:
355
+ continue
356
+ results.append({
357
+ "title": str(title)[:200],
358
+ "url": link,
359
+ "snippet": (" · ".join(parts) + " · " + str(bib.get("abstract", "")))[:300],
360
+ "source": "doaj",
361
+ "score": rank_score(0.7, _rk5),
362
+ })
363
+ return results
364
+ return _engine
365
+
366
+
367
+ # ── Europeana 欧洲文化遗产 ────────────────────────────────────────────────────
368
+
369
+ def _build_europeana_engine(spec: dict[str, Any]) -> Any:
370
+ """Europeana 欧洲文化遗产聚合(api.europeana.eu,官方公开 demo key api2demo)。"""
371
+ timeout = spec.get("timeout", 12)
372
+
373
+ @safe_search
374
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
375
+ to = _timeout or timeout
376
+ url = (f"https://api.europeana.eu/record/v2/search.json?wskey=api2demo"
377
+ f"&query={urllib.parse.quote(query)}&rows={min(n, 10)}")
378
+ try:
379
+ data = _http_json(url, to, engine=spec.get("_name", ""))
380
+ except Exception as e:
381
+ logger.warning(f"Europeana 失败: {e}")
382
+ return []
383
+ results = []
384
+ for _rk6, it in enumerate((data.get("items") or [])[:n]):
385
+ if not isinstance(it, dict):
386
+ continue
387
+ title = it.get("title")
388
+ if isinstance(title, list):
389
+ title = title[0] if title else ""
390
+ link = it.get("link", "")
391
+ provider = it.get("dataProvider")
392
+ if isinstance(provider, list):
393
+ provider = provider[0] if provider else ""
394
+ if not title and not link:
395
+ continue
396
+ results.append({
397
+ "title": str(title)[:200],
398
+ "url": link,
399
+ "snippet": (f"provider: {provider}" if provider else "")[:300],
400
+ "source": "europeana",
401
+ "score": rank_score(0.7, _rk6),
402
+ })
403
+ return results
404
+ return _engine
405
+
406
+
407
+ # ── HAL 法国科研开放仓储 ──────────────────────────────────────────────────────
408
+
409
+ def _build_hal_engine(spec: dict[str, Any]) -> Any:
410
+ """HAL 法国全国科研机构开放仓储(api.archives-ouvertes.fr Solr JSON)。"""
411
+ timeout = spec.get("timeout", 12)
412
+
413
+ @safe_search
414
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
415
+ to = _timeout or timeout
416
+ url = ("https://api.archives-ouvertes.fr/search/?wt=json"
417
+ f"&q={urllib.parse.quote(query)}&rows={min(n, 10)}"
418
+ "&fl=title_s,uri_s,abstract_s,authFullName_s,producedDate_s")
419
+ try:
420
+ data = _http_json(url, to, engine=spec.get("_name", ""))
421
+ except Exception as e:
422
+ logger.warning(f"HAL 失败: {e}")
423
+ return []
424
+ results = []
425
+ for _rk7, doc in enumerate((data.get("response", {}).get("docs") or [])[:n]):
426
+ title = doc.get("title_s")
427
+ if isinstance(title, list):
428
+ title = title[0] if title else ""
429
+ link = doc.get("uri_s", "")
430
+ authors = doc.get("authFullName_s") or []
431
+ date = doc.get("producedDate_s") or ""
432
+ parts = [p for p in (", ".join(str(a) for a in authors[:3]), str(date)) if p]
433
+ if not title and not link:
434
+ continue
435
+ results.append({
436
+ "title": str(title)[:200],
437
+ "url": link,
438
+ "snippet": (" · ".join(parts) + " · " + str(doc.get("abstract_s", "")))[:300],
439
+ "source": "hal",
440
+ "score": rank_score(0.7, _rk7),
441
+ })
442
+ return results
443
+ return _engine
444
+
445
+
446
+ # ── EU Open Data Portal ───────────────────────────────────────────────────────
447
+
448
+ def _build_eu_opendata_engine(spec: dict[str, Any]) -> Any:
449
+ """EU Open Data Portal(data.europa.eu/api/hub,24 语言元数据)。"""
450
+ timeout = spec.get("timeout", 12)
451
+
452
+ @safe_search
453
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
454
+ to = _timeout or timeout
455
+ url = f"https://data.europa.eu/api/hub/search/search?q={urllib.parse.quote(query)}&pageSize={min(n, 10)}"
456
+ try:
457
+ data = _http_json(url, to, engine=spec.get("_name", ""))
458
+ except Exception as e:
459
+ logger.warning(f"EU ODP 失败: {e}")
460
+ return []
461
+ results = []
462
+ for _rk8, it in enumerate((data.get("result", {}).get("results") or [])[:n]):
463
+ if not isinstance(it, dict):
464
+ continue
465
+ # title 是 {语言code: 标题} 映射;优先 en/zh,否则取首个
466
+ title = ""
467
+ tl = it.get("title") or {}
468
+ if isinstance(tl, dict):
469
+ for lang in ("en", "zh", "fr", "de"):
470
+ if tl.get(lang):
471
+ title = tl[lang]
472
+ break
473
+ if not title and tl:
474
+ title = next(iter(tl.values()))
475
+ url_ = (it.get("landing_page") or it.get("resource")
476
+ or it.get("id") or "")
477
+ if isinstance(url_, list):
478
+ # landing_page 是 [{resource: url}] 数组
479
+ url_ = url_[0].get("resource", "") if url_ and isinstance(url_[0], dict) else ""
480
+ if not title and not url_:
481
+ continue
482
+ results.append({
483
+ "title": str(title)[:200],
484
+ "url": str(url_),
485
+ "snippet": "",
486
+ "source": "eu_opendata",
487
+ "score": rank_score(0.7, _rk8),
488
+ })
489
+ return results
490
+ return _engine
491
+
492
+
493
+ # ── Open-Meteo 全球天气 ───────────────────────────────────────────────────────
494
+
495
+ def _build_open_meteo_engine(spec: dict[str, Any]) -> Any:
496
+ """Open-Meteo 全球天气(geocoding + forecast,免认证)。
497
+
498
+ 查询词视为城市名/地名,先 geocode 取坐标再取当前天气。
499
+ """
500
+ timeout = spec.get("timeout", 12)
501
+
502
+ @safe_search
503
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
504
+ to = _timeout or timeout
505
+ # geocoding 只吃地名:完整天气问句(「今日北京天气」)先剥时间/疑问词,
506
+ # 否则上游 0 候选、引擎静默返回空
507
+ place_q = _geo_core(query)
508
+ try:
509
+ geo = _http_json(
510
+ f"https://geocoding-api.open-meteo.com/v1/search?name={urllib.parse.quote(place_q)}&count=3&language=zh",
511
+ to, engine=spec.get("_name", ""))
512
+ except Exception as e:
513
+ logger.warning(f"Open-Meteo geocode 失败: {e}")
514
+ return []
515
+ results = []
516
+ places = [p for p in (geo.get("results") or [])[:n]
517
+ if p.get("latitude") is not None and p.get("longitude") is not None]
518
+ if not places:
519
+ return results
520
+
521
+ def _fetch_place(item: tuple[int, dict[str, Any]]):
522
+ """单个 place 的 forecast;失败返回 None(与串行版逐个 except 一致)。"""
523
+ _rk9, place = item
524
+ lat, lon = place["latitude"], place["longitude"]
525
+ try:
526
+ wx = _http_json(
527
+ f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current_weather=true",
528
+ to, engine=spec.get("_name", ""))
529
+ except Exception:
530
+ return None
531
+ cw = wx.get("current_weather") or {}
532
+ name = place.get("name", "")
533
+ country = place.get("country", "")
534
+ temp = cw.get("temperature")
535
+ windspeed = cw.get("windspeed")
536
+ wcode = cw.get("weathercode")
537
+ wmo = {0: "晴", 1: "基本晴", 2: "多云", 3: "阴", 45: "雾", 48: "雾凇",
538
+ 51: "毛毛雨", 61: "小雨", 63: "中雨", 65: "大雨", 71: "小雪",
539
+ 73: "中雪", 75: "大雪", 80: "阵雨", 95: "雷暴"}.get(wcode, f"code{wcode}")
540
+ return (_rk9, {
541
+ "title": f"{name} · {country} 天气",
542
+ "url": f"https://open-meteo.com/en/docs?latitude={lat}&longitude={lon}",
543
+ "snippet": (f"当前 {temp}°C · {wmo} · 风速 {windspeed}km/h"
544
+ if temp is not None else "暂无数据")[:300],
545
+ "source": "open_meteo",
546
+ "score": rank_score(0.7, _rk9),
547
+ })
548
+
549
+ # 多 place 的 forecast 并行(2026-09-29):串行版在 geocode 返回 3 个
550
+ # 候选时墙钟 = geocode + 3×forecast(实测 9.28s 恒定);有界并发后
551
+ # = geocode + max(forecast)(实测 3.9-5.1s)。总上限 to+2s ≤ 串行
552
+ # 最坏 n×to;daemon 线程 + 轮询,卡住的 HTTP 读拖不垮止损
553
+ # (bounded_run 文档依据;与 wx.py:252「同坐标多端点并行」同哲学)。
554
+ finished, _unfinished = run_bounded(
555
+ list(enumerate(places)), _fetch_place,
556
+ wait_s=float(to) + 2.0, max_workers=3)
557
+ # 保序:按候选序号重排,与串行版输出逐位一致(rank_score 依赖 _rk9)
558
+ results = [r for _rk, r in sorted(
559
+ (v for _i, v in finished if isinstance(v, tuple)),
560
+ key=lambda t: t[0])]
561
+ return results
562
+ return _engine
563
+
564
+
565
+ # ── SearchMySite 小型个人站索引(HTML)───────────────────────────────────────
566
+
567
+ def _build_searchmysite_engine(spec: dict[str, Any]) -> Any:
568
+ """SearchMySite 人工审核个人站索引(searchmysite.net,HTML 解析)。
569
+
570
+ 只收非商业独立博客,与 marginalia/wiby 同类;结果含标题与正文摘要。
571
+ """
572
+ timeout = spec.get("timeout", 12)
573
+
574
+ @safe_search
575
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
576
+ to = _timeout or timeout
577
+ url = f"https://searchmysite.net/search/?q={urllib.parse.quote(query)}"
578
+ try:
579
+ raw = _http_text(url, to, engine=spec.get("_name", ""))
580
+ except Exception as e:
581
+ logger.warning(f"SearchMySite 失败: {e}")
582
+ return []
583
+ results = []
584
+ # 结果容器 class="search-result",按容器切段后取标题与链接
585
+ # (容器内 href 先于 class="result-link",正则需兼容两种顺序)
586
+ for _rk10, m in enumerate(re.finditer(r'<div class="search-result[^"]*">(.*?)(?=<div class="search-result|$)', raw, re.S)):
587
+ seg = m.group(1)
588
+ tm = re.search(r'result-title-txt[^>]*>(.*?)</span>', seg, re.S)
589
+ lm = re.search(r'href="(https?://[^"]+)"[^>]*class="result-link"', seg) or \
590
+ re.search(r'class="result-link"[^>]*href="(https?://[^"]+)"', seg)
591
+ title = re.sub(r"<[^>]+>", "", tm.group(1)).strip() if tm else ""
592
+ link = lm.group(1) if lm else ""
593
+ if not title and not link:
594
+ continue
595
+ results.append({
596
+ "title": title[:200] or link[:200],
597
+ "url": link,
598
+ "snippet": "",
599
+ "source": "searchmysite",
600
+ "score": rank_score(0.7, _rk10),
601
+ })
602
+ if len(results) >= n:
603
+ break
604
+ return results
605
+ return _engine
606
+
607
+
608
+ # ── Lieu webring 搜索引擎(HTML)──────────────────────────────────────────────
609
+
610
+ def _build_lieu_engine(spec: dict[str, Any]) -> Any:
611
+ """Lieu webring 专用搜索(lieu.cblgh.org,HTML)。
612
+
613
+ 只索引加入 webring 的站点,比 wiby 更小众;结果即站点内链接。
614
+ """
615
+ timeout = spec.get("timeout", 12)
616
+
617
+ @safe_search
618
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
619
+ to = _timeout or timeout
620
+ url = f"https://lieu.cblgh.org/?q={urllib.parse.quote(query)}"
621
+ try:
622
+ raw = _http_text(url, to, engine=spec.get("_name", ""))
623
+ except Exception as e:
624
+ logger.warning(f"Lieu 失败: {e}")
625
+ return []
626
+ results = []
627
+ # 结果项 <li class="result|entry">,内含 <a href="https://...">文本</a>
628
+ for _rk11, m in enumerate(re.finditer(
629
+ r'<li class="(?:result|entry)[^"]*">.*?<a[^>]+href="(https?://[^"]+)"[^>]*>(.*?)</a>',
630
+ raw, re.S)):
631
+ link = m.group(1)
632
+ title = re.sub(r"<[^>]+>", "", m.group(2)).strip()
633
+ if not title and not link:
634
+ continue
635
+ results.append({
636
+ "title": title[:200] or link[:200],
637
+ "url": link,
638
+ "snippet": "",
639
+ "source": "lieu",
640
+ "score": rank_score(0.7, _rk11),
641
+ })
642
+ if len(results) >= n:
643
+ break
644
+ return results
645
+ return _engine
646
+
647
+
648
+ # ── OpenSky 实时航班 ──────────────────────────────────────────────────────────
649
+
650
+ def _build_opensky_engine(spec: dict[str, Any]) -> Any:
651
+ """OpenSky Network 全球实时航班(ADS-B,免 key)。
652
+
653
+ 查询词含城市/地区名时按 bounding box 返回该区域当前航班。
654
+ """
655
+ timeout = spec.get("timeout", 12)
656
+ # 常见都会区 bbox(lamin,lomin,lamax,lomax)
657
+ _BBOX = {
658
+ "北京": ("39.4", "115.4", "41.1", "117.5"), "上海": ("30.6", "120.8", "31.9", "122.1"),
659
+ "东京": ("35.4", "139.4", "36.1", "140.2"), "纽约": ("40.4", "-74.3", "41.0", "-73.7"),
660
+ "伦敦": ("51.2", "-0.7", "51.7", "0.4"), "巴黎": ("48.6", "2.1", "49.1", "2.6"),
661
+ "新加坡": ("1.1", "103.6", "1.5", "104.1"), "香港": ("22.1", "113.8", "22.6", "114.4"),
662
+ }
663
+
664
+ @safe_search
665
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
666
+ to = _timeout or timeout
667
+ box = None
668
+ for name, b in _BBOX.items():
669
+ if name in query:
670
+ box = b
671
+ break
672
+ if box is None:
673
+ return []
674
+ lamin, lomin, lamax, lomax = box
675
+ url = (f"https://opensky-network.org/api/states/all?lamin={lamin}&lomin={lomin}"
676
+ f"&lamax={lamax}&lomax={lomax}")
677
+ try:
678
+ data = _http_json(url, to, engine=spec.get("_name", ""))
679
+ except Exception as e:
680
+ logger.warning(f"OpenSky 失败: {e}")
681
+ return []
682
+ results = []
683
+ for _rk12, st in enumerate((data.get("states") or [])[:n]):
684
+ if not isinstance(st, list) or len(st) < 8:
685
+ continue
686
+ callsign = (st[1] or "").strip()
687
+ country = st[2] or ""
688
+ lat, lon = st[6], st[5]
689
+ alt, vel = st[7], st[9]
690
+ if not callsign and not lat:
691
+ continue
692
+ results.append({
693
+ "title": f"{callsign or '未知航班'} · {country}",
694
+ "url": f"https://globe.adsbexchange.com/?lat={lat}&lon={lon}&zoom=9",
695
+ "snippet": (f"高度 {alt}m · 地速 {vel}m/s · 位置 {lat:.2f},{lon:.2f}"
696
+ if alt else f"位置 {lat:.2f},{lon:.2f}")[:300],
697
+ "source": "opensky",
698
+ "score": rank_score(0.7, _rk12),
699
+ })
700
+ return results
701
+ return _engine
702
+
703
+
704
+ # ── Electricity Maps 电网碳强度 ───────────────────────────────────────────────
705
+
706
+ def _build_electricity_maps_engine(spec: dict[str, Any]) -> Any:
707
+ """Electricity Maps 全球电网分区(api.electricitymap.org/v3/zones,免 key)。
708
+
709
+ zones 列表为静态目录;查询词匹配国家/区域名时返回分区信息。
710
+ """
711
+ timeout = spec.get("timeout", 12)
712
+
713
+ @safe_search
714
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
715
+ to = _timeout or timeout
716
+ url = "https://api.electricitymap.org/v3/zones"
717
+ try:
718
+ data = _http_json(url, to, engine=spec.get("_name", ""))
719
+ except Exception as e:
720
+ logger.warning(f"Electricity Maps 失败: {e}")
721
+ return []
722
+ if not isinstance(data, dict):
723
+ return []
724
+ q = query.lower()
725
+ results = []
726
+ for _rk13, (key, zone) in enumerate(data.items()):
727
+ name = zone.get("zoneName", "")
728
+ if q in key.lower() or q in name.lower() or q in (zone.get("countryCode") or "").lower():
729
+ results.append({
730
+ "title": f"{name} 电网分区",
731
+ "url": f"https://app.electricitymaps.com/zone/{key}",
732
+ "snippet": (f"分区键 {key} · 商业可用: {zone.get('isCommerciallyAvailable')}"
733
+ f" · 层级 {zone.get('tier')}")[:300],
734
+ "source": "electricity_maps",
735
+ "score": rank_score(0.7, _rk13),
736
+ })
737
+ if len(results) >= n:
738
+ break
739
+ return results
740
+ return _engine
741
+
742
+
743
+ # ── USDA FoodData Central 营养成分 ────────────────────────────────────────────
744
+
745
+ def _build_usda_engine(spec: dict[str, Any]) -> Any:
746
+ """USDA FoodData Central 营养成分(api.nal.usda.gov,官方 DEMO_KEY)。"""
747
+ timeout = spec.get("timeout", 12)
748
+
749
+ @safe_search
750
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
751
+ to = _timeout or timeout
752
+ url = (f"https://api.nal.usda.gov/fdc/v1/foods/search?api_key=DEMO_KEY"
753
+ f"&query={urllib.parse.quote(query)}&pageSize={min(n, 10)}")
754
+ try:
755
+ data = _http_json(url, to, engine=spec.get("_name", ""))
756
+ except Exception as e:
757
+ logger.warning(f"USDA 失败: {e}")
758
+ return []
759
+ results = []
760
+ for _rk14, food in enumerate((data.get("foods") or [])[:n]):
761
+ if not isinstance(food, dict):
762
+ continue
763
+ desc = food.get("description", "")
764
+ fdc = food.get("fdcId", "")
765
+ brand = food.get("brandOwner") or ""
766
+ parts = [p for p in (food.get("foodCategory", ""), brand) if p]
767
+ if not desc:
768
+ continue
769
+ results.append({
770
+ "title": desc[:200],
771
+ "url": f"https://fdc.nal.usda.gov/food-details/{fdc}/nutrients" if fdc else "",
772
+ "snippet": (" · ".join(parts) + f" · FDC {fdc}")[:300] if fdc else " · ".join(parts)[:300],
773
+ "source": "usda",
774
+ "score": rank_score(0.7, _rk14),
775
+ })
776
+ return results
777
+ return _engine
778
+
779
+
780
+ # ── Tatoeba 双语例句 ──────────────────────────────────────────────────────────
781
+
782
+ def _build_tatoeba_engine(spec: dict[str, Any]) -> Any:
783
+ """Tatoeba 多语言例句库(tatoeba.org/api_v0,400+ 语言对)。"""
784
+ timeout = spec.get("timeout", 12)
785
+
786
+ @safe_search
787
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
788
+ to = _timeout or timeout
789
+ url = (f"https://tatoeba.org/en/api_v0/search?query={urllib.parse.quote(query)}"
790
+ f"&limit={min(n, 10)}")
791
+ try:
792
+ data = _http_json(url, to, engine=spec.get("_name", ""))
793
+ except Exception as e:
794
+ logger.warning(f"Tatoeba 失败: {e}")
795
+ return []
796
+ results = []
797
+ sentences = data.get("results")
798
+ if isinstance(sentences, dict):
799
+ sentences = sentences.get("Sentences") or []
800
+ for _rk15, s in enumerate((sentences or [])[:n]):
801
+ if not isinstance(s, dict):
802
+ continue
803
+ text = s.get("text", "")
804
+ lang = s.get("lang", "")
805
+ trans = s.get("translations") or []
806
+ t_texts = []
807
+ # translations 是 list of lists(每层一个目标语言数组)
808
+ for t in trans[:3]:
809
+ arr = t if isinstance(t, list) else []
810
+ t_texts.extend(str(x.get("text", "")) for x in arr[:2] if isinstance(x, dict))
811
+ snippet = " ⇄ ".join(t for t in t_texts if t)[:200] or f"({lang})"
812
+ if not text:
813
+ continue
814
+ results.append({
815
+ "title": text[:200],
816
+ "url": f"https://tatoeba.org/en/sentences/show/{s.get('id', '')}" if s.get("id") else "",
817
+ "snippet": snippet,
818
+ "source": "tatoeba",
819
+ "score": rank_score(0.7, _rk15),
820
+ })
821
+ return results
822
+ return _engine
823
+
824
+
825
+ # ── Figshare 科研数据集 ───────────────────────────────────────────────────────
826
+
827
+ def _build_figshare_engine(spec: dict[str, Any]) -> Any:
828
+ """Figshare 科研数据集检索(api.figshare.com/v2/articles)。"""
829
+ timeout = spec.get("timeout", 12)
830
+
831
+ @safe_search
832
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
833
+ to = _timeout or timeout
834
+ url = (f"https://api.figshare.com/v2/articles?search_for={urllib.parse.quote(query)}"
835
+ f"&page_size={min(n, 10)}")
836
+ try:
837
+ data = _http_json(url, to, engine=spec.get("_name", ""))
838
+ except Exception as e:
839
+ logger.warning(f"Figshare 失败: {e}")
840
+ return []
841
+ results = []
842
+ for _rk16, art in enumerate((data if isinstance(data, list) else [])[:n]):
843
+ if not isinstance(art, dict):
844
+ continue
845
+ title = art.get("title", "")
846
+ if not title:
847
+ continue
848
+ results.append({
849
+ "title": str(title)[:200],
850
+ "url": art.get("url_public_api") or art.get("url_public_html") or "",
851
+ "snippet": f"DOI: {art.get('doi', '')}"[:300] if art.get("doi") else "",
852
+ "source": "figshare",
853
+ "score": rank_score(0.7, _rk16),
854
+ })
855
+ return results
856
+ return _engine
857
+
858
+
859
+ # ── 腾讯 K 线(前复权日 K)────────────────────────────────────────────────────
860
+
861
+ def _resolve_tencent_symbol(q: str, to: float, headers: dict, engine: str = "") -> str:
862
+ """腾讯代码解析(与行情引擎同源逻辑:smartbox 建议接口)。"""
863
+ _STOP = ("股价", "行情", "股票", "价格", "走势", "最新", "今日", "报价", "查询",
864
+ "怎么样", "多少", "怎么", "了", "吗", "的", "a股", "港股", "美股",
865
+ "K线", "k线", "走势图")
866
+ cands = [q]
867
+ for token in re.split(r"[\s,,、/]+", q):
868
+ t = token.strip()
869
+ if not t:
870
+ continue
871
+ for stop in _STOP:
872
+ t = t.replace(stop, "")
873
+ t = t.strip()
874
+ if 2 <= len(t) <= 8 and t not in cands:
875
+ cands.append(t)
876
+ for c in cands:
877
+ try:
878
+ req = urllib.request.Request(
879
+ f"https://smartbox.gtimg.cn/s3/?v=2&t=all&q={urllib.parse.quote(c)}",
880
+ headers=headers)
881
+ with http_open(req, timeout=to, engine=engine) as resp:
882
+ text = resp.read().decode("gbk", "replace")
883
+ except Exception as e:
884
+ logger.warning(f"腾讯代码解析失败: {e}")
885
+ continue
886
+ for m in re.finditer(r'v_hint="([^"]+)"', text):
887
+ parts = m.group(1).split("~")
888
+ if len(parts) >= 3 and parts[2]:
889
+ return parts[0] + parts[1]
890
+ return ""
891
+
892
+
893
+ def _build_tencent_kline_engine(spec: dict[str, Any]) -> Any:
894
+ """腾讯财经前复权日 K 线(web.ifzq.gtimg.cn,免认证 JSON)。
895
+
896
+ 查询「茅台 K线」类意图:代码解析 + 最近 N 日 OHLCV。
897
+ """
898
+ timeout = spec.get("timeout", 10)
899
+
900
+ @safe_search
901
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
902
+ to = _timeout or timeout
903
+ headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
904
+ "Referer": "https://gu.qq.com/"}
905
+ symbol = _resolve_tencent_symbol(query, to, headers, engine=spec.get("_name", ""))
906
+ if not symbol:
907
+ return []
908
+ days = min(max(n * 20, 20), 120)
909
+ url = (f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
910
+ f"?param={symbol},day,,,{days},qfq")
911
+ try:
912
+ req = urllib.request.Request(url, headers=headers)
913
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
914
+ data = json.loads(resp.read().decode("utf-8", "replace"))
915
+ except Exception as e:
916
+ logger.warning(f"腾讯 K 线失败: {e}")
917
+ return []
918
+ node = ((data.get("data") or {}).get(symbol) or {})
919
+ klines = node.get("qfqday") or node.get("day") or []
920
+ if not klines:
921
+ return []
922
+ results = []
923
+ # 最近 n 根:每根一行
924
+ for _rk17, row in enumerate(klines[-n:]):
925
+ if len(row) < 6:
926
+ continue
927
+ date, opn, close, high, low, vol = row[0], row[1], row[2], row[3], row[4], row[5]
928
+ results.append({
929
+ "title": f"{symbol} {date} K线",
930
+ "url": f"https://gu.qq.com/{symbol}/kline",
931
+ "snippet": (f"开 {opn} · 收 {close} · 高 {high} · 低 {low} · 量 {vol}")[:300],
932
+ "source": "tencent_kline",
933
+ "score": rank_score(0.7, _rk17),
934
+ })
935
+ return results
936
+ return _engine
937
+
938
+
939
+ # ── QQ 音乐搜索 ───────────────────────────────────────────────────────────────
940
+
941
+ def _build_qq_music_engine(spec: dict[str, Any]) -> Any:
942
+ """QQ 音乐搜索(c.y.qq.com,需 Referer,免认证 JSON)。"""
943
+ timeout = spec.get("timeout", 10)
944
+
945
+ @safe_search
946
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
947
+ to = _timeout or timeout
948
+ url = (f"https://c.y.qq.com/soso/fcgi-bin/client_search_cp?w={urllib.parse.quote(query)}"
949
+ f"&format=json&n={min(n, 10)}&p=1")
950
+ try:
951
+ req = urllib.request.Request(
952
+ url, headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
953
+ "Referer": "https://y.qq.com/"})
954
+ with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
955
+ data = json.loads(resp.read().decode("utf-8", "replace"))
956
+ except Exception as e:
957
+ logger.warning(f"QQ 音乐失败: {e}")
958
+ return []
959
+ songs = ((data.get("data") or {}).get("song") or {}).get("list") or []
960
+ results = []
961
+ for _rk18, s in enumerate(songs[:n]):
962
+ if not isinstance(s, dict):
963
+ continue
964
+ title = s.get("songname", "")
965
+ singer = " / ".join(str(a.get("name", "")) for a in (s.get("singer") or []) if isinstance(a, dict))
966
+ album = s.get("albumname", "")
967
+ mid = s.get("songmid", "")
968
+ if not title:
969
+ continue
970
+ parts = [p for p in (singer, album) if p]
971
+ results.append({
972
+ "title": f"{title} · {singer}"[:200] if singer else title[:200],
973
+ "url": f"https://y.qq.com/n/ryqq/songDetail/{mid}" if mid else "",
974
+ "snippet": (" · ".join(parts))[:300],
975
+ "source": "qq_music",
976
+ "score": rank_score(0.7, _rk18),
977
+ })
978
+ return results
979
+ return _engine
980
+
981
+
982
+ # ── X/Twitter 单条推文(syndication 免登录通道)─────────────────────────────
983
+
984
+ # 推文 URL / 裸 ID 提取。链接形态覆盖 x.com 与 twitter.com 两代域名,
985
+ # 以及 /statuses/ 老路径与移动端分享链接的 query 参数形态。
986
+ _TWEET_URL_RE = re.compile(
987
+ r"(?:x\.com|twitter\.com)/[^/\s]+/(?:status|statuses)/(\d{10,20})", re.I)
988
+ _TWEET_BARE_ID_RE = re.compile(r"^\d{10,20}$")
989
+ _TWEET_QUERY_ID_RE = re.compile(r"[?&](?:tweet_id|status_id|id)=(\d{10,20})")
990
+
991
+
992
+ def extract_tweet_id(text: str) -> str | None:
993
+ """从查询词提取推文 ID:完整 URL、分享链接参数、裸 ID 均可。"""
994
+ if not text:
995
+ return None
996
+ m = _TWEET_URL_RE.search(text)
997
+ if m:
998
+ return m.group(1)
999
+ m = _TWEET_QUERY_ID_RE.search(text)
1000
+ if m:
1001
+ return m.group(1)
1002
+ text = text.strip()
1003
+ if _TWEET_BARE_ID_RE.match(text):
1004
+ return text
1005
+ return None
1006
+
1007
+
1008
+ # syndication 端点要求 token 参数存在,但值不参与校验。
1009
+ # 实测 2026-09-12(推文 1585841080431321088):token=a、token=0、
1010
+ # token=aaaaaaaaaaaa 三者都返回完整推文,省略 token 参数则返回空响应体。
1011
+ # 因此这里用固定值——按 id 推导 base36 没有意义:那段手写浮点实现既与服务端
1012
+ # 计算方式无关(服务端不校验),也与它自己声称的公式不一致(固定 12 位小数 vs
1013
+ # 最短往返表示;只去首尾 0 vs 去所有 0),测试还把它锁成了金标。
1014
+ # 若将来服务端真的开始校验,再按公开公式
1015
+ # ((id / 1e15) * π).toString(36) 去掉所有 '0' 与 '.'
1016
+ # 重做推导,并在此处补真实向量测试。
1017
+ _SYNDICATION_TOKEN = "argo"
1018
+
1019
+
1020
+ def syndication_token(tweet_id: str) -> str:
1021
+ """syndication 展示参数(服务端不校验值,见上方常量注释)。
1022
+
1023
+ 保留函数签名:调用方与测试按「请求必须带非空 token」这一契约依赖它。
1024
+ """
1025
+ return _SYNDICATION_TOKEN
1026
+
1027
+
1028
+ def _build_twitter_syndication_engine(spec: dict[str, Any]) -> Any:
1029
+ """X/Twitter 单条推文(syndication 通道,免登录、零 key)。
1030
+
1031
+ 为什么需要第二条通道:X 的网页与官方 API 对未登录请求一律拒绝,
1032
+ 唯一常年开放的入口是面向第三方网页嵌入的 syndication 接口——它
1033
+ 本来就是给外部站点引用单条推文用的,天然公开且带完整正文与媒体。
1034
+
1035
+ 语义是「按推文取单条」的抽取型引擎,不是搜索:查询词接受推文 URL
1036
+ (x.com / twitter.com / 带 tweet_id 参数的分享链接)或裸 ID。拿它搜
1037
+ 关键词时诚实返回空列表,不回落到伪造结果——syndication 没有搜索端点。
1038
+ """
1039
+ timeout = spec.get("timeout", 8)
1040
+
1041
+ @safe_search
1042
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1043
+ to = _timeout or timeout
1044
+ tweet_id = extract_tweet_id(query)
1045
+ if not tweet_id:
1046
+ return []
1047
+ token = syndication_token(tweet_id)
1048
+ url = (f"https://cdn.syndication.twimg.com/tweet-result"
1049
+ f"?id={tweet_id}&token={token}")
1050
+ headers = {
1051
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
1052
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
1053
+ "Chrome/150.0.0.0 Safari/537.36",
1054
+ "Accept": "application/json",
1055
+ "Referer": "https://platform.twitter.com/",
1056
+ }
1057
+ from engines_base import _http_get_raw
1058
+ raw = _http_get_raw(url, headers, to, engine="twitter_syndication")
1059
+ if not raw:
1060
+ return []
1061
+ try:
1062
+ data = json.loads(raw)
1063
+ except json.JSONDecodeError:
1064
+ return []
1065
+ # 正常推文 __typename="Tweet";墓碑(已删除/受限)返回 TweetTombstone
1066
+ # 等其他类型或空对象,一律按无结果处理,不伪造占位
1067
+ if not isinstance(data, dict) or not data:
1068
+ return []
1069
+ if data.get("__typename") and data["__typename"] != "Tweet":
1070
+ return []
1071
+ text = str(data.get("text") or "")
1072
+ # 作者字段:现行响应在 user 键(author 为历史兼容候选)
1073
+ author = data.get("user") or data.get("author") or {}
1074
+ screen_name = str(author.get("screen_name") or "")
1075
+ display_name = str(author.get("name") or "")
1076
+ created = str(data.get("created_at") or "")
1077
+ media = data.get("mediaDetails") or (data.get("entities") or {}).get("media") or []
1078
+ media_note = f"(含 {len(media)} 项媒体)" if media else ""
1079
+ canonical_url = (f"https://x.com/{screen_name}/status/{tweet_id}"
1080
+ if screen_name else
1081
+ f"https://x.com/i/web/status/{tweet_id}")
1082
+ title = f"@{screen_name}: {text[:80]}" if screen_name else text[:80]
1083
+ snippet_parts = []
1084
+ if created:
1085
+ snippet_parts.append(created)
1086
+ if display_name:
1087
+ snippet_parts.append(display_name)
1088
+ snippet_parts.append(text[:280])
1089
+ if media_note:
1090
+ snippet_parts.append(media_note)
1091
+ return [{
1092
+ "title": title[:100],
1093
+ "url": canonical_url,
1094
+ "snippet": " | ".join(p for p in snippet_parts if p),
1095
+ "source": "twitter_syndication",
1096
+ "score": 0.9,
1097
+ }]
1098
+ return _engine
1099
+
1100
+
1101
+ # ── 批次八:数据源扩展(2026-09-12)──────────────────────────────────────────
1102
+
1103
+ # Google News RSS 的 hl/gl/ceid 三参数必须成组(ceid 是 "地区:语言" 复合值),
1104
+ # 不能走 engines_base 的单参数 _lang_param 动态化——这里按查询主语言整组切换。
1105
+ _NEWS_LOCALES = {
1106
+ "zh": ("zh-CN", "CN", "CN:zh-Hans"),
1107
+ "ja": ("ja", "JP", "JP:ja"),
1108
+ "ko": ("ko", "KR", "KR:ko"),
1109
+ "en": ("en-US", "US", "US:en"),
1110
+ }
1111
+
1112
+
1113
+ def _news_locale(query: str) -> tuple[str, str, str]:
1114
+ """按查询主语言选 (hl, gl, ceid);检测不了默认 en-US。"""
1115
+ try:
1116
+ from lang_detect import detect_language
1117
+ lang = (detect_language(query) or "en").split("-")[0]
1118
+ except ImportError:
1119
+ lang = "en"
1120
+ return _NEWS_LOCALES.get(lang, _NEWS_LOCALES["en"])
1121
+
1122
+
1123
+ def _build_google_news_engine(spec: dict[str, Any]) -> Any:
1124
+ """Google News RSS(多语言新闻,免认证,结构化 RSS 2.0)。
1125
+
1126
+ engines_base._parse_xml 只解析 Atom(arXiv 型),Google News 是 RSS
1127
+ channel/item 结构且标题带「 - 媒体名」尾巴,这里单独解析并剥短尾巴。
1128
+ 查询里的 when:1d / site: 等运算符原样透传给 Google。
1129
+ """
1130
+ timeout = spec.get("timeout", 12)
1131
+
1132
+ @safe_search
1133
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1134
+ q = query.strip()
1135
+ if not q:
1136
+ return []
1137
+ to = _timeout or timeout
1138
+ hl, gl, ceid = _news_locale(q)
1139
+ url = (f"https://news.google.com/rss/search?q={urllib.parse.quote(q)}"
1140
+ f"&hl={hl}&gl={gl}&ceid={ceid}")
1141
+ raw = _http_get_raw(url, {"User-Agent": "argo-search/1.0 (+google_news)", "Accept": "application/rss+xml"},
1142
+ to, engine=spec.get("_name", "google_news"))
1143
+ if raw is None:
1144
+ return []
1145
+ try:
1146
+ root = ET.fromstring(raw)
1147
+ except ET.ParseError:
1148
+ return []
1149
+ channel = root.find("channel")
1150
+ if channel is None:
1151
+ return []
1152
+ out = []
1153
+ for item in channel.findall("item")[:max(int(n), 1)]:
1154
+ title = (item.findtext("title") or "").strip()
1155
+ link = (item.findtext("link") or "").strip()
1156
+ if not title or not link:
1157
+ continue
1158
+ # 「标题 - 媒体名」尾巴只在水线以下剥:尾巴过长说明是标题本体的一部分
1159
+ if " - " in title:
1160
+ head, tail = title.rsplit(" - ", 1)
1161
+ if 0 < len(tail) <= 25:
1162
+ title = head.strip()
1163
+ pub = (item.findtext("pubDate") or "").strip()
1164
+ out.append({
1165
+ "title": title[:200],
1166
+ "url": link,
1167
+ "snippet": "",
1168
+ "source": "google_news",
1169
+ "published_at": pub,
1170
+ })
1171
+ return out
1172
+ return _engine
1173
+
1174
+
1175
+ def _build_met_museum_engine(spec: dict[str, Any]) -> Any:
1176
+ """Met Museum 藏品库(艺术/博物馆藏品,两跳:search → objects/{id})。
1177
+
1178
+ search 只回 objectIDs 数组,详情逐个取(上限 6 条控二跳延迟);artist 为空
1179
+ 是常态(大量藏品无署名),标题用 title 保底。
1180
+ """
1181
+ timeout = spec.get("timeout", 20)
1182
+ _BASE = "https://collectionapi.metmuseum.org/public/collection/v1"
1183
+
1184
+ @safe_search
1185
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1186
+ q = query.strip()
1187
+ if not q:
1188
+ return []
1189
+ to = _timeout or timeout
1190
+ engine_name = spec.get("_name", "met_museum")
1191
+ headers = {"User-Agent": "argo-search/1.0 (+met_museum)", "Accept": "application/json"}
1192
+ search_url = f"{_BASE}/search?q={urllib.parse.quote(q)}&hasImages=true"
1193
+ raw = _http_get_raw(search_url, headers, to, engine=engine_name)
1194
+ if raw is None:
1195
+ return []
1196
+ try:
1197
+ ids = (json.loads(raw) or {}).get("objectIDs") or []
1198
+ except (json.JSONDecodeError, ValueError):
1199
+ return []
1200
+ out = []
1201
+ for oid in ids[:min(max(int(n), 1), 6)]:
1202
+ obj_raw = _http_get_raw(f"{_BASE}/objects/{oid}", headers, to, engine=engine_name)
1203
+ if obj_raw is None:
1204
+ continue
1205
+ try:
1206
+ d = json.loads(obj_raw)
1207
+ except (json.JSONDecodeError, ValueError):
1208
+ continue
1209
+ title = str(d.get("title") or "").strip()
1210
+ if not title:
1211
+ continue
1212
+ artist = str(d.get("artistDisplayName") or "").strip()
1213
+ bits = [b for b in (artist, str(d.get("objectDate") or "").strip(),
1214
+ str(d.get("medium") or "").strip(),
1215
+ str(d.get("department") or "").strip()) if b]
1216
+ _row: dict[str, Any] = {
1217
+ "title": title,
1218
+ "url": str(d.get("objectURL") or "").strip(),
1219
+ "snippet": " · ".join(bits)[:300],
1220
+ "source": "met_museum",
1221
+ }
1222
+ # 图片字段:search 已带 hasImages=true(只筛有图的),但详情里
1223
+ # primaryImage/primaryImageSmall 仍可能是空串,回落到 Small;
1224
+ # 两者都空则不加字段,不发空链接。
1225
+ #
1226
+ # 上游权利策略是本字段的主要变量(实测 2026-09-13):Met 对
1227
+ # `isPublicDomain: false` 的藏品**一律不给图片链接**("monet" 前
1228
+ # 四个 objectID 全空,仅公版件有值)。所以这里取不到图不代表
1229
+ # 解析坏了——Met 只开放公有领域影像,受版权保护的只给著录信息。
1230
+ _img = (str(d.get("primaryImage") or "").strip()
1231
+ or str(d.get("primaryImageSmall") or "").strip())
1232
+ if _img:
1233
+ _row["image_url"] = _img
1234
+ _row["image_license"] = (
1235
+ "公版(Met Open Access)" if d.get("isPublicDomain")
1236
+ else "受版权保护,仅供检索定位(Met)")
1237
+ out.append(_row)
1238
+ return out
1239
+ return _engine