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,397 @@
1
+ #!/usr/bin/env python3
2
+ """Twitter/X 搜索引擎
3
+
4
+ 后端优先级:
5
+ 1. FxTwitter API v2(api.fxtwitter.com,零 Key)— 主路径
6
+ 2. twitter CLI(tw)
7
+ 3. nitter 公开实例 HTML
8
+
9
+ FxTwitter 能力:
10
+ - GET /2/search?q=...&count=N 推文搜索
11
+ - GET /2/status/{id} 单条推文
12
+ - 旧版 GET /:user?/status/:id 兼容
13
+
14
+ 文档:https://docs.fxembed.com/api/twitter/
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ import urllib.parse
25
+ from typing import Any
26
+
27
+ # 出口调度唯一入口(issue #13 同类修复):urlopen 不认 config.yaml 的
28
+ # network.proxy,裸用会在「必须经代理才能出网」的环境里整源连不上。
29
+ import os
30
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
31
+ from fxtwitter_api import extract_status_id as _fx_extract_status_id # noqa: E402
32
+ from fxtwitter_api import http_get as _fx_http_get # noqa: E402
33
+
34
+ FXTWITTER_BASE = "https://api.fxtwitter.com"
35
+ USER_AGENT = "argo-search/1.0 (+https://github.com/taxueseek/argo; fxtwitter)"
36
+
37
+ # 推文 URL / snowflake id 的解析与 HTTP 出口统一收在 fxtwitter_api(打包器
38
+ # tweet.py 用的是同一份);本模块只保留「引擎侧阈值」——纯数字要求 ≥10 位,
39
+ # 见 _extract_status_id。此前这里自带一份正则,与打包器那份迟早漂移。
40
+
41
+
42
+ def _http_get_with_retry(
43
+ url: str,
44
+ headers: dict | None = None,
45
+ timeout: int = 10,
46
+ max_retries: int = 2,
47
+ ) -> tuple[bytes, int]:
48
+ """带重试的 HTTP GET(实现唯一在 fxtwitter_api.http_get)。
49
+
50
+ 保留本名与签名:既有测试 patch 的正是 `tw._http_get_with_retry`,薄壳让
51
+ patch 语义不变;同时引擎与打包器共用同一份「重试 + 代理 + SSRF 守卫」纪律,
52
+ 不再各写一遍。
53
+ """
54
+ return _fx_http_get(url, headers=headers, timeout=timeout,
55
+ max_retries=max_retries)
56
+ return b"", 0
57
+
58
+
59
+ def _extract_status_id(query: str) -> str | None:
60
+ """从查询中提取推文 ID(URL 或纯数字)。
61
+
62
+ 解析器唯一实现在 fxtwitter_api.extract_status_id;这里只固定搜索侧的阈值
63
+ min_digits=10——`argo search "20"` 里的 20 可能是页码或数量,不该当推文 ID;
64
+ 而 `argo tweet "20"` 是用户显式给的 ID(那边传 1)。
65
+ """
66
+ return _fx_extract_status_id(query or "", min_digits=10)
67
+
68
+
69
+ def _status_to_result(item: dict[str, Any], rank: int = 0) -> dict[str, Any] | None:
70
+ """将 FxTwitter status 对象转为统一 schema。"""
71
+ if not isinstance(item, dict):
72
+ return None
73
+ # v2 status 可能包在 status 键下,或直接是 status 对象
74
+ if "text" not in item and isinstance(item.get("status"), dict):
75
+ item = item["status"]
76
+ text = (item.get("text") or "").strip()
77
+ url = item.get("url") or ""
78
+ sid = str(item.get("id") or "")
79
+ if not url and sid:
80
+ author = item.get("author") if isinstance(item.get("author"), dict) else {}
81
+ handle = author.get("screen_name") or "i"
82
+ url = f"https://x.com/{handle}/status/{sid}"
83
+ if not text and not url:
84
+ return None
85
+
86
+ author = item.get("author") if isinstance(item.get("author"), dict) else {}
87
+ likes = item.get("likes") or 0
88
+ reposts = item.get("reposts") if item.get("reposts") is not None else item.get("retweets") or 0
89
+ replies = item.get("replies") or 0
90
+ views = item.get("views")
91
+ quotes = item.get("quotes") or 0
92
+ bookmarks = item.get("bookmarks") or 0
93
+
94
+ title = text[:100] + ("..." if len(text) > 100 else "")
95
+ if not title:
96
+ title = f"Tweet {sid}" if sid else url
97
+
98
+ # 互动分:简单归一化,便于排序参考
99
+ engagement = int(likes or 0) + int(reposts or 0) * 2 + int(replies or 0)
100
+ score = max(1.0 - rank * 0.05, 0.1)
101
+ if engagement > 0:
102
+ score = min(1.0, score + min(engagement / 10000.0, 0.3))
103
+
104
+ return {
105
+ "title": title,
106
+ "url": url,
107
+ "snippet": text[:300],
108
+ "source": "twitter",
109
+ "score": round(score, 3),
110
+ "published_at": item.get("created_at") or None,
111
+ "social_meta": {
112
+ "platform": "twitter",
113
+ "content_type": "tweet",
114
+ "id": sid,
115
+ "author": author.get("screen_name") or "",
116
+ "author_name": author.get("name") or "",
117
+ "likes": likes,
118
+ "retweets": reposts,
119
+ "reposts": reposts,
120
+ "replies": replies,
121
+ "views": views,
122
+ "quotes": quotes,
123
+ "bookmarks": bookmarks,
124
+ "lang": item.get("lang"),
125
+ "provider": "fxtwitter",
126
+ },
127
+ }
128
+
129
+
130
+ def fetch_status(status_id: str, timeout: int = 10) -> list[dict[str, Any]]:
131
+ """按推文 ID 拉取单条(v2 优先,旧端点保底)。"""
132
+ endpoints = [
133
+ f"{FXTWITTER_BASE}/2/status/{status_id}",
134
+ f"{FXTWITTER_BASE}/status/{status_id}",
135
+ ]
136
+ last_err: Exception | None = None
137
+ for url in endpoints:
138
+ try:
139
+ body, _ = _http_get_with_retry(url, timeout=timeout, max_retries=2)
140
+ data = json.loads(body.decode("utf-8"))
141
+ if not isinstance(data, dict):
142
+ continue
143
+ code = data.get("code", 200)
144
+ if code not in (200, None, "200"):
145
+ continue
146
+ # v2: {status: {...}} ; v1: {tweet: {...}}
147
+ item = data.get("status") or data.get("tweet") or data
148
+ result = _status_to_result(item if isinstance(item, dict) else {}, rank=0)
149
+ if result:
150
+ return [result]
151
+ except Exception as e:
152
+ last_err = e
153
+ continue
154
+ if last_err is not None:
155
+ # 全部端点异常 ≠ 「没有这条推文」:带 error 浮上来(同 search_fxtwitter)
156
+ return [{"error": f"twitter {type(last_err).__name__}: {last_err}",
157
+ "source": "twitter"}]
158
+ return []
159
+
160
+
161
+ def search_fxtwitter(query: str, n: int = 5, timeout: int = 10) -> list[dict[str, Any]]:
162
+ """通过 FxTwitter /2/search 搜索推文。"""
163
+ if not query or not query.strip():
164
+ return []
165
+
166
+ # URL / ID 查询 → 单条拉取
167
+ status_id = _extract_status_id(query)
168
+ if status_id:
169
+ return fetch_status(status_id, timeout=timeout)[:n]
170
+
171
+ params = urllib.parse.urlencode({"q": query.strip(), "count": max(1, min(int(n), 20))})
172
+ url = f"{FXTWITTER_BASE}/2/search?{params}"
173
+ try:
174
+ body, _ = _http_get_with_retry(url, timeout=timeout, max_retries=2)
175
+ data = json.loads(body.decode("utf-8"))
176
+ except Exception as e:
177
+ # 吞异常返空会让 MCP 社交搜索把网络故障静默报成「平台成功、0 结果」
178
+ # (mcp_handlers 的 err=None 设计依赖引擎抛异常或返 error 占位,
179
+ # 见 tests/test_regression_p0p1.py「零结果应以 errors 提示」契约)。
180
+ # 对齐 zhihu_engine 范式:失败必须带着 error 字段浮上来。
181
+ return [{"error": f"twitter {type(e).__name__}: {e}", "source": "twitter"}]
182
+
183
+ if not isinstance(data, dict):
184
+ return []
185
+ code = data.get("code", 200)
186
+ if code not in (200, None, "200"):
187
+ return []
188
+
189
+ results: list[dict[str, Any]] = []
190
+ for i, item in enumerate(data.get("results") or []):
191
+ if not isinstance(item, dict):
192
+ continue
193
+ # 仅处理 status 类型;忽略用户卡等
194
+ if item.get("type") and item.get("type") != "status":
195
+ continue
196
+ parsed = _status_to_result(item, rank=i)
197
+ if parsed:
198
+ results.append(parsed)
199
+ if len(results) >= n:
200
+ break
201
+ return results
202
+
203
+
204
+ # Nitter 实例健康状态(进程内记忆):记录每个实例的连续失败次数和
205
+ # 最后健康时间,优先尝试最近健康的实例。实例不可达时不永久封禁,
206
+ # 而是逐步降权——Nitter 实例可用性波动大,单次失败不等于永久不可用。
207
+ _nitter_health: dict[str, dict] = {}
208
+ _NITTER_MAX_FAILURES = 3
209
+
210
+
211
+ def _nitter_healthy_instances() -> list[str]:
212
+ """按健康状态排序的 Nitter 实例列表,最近健康的优先。"""
213
+ instances = [
214
+ "https://nitter.net",
215
+ "https://nitter.privacydev.net",
216
+ "https://nitter.poast.org",
217
+ "https://nitter.privacyredirect.com",
218
+ "https://nitter.tiekoetter.com",
219
+ ]
220
+ def _sort_key(base: str) -> tuple:
221
+ h = _nitter_health.get(base)
222
+ if h is None:
223
+ return (0, 0.0) # 未记录过,优先尝试
224
+ fails = h.get("fails", 0)
225
+ last_ok = h.get("last_ok", 0.0)
226
+ return (fails, -last_ok) # 失败少的优先,最近健康的优先
227
+ return sorted(instances, key=_sort_key)
228
+
229
+
230
+ def search_nitter(query: str, n: int = 5) -> list[dict]:
231
+ """通过 nitter 公开实例搜索推文(零认证,保底)。
232
+
233
+ 多实例故障转移:按健康状态排序,优先尝试最近健康的实例。
234
+ 实例失败时降权(不永久封禁),成功时恢复权重。
235
+ """
236
+ encoded_query = urllib.parse.quote(query)
237
+
238
+ for base in _nitter_healthy_instances():
239
+ try:
240
+ url = f"{base}/search?f=tweets&q={encoded_query}&since=2025-01-01"
241
+ headers = {
242
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
243
+ }
244
+ body, _ = _http_get_with_retry(url, headers=headers, timeout=8, max_retries=1)
245
+ html = body.decode("utf-8", errors="replace")
246
+ # 更新健康状态
247
+ _nitter_health[base] = {"fails": 0, "last_ok": time.time()}
248
+ results = _parse_nitter_html(html, n)
249
+ # 空结果也算实例健康(只是没有匹配内容),不降权
250
+ return results
251
+ except Exception:
252
+ # 失败时增加失败计数
253
+ h = _nitter_health.get(base) or {}
254
+ h["fails"] = h.get("fails", 0) + 1
255
+ _nitter_health[base] = h
256
+ continue
257
+ return []
258
+
259
+
260
+ def _parse_nitter_html(html: str, n: int) -> list[dict]:
261
+ """解析 nitter 搜索结果 HTML。"""
262
+ results = []
263
+ tweet_pattern = re.compile(
264
+ r'<div class="tweet-content[^"]*".*?'
265
+ r'<a class="tweet-link" href="([^"]+)".*?'
266
+ r'<div class="tweet-content[^"]*"[^>]*>(.*?)</div>',
267
+ re.DOTALL,
268
+ )
269
+ for match in tweet_pattern.finditer(html):
270
+ url = match.group(1)
271
+ content = match.group(2)
272
+ content = re.sub(r"<[^>]+>", " ", content).strip()
273
+ content = re.sub(r"\s+", " ", content)
274
+ if len(content) > 10:
275
+ results.append({
276
+ "title": content[:100] + ("..." if len(content) > 100 else ""),
277
+ "url": url if url.startswith("http") else f"https://twitter.com{url}",
278
+ "snippet": content[:300],
279
+ "source": "twitter",
280
+ "score": max(1.0 - len(results) * 0.1, 0.1),
281
+ "social_meta": {
282
+ "platform": "twitter",
283
+ "content_type": "tweet",
284
+ "provider": "nitter",
285
+ },
286
+ })
287
+ if len(results) >= n:
288
+ break
289
+ return results
290
+
291
+
292
+ def search(query: str, n: int = 5) -> list[dict]:
293
+ """主搜索入口:FxTwitter → tw CLI → nitter。"""
294
+ # 1) FxTwitter(主路径)
295
+ try:
296
+ results = search_fxtwitter(query, n=n, timeout=10)
297
+ if results:
298
+ return results
299
+ except Exception:
300
+ pass
301
+
302
+ # 2) twitter CLI
303
+ try:
304
+ result = subprocess.run(
305
+ ["tw", "search", query, "--limit", str(n), "--json"],
306
+ capture_output=True,
307
+ text=True,
308
+ encoding="utf-8", # Windows GBK locale 下 UTF-8 输出会解码崩
309
+ errors="replace",
310
+ timeout=10,
311
+ )
312
+ if result.returncode == 0 and result.stdout.strip():
313
+ parsed = _parse_tw_json(result.stdout, n)
314
+ if parsed:
315
+ return parsed
316
+ except (FileNotFoundError, subprocess.TimeoutExpired):
317
+ pass
318
+
319
+ # 3) nitter 保底
320
+ return search_nitter(query, n)
321
+
322
+
323
+ def _parse_tw_json(raw: str, n: int) -> list[dict]:
324
+ """解析 twitter CLI JSON 输出。"""
325
+ results = []
326
+ for line in raw.strip().split("\n"):
327
+ try:
328
+ tweet = json.loads(line)
329
+ if not isinstance(tweet, dict):
330
+ continue
331
+ results.append({
332
+ "title": (tweet.get("text", "") or tweet.get("full_text", ""))[:100],
333
+ "url": tweet.get("url", f"https://twitter.com/i/status/{tweet.get('id', '')}"),
334
+ "snippet": tweet.get("text", "") or tweet.get("full_text", ""),
335
+ "source": "twitter",
336
+ "score": max(1.0 - len(results) * 0.1, 0.1),
337
+ "social_meta": {
338
+ "platform": "twitter",
339
+ "content_type": "tweet",
340
+ "author": tweet.get("user", {}).get("screen_name", ""),
341
+ "likes": tweet.get("favorite_count", 0),
342
+ "retweets": tweet.get("retweet_count", 0),
343
+ "replies": tweet.get("reply_count", 0),
344
+ "provider": "tw_cli",
345
+ },
346
+ })
347
+ except (json.JSONDecodeError, ValueError):
348
+ continue
349
+ if len(results) >= n:
350
+ break
351
+ return results
352
+
353
+
354
+ def main():
355
+ import argparse
356
+
357
+ parser = argparse.ArgumentParser(description="Twitter search engine (FxTwitter)")
358
+ parser.add_argument("action", nargs="?", default="search")
359
+ parser.add_argument("query", nargs="?")
360
+ parser.add_argument("-n", type=int, default=5)
361
+ parser.add_argument("--json", action="store_true")
362
+ args = parser.parse_args()
363
+
364
+ if not args.query:
365
+ print("[]")
366
+ return
367
+
368
+ action = (args.action or "search").lower()
369
+ if action == "status" and args.query:
370
+ results = fetch_status(args.query)
371
+ elif action in ("search", "s") and args.query:
372
+ results = search(args.query, args.n)
373
+ elif args.query:
374
+ # 未知 action 时把 query 当搜索词
375
+ results = search(args.query, args.n)
376
+ else:
377
+ # 兼容:`twitter_engine.py "查询词"`
378
+ results = search(args.action, args.n)
379
+
380
+ if args.json:
381
+ print(json.dumps(results, ensure_ascii=False))
382
+ else:
383
+ for i, r in enumerate(results, 1):
384
+ print(f"### {i}. {r['title']}")
385
+ print(f"- **URL**: {r['url']}")
386
+ print(f"- {r['snippet'][:200]}")
387
+ meta = r.get("social_meta") or {}
388
+ if meta.get("likes") is not None:
389
+ print(
390
+ f"- likes={meta.get('likes')} reposts={meta.get('retweets') or meta.get('reposts')} "
391
+ f"replies={meta.get('replies')} @{meta.get('author', '')}"
392
+ )
393
+ print()
394
+
395
+
396
+ if __name__ == "__main__":
397
+ main()
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """V2EX 社区搜索引擎(公开网页,零密钥)
3
+
4
+ 复用 argo route 库 v2ex builder 的 HTML 解析逻辑,保持一致 social_engines schema。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import urllib.parse
12
+ import urllib.request
13
+ from typing import Any
14
+
15
+ # 出口调度唯一入口(issue #13 同类修复):urlopen 不认 config.yaml 的
16
+ # network.proxy,裸用会在「必须经代理才能出网」的环境里整源连不上。
17
+ import os
18
+ import sys
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+ from net_proxy import open_url # noqa: E402
21
+
22
+
23
+ def _http_get(url: str, timeout: int = 10) -> str:
24
+ req = urllib.request.Request(
25
+ url,
26
+ headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"},
27
+ )
28
+ with open_url(req, timeout=timeout) as resp:
29
+ return resp.read().decode("utf-8", "replace")
30
+
31
+
32
+ def search(query: str, n: int = 5) -> list[dict[str, Any]]:
33
+ """V2EX 站内搜索(q 参数 + 结果页解析)。"""
34
+ if not query or not query.strip():
35
+ return []
36
+ url = f"https://www.v2ex.com/search?q={urllib.parse.quote(query.strip())}"
37
+ try:
38
+ html = _http_get(url)
39
+ except Exception as e:
40
+ # 吞异常返空会让 MCP 社交搜索把网络故障静默报成「平台成功、0 结果」
41
+ # (mcp_handlers 的 err=None 设计依赖引擎抛异常或返 error 占位,
42
+ # 见 tests/test_regression_p0p1.py「零结果应以 errors 提示」契约)。
43
+ # 对齐 zhihu_engine 范式:失败必须带着 error 字段浮上来。
44
+ return [{"error": f"v2ex {type(e).__name__}: {e}", "source": "v2ex"}]
45
+
46
+ results: list[dict[str, Any]] = []
47
+ # 主题列表:<a href="/t/xxxx"> 标题 </a>
48
+ topic_re = re.compile(r'<a[^>]*href="(/t/\d+)[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
49
+ seen = set()
50
+ for m in topic_re.finditer(html):
51
+ path, raw_title = m.group(1), m.group(2)
52
+ if path in seen:
53
+ continue
54
+ seen.add(path)
55
+ title = re.sub(r"<[^>]+>", "", raw_title).strip()
56
+ if not title or len(title) < 2:
57
+ continue
58
+ results.append({
59
+ "title": title[:100] + ("..." if len(title) > 100 else ""),
60
+ "url": f"https://www.v2ex.com{path}",
61
+ "snippet": title[:300],
62
+ "source": "v2ex",
63
+ "score": max(1.0 - len(results) * 0.1, 0.1),
64
+ "social_meta": {
65
+ "platform": "v2ex",
66
+ "content_type": "topic",
67
+ # 剥的是**前缀**,不是字符集:lstrip("/t/") 会连 topic id 里
68
+ # 出现的 't' 一起吃掉(当前 href 正则限定 \d+ 才没暴露)。
69
+ "id": path.removeprefix("/t/"),
70
+ "provider": "v2ex_html",
71
+ },
72
+ })
73
+ if len(results) >= n:
74
+ break
75
+ return results
76
+
77
+
78
+ def main():
79
+ import argparse
80
+ parser = argparse.ArgumentParser(description="V2EX search engine")
81
+ parser.add_argument("action", nargs="?", default="search")
82
+ parser.add_argument("query", nargs="?")
83
+ parser.add_argument("-n", type=int, default=5)
84
+ parser.add_argument("--json", action="store_true")
85
+ args = parser.parse_args()
86
+
87
+ if not args.query:
88
+ print("[]")
89
+ return
90
+ results = search(args.query, args.n)
91
+ if args.json:
92
+ print(json.dumps(results, ensure_ascii=False))
93
+ else:
94
+ for i, r in enumerate(results, 1):
95
+ print(f"### {i}. {r['title']}")
96
+ print(f"- **URL**: {r['url']}")
97
+ print()
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """微博搜索引擎
3
+
4
+ 使用微博公开搜索 API(无需登录可搜索热门内容)。
5
+ """
6
+
7
+ import json
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+
13
+ # 出口调度唯一入口(issue #13 同类修复):urlopen 原生只认标准 HTTP(S)_PROXY
14
+ # 环境变量,**不认** config.yaml 的 network.proxy —— 裸用会在「必须经代理才能
15
+ # 出网」的环境里整源连不上。本文件在 scripts/ 子目录,父目录不在 sys.path 上。
16
+ import os
17
+ import sys
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+ from net_proxy import open_url # noqa: E402
20
+
21
+
22
+ def _http_get_with_retry(url: str, headers: dict, timeout: int = 10, max_retries: int = 2):
23
+ """带重试的 HTTP GET,尊重 429 + Retry-After。
24
+
25
+ 仅使用 stdlib,不引入第三方依赖。
26
+ 返回 (body_bytes, status_code)。失败时抛出最后一次异常。
27
+ """
28
+ for attempt in range(max_retries + 1):
29
+ try:
30
+ req = urllib.request.Request(url, headers=headers)
31
+ with open_url(req, timeout=timeout) as resp:
32
+ return resp.read(), resp.status
33
+ except urllib.error.HTTPError as e:
34
+ if e.code == 429 and attempt < max_retries:
35
+ retry_after = int(e.headers.get("Retry-After", "5"))
36
+ time.sleep(min(retry_after, 30))
37
+ continue
38
+ raise
39
+ except (urllib.error.URLError, OSError):
40
+ if attempt < max_retries:
41
+ time.sleep(2 ** attempt + 0.5)
42
+ continue
43
+ raise
44
+ return b"", 0
45
+
46
+
47
+ def search(query: str, n: int = 5) -> list[dict]:
48
+ """通过微博搜索 API 搜索"""
49
+ encoded = urllib.parse.quote(query)
50
+ url = f"https://m.weibo.cn/api/container/getIndex?containerid=100103type%3D1%26q%3D{encoded}&page_type=searchall"
51
+ headers = {
52
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
53
+ "Referer": "https://m.weibo.cn"
54
+ }
55
+ try:
56
+ body, _ = _http_get_with_retry(url, headers, timeout=10, max_retries=2)
57
+ data = json.loads(body.decode("utf-8"))
58
+ return _parse_weibo_response(data, n)
59
+ except Exception as e:
60
+ # 吞异常返空会让 MCP 社交搜索把网络故障静默报成「平台成功、0 结果」
61
+ # (mcp_handlers 的 err=None 设计依赖引擎抛异常或返 error 占位,
62
+ # 见 tests/test_regression_p0p1.py「零结果应以 errors 提示」契约)。
63
+ # 对齐 zhihu_engine 范式:失败必须带着 error 字段浮上来。
64
+ return [{"error": f"weibo {type(e).__name__}: {e}", "source": "weibo"}]
65
+
66
+
67
+ def _parse_weibo_response(data: dict, n: int) -> list[dict]:
68
+ """解析微博 API 响应"""
69
+ results = []
70
+ for card in data.get("data", {}).get("cards", []):
71
+ for card_group in card.get("card_group", []):
72
+ mblog = card_group.get("mblog", {})
73
+ if not mblog:
74
+ continue
75
+ text = mblog.get("text", "")
76
+ import re
77
+ text = re.sub(r'<[^>]+>', '', text).strip()
78
+ if len(text) < 10:
79
+ continue
80
+ results.append({
81
+ "title": text[:100] + ("..." if len(text) > 100 else ""),
82
+ "url": f"https://weibo.com/{mblog.get('user', {}).get('id', '')}/{mblog.get('bid', '')}",
83
+ "snippet": text[:300],
84
+ "source": "weibo",
85
+ "score": max(1.0 - len(results) * 0.1, 0.1),
86
+ "social_meta": {
87
+ "platform": "weibo",
88
+ "content_type": "post",
89
+ "author": mblog.get("user", {}).get("screen_name", ""),
90
+ "likes": mblog.get("attitudes_count", 0),
91
+ "reposts": mblog.get("reposts_count", 0),
92
+ "comments": mblog.get("comments_count", 0),
93
+ "verified": mblog.get("user", {}).get("verified", False),
94
+ }
95
+ })
96
+ if len(results) >= n:
97
+ break
98
+ if len(results) >= n:
99
+ break
100
+ return results
101
+
102
+
103
+ def main():
104
+ import argparse
105
+ parser = argparse.ArgumentParser(description="Weibo search engine")
106
+ parser.add_argument("action", nargs="?", default="search")
107
+ parser.add_argument("query", nargs="?")
108
+ parser.add_argument("-n", type=int, default=5)
109
+ parser.add_argument("--json", action="store_true")
110
+ args = parser.parse_args()
111
+
112
+ if not args.query:
113
+ print("[]")
114
+ return
115
+
116
+ results = search(args.query, args.n)
117
+ if args.json:
118
+ print(json.dumps(results, ensure_ascii=False))
119
+ else:
120
+ for i, r in enumerate(results, 1):
121
+ print(f"### {i}. {r['title']}")
122
+ print(f"- **URL**: {r['url']}")
123
+ print(f"- {r['snippet'][:200]}")
124
+ print()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()