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,1468 @@
1
+ #!/usr/bin/env python3
2
+ """批次九构建器:免密钥垂直数据源扩展(2026-09-13)。
3
+
4
+ 收录原则与批次七/八一致:免密钥开箱可用、填真空白、过收录检查。
5
+ 本批聚焦 argo 此前的七个真实空白域:
6
+
7
+ who_don 公共卫生事件(WHO 官方 OData)
8
+ who_gho 全球卫生指标(WHO GHO OData)
9
+ gdacs 多灾种预警(洪水/台风/野火,补 usgs 只有地震)
10
+ nebula —
11
+ osv —
12
+ obis 海洋物种观测(2.29 亿条,补 gbif 的陆生视角)
13
+ worms 海洋分类学权威命名
14
+ energy_charts 欧洲发电结构/可再生占比
15
+ openf1 F1 赛车
16
+ jolpica F1 积分榜(Ergast 继任)
17
+ openligadb 德甲足球赛程比分
18
+ artic 芝加哥艺术博物馆(补 art_museum 域)
19
+ cleveland 克利夫兰艺术博物馆(CC0 图像)
20
+ tvmaze 电视剧元数据(补 film_search 的剧集维度)
21
+ jikan 动漫库(MyAnimeList 数据)
22
+ deezer 音乐艺人/专辑(补 media_search 的西语圈)
23
+ listenbrainz 收听趋势(社区收听数据)
24
+ egov_law 日本法令(e-Gov 官方全文,补 kor_law/flk 之外的日韩)
25
+ k10plus 德国最大联合目录(SRU 标准,补 dnb 之外的馆藏)
26
+ ror 研究机构标识(补 org_entity 的学术机构维度)
27
+ nhtsa_vpic 车辆厂商本体
28
+ soilgrids 全球土壤属性(逐点栅格)
29
+ noaa_swpc 空间天气(Kp 指数/太阳活动区)
30
+ satnogs 卫星目录(配合 TLE 镜像)
31
+ tle_mirror TLE 两行根数(Celestrak 不可达时的替代)
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import logging
38
+ import re
39
+ import time
40
+ import urllib.error
41
+ import urllib.parse
42
+ import urllib.request
43
+ from typing import Any
44
+
45
+ from engines_base import (
46
+ rank_score,
47
+ safe_search,
48
+ http_open,
49
+ )
50
+
51
+ logger = logging.getLogger("unified_search.engines")
52
+
53
+ _UA = "argo-search/2.8 (+batch9)"
54
+
55
+ # e-Gov 法令列表进程内缓存(体积大、变动低频)
56
+ _EGOV_ENTRIES: list[str] | None = None
57
+ _EGOV_FETCHED_AT: float = 0.0
58
+ def _latin_query(q: str) -> str:
59
+ """从中英混排查询里抽出拉丁学名部分。
60
+
61
+ 「Gadus morhua 海洋物种」->「Gadus morhua」;纯中文查询返回空串
62
+ (交回百科类引擎,OBIS/WoRMS 无中文名索引)。
63
+ """
64
+ if not q:
65
+ return ""
66
+ stripped = re.sub(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]+", " ", q)
67
+ stripped = re.sub(r"[^A-Za-z0-9\-\s]", " ", stripped)
68
+ return " ".join(stripped.split())
69
+
70
+
71
+ def _terms(q: str) -> list[str]:
72
+ """切检索词:兼顾半角/全角空格(U+3000 不被 \\s 匹配,中文输入常见)。"""
73
+ return [t for t in re.split(r"[\s\u3000]+", (q or "").strip()) if t]
74
+
75
+
76
+
77
+ def _read(resp, engine: str = "") -> bytes:
78
+ """读取响应体,容忍 IncompleteRead。
79
+
80
+ 大体积响应(e-Gov 法令列表实测 2.7MB)在 http.client 上偶发
81
+ `IncompleteRead` —— 此时已读到的部分数据仍然可用(我们先拿头部/整体结构),
82
+ 所以接受 partial 而不是直接失败。
83
+ """
84
+ try:
85
+ return resp.read()
86
+ except Exception as e: # http.client.IncompleteRead 等
87
+ partial = getattr(e, "partial", None)
88
+ if partial:
89
+ logger.warning(f"{engine} 响应不完整,使用已读部分 {len(partial)}B")
90
+ return partial
91
+ raise
92
+
93
+
94
+ def _json(url: str, to: float, engine: str = "") -> Any:
95
+ req = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
96
+ with http_open(req, timeout=to, engine=engine) as resp:
97
+ return json.loads(_read(resp, engine).decode("utf-8", "replace"))
98
+
99
+
100
+ def _text(url: str, to: float, engine: str = "", accept: str = "*/*") -> str:
101
+ req = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": accept})
102
+ with http_open(req, timeout=to, engine=engine) as resp:
103
+ return _read(resp, engine).decode("utf-8", "replace")
104
+
105
+
106
+ # ── WHO DON 疫情暴发通报 ──────────────────────────────────────────────────────
107
+
108
+ def _build_who_don_engine(spec: dict[str, Any]) -> Any:
109
+ """WHO Disease Outbreak News(官方 OData,$filter contains 语法)。"""
110
+ timeout = spec.get("timeout", 15)
111
+ base = "https://www.who.int/api/news/diseaseoutbreaknews"
112
+
113
+ @safe_search
114
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
115
+ to = _timeout or timeout
116
+ q = (query or "").strip()
117
+ # OData:contains(Title,'x') 对中文无效(WHO 通报为英文),
118
+ # 取最近 n 条并按查询词做本地过滤,兼顾「查询无关时仍给官方事件流」。
119
+ # 不传 $top/$orderby:未编码的 $ 参数在此网关返回 400,改为取回后本地按日期排序
120
+ url = base
121
+ try:
122
+ data = _json(url, to, spec.get("_name", ""))
123
+ except Exception as e:
124
+ logger.warning(f"WHO DON 失败: {e}")
125
+ return []
126
+ # 该网关把 OData 的 value 展平为顶层 list;兼容两种形态
127
+ if isinstance(data, list):
128
+ items = data
129
+ else:
130
+ items = data.get("value") or []
131
+ if not isinstance(items, list):
132
+ return []
133
+ # 本地按发布日期倒序(新事件优先)
134
+ items = sorted(
135
+ [i for i in items if isinstance(i, dict)],
136
+ key=lambda i: str(i.get("PublicationDate") or ""), reverse=True)
137
+ # 只保留真正的暴发通报条目(该端点混有其它新闻类型)
138
+ items = [i for i in items if (i.get("Title") or i.get("OverrideTitle"))]
139
+ terms = [t for t in _terms(q.lower()) if len(t) > 1]
140
+ hits, rest = [], []
141
+ for it in items:
142
+ if not isinstance(it, dict):
143
+ continue
144
+ blob = " ".join(str(it.get(k) or "") for k in
145
+ ("Title", "OverrideTitle", "Overview")).lower()
146
+ (hits if (terms and any(t in blob for t in terms)) else rest).append(it)
147
+ out = (hits + rest)[:n]
148
+ results = []
149
+ for _rk, it in enumerate(out):
150
+ title = str(it.get("Title") or it.get("OverrideTitle") or "").strip()
151
+ if not title:
152
+ continue
153
+ results.append({
154
+ "title": title[:200],
155
+ "url": f"https://www.who.int/emergencies/disease-outbreak-news/item/{it.get('Id','')}",
156
+ "snippet": str(it.get("Overview") or it.get("Summary") or "").strip()[:300],
157
+ "source": "who_don",
158
+ "score": rank_score(0.9, _rk),
159
+ "published_at": it.get("PublicationDate") or "",
160
+ })
161
+ return results
162
+ return _engine
163
+
164
+
165
+ # ── WHO GHO 全球卫生指标 ──────────────────────────────────────────────────────
166
+
167
+ def _build_who_gho_engine(spec: dict[str, Any]) -> Any:
168
+ """WHO Global Health Observatory(官方 OData,指标目录检索)。"""
169
+ timeout = spec.get("timeout", 15)
170
+ base = "https://ghoapi.azureedge.net/api/Indicator"
171
+
172
+ @safe_search
173
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
174
+ to = _timeout or timeout
175
+ q = (query or "").strip().lower()
176
+ if not q:
177
+ return []
178
+ # 指标目录不大(数千条),一次取回本地过滤,避免 OData contains 的引号转义坑
179
+ try:
180
+ # 不传 $top:部分网关对未编码的 $ 参数返回 400;目录本身只有数千条
181
+ data = _json(base, to, spec.get("_name", ""))
182
+ except Exception as e:
183
+ logger.warning(f"WHO GHO 失败: {e}")
184
+ return []
185
+ vals = data.get("value") or []
186
+ terms = [t for t in _terms(q) if len(t) > 2]
187
+ scored = []
188
+ for it in vals:
189
+ if not isinstance(it, dict):
190
+ continue
191
+ name = str(it.get("IndicatorName") or "")
192
+ code = str(it.get("IndicatorCode") or "")
193
+ low = f"{name} {code}".lower()
194
+ hits = sum(1 for t in terms if t in low)
195
+ if hits:
196
+ scored.append((hits, name, code))
197
+ scored.sort(key=lambda x: -x[0])
198
+ results = []
199
+ for _rk, (_h, name, code) in enumerate(scored[:n]):
200
+ results.append({
201
+ "title": name[:200],
202
+ "url": f"https://www.who.int/data/gho/data/indicators/indicator-details/GHO/{code}",
203
+ "snippet": f"WHO GHO 指标 · {code}"[:300],
204
+ "source": "who_gho",
205
+ "score": rank_score(0.85, _rk),
206
+ })
207
+ return results
208
+ return _engine
209
+
210
+
211
+ # ── GDACS 多灾种预警 ──────────────────────────────────────────────────────────
212
+
213
+ def _build_gdacs_engine(spec: dict[str, Any]) -> Any:
214
+ """GDACS 全球灾害预警(EU/JRC 官方 GeoJSON,多灾种)。"""
215
+ timeout = spec.get("timeout", 20)
216
+ base = ("https://www.gdacs.org/gdacsapi/api/events/geteventlist/SEARCH"
217
+ "?eventlist=EQ,TC,FL,WF,DR,VO")
218
+
219
+ @safe_search
220
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
221
+ to = _timeout or timeout
222
+ q = (query or "").strip().lower()
223
+ try:
224
+ data = _json(base, to, spec.get("_name", ""))
225
+ except Exception as e:
226
+ logger.warning(f"GDACS 失败: {e}")
227
+ return []
228
+ feats = data.get("features") or []
229
+ terms = [t for t in _terms(q) if len(t) > 2]
230
+ hits, rest = [], []
231
+ for f in feats:
232
+ if not isinstance(f, dict):
233
+ continue
234
+ p = f.get("properties") or {}
235
+ blob = f"{p.get('name','')} {p.get('eventtype','')} {p.get('htmldescription','')}".lower()
236
+ (hits if (terms and any(t in blob for t in terms)) else rest).append(p)
237
+ out = (hits + rest)[:n]
238
+ results = []
239
+ for _rk, p in enumerate(out):
240
+ name = (p.get("name") or p.get("htmldescription") or "").strip()
241
+ if not name:
242
+ continue
243
+ alert = (p.get("alertlevel") or "").lower()
244
+ base_score = 0.9 if alert in ("red",) else (0.85 if alert == "orange" else 0.8)
245
+ # GDACS 的 properties.url 是 dict({report, geometry, ...}),
246
+ # 不是字符串——直接透传会在下游 url.lower() 处抛 AttributeError。
247
+ u = p.get("url")
248
+ if isinstance(u, dict):
249
+ link = u.get("report") or u.get("geometry") or ""
250
+ else:
251
+ link = u or ""
252
+ if not link:
253
+ link = "https://www.gdacs.org/"
254
+ results.append({
255
+ "title": f"[{(p.get('eventtype') or '').upper()}] {name}"[:200],
256
+ "url": link,
257
+ "snippet": (p.get("htmldescription") or "")[:300],
258
+ "source": "gdacs",
259
+ "score": rank_score(base_score, _rk),
260
+ "published_at": (p.get("fromdate") or "")[:10],
261
+ })
262
+ return results
263
+ return _engine
264
+
265
+
266
+ # ── OBIS 海洋生物观测 ─────────────────────────────────────────────────────────
267
+
268
+ def _build_obis_engine(spec: dict[str, Any]) -> Any:
269
+ """OBIS 海洋物种观测(IOC/UNESCO,2.29 亿条记录)。"""
270
+ timeout = spec.get("timeout", 15)
271
+
272
+ @safe_search
273
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
274
+ to = _timeout or timeout
275
+ q = (query or "").strip()
276
+ if not q:
277
+ return []
278
+ # OBIS 的 scientificname 过滤对拉丁学名准确;混排查询抽出拉丁部分再查
279
+ q = _latin_query(q)
280
+ if not q:
281
+ return []
282
+ url = ("https://api.obis.org/v3/occurrence?scientificname="
283
+ f"{urllib.parse.quote(q)}&size={n}")
284
+ try:
285
+ data = _json(url, to, spec.get("_name", ""))
286
+ except Exception as e:
287
+ logger.warning(f"OBIS 失败: {e}")
288
+ return []
289
+ total = data.get("total")
290
+ results = []
291
+ seen = set()
292
+ for _rk, r in enumerate((data.get("results") or [])[:n]):
293
+ if not isinstance(r, dict):
294
+ continue
295
+ sci = r.get("scientificName") or r.get("acceptedNameUsage") or ""
296
+ if not sci or sci in seen:
297
+ continue
298
+ seen.add(sci)
299
+ results.append({
300
+ "title": f"{sci}(海洋观测记录)"[:200],
301
+ "url": "https://obis.org/",
302
+ "snippet": (f"OBIS 海洋生物多样性 · 记录总数 {total} · "
303
+ f"深度 {r.get('depth','-')} m")[:300],
304
+ "source": "obis",
305
+ "score": rank_score(0.85, _rk),
306
+ })
307
+ return results
308
+ return _engine
309
+
310
+
311
+ def _build_worms_engine(spec: dict[str, Any]) -> Any:
312
+ """WoRMS 海洋物种权威命名(分类学标准)。"""
313
+ timeout = spec.get("timeout", 15)
314
+
315
+ @safe_search
316
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
317
+ to = _timeout or timeout
318
+ q = (query or "").strip()
319
+ q = _latin_query(q)
320
+ if not q:
321
+ return []
322
+ url = f"https://www.marinespecies.org/rest/AphiaRecordsByName/{urllib.parse.quote(q)}?like=true"
323
+ try:
324
+ data = _json(url, to, spec.get("_name", ""))
325
+ except Exception as e:
326
+ logger.warning(f"WoRMS 失败: {e}")
327
+ return []
328
+ if not isinstance(data, list):
329
+ return []
330
+ results = []
331
+ for _rk, r in enumerate(data[:n]):
332
+ if not isinstance(r, dict):
333
+ continue
334
+ name = r.get("scientificname") or ""
335
+ if not name:
336
+ continue
337
+ results.append({
338
+ "title": f"{name}({'已接受名' if r.get('status') == 'accepted' else r.get('status') or ''})"[:200],
339
+ "url": f"https://www.marinespecies.org/aphia.php?p=taxdetails&id={r.get('AphiaID','')}",
340
+ "snippet": (f"WoRMS AphiaID {r.get('AphiaID','')} · {r.get('rank','')} · "
341
+ f"{r.get('kingdom','')}")[:300],
342
+ "source": "worms",
343
+ "score": rank_score(0.85, _rk),
344
+ })
345
+ return results
346
+ return _engine
347
+
348
+
349
+ # ── Energy-Charts 欧洲发电结构 ────────────────────────────────────────────────
350
+
351
+ _EC_COUNTRY = {
352
+ "germany": "de", "德国": "de", "france": "fr", "法国": "fr",
353
+ "spain": "es", "西班牙": "es", "italy": "it", "意大利": "it",
354
+ "poland": "pl", "波兰": "pl", "austria": "at", "奥地利": "at",
355
+ "netherlands": "nl", "荷兰": "nl", "belgium": "be", "比利时": "be",
356
+ "switzerland": "ch", "瑞士": "ch", "denmark": "dk", "丹麦": "dk",
357
+ "sweden": "se", "瑞典": "se", "norway": "no", "挪威": "no",
358
+ "portugal": "pt", "葡萄牙": "pt", "czech": "cz", "捷克": "cz",
359
+ }
360
+ _EC_LABEL = {
361
+ "de": "德国", "fr": "法国", "es": "西班牙", "it": "意大利", "pl": "波兰",
362
+ "at": "奥地利", "nl": "荷兰", "be": "比利时", "ch": "瑞士", "dk": "丹麦",
363
+ "se": "瑞典", "no": "挪威", "pt": "葡萄牙", "cz": "捷克",
364
+ }
365
+
366
+
367
+ def _build_energy_charts_engine(spec: dict[str, Any]) -> Any:
368
+ """Energy-Charts 发电结构(Fraunhofer ISE,免认证,需署名)。"""
369
+ timeout = spec.get("timeout", 20)
370
+
371
+ @safe_search
372
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
373
+ to = _timeout or timeout
374
+ q = (query or "").strip()
375
+ low = q.lower()
376
+ cc = None
377
+ for k, v in _EC_COUNTRY.items():
378
+ if k in low:
379
+ cc = v
380
+ break
381
+ if cc is None:
382
+ cc = "de" # 默认德国(数据集覆盖最全)
383
+ # 取最近 2 天的逐时发电结构
384
+ from datetime import date, timedelta
385
+ end = date.today()
386
+ start = end - timedelta(days=1)
387
+ url = ("https://api.energy-charts.info/public_power?country="
388
+ f"{cc}&start={start.isoformat()}&end={end.isoformat()}")
389
+ try:
390
+ data = _json(url, to, spec.get("_name", ""))
391
+ except Exception as e:
392
+ logger.warning(f"Energy-Charts 失败: {e}")
393
+ return []
394
+ types = data.get("production_types") or []
395
+ if not isinstance(types, list):
396
+ return []
397
+ label = _EC_LABEL.get(cc, cc.upper())
398
+ results = []
399
+ # 按累计发电量排序,给该国的发电结构快照
400
+ ranked = []
401
+ for t in types:
402
+ if not isinstance(t, dict):
403
+ continue
404
+ name = t.get("name") or ""
405
+ series = t.get("data") or []
406
+ total = sum(v for v in series if isinstance(v, (int, float)))
407
+ ranked.append((total, name))
408
+ ranked.sort(key=lambda x: -x[0])
409
+ for _rk, (total, name) in enumerate(ranked[:n]):
410
+ if not name:
411
+ continue
412
+ results.append({
413
+ "title": f"{label} 发电结构 · {name}"[:200],
414
+ "url": "https://www.energy-charts.info/",
415
+ "snippet": (f"{label} 近 24h {name} 累计发电 {round(total/1000, 1)} GWh "
416
+ f"(数据来源 Fraunhofer ISE Energy-Charts)")[:300],
417
+ "source": "energy_charts",
418
+ "score": rank_score(0.85, _rk),
419
+ })
420
+ return results
421
+ return _engine
422
+
423
+
424
+ # ── F1:OpenF1 + Jolpica ─────────────────────────────────────────────────────
425
+
426
+ def _build_jolpica_engine(spec: dict[str, Any]) -> Any:
427
+ """Jolpica F1(Ergast 继任 API,积分榜/赛程)。"""
428
+ timeout = spec.get("timeout", 20)
429
+
430
+ @safe_search
431
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
432
+ to = _timeout or timeout
433
+ q = (query or "").strip().lower()
434
+ year = None
435
+ m = re.search(r"\b(19|20)\d{2}\b", q)
436
+ if m:
437
+ year = m.group(0)
438
+ else:
439
+ from datetime import date
440
+ year = str(date.today().year)
441
+ url = f"https://api.jolpi.ca/ergast/f1/{year}/driverstandings/"
442
+ try:
443
+ data = _json(url, to, spec.get("_name", ""))
444
+ except Exception as e:
445
+ logger.warning(f"Jolpica 失败: {e}")
446
+ return []
447
+ try:
448
+ lists = data["MRData"]["StandingsTable"]["StandingsLists"][0]["DriverStandings"]
449
+ except (KeyError, IndexError, TypeError):
450
+ return []
451
+ results = []
452
+ terms = [t for t in _terms(q) if len(t) > 2 and not t.isdigit()]
453
+ filtered = []
454
+ for d in lists:
455
+ if not isinstance(d, dict):
456
+ continue
457
+ drv = d.get("Driver") or {}
458
+ nm = f"{drv.get('givenName','')} {drv.get('familyName','')}".strip()
459
+ cons = ", ".join((c.get("Constructor") or {}).get("name", "")
460
+ for c in (d.get("Constructors") or []) if isinstance(c, dict))
461
+ blob = f"{nm} {cons}".lower()
462
+ filtered.append((1 if (terms and any(t in blob for t in terms)) else 0, d, nm, cons))
463
+ filtered.sort(key=lambda x: -x[0])
464
+ for _rk, (_h, d, nm, cons) in enumerate(filtered[:n]):
465
+ if not nm:
466
+ continue
467
+ results.append({
468
+ "title": f"F1 {year} 车手积分榜 · P{d.get('position','')} {nm}"[:200],
469
+ "url": (d.get("Driver") or {}).get("url") or "https://www.formula1.com/",
470
+ "snippet": (f"{nm} · {cons} · 积分 {d.get('points','')} · 胜场 {d.get('wins','')}"
471
+ f"({year} 赛季)")[:300],
472
+ "source": "jolpica",
473
+ "score": rank_score(0.85, _rk),
474
+ })
475
+ return results
476
+ return _engine
477
+
478
+
479
+ def _build_openf1_engine(spec: dict[str, Any]) -> Any:
480
+ """OpenF1 车手/车队(两级:sessions → drivers)。"""
481
+ timeout = spec.get("timeout", 20)
482
+
483
+ @safe_search
484
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
485
+ to = _timeout or timeout
486
+ q = (query or "").strip().lower()
487
+ try:
488
+ sessions = _json("https://api.openf1.org/v1/sessions?year=2026", to, spec.get("_name", ""))
489
+ except Exception as e:
490
+ logger.warning(f"OpenF1 sessions 失败: {e}")
491
+ return []
492
+ if not isinstance(sessions, list) or not sessions:
493
+ return []
494
+ # 取最近一场有 session_key 的场次
495
+ sk = None
496
+ for s in reversed(sessions):
497
+ if isinstance(s, dict) and s.get("session_key"):
498
+ sk = s["session_key"]
499
+ break
500
+ if sk is None:
501
+ return []
502
+ try:
503
+ drivers = _json(f"https://api.openf1.org/v1/drivers?session_key={sk}", to, spec.get("_name", ""))
504
+ except Exception as e:
505
+ logger.warning(f"OpenF1 drivers 失败: {e}")
506
+ return []
507
+ if not isinstance(drivers, list):
508
+ return []
509
+ terms = [t for t in _terms(q) if len(t) > 2]
510
+ rows = []
511
+ for d in drivers:
512
+ if not isinstance(d, dict):
513
+ continue
514
+ nm = d.get("full_name") or d.get("broadcast_name") or ""
515
+ team = d.get("team_name") or ""
516
+ blob = f"{nm} {team}".lower()
517
+ rows.append((1 if (terms and any(t in blob for t in terms)) else 0, d, nm, team))
518
+ rows.sort(key=lambda x: -x[0])
519
+ results = []
520
+ for _rk, (_h, d, nm, team) in enumerate(rows[:n]):
521
+ if not nm:
522
+ continue
523
+ results.append({
524
+ "title": f"F1 {nm} · {team}"[:200],
525
+ "url": "https://openf1.org/",
526
+ "snippet": (f"车号 {d.get('driver_number','')} · {team} · "
527
+ f"{d.get('country_code','')}(OpenF1 遥测数据)")[:300],
528
+ "source": "openf1",
529
+ "score": rank_score(0.8, _rk),
530
+ })
531
+ return results
532
+ return _engine
533
+
534
+
535
+ def _build_openligadb_engine(spec: dict[str, Any]) -> Any:
536
+ """OpenLigaDB 德甲/欧洲联赛赛程比分(免认证)。"""
537
+ timeout = spec.get("timeout", 15)
538
+ LEAGUES = {"bundesliga": "bl1", "德甲": "bl1", "bl1": "bl1",
539
+ "bl2": "bl2", "德乙": "bl2", "premier": "bl1"}
540
+
541
+ @safe_search
542
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
543
+ to = _timeout or timeout
544
+ q = (query or "").strip()
545
+ low = q.lower()
546
+ league = "bl1"
547
+ for k, v in LEAGUES.items():
548
+ if k in low:
549
+ league = v
550
+ break
551
+ from datetime import date
552
+ season = date.today().year
553
+ url = f"https://api.openligadb.de/getmatchdata/{league}/{season}"
554
+ try:
555
+ data = _json(url, to, spec.get("_name", ""))
556
+ except Exception as e:
557
+ logger.warning(f"OpenLigaDB 失败: {e}")
558
+ return []
559
+ if not isinstance(data, list):
560
+ return []
561
+ terms = [t for t in _terms(low) if len(t) > 2 and not t.isdigit()]
562
+ rows = []
563
+ for m in data:
564
+ if not isinstance(m, dict):
565
+ continue
566
+ t1 = ((m.get("team1") or {}).get("teamName") or "")
567
+ t2 = ((m.get("team2") or {}).get("teamName") or "")
568
+ blob = f"{t1} {t2}".lower()
569
+ rows.append((1 if (terms and any(t in blob for t in terms)) else 0, m, t1, t2))
570
+ # 无命中时给最近场次(按时间倒序取尾部)
571
+ rows.sort(key=lambda x: x[0])
572
+ hits = [r for r in rows if r[0] == 1]
573
+ pool = (hits if hits else rows)[:n]
574
+ results = []
575
+ for _rk, (_h, m, t1, t2) in enumerate(pool):
576
+ if not t1 and not t2:
577
+ continue
578
+ res = m.get("matchResults") or []
579
+ score_txt = ""
580
+ if res and isinstance(res[-1], dict):
581
+ score_txt = f" 比分 {res[-1].get('pointsTeam1','')}-{res[-1].get('pointsTeam2','')}"
582
+ results.append({
583
+ "title": f"{t1} vs {t2}{score_txt}"[:200],
584
+ "url": "https://www.openligadb.de/",
585
+ "snippet": (f"{m.get('leagueName','')} · 第 {m.get('groupName') or ''} 轮 · "
586
+ f"{str(m.get('matchDateTime',''))[:16]}")[:300],
587
+ "source": "openligadb",
588
+ "score": rank_score(0.8, _rk),
589
+ "published_at": str(m.get("matchDateTime") or "")[:10],
590
+ })
591
+ return results
592
+ return _engine
593
+
594
+
595
+ # ── 博物馆:AIC + Cleveland(补 art_museum 域)────────────────────────────────
596
+
597
+ def _build_artic_engine(spec: dict[str, Any]) -> Any:
598
+ """Art Institute of Chicago 馆藏(免认证,含 IIIF 图像)。"""
599
+ timeout = spec.get("timeout", 20)
600
+
601
+ @safe_search
602
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
603
+ to = _timeout or timeout
604
+ q = (query or "").strip()
605
+ if not q:
606
+ return []
607
+ url = ("https://api.artic.edu/api/v1/artworks/search?"
608
+ f"q={urllib.parse.quote(q)}&limit={n}"
609
+ "&fields=id,title,artist_display,date_display,image_id")
610
+ try:
611
+ data = _json(url, to, spec.get("_name", ""))
612
+ except Exception as e:
613
+ logger.warning(f"AIC 失败: {e}")
614
+ return []
615
+ # IIIF 基址取响应自带的 config.iiif_url,不硬编码——上游换域时硬编码会
616
+ # 静默失效。该图床要求带 AIC-User-Agent 头才放行直连(否则 403 HEAD)。
617
+ iiif = str(((data.get("config") or {}).get("iiif_url")) or "").rstrip("/")
618
+ results = []
619
+ for _rk, it in enumerate((data.get("data") or [])[:n]):
620
+ if not isinstance(it, dict):
621
+ continue
622
+ title = (it.get("title") or "").strip()
623
+ if not title:
624
+ continue
625
+ _row = {
626
+ "title": title[:200],
627
+ "url": f"https://www.artic.edu/artworks/{it.get('id','')}",
628
+ "snippet": (f"{it.get('artist_display','')} · {it.get('date_display','')}"
629
+ "(芝加哥艺术博物馆)")[:300],
630
+ "source": "artic",
631
+ "score": rank_score(0.85, _rk),
632
+ }
633
+ # 图片字段:请求里本就取了 image_id(此前取到即丢),这里按 AIC
634
+ # 官方 IIIF 模板拼出可直接打开的图;无 image_id 的藏品不加字段。
635
+ _iid = str(it.get("image_id") or "").strip()
636
+ if _iid and iiif:
637
+ _row["image_url"] = f"{iiif}/{_iid}/full/843,/0/default.jpg"
638
+ _row["image_license"] = "AIC 公开接口(权利状态见作品页)"
639
+ results.append(_row)
640
+ return results
641
+ return _engine
642
+
643
+
644
+ def _build_cleveland_engine(spec: dict[str, Any]) -> Any:
645
+ """克利夫兰艺术博物馆(CC0 开放图像)。"""
646
+ timeout = spec.get("timeout", 20)
647
+
648
+ @safe_search
649
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
650
+ to = _timeout or timeout
651
+ q = (query or "").strip()
652
+ if not q:
653
+ return []
654
+ url = ("https://openaccess-api.clevelandart.org/api/artworks/?"
655
+ f"q={urllib.parse.quote(q)}&limit={n}&has_image=1")
656
+ try:
657
+ data = _json(url, to, spec.get("_name", ""))
658
+ except Exception as e:
659
+ logger.warning(f"Cleveland 失败: {e}")
660
+ return []
661
+ results = []
662
+ for _rk, it in enumerate((data.get("data") or [])[:n]):
663
+ if not isinstance(it, dict):
664
+ continue
665
+ title = (it.get("title") or "").strip()
666
+ if not title:
667
+ continue
668
+ _lic = str(it.get("share_license_status") or "").strip()
669
+ _row = {
670
+ "title": title[:200],
671
+ "url": it.get("url") or "https://www.clevelandart.org/",
672
+ "snippet": (f"{it.get('tombstone','')} · 授权 {_lic}"
673
+ "(克利夫兰艺术博物馆)")[:300],
674
+ "source": "cleveland",
675
+ "score": rank_score(0.85, _rk),
676
+ }
677
+ # 图片字段:请求已带 has_image=1,这里把 images.web.url 一并带上
678
+ # (web 尺寸适合直接展示,full/print 留给需要原图的场景)。
679
+ _img = str((((it.get("images") or {}).get("web") or {}).get("url")) or "").strip()
680
+ if _img:
681
+ _row["image_url"] = _img
682
+ if _lic:
683
+ _row["image_license"] = _lic
684
+ results.append(_row)
685
+ return results
686
+ return _engine
687
+
688
+
689
+ # ── 影视/动漫/音乐 ────────────────────────────────────────────────────────────
690
+
691
+ def _build_tvmaze_engine(spec: dict[str, Any]) -> Any:
692
+ """TVMaze 电视剧元数据(免认证,补 film_search 的剧集维度)。"""
693
+ timeout = spec.get("timeout", 15)
694
+
695
+ @safe_search
696
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
697
+ to = _timeout or timeout
698
+ q = (query or "").strip()
699
+ if not q:
700
+ return []
701
+ url = f"https://api.tvmaze.com/search/shows?q={urllib.parse.quote(q)}"
702
+ try:
703
+ data = _json(url, to, spec.get("_name", ""))
704
+ except Exception as e:
705
+ logger.warning(f"TVMaze 失败: {e}")
706
+ return []
707
+ results = []
708
+ for _rk, row in enumerate((data or [])[:n]):
709
+ if not isinstance(row, dict):
710
+ continue
711
+ s = row.get("show") or {}
712
+ name = (s.get("name") or "").strip()
713
+ if not name:
714
+ continue
715
+ genres = ", ".join(s.get("genres") or [])
716
+ results.append({
717
+ "title": f"{name}({s.get('premiered','')[:4]})"[:200],
718
+ "url": s.get("url") or "",
719
+ "snippet": (f"{s.get('language','')} · {genres} · "
720
+ f"评分 {((s.get('rating') or {}).get('average')) or '-'}"
721
+ "(TVMaze)")[:300],
722
+ "source": "tvmaze",
723
+ "score": rank_score(0.8, _rk),
724
+ "published_at": s.get("premiered") or "",
725
+ })
726
+ return results
727
+ return _engine
728
+
729
+
730
+ def _build_jikan_engine(spec: dict[str, Any]) -> Any:
731
+ """Jikan(MyAnimeList 非官方 API)动漫元数据。"""
732
+ timeout = spec.get("timeout", 20)
733
+
734
+ @safe_search
735
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
736
+ to = _timeout or timeout
737
+ q = (query or "").strip()
738
+ if not q:
739
+ return []
740
+ url = f"https://api.jikan.moe/v4/anime?q={urllib.parse.quote(q)}&limit={n}"
741
+ try:
742
+ data = _json(url, to, spec.get("_name", ""))
743
+ except Exception as e:
744
+ logger.warning(f"Jikan 失败: {e}")
745
+ return []
746
+ results = []
747
+ for _rk, a in enumerate((data.get("data") or [])[:n]):
748
+ if not isinstance(a, dict):
749
+ continue
750
+ title = a.get("title") or a.get("title_japanese") or ""
751
+ if not title:
752
+ continue
753
+ results.append({
754
+ "title": title[:200],
755
+ "url": a.get("url") or "",
756
+ "snippet": (f"{a.get('type','')} · {a.get('episodes','') or '-'} 集 · "
757
+ f"评分 {a.get('score') or '-'} · {a.get('status','')}"
758
+ "(MyAnimeList)")[:300],
759
+ "source": "jikan",
760
+ "score": rank_score(0.8, _rk),
761
+ })
762
+ return results
763
+ return _engine
764
+
765
+
766
+ def _build_deezer_engine(spec: dict[str, Any]) -> Any:
767
+ """Deezer 音乐艺人/专辑(免认证)。"""
768
+ timeout = spec.get("timeout", 15)
769
+
770
+ @safe_search
771
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
772
+ to = _timeout or timeout
773
+ q = (query or "").strip()
774
+ if not q:
775
+ return []
776
+ url = f"https://api.deezer.com/search/artist?q={urllib.parse.quote(q)}&limit={n}"
777
+ try:
778
+ data = _json(url, to, spec.get("_name", ""))
779
+ except Exception as e:
780
+ logger.warning(f"Deezer 失败: {e}")
781
+ return []
782
+ results = []
783
+ for _rk, a in enumerate((data.get("data") or [])[:n]):
784
+ if not isinstance(a, dict):
785
+ continue
786
+ name = (a.get("name") or "").strip()
787
+ if not name:
788
+ continue
789
+ results.append({
790
+ "title": f"{name}(音乐人)"[:200],
791
+ "url": a.get("link") or "",
792
+ "snippet": (f"Deezer 音乐人 · 专辑 {a.get('nb_album','')} · "
793
+ f"粉丝 {a.get('nb_fan','')}")[:300],
794
+ "source": "deezer",
795
+ "score": rank_score(0.8, _rk),
796
+ })
797
+ return results
798
+ return _engine
799
+
800
+
801
+ def _build_listenbrainz_engine(spec: dict[str, Any]) -> Any:
802
+ """ListenBrainz 收听趋势(社区收听统计,补 MusicBrainz 元数据的动态维度)。"""
803
+ timeout = spec.get("timeout", 15)
804
+
805
+ @safe_search
806
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
807
+ to = _timeout or timeout
808
+ q = (query or "").strip().lower()
809
+ url = "https://api.listenbrainz.org/1/stats/sitewide/artists?range=week"
810
+ try:
811
+ data = _json(url, to, spec.get("_name", ""))
812
+ except Exception as e:
813
+ logger.warning(f"ListenBrainz 失败: {e}")
814
+ return []
815
+ arts = ((data.get("payload") or {}).get("artists")) or []
816
+ terms = [t for t in _terms(q) if len(t) > 2]
817
+ rows = []
818
+ for a in arts:
819
+ if not isinstance(a, dict):
820
+ continue
821
+ nm = a.get("artist_name") or ""
822
+ rows.append((1 if (terms and any(t in nm.lower() for t in terms)) else 0, a, nm))
823
+ rows.sort(key=lambda x: -x[0])
824
+ results = []
825
+ for _rk, (_h, a, nm) in enumerate(rows[:n]):
826
+ if not nm:
827
+ continue
828
+ results.append({
829
+ "title": f"{nm}(本周收听榜)"[:200],
830
+ "url": f"https://listenbrainz.org/artist/{a.get('artist_mbid','')}",
831
+ "snippet": f"ListenBrainz 全站本周收听 {a.get('listen_count','')} 次"[:300],
832
+ "source": "listenbrainz",
833
+ "score": rank_score(0.75, _rk),
834
+ })
835
+ return results
836
+ return _engine
837
+
838
+
839
+ # ── 法律/图书馆/机构 ──────────────────────────────────────────────────────────
840
+
841
+ def _build_egov_law_engine(spec: dict[str, Any]) -> Any:
842
+ """日本 e-Gov 法令(官方全文,免认证 XML)。"""
843
+ timeout = spec.get("timeout", 20)
844
+
845
+ @safe_search
846
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
847
+ to = _timeout or timeout
848
+ q = (query or "").strip()
849
+ if not q:
850
+ return []
851
+ # lawlists 全量返回法令名列表 XML(~2.7MB),本地按关键词过滤。
852
+ # 该端点无服务端检索参数且体积大,加进程内缓存(TTL 1h)避免每次查询
853
+ # 都重新拉取——实测单次 3.5s,缓存后 <10ms。
854
+ global _EGOV_ENTRIES, _EGOV_FETCHED_AT
855
+ now = time.time()
856
+ if _EGOV_ENTRIES is None or (now - _EGOV_FETCHED_AT) > 3600:
857
+ try:
858
+ xml = _text("https://laws.e-gov.go.jp/api/1/lawlists/1", to,
859
+ spec.get("_name", ""), "application/xml")
860
+ except Exception as e:
861
+ logger.warning(f"e-Gov 失败: {e}")
862
+ return []
863
+ _EGOV_ENTRIES = re.findall(
864
+ r"<LawNameListInfo>(.*?)</LawNameListInfo>", xml, re.S)
865
+ _EGOV_FETCHED_AT = now
866
+ entries = _EGOV_ENTRIES
867
+ # 检索词做 OR + 打分:多词查询(如「日本民法 条文」)里「条文」
868
+ # 不会出现在法令名中,若要求每个词都命中会全数落空(实测踩过)。
869
+ # 同时把「日本/日语」等修饰词剔除——它们指语言而非法令名内容。
870
+ _STOP = {"日本", "日语", "日文", "条文", "法律", "法令", "法条", "japan", "japanese"}
871
+ terms = [t for t in _terms(q) if t and t.lower() not in _STOP]
872
+ # 中日文无空格分词:整串「日本民法」在法令名里查不到(法令名形如
873
+ # 「民法」「民法施行法」)。故对每个词补出 **2 字以上子串** 作为候选,
874
+ # 并对前缀「日本」做剥离,让「日本民法」也能命中「民法」。
875
+ expanded = []
876
+ for t in terms:
877
+ if not t:
878
+ continue
879
+ if t.startswith(("日本", "日本国")):
880
+ t = t[2:] if t.startswith("日本国") else t[2:]
881
+ if t:
882
+ expanded.append(t)
883
+ # 长词切成 2-gram 片段(中文/日文检索的常用退化策略)
884
+ if len(t) >= 4 and not t.isascii():
885
+ for i in range(len(t) - 1):
886
+ expanded.append(t[i:i + 2])
887
+ terms = list(dict.fromkeys(expanded)) or [q]
888
+ hits = []
889
+ for e in entries:
890
+ mid = re.search(r"<LawId>(.*?)</LawId>", e, re.S)
891
+ mnm = re.search(r"<LawName>(.*?)</LawName>", e, re.S)
892
+ mno = re.search(r"<LawNo>(.*?)</LawNo>", e, re.S)
893
+ if not mnm:
894
+ continue
895
+ name = mnm.group(1).strip()
896
+ blob = f"{name} {mno.group(1) if mno else ''}"
897
+ score = sum(1 for t in terms if t in blob)
898
+ # 命中任一检索词即入选,完全没命中则只保留含「法」的条目做保底
899
+ if score or any(t in blob for t in terms):
900
+ hits.append((score, name, mid.group(1).strip() if mid else "",
901
+ mno.group(1).strip() if mno else ""))
902
+ hits.sort(key=lambda x: -x[0])
903
+ results = []
904
+ for _rk, (_s, name, lid, lno) in enumerate(hits[:n]):
905
+ _row = {
906
+ "title": name[:200],
907
+ "url": f"https://laws.e-gov.go.jp/law/{lid}" if lid else "https://laws.e-gov.go.jp/",
908
+ "snippet": f"日本法令 · {lno}(e-Gov 法令検索)"[:300],
909
+ "source": "egov_law",
910
+ "score": rank_score(0.85, _rk),
911
+ }
912
+ # 源内全文直出:`/law/{id}` 是 JS 空壳页(实测去标签后只剩
913
+ # 「e-Gov 法令検索」几个字,拿不到条文),而 lawdata 端点返回
914
+ # 该法令的官方全文 XML。所以把**可确定性取正文**的 URL 单独带上,
915
+ # 交给下游取数建议优先使用,避免为了正文去走浏览器渲染那几级。
916
+ if lid:
917
+ _row["full_text_url"] = f"https://laws.e-gov.go.jp/api/1/lawdata/{lid}"
918
+ results.append(_row)
919
+ return results
920
+ return _engine
921
+
922
+
923
+ def _build_k10plus_engine(spec: dict[str, Any]) -> Any:
924
+ """K10plus SRU 联合目录(德国最大,SRU 标准协议)。
925
+
926
+ SRU 检索在 k10plus 上较慢(实测常 5-15s),超时给足。
927
+ """
928
+ timeout = spec.get("timeout", 30)
929
+
930
+ @safe_search
931
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
932
+ to = _timeout or timeout
933
+ q = (query or "").strip()
934
+ if not q:
935
+ return []
936
+ url = ("https://sru.k10plus.de/gvk?version=1.1&operation=searchRetrieve"
937
+ f"&query={urllib.parse.quote('pica.all=' + q)}"
938
+ f"&maximumRecords={max(n, 1)}&recordSchema=dc")
939
+ try:
940
+ xml = _text(url, to, spec.get("_name", ""), "application/xml")
941
+ except Exception as e:
942
+ logger.warning(f"K10plus 失败: {e}")
943
+ return []
944
+ # SRU 响应的 record 可能带命名空间前缀(zs:record),两种都收
945
+ recs = re.findall(r"<(?:\w+:)?record[^>]*>(.*?)</(?:\w+:)?record>", xml, re.S)
946
+ if not recs:
947
+ # 退一步:按 numberOfRecords 判空,避免把「确实无结果」误报为失败
948
+ recs = []
949
+ results = []
950
+ for _rk, r in enumerate(recs[:n]):
951
+ # OAI-DC 的元素带 inline xmlns 属性(<dc:title xmlns:dc="...">),
952
+ # 正则必须容忍标签属性,否则全部匹配失败(实测踩过)。
953
+ t = re.search(r"<dc:title\b[^>]*>(.*?)</dc:title>", r, re.S)
954
+ c = re.search(r"<dc:(?:creator|contributor)\b[^>]*>(.*?)</dc:(?:creator|contributor)>", r, re.S)
955
+ d = re.search(r"<dc:date\b[^>]*>(.*?)</dc:date>", r, re.S)
956
+ i = re.search(r"<dc:identifier\b[^>]*>(.*?)</dc:identifier>", r, re.S)
957
+ if not t:
958
+ continue
959
+ title = re.sub(r"<[^>]+>", "", t.group(1)).strip()
960
+ if not title:
961
+ continue
962
+ results.append({
963
+ "title": title[:200],
964
+ "url": (re.sub(r"<[^>]+>", "", i.group(1)).strip() if i else "https://k10plus.de/"),
965
+ "snippet": (f"{re.sub(r'<[^>]+>','',c.group(1)).strip() if c else ''} · "
966
+ f"{re.sub(r'<[^>]+>','',d.group(1)).strip() if d else ''}"
967
+ "(K10plus 联合目录)")[:300],
968
+ "source": "k10plus",
969
+ "score": rank_score(0.8, _rk),
970
+ })
971
+ return results
972
+ return _engine
973
+
974
+
975
+ def _build_ror_engine(spec: dict[str, Any]) -> Any:
976
+ """ROR 研究机构标识(免认证,机构 ID 与域名映射)。"""
977
+ timeout = spec.get("timeout", 15)
978
+
979
+ @safe_search
980
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
981
+ to = _timeout or timeout
982
+ q = (query or "").strip()
983
+ if not q:
984
+ return []
985
+ url = f"https://api.ror.org/organizations?query={urllib.parse.quote(q)}"
986
+ try:
987
+ data = _json(url, to, spec.get("_name", ""))
988
+ except Exception as e:
989
+ logger.warning(f"ROR 失败: {e}")
990
+ return []
991
+ results = []
992
+ items = data.get("items")
993
+ if not isinstance(items, list):
994
+ items = []
995
+ for _rk, o in enumerate(items[:n]):
996
+ if not isinstance(o, dict):
997
+ continue
998
+ # ROR v2 的机构名在 names[] 里,主名标 types 含 ror_display;
999
+ # 顶层 name 字段已不再返回(实测为 None)。
1000
+ name = ""
1001
+ names = o.get("names") or []
1002
+ for nm in names:
1003
+ if not isinstance(nm, dict):
1004
+ continue
1005
+ types = nm.get("types") or []
1006
+ if "ror_display" in types:
1007
+ name = str(nm.get("value") or "").strip()
1008
+ break
1009
+ if not name:
1010
+ for nm in names:
1011
+ if isinstance(nm, dict) and nm.get("value"):
1012
+ name = str(nm["value"]).strip()
1013
+ break
1014
+ if not name:
1015
+ continue
1016
+ # 国家在 locations[].geonames_details.country_name
1017
+ country = ""
1018
+ for loc in (o.get("locations") or []):
1019
+ if isinstance(loc, dict):
1020
+ gd = loc.get("geonames_details") or {}
1021
+ if gd.get("country_name"):
1022
+ country = str(gd["country_name"])
1023
+ break
1024
+ domains = ", ".join((o.get("domains") or [])[:2])
1025
+ results.append({
1026
+ "title": name[:200],
1027
+ "url": o.get("id") or "",
1028
+ "snippet": (f"ROR {str(o.get('id','')).rsplit('/',1)[-1]} · "
1029
+ f"{country} · {domains}(研究机构唯一标识)")[:300],
1030
+ "source": "ror",
1031
+ "score": rank_score(0.8, _rk),
1032
+ })
1033
+ return results
1034
+ return _engine
1035
+
1036
+
1037
+ # ── 科学:土壤 / 空间天气 / 卫星 ──────────────────────────────────────────────
1038
+
1039
+ def _build_soilgrids_engine(spec: dict[str, Any]) -> Any:
1040
+ """ISRIC SoilGrids 全球土壤属性(逐点栅格,免认证)。"""
1041
+ timeout = spec.get("timeout", 25)
1042
+ PROPS = ["clay", "sand", "silt", "soc", "phh2o", "nitrogen", "cec", "bdod"]
1043
+
1044
+ @safe_search
1045
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1046
+ to = _timeout or timeout
1047
+ q = (query or "").strip()
1048
+ if not q:
1049
+ return []
1050
+ # 先地理编码拿坐标(复用 Open-Meteo geocoding,免认证)
1051
+ try:
1052
+ geo = _json("https://geocoding-api.open-meteo.com/v1/search?name="
1053
+ f"{urllib.parse.quote(q)}&count=1&language=zh", to, spec.get("_name", ""))
1054
+ except Exception as e:
1055
+ logger.warning(f"SoilGrids 地理编码失败: {e}")
1056
+ return []
1057
+ places = geo.get("results") or []
1058
+ if not places:
1059
+ return []
1060
+ lat, lon = places[0].get("latitude"), places[0].get("longitude")
1061
+ place = places[0].get("name", "")
1062
+ if lat is None or lon is None:
1063
+ return []
1064
+ results = []
1065
+ for _rk, prop in enumerate(PROPS[:n]):
1066
+ try:
1067
+ d = _json("https://rest.isric.org/soilgrids/v2.0/properties/query?"
1068
+ f"lon={lon}&lat={lat}&property={prop}&depth=0-5cm&value=mean",
1069
+ to, spec.get("_name", ""))
1070
+ except Exception:
1071
+ continue
1072
+ try:
1073
+ val = d["properties"]["layers"][0]["depths"][0]["values"]["mean"]
1074
+ except (KeyError, IndexError, TypeError):
1075
+ continue
1076
+ if val is None:
1077
+ continue
1078
+ results.append({
1079
+ "title": f"{place} 土壤属性 · {prop}"[:200],
1080
+ "url": f"https://soilgrids.org/?lat={lat}&lon={lon}",
1081
+ "snippet": (f"{place}({round(lat,3)},{round(lon,3)})0-5cm {prop} 均值 "
1082
+ f"{val}(ISRIC SoilGrids)")[:300],
1083
+ "source": "soilgrids",
1084
+ "score": rank_score(0.8, _rk),
1085
+ })
1086
+ return results
1087
+ return _engine
1088
+
1089
+
1090
+ def _build_noaa_swpc_engine(spec: dict[str, Any]) -> Any:
1091
+ """NOAA SWPC 空间天气(Kp 指数 + 太阳活动区,官方免认证)。"""
1092
+ timeout = spec.get("timeout", 15)
1093
+
1094
+ @safe_search
1095
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1096
+ to = _timeout or timeout
1097
+ q = (query or "").strip().lower()
1098
+ results = []
1099
+ # 1) 行星 Kp 指数(最近若干条)
1100
+ try:
1101
+ kp = _json("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json",
1102
+ to, spec.get("_name", ""))
1103
+ if isinstance(kp, list) and len(kp) > 1:
1104
+ for _rk, row in enumerate(list(reversed(kp[1:]))[:max(1, n // 2)]):
1105
+ if not isinstance(row, list) or len(row) < 3:
1106
+ continue
1107
+ results.append({
1108
+ "title": f"行星 Kp 指数 {row[1]}({str(row[0])[:16]})"[:200],
1109
+ "url": "https://www.swpc.noaa.gov/products/planetary-k-index",
1110
+ "snippet": f"NOAA SWPC 地磁活动指数 Kp={row[1]} · a_running={row[2]}"[:300],
1111
+ "source": "noaa_swpc",
1112
+ "score": rank_score(0.85, len(results)),
1113
+ })
1114
+ except Exception as e:
1115
+ logger.warning(f"SWPC Kp 失败: {e}")
1116
+ # 2) 太阳活动区(黑子/耀斑)
1117
+ try:
1118
+ regs = _json("https://services.swpc.noaa.gov/json/solar_regions.json",
1119
+ to, spec.get("_name", ""))
1120
+ if isinstance(regs, list):
1121
+ for reg in list(reversed(regs))[:max(1, n - len(results))]:
1122
+ if not isinstance(reg, dict):
1123
+ continue
1124
+ results.append({
1125
+ "title": f"太阳活动区 {reg.get('region','')}({reg.get('location','')})"[:200],
1126
+ "url": "https://www.swpc.noaa.gov/products/solar-regions",
1127
+ "snippet": (f"NOAA SWPC 太阳活动区 · X 级耀斑 {reg.get('x_xray_events',0)} · "
1128
+ f"M 级 {reg.get('m_flares',0)}")[:300],
1129
+ "source": "noaa_swpc",
1130
+ "score": rank_score(0.8, len(results)),
1131
+ })
1132
+ except Exception as e:
1133
+ logger.warning(f"SWPC regions 失败: {e}")
1134
+ return results[:n]
1135
+ return _engine
1136
+
1137
+
1138
+ def _build_satnogs_engine(spec: dict[str, Any]) -> Any:
1139
+ """SatNOGS 卫星目录(免认证,NORAD ID 与发射信息)。"""
1140
+ timeout = spec.get("timeout", 20)
1141
+
1142
+ @safe_search
1143
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1144
+ to = _timeout or timeout
1145
+ q = (query or "").strip()
1146
+ if not q:
1147
+ return []
1148
+ url = f"https://db.satnogs.org/api/satellites/?format=json&search={urllib.parse.quote(q)}&limit={n}"
1149
+ try:
1150
+ data = _json(url, to, spec.get("_name", ""))
1151
+ except Exception as e:
1152
+ logger.warning(f"SatNOGS 失败: {e}")
1153
+ return []
1154
+ if not isinstance(data, list):
1155
+ return []
1156
+ results = []
1157
+ for _rk, s in enumerate(data[:n]):
1158
+ if not isinstance(s, dict):
1159
+ continue
1160
+ name = (s.get("name") or "").strip()
1161
+ if not name:
1162
+ continue
1163
+ results.append({
1164
+ "title": f"{name}(NORAD {s.get('norad_cat_id','')})"[:200],
1165
+ "url": f"https://db.satnogs.org/satellite/{s.get('norad_cat_id','')}",
1166
+ "snippet": (f"卫星状态 {s.get('status','')} · 发射 {s.get('launched','')}"
1167
+ "(SatNOGS DB)")[:300],
1168
+ "source": "satnogs",
1169
+ "score": rank_score(0.8, _rk),
1170
+ "published_at": s.get("launched") or "",
1171
+ })
1172
+ return results
1173
+ return _engine
1174
+
1175
+
1176
+ def _build_tle_mirror_engine(spec: dict[str, Any]) -> Any:
1177
+ """TLE 两行根数镜像(Celestrak 不可达时的替代,第三方非官方)。"""
1178
+ timeout = spec.get("timeout", 15)
1179
+
1180
+ @safe_search
1181
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1182
+ to = _timeout or timeout
1183
+ q = (query or "").strip()
1184
+ if not q:
1185
+ return []
1186
+ url = f"https://tle.ivanstanojevic.me/api/tle?search={urllib.parse.quote(q)}"
1187
+ try:
1188
+ data = _json(url, to, spec.get("_name", ""))
1189
+ except Exception as e:
1190
+ logger.warning(f"TLE 镜像失败: {e}")
1191
+ return []
1192
+ members = data.get("member") or []
1193
+ results = []
1194
+ for _rk, m in enumerate(members[:n]):
1195
+ if not isinstance(m, dict):
1196
+ continue
1197
+ name = (m.get("name") or "").strip()
1198
+ if not name:
1199
+ continue
1200
+ line1 = m.get("line1") or ""
1201
+ results.append({
1202
+ "title": f"{name}(TLE 轨道根数)"[:200],
1203
+ "url": f"https://tle.ivanstanojevic.me/api/tle/{m.get('satelliteId','')}",
1204
+ "snippet": (f"NORAD {m.get('satelliteId','')} · {str(line1)[:60]}"
1205
+ "(第三方 TLE 镜像,非官方 SLA)")[:300],
1206
+ "source": "tle_mirror",
1207
+ "score": rank_score(0.75, _rk),
1208
+ })
1209
+ return results
1210
+ return _engine
1211
+
1212
+
1213
+ # 常见车企中文名 → vPIC 英文厂商名(vPIC 无中文索引,中文问法需先归一)
1214
+ _VPIC_ZH = {
1215
+ "特斯拉": "TESLA", "比亚迪": "BYD", "蔚来": "NIO", "小鹏": "XPENG",
1216
+ "理想": "LI AUTO", "吉利": "GEELY", "长城": "GREAT WALL", "奇瑞": "CHERY",
1217
+ "长安": "CHANGAN", "上汽": "SAIC", "一汽": "FAW", "广汽": "GAC",
1218
+ "东风": "DONGFENG", "北汽": "BAIC", "五菱": "WULING", "红旗": "HONGQI",
1219
+ "丰田": "TOYOTA", "本田": "HONDA", "日产": "NISSAN", "马自达": "MAZDA",
1220
+ "大众": "VOLKSWAGEN", "奥迪": "AUDI", "宝马": "BMW", "奔驰": "MERCEDES-BENZ",
1221
+ "保时捷": "PORSCHE", "福特": "FORD", "通用": "GENERAL MOTORS",
1222
+ "别克": "BUICK", "雪佛兰": "CHEVROLET", "凯迪拉克": "CADILLAC",
1223
+ "现代": "HYUNDAI", "起亚": "KIA", "沃尔沃": "VOLVO", "路虎": "LAND ROVER",
1224
+ "捷豹": "JAGUAR", "标致": "PEUGEOT", "雪铁龙": "CITROEN", "雷诺": "RENAULT",
1225
+ "菲亚特": "FIAT", "法拉利": "FERRARI", "兰博基尼": "LAMBORGHINI",
1226
+ "玛莎拉蒂": "MASERATI", "宾利": "BENTLEY", "劳斯莱斯": "ROLLS-ROYCE",
1227
+ "阿斯顿马丁": "ASTON MARTIN", "三菱": "MITSUBISHI", "铃木": "SUZUKI",
1228
+ "斯巴鲁": "SUBARU", "雷克萨斯": "LEXUS", "英菲尼迪": "INFINITI",
1229
+ "讴歌": "ACURA", "吉普": "JEEP", "道奇": "DODGE", "克莱斯勒": "CHRYSLER",
1230
+ "斯柯达": "SKODA", "西雅特": "SEAT", "欧宝": "OPEL", "依维柯": "IVECO",
1231
+ }
1232
+
1233
+
1234
+ def _build_nhtsa_vpic_engine(spec: dict[str, Any]) -> Any:
1235
+ """NHTSA vPIC 车辆厂商/车型本体(免认证)。
1236
+
1237
+ vPIC 只有英文厂商名,中文问法先过 _VPIC_ZH 归一(否则「特斯拉 车型」恒空)。
1238
+ """
1239
+ timeout = spec.get("timeout", 20)
1240
+
1241
+ @safe_search
1242
+ def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
1243
+ to = _timeout or timeout
1244
+ q = (query or "").strip()
1245
+ if not q:
1246
+ return []
1247
+ try:
1248
+ data = _json("https://vpic.nhtsa.dot.gov/api/vehicles/getallmakes?format=json",
1249
+ to, spec.get("_name", ""))
1250
+ except Exception as e:
1251
+ logger.warning(f"NHTSA vPIC 失败: {e}")
1252
+ return []
1253
+ makes = (data.get("Results") or [])
1254
+ # 中文厂商名归一为英文;再去掉「车型/厂商/汽车」这类检索噪声词
1255
+ _NOISE = {"车型", "厂商", "汽车", "牌子", "品牌", "哪个", "什么", "make", "model"}
1256
+ norm = q
1257
+ for zh, en in _VPIC_ZH.items():
1258
+ if zh in norm:
1259
+ norm = norm.replace(zh, en)
1260
+ break
1261
+ terms = [t for t in _terms(norm.lower()) if len(t) > 1 and t not in _NOISE]
1262
+ hits = []
1263
+ for m in makes:
1264
+ if not isinstance(m, dict):
1265
+ continue
1266
+ nm = m.get("Make_Name") or ""
1267
+ if terms and any(t in nm.lower() for t in terms):
1268
+ hits.append((m.get("Make_ID"), nm))
1269
+ results = []
1270
+ for _rk, (mid, nm) in enumerate(hits[:n]):
1271
+ results.append({
1272
+ "title": f"{nm}(汽车厂商)"[:200],
1273
+ "url": f"https://vpic.nhtsa.dot.gov/api/vehicles/getmodelsformake/{urllib.parse.quote(nm)}?format=json",
1274
+ "snippet": f"NHTSA vPIC 厂商 ID {mid} · 含该厂商全部车型"[:300],
1275
+ "source": "nhtsa_vpic",
1276
+ "score": rank_score(0.8, _rk),
1277
+ })
1278
+ return results
1279
+ return _engine
1280
+
1281
+
1282
+ # ── PubMed E-utilities(两段式:esearch 取 ID → esummary 取详情)──────────────
1283
+
1284
+ def _build_pubmed_engine(spec: dict[str, Any]) -> Any:
1285
+ """PubMed 生物医学文献(NCBI E-utilities,免 key)。
1286
+
1287
+ ## 为什么需要自定义 builder 而不是声明式 spec
1288
+
1289
+ 旧实现是纯声明式:一次 esearch 拿 `idlist`,再靠 output_map
1290
+ `item_title/item_url/item_summary: pmid` 取字段。它有两处结构性错误,
1291
+ 合起来让这个标着 ready 的引擎**静默返回 0 条**:
1292
+
1293
+ ① **请求被注入 `format=json` 而 400**。E-utilities 的 `format` 是输出
1294
+ 格式参数且不接受 json;同一条 URL 加 `&format=json` 实测 400、去掉
1295
+ 200。根因在 engines_base 把解析提示当查询参数发(已加
1296
+ `format_is_query_param` 开关,本引擎声明 false)。
1297
+ ② **idlist 是字符串数组**。`_make_field_parser` 对非 dict 条目
1298
+ `continue`,所以哪怕请求通了也一条都解析不出来;退一步说,
1299
+ esearch 只给 PMID,本就拿不到标题与摘要。
1300
+
1301
+ 故本 builder 做两段:esearch 取 PMID → esummary 取 title/pubdate/
1302
+ source/DOI。第二段失败时**降级为带 PMID 的最小条目**而非整体返回空
1303
+ ——至少让用户能拿到可点的 PubMed 链接。
1304
+ """
1305
+ timeout = spec.get("timeout", 12)
1306
+ base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
1307
+ # NCBI 免 key 档限 3 req/s;声明在 spec 上由 http_client 统一节流
1308
+ tool = "argo-search"
1309
+
1310
+ @safe_search
1311
+ def _engine(query: str, n: int = 5, _timeout: float | None = None,
1312
+ **kwargs) -> list[dict[str, Any]]:
1313
+ to = _timeout or timeout
1314
+ q = (query or "").strip()
1315
+ if not q:
1316
+ return []
1317
+ eng = spec.get("_name", "local_pubmed")
1318
+ # 两段式要打两次网(实测 esearch≈1.4s、esummary≈1.1s,NCBI 本身不慢)。
1319
+ # 关键:**第二段必须分到独立预算**。若两段共用同一个 to,esearch 用掉
1320
+ # 大半后 esummary 只剩零头,并发场景下必然 timeout,整体降级成「无结果」
1321
+ # ——用户看到的是「这个源坏了」,而实际只是预算没切分。按 6:4 切,
1322
+ # 每段不低于 3s(低于 3s 时 NCBI 首包都可能收不完)。
1323
+ if to > 6.0:
1324
+ t1 = max(3.0, min(to * 0.6, to - 3.0))
1325
+ t2 = max(3.0, to - t1)
1326
+ else:
1327
+ t1 = t2 = to
1328
+ # 医学语料多为英文;中文查询直接进 esearch 会命中极少,交由
1329
+ # recovery/通用源补位(此处不做静默翻译,避免伪造查询语义)。
1330
+ want = max(1, min(int(n or 5), 20))
1331
+ url1 = (f"{base}/esearch.fcgi?db=pubmed&retmode=json&sort=relevance"
1332
+ f"&retmax={want}&tool={tool}&term={urllib.parse.quote(q)}")
1333
+ try:
1334
+ d1 = _json(url1, t1, eng)
1335
+ except Exception as e:
1336
+ logger.warning(f"PubMed esearch 失败: {e}")
1337
+ return []
1338
+ ids = (((d1 or {}).get("esearchresult") or {}).get("idlist")) or []
1339
+ ids = [str(i) for i in ids if str(i).strip()][:want]
1340
+ if not ids:
1341
+ return []
1342
+
1343
+ # 第二段:取详情。失败则降级为 PMID 最小条目(可点链接仍可用)。
1344
+ details: dict[str, dict] = {}
1345
+ try:
1346
+ url2 = (f"{base}/esummary.fcgi?db=pubmed&retmode=json"
1347
+ f"&tool={tool}&id={','.join(ids)}")
1348
+ d2 = _json(url2, t2, eng)
1349
+ res = (d2 or {}).get("result") or {}
1350
+ for pid in ids:
1351
+ it = res.get(pid)
1352
+ if isinstance(it, dict):
1353
+ details[pid] = it
1354
+ except Exception as e:
1355
+ logger.warning(f"PubMed esummary 失败(降级为 PMID 条目): {e}")
1356
+
1357
+ results: list[dict[str, Any]] = []
1358
+ for _rk, pid in enumerate(ids):
1359
+ it = details.get(pid) or {}
1360
+ title = str(it.get("title") or "").strip()
1361
+ src = str(it.get("source") or "").strip()
1362
+ pubdate = str(it.get("pubdate") or it.get("epubdate") or "").strip()
1363
+ # elocationid 形如 "doi: 10.xxxx/yyy";抽出后可给可验证的出处
1364
+ eloc = str(it.get("elocationid") or "")
1365
+ doi = ""
1366
+ m = re.search(r"10\.\d{4,9}/\S+", eloc)
1367
+ if m:
1368
+ doi = m.group(0).rstrip(".")
1369
+ # 摘要不在 esummary 里(需 efetch),如实给期刊/日期/DOI 而不是编造
1370
+ bits = [b for b in (src, pubdate, f"DOI {doi}" if doi else "") if b]
1371
+ snippet = " · ".join(bits) or "PubMed 记录(详情见原文)"
1372
+ results.append({
1373
+ "title": (title or f"PubMed {pid}")[:200],
1374
+ "url": f"https://pubmed.ncbi.nlm.nih.gov/{pid}/",
1375
+ "snippet": snippet[:300],
1376
+ "source": eng,
1377
+ "score": rank_score(0.9, _rk),
1378
+ "published_at": pubdate,
1379
+ })
1380
+ return results
1381
+ return _engine
1382
+
1383
+
1384
+ # ── CORE:开放获取论文全文聚合(core.ac.uk v3,免 key)─────────────────────
1385
+
1386
+ _CORE_ERROR_KEYS = ("message", "error", "detail")
1387
+
1388
+
1389
+ def _build_core_engine(spec: dict[str, Any]) -> Any:
1390
+ """CORE 开放获取全文(api.core.ac.uk v3,免 key)。
1391
+
1392
+ ## 两个必须写在这里的理由
1393
+
1394
+ ① **限流是 HTTP 200 + 错误封套**。CORE 后端是 Azure Search,被限流时
1395
+ 返回 `{"message": "Azure search failed with status code: 503 ..."}`
1396
+ 且 HTTP 状态是 **200**。通用 `_envelope_error` 要求 `Code` 与
1397
+ `message` 同时在场(火山/知乎那种封套),这里只有 `message`,
1398
+ 识别不出来 → 会被 pipeline 当成「这个词没结果」静默吞掉。
1399
+ 本 builder 显式检查:**响应同时缺 `results` 又带 message/error 时报错**,
1400
+ 让熔断器拿到失败信号而不是假空结果。
1401
+ ② **端点必须带尾斜杠**。`/v3/search/works?q=` 会 301 到
1402
+ `/v3/search/works/?q=`,多一次往返且部分客户端不跟随。
1403
+
1404
+ 无 `downloadUrl` 的条目退回 CORE 详情页;仍无则用 DOI 兜底。
1405
+ """
1406
+ timeout = spec.get("timeout", 20)
1407
+ endpoint = "https://api.core.ac.uk/v3/search/works/"
1408
+
1409
+ @safe_search
1410
+ def _engine(query: str, n: int = 5, _timeout: float | None = None,
1411
+ **kwargs) -> list[dict[str, Any]]:
1412
+ to = _timeout or timeout
1413
+ q = (query or "").strip()
1414
+ if not q:
1415
+ return []
1416
+ eng = spec.get("_name", "core")
1417
+ want = max(1, min(int(n or 5), 20))
1418
+ url = f"{endpoint}?q={urllib.parse.quote(q)}&limit={want}"
1419
+ try:
1420
+ data = _json(url, to, eng)
1421
+ except Exception as e:
1422
+ logger.warning(f"CORE 请求失败: {e}")
1423
+ raise
1424
+ # 显式限流/错误封套识别(见文档串①)
1425
+ if isinstance(data, dict) and not data.get("results"):
1426
+ for k in _CORE_ERROR_KEYS:
1427
+ if data.get(k):
1428
+ msg = str(data[k])[:200]
1429
+ logger.warning(f"CORE 上游错误封套: {msg}")
1430
+ raise RuntimeError(f"core upstream error: {msg}")
1431
+ items = (data or {}).get("results") if isinstance(data, dict) else None
1432
+ if not isinstance(items, list):
1433
+ return []
1434
+
1435
+ results: list[dict[str, Any]] = []
1436
+ for _rk, it in enumerate(items[:want]):
1437
+ if not isinstance(it, dict):
1438
+ continue
1439
+ title = str(it.get("title") or "").strip()
1440
+ if not title:
1441
+ continue
1442
+ abstract = str(it.get("abstract") or "").strip()
1443
+ doi = str(it.get("doi") or "").strip()
1444
+ dl = str(it.get("downloadUrl") or "").strip()
1445
+ year = str(it.get("yearPublished") or "").strip()
1446
+ pub = str(it.get("publisher") or "").strip()
1447
+ # 出处优先级:可下载全文 > DOI > CORE 详情页(全部可验证)
1448
+ if dl:
1449
+ url_out = dl
1450
+ elif doi and doi.lower() != "none":
1451
+ url_out = f"https://doi.org/{doi}"
1452
+ else:
1453
+ url_out = f"https://core.ac.uk/works/{it.get('id','')}"
1454
+ bits = [b for b in (pub, year, f"DOI {doi}" if doi and doi.lower() != 'none' else "") if b]
1455
+ if abstract:
1456
+ snippet = abstract
1457
+ else:
1458
+ snippet = " · ".join(bits) or "CORE 开放获取记录"
1459
+ results.append({
1460
+ "title": title[:200],
1461
+ "url": url_out,
1462
+ "snippet": snippet[:300],
1463
+ "source": eng,
1464
+ "score": rank_score(0.85, _rk),
1465
+ "published_at": year,
1466
+ })
1467
+ return results
1468
+ return _engine