argo-search 1.0.1 → 2.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (472) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +735 -0
  3. package/README.es.md +777 -0
  4. package/README.ja.md +777 -0
  5. package/README.ko.md +777 -0
  6. package/README.md +778 -320
  7. package/SKILL.md +135 -0
  8. package/assets/readme/hero.svg +121 -0
  9. package/assets/readme/made-with-beautify.svg +10 -0
  10. package/assets/readme/proof-routes.svg +73 -0
  11. package/assets/readme/why-better.svg +104 -0
  12. package/assets/readme/workflow.svg +83 -0
  13. package/backends/domain_profiles.json +2356 -96
  14. package/backends/engine_registry.yaml +3428 -503
  15. package/backends/query_synonyms_cn.json +35 -0
  16. package/backends/quota_profiles.json +2488 -160
  17. package/backends/source_types_cn.json +109 -0
  18. package/backends/text_lexicon.json +44 -0
  19. package/bin/argo +772 -0
  20. package/bin/argo.js +102 -39
  21. package/config.yaml +5139 -367
  22. package/cordis.patch.yml +21 -0
  23. package/data/countries.json +40 -0
  24. package/data/lang_matrix/lang_capability.json +842 -0
  25. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  26. package/data/regions_cn.json +1 -0
  27. package/docs/ADDING_NEW_ENGINE.md +184 -0
  28. package/docs/ARGO_INTRO.md +124 -0
  29. package/docs/DATA_SOURCE_GAPS.md +81 -0
  30. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  31. package/docs/ENGINE_CATALOG.md +575 -0
  32. package/docs/MCP_SETUP.md +108 -0
  33. package/docs/PLATFORM_PATHS.md +111 -0
  34. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  35. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  36. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  37. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  38. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  39. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  40. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  41. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  42. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  43. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  44. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  45. package/docs/SEARCH_ARCHIVE.md +115 -0
  46. package/docs/assets/architecture.svg +104 -0
  47. package/docs/assets/hero.svg +98 -0
  48. package/docs/engines/bangumi.md +27 -0
  49. package/docs/engines/biorxiv.md +27 -0
  50. package/docs/engines/clawhub.md +27 -0
  51. package/docs/engines/crt_sh.md +27 -0
  52. package/docs/engines/datacite.md +27 -0
  53. package/docs/engines/deps_dev.md +27 -0
  54. package/docs/engines/douban_movie.md +27 -0
  55. package/docs/engines/endoflife.md +27 -0
  56. package/docs/engines/flk_law.md +27 -0
  57. package/docs/engines/fxtwitter.md +27 -0
  58. package/docs/engines/google_news.md +27 -0
  59. package/docs/engines/gov_regulations.md +27 -0
  60. package/docs/engines/guqian.md +27 -0
  61. package/docs/engines/hackernews.md +27 -0
  62. package/docs/engines/iplant.md +27 -0
  63. package/docs/engines/keenable.md +27 -0
  64. package/docs/engines/met_museum.md +27 -0
  65. package/docs/engines/nasa_images.md +27 -0
  66. package/docs/engines/netease_music.md +27 -0
  67. package/docs/engines/npm.md +27 -0
  68. package/docs/engines/nvd.md +27 -0
  69. package/docs/engines/openreview.md +27 -0
  70. package/docs/engines/openstd.md +27 -0
  71. package/docs/engines/parallel_free.md +27 -0
  72. package/docs/engines/people_daily.md +27 -0
  73. package/docs/engines/realtime_index.md +27 -0
  74. package/docs/engines/sec_edgar.md +27 -0
  75. package/docs/engines/seltz.md +27 -0
  76. package/docs/engines/skillsmp.md +27 -0
  77. package/docs/engines/sspai.md +27 -0
  78. package/docs/engines/std_samr.md +27 -0
  79. package/docs/engines/train.md +27 -0
  80. package/docs/engines/un_comtrade.md +27 -0
  81. package/docs/engines/wikisource.md +27 -0
  82. package/docs/engines/zdic.md +27 -0
  83. package/docs/engines/zenodo.md +27 -0
  84. package/docs/engines/zhihu_hot_app.md +27 -0
  85. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  86. package/engines/README.md +10 -0
  87. package/engines/_template_cli.yaml +39 -0
  88. package/engines/_template_http.yaml +37 -0
  89. package/engines/plugins/_template_plugin.py +50 -0
  90. package/engines/specs/_cisa_kev.yaml +54 -0
  91. package/engines/specs/_osv.yaml +63 -0
  92. package/engines/specs/aljazeera.yaml +43 -0
  93. package/engines/specs/antara.yaml +43 -0
  94. package/engines/specs/artic.yaml +20 -0
  95. package/engines/specs/aviation_weather.yaml +39 -0
  96. package/engines/specs/bangumi.yaml +21 -0
  97. package/engines/specs/biorxiv.yaml +24 -0
  98. package/engines/specs/carbon_intensity.yaml +32 -0
  99. package/engines/specs/cfets.yaml +41 -0
  100. package/engines/specs/cinii.yaml +44 -0
  101. package/engines/specs/clawhub.yaml +49 -0
  102. package/engines/specs/cleveland.yaml +19 -0
  103. package/engines/specs/cn_ai_news.yaml +43 -0
  104. package/engines/specs/core.yaml +35 -0
  105. package/engines/specs/crt_sh.yaml +38 -0
  106. package/engines/specs/ct24.yaml +43 -0
  107. package/engines/specs/datacite.yaml +27 -0
  108. package/engines/specs/datagov_uk.yaml +41 -0
  109. package/engines/specs/ddgs_images.yaml +45 -0
  110. package/engines/specs/deepseek_search.yaml +74 -0
  111. package/engines/specs/deezer.yaml +20 -0
  112. package/engines/specs/defillama.yaml +38 -0
  113. package/engines/specs/deps_dev.yaml +21 -0
  114. package/engines/specs/digup.yaml +36 -0
  115. package/engines/specs/doi.yaml +39 -0
  116. package/engines/specs/douban_movie.yaml +22 -0
  117. package/engines/specs/dw_news.yaml +47 -0
  118. package/engines/specs/egov_law.yaml +21 -0
  119. package/engines/specs/elpais.yaml +43 -0
  120. package/engines/specs/endoflife.yaml +21 -0
  121. package/engines/specs/energy_charts.yaml +21 -0
  122. package/engines/specs/factcheck_org.yaml +52 -0
  123. package/engines/specs/faz.yaml +43 -0
  124. package/engines/specs/federal_register.yaml +57 -0
  125. package/engines/specs/firecrawl.yaml +53 -0
  126. package/engines/specs/flk_law.yaml +24 -0
  127. package/engines/specs/folha.yaml +43 -0
  128. package/engines/specs/france24.yaml +44 -0
  129. package/engines/specs/full_fact.yaml +47 -0
  130. package/engines/specs/fxtwitter.yaml +36 -0
  131. package/engines/specs/gbfs_nyc.yaml +32 -0
  132. package/engines/specs/gdacs.yaml +21 -0
  133. package/engines/specs/gdelt.yaml +64 -0
  134. package/engines/specs/gleif.yaml +55 -0
  135. package/engines/specs/google_news.yaml +22 -0
  136. package/engines/specs/gov_regulations.yaml +38 -0
  137. package/engines/specs/guardian_rss.yaml +59 -0
  138. package/engines/specs/hkex.yaml +40 -0
  139. package/engines/specs/hurriyet.yaml +43 -0
  140. package/engines/specs/iplant.yaml +25 -0
  141. package/engines/specs/jikan.yaml +24 -0
  142. package/engines/specs/jolpica.yaml +20 -0
  143. package/engines/specs/k10plus.yaml +20 -0
  144. package/engines/specs/keenable.yaml +44 -0
  145. package/engines/specs/keenable_free.yaml +36 -0
  146. package/engines/specs/lefigaro.yaml +43 -0
  147. package/engines/specs/listenbrainz.yaml +19 -0
  148. package/engines/specs/matichon.yaml +43 -0
  149. package/engines/specs/met_museum.yaml +21 -0
  150. package/engines/specs/nasa_images.yaml +45 -0
  151. package/engines/specs/netease_music.yaml +38 -0
  152. package/engines/specs/nhk.yaml +43 -0
  153. package/engines/specs/nhtsa_vpic.yaml +20 -0
  154. package/engines/specs/noaa_swpc.yaml +20 -0
  155. package/engines/specs/nvd.yaml +38 -0
  156. package/engines/specs/obis.yaml +20 -0
  157. package/engines/specs/opencitations.yaml +52 -0
  158. package/engines/specs/openf1.yaml +21 -0
  159. package/engines/specs/openfoodfacts.yaml +45 -0
  160. package/engines/specs/openligadb.yaml +20 -0
  161. package/engines/specs/openreview.yaml +37 -0
  162. package/engines/specs/openstd.yaml +21 -0
  163. package/engines/specs/people_daily.yaml +22 -0
  164. package/engines/specs/realtime_index.yaml +34 -0
  165. package/engines/specs/ror.yaml +20 -0
  166. package/engines/specs/satnogs.yaml +20 -0
  167. package/engines/specs/sec_edgar.yaml +46 -0
  168. package/engines/specs/shfe.yaml +39 -0
  169. package/engines/specs/skillsmp.yaml +50 -0
  170. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  171. package/engines/specs/soilgrids.yaml +25 -0
  172. package/engines/specs/sspai.yaml +36 -0
  173. package/engines/specs/stackexchange.yaml +46 -0
  174. package/engines/specs/std_samr.yaml +21 -0
  175. package/engines/specs/tass.yaml +43 -0
  176. package/engines/specs/tavily_free.yaml +56 -0
  177. package/engines/specs/tle_mirror.yaml +21 -0
  178. package/engines/specs/train.yaml +37 -0
  179. package/engines/specs/tvmaze.yaml +20 -0
  180. package/engines/specs/twitter_syndication.yaml +31 -0
  181. package/engines/specs/un_comtrade.yaml +23 -0
  182. package/engines/specs/unpaywall.yaml +54 -0
  183. package/engines/specs/vnpress.yaml +43 -0
  184. package/engines/specs/weather.yaml +32 -0
  185. package/engines/specs/who_don.yaml +21 -0
  186. package/engines/specs/who_gho.yaml +20 -0
  187. package/engines/specs/wikimedia_commons.yaml +34 -0
  188. package/engines/specs/wikisource.yaml +22 -0
  189. package/engines/specs/worms.yaml +19 -0
  190. package/engines/specs/x_trending.yaml +34 -0
  191. package/engines/specs/yna.yaml +43 -0
  192. package/engines/specs/ynet.yaml +43 -0
  193. package/engines/specs/yuanbao.yaml +93 -0
  194. package/engines/specs/zbmath.yaml +40 -0
  195. package/engines/specs/zdic.yaml +22 -0
  196. package/engines/specs/zenodo.yaml +27 -0
  197. package/engines/specs/zhihu_hot_app.yaml +35 -0
  198. package/mcp/clients.yaml +106 -0
  199. package/package.json +55 -12
  200. package/packages/dsh-plugin/README.md +152 -0
  201. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  202. package/packages/dsh-plugin/dsh/client.js +1533 -0
  203. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  204. package/packages/dsh-plugin/dsh/index.js +527 -0
  205. package/packages/dsh-plugin/dsh/native-tools.mjs +749 -0
  206. package/packages/dsh-plugin/dsh/runner.js +666 -0
  207. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  208. package/packages/dsh-plugin/package.json +38 -0
  209. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  210. package/packages/dsh-plugin/test/client.test.js +406 -0
  211. package/packages/dsh-plugin/test/config.test.js +30 -0
  212. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  213. package/packages/dsh-plugin/test/index.test.js +1382 -0
  214. package/references/query-templates.md +96 -0
  215. package/references/query-templates.yaml +118 -0
  216. package/scripts/ab_eval.py +219 -0
  217. package/scripts/ab_eval_p0p1.py +266 -0
  218. package/scripts/adaptive.py +294 -70
  219. package/scripts/answer.py +154 -0
  220. package/scripts/archive_run.py +591 -0
  221. package/scripts/argo_engine_registry.py +172 -0
  222. package/scripts/argo_paths.py +728 -0
  223. package/scripts/article.py +136 -0
  224. package/scripts/article_meta.py +131 -0
  225. package/scripts/arxiv_source.py +201 -0
  226. package/scripts/badcase_ledger.py +252 -0
  227. package/scripts/batch_probe.py +241 -0
  228. package/scripts/benchmark.py +19 -0
  229. package/scripts/bounded_run.py +144 -0
  230. package/scripts/browser_auth.py +323 -0
  231. package/scripts/budget.py +247 -0
  232. package/scripts/budget_cli.py +93 -0
  233. package/scripts/build_release_assets.sh +65 -0
  234. package/scripts/burstiness_detector.py +147 -0
  235. package/scripts/cache.py +1066 -67
  236. package/scripts/cache_guard.py +271 -0
  237. package/scripts/cache_key_vdom.py +56 -0
  238. package/scripts/cache_similarity.py +170 -0
  239. package/scripts/caliber_isolation.py +195 -0
  240. package/scripts/calibrate_lowquality.py +210 -0
  241. package/scripts/candidate_envelope.py +366 -0
  242. package/scripts/chrome_cdp.py +871 -0
  243. package/scripts/ci_drift_gate.py +90 -0
  244. package/scripts/circuit_breaker.py +389 -0
  245. package/scripts/citations.py +334 -0
  246. package/scripts/cite_cli.py +59 -0
  247. package/scripts/cjk_tokens.py +95 -0
  248. package/scripts/clarify.py +47 -24
  249. package/scripts/cli_io.py +240 -0
  250. package/scripts/config.py +683 -45
  251. package/scripts/config_cache.py +402 -0
  252. package/scripts/conn_pool.py +220 -0
  253. package/scripts/content_boundary.py +46 -0
  254. package/scripts/content_security.py +685 -0
  255. package/scripts/content_signals.py +751 -0
  256. package/scripts/crawl.py +74 -35
  257. package/scripts/cross_lingual_trace.py +293 -0
  258. package/scripts/daemon.py +866 -0
  259. package/scripts/digup_bridge.py +214 -0
  260. package/scripts/dispatch_result.py +98 -0
  261. package/scripts/engine_admission.py +366 -0
  262. package/scripts/engine_dispatch.py +982 -0
  263. package/scripts/engine_env.py +539 -0
  264. package/scripts/engine_failure.py +411 -0
  265. package/scripts/engine_families.py +738 -0
  266. package/scripts/engine_policy.py +218 -0
  267. package/scripts/engine_requires.py +190 -0
  268. package/scripts/engine_status.py +322 -0
  269. package/scripts/engine_validate.py +638 -0
  270. package/scripts/engines.py +799 -294
  271. package/scripts/engines_base.py +1521 -0
  272. package/scripts/engines_builders.py +310 -0
  273. package/scripts/engines_builders_batch10.py +204 -0
  274. package/scripts/engines_builders_batch11.py +174 -0
  275. package/scripts/engines_builders_batch12.py +103 -0
  276. package/scripts/engines_builders_batch13.py +340 -0
  277. package/scripts/engines_builders_batch14.py +656 -0
  278. package/scripts/engines_builders_batch15.py +249 -0
  279. package/scripts/engines_builders_batch9.py +1468 -0
  280. package/scripts/engines_builders_cn.py +2283 -0
  281. package/scripts/engines_builders_data.py +2923 -0
  282. package/scripts/engines_builders_data_macro.py +775 -0
  283. package/scripts/engines_builders_feeds.py +255 -0
  284. package/scripts/engines_builders_intl.py +1239 -0
  285. package/scripts/engines_builders_keyless.py +111 -0
  286. package/scripts/engines_builders_search.py +440 -0
  287. package/scripts/engines_builders_tech.py +1359 -0
  288. package/scripts/evidence.py +620 -166
  289. package/scripts/evidence_loop.py +624 -0
  290. package/scripts/evidence_tier.py +298 -0
  291. package/scripts/except_sets.py +25 -0
  292. package/scripts/extract.py +55 -16
  293. package/scripts/fact_align.py +207 -0
  294. package/scripts/fact_crosscheck.py +230 -0
  295. package/scripts/fetch.py +25 -12
  296. package/scripts/fetch_browser.py +87 -0
  297. package/scripts/fetch_jsrun.py +122 -0
  298. package/scripts/fetch_quality.py +225 -0
  299. package/scripts/fetch_render_tinyfish.py +117 -0
  300. package/scripts/fetch_v3.py +1804 -0
  301. package/scripts/focus_extract.py +350 -0
  302. package/scripts/fulltext_store.py +169 -0
  303. package/scripts/fxtwitter_api.py +325 -0
  304. package/scripts/gen_engine_catalog.py +364 -0
  305. package/scripts/gen_native_tools.py +91 -0
  306. package/scripts/gen_routing_snapshot.py +195 -0
  307. package/scripts/health_check.py +140 -402
  308. package/scripts/health_probe.py +23 -8
  309. package/scripts/hot_state.py +134 -0
  310. package/scripts/http_client.py +1053 -0
  311. package/scripts/image_ops.py +430 -0
  312. package/scripts/image_vision/vision_probe.swift +126 -0
  313. package/scripts/install.ps1 +166 -0
  314. package/scripts/install.sh +221 -0
  315. package/scripts/job.py +1186 -0
  316. package/scripts/jump_resolver.py +235 -0
  317. package/scripts/lang_capability.py +150 -0
  318. package/scripts/lang_detect.py +289 -0
  319. package/scripts/lang_matrix_probe.py +329 -0
  320. package/scripts/lang_pref.py +311 -0
  321. package/scripts/link_source.py +340 -0
  322. package/scripts/local_image.py +970 -0
  323. package/scripts/local_seek.py +402 -0
  324. package/scripts/macro_countries.py +97 -0
  325. package/scripts/matrix_search_eval.py +1187 -0
  326. package/scripts/mcp_compact.py +234 -0
  327. package/scripts/mcp_diag.py +8 -3
  328. package/scripts/mcp_handlers.py +492 -0
  329. package/scripts/mcp_handlers_surface.py +157 -0
  330. package/scripts/mcp_launch.sh +124 -0
  331. package/scripts/mcp_local.py +241 -0
  332. package/scripts/mcp_server.py +125 -419
  333. package/scripts/mcp_setup.py +431 -0
  334. package/scripts/mcp_tools.py +331 -0
  335. package/scripts/mcp_transport.py +191 -0
  336. package/scripts/mcp_warm.py +127 -0
  337. package/scripts/min_version_scan.py +335 -0
  338. package/scripts/net_proxy.py +258 -0
  339. package/scripts/network_aware.py +152 -0
  340. package/scripts/output_transparency.py +200 -0
  341. package/scripts/paid_policy.py +98 -0
  342. package/scripts/paper.py +274 -0
  343. package/scripts/pdf_extract.py +518 -0
  344. package/scripts/phrase_filter.py +66 -0
  345. package/scripts/plan.py +422 -0
  346. package/scripts/postprocess_signals.py +150 -0
  347. package/scripts/pre-commit +48 -0
  348. package/scripts/query_enhance.py +157 -0
  349. package/scripts/query_operators.py +196 -0
  350. package/scripts/query_rewriter.py +116 -7
  351. package/scripts/query_signals.py +300 -0
  352. package/scripts/query_templates.py +167 -0
  353. package/scripts/query_understanding.py +411 -0
  354. package/scripts/query_variants.py +298 -0
  355. package/scripts/quota.py +633 -76
  356. package/scripts/rank_signals.py +775 -0
  357. package/scripts/ranking_eval.py +319 -0
  358. package/scripts/readability_extract.py +433 -0
  359. package/scripts/recompute.py +415 -0
  360. package/scripts/recovery.py +616 -0
  361. package/scripts/recovery_l0.py +160 -0
  362. package/scripts/recovery_runner.py +150 -0
  363. package/scripts/redskill/redskill_engine.py +332 -0
  364. package/scripts/regression_p0p1.py +318 -0
  365. package/scripts/replay_eval.py +658 -0
  366. package/scripts/rerank_tfidf.py +225 -0
  367. package/scripts/research.py +607 -403
  368. package/scripts/research_cli.py +361 -0
  369. package/scripts/research_dossier.py +492 -0
  370. package/scripts/research_expand.py +169 -0
  371. package/scripts/research_gates.py +272 -0
  372. package/scripts/research_report.py +209 -0
  373. package/scripts/research_strategy.py +37 -0
  374. package/scripts/research_work_packages.py +195 -0
  375. package/scripts/result_lang.py +311 -0
  376. package/scripts/retry_policy.py +76 -0
  377. package/scripts/robots_guard.py +259 -0
  378. package/scripts/route.py +921 -254
  379. package/scripts/route_bangs.py +40 -0
  380. package/scripts/route_cache.py +243 -0
  381. package/scripts/route_combo.py +917 -0
  382. package/scripts/route_domains.py +341 -0
  383. package/scripts/route_lang.py +605 -0
  384. package/scripts/route_log.py +63 -0
  385. package/scripts/route_policy.py +250 -0
  386. package/scripts/rrf_classic.py +66 -0
  387. package/scripts/screenshot.py +73 -0
  388. package/scripts/search.py +886 -406
  389. package/scripts/search_attribution.py +185 -0
  390. package/scripts/search_benchmark.py +407 -0
  391. package/scripts/search_cli.py +640 -0
  392. package/scripts/search_entry.py +395 -0
  393. package/scripts/search_output.py +690 -0
  394. package/scripts/search_pipeline.py +926 -0
  395. package/scripts/search_rank.py +991 -0
  396. package/scripts/search_types.py +129 -16
  397. package/scripts/seek_locator.py +68 -0
  398. package/scripts/semantic_evidence.py +391 -0
  399. package/scripts/serp_guard.py +336 -0
  400. package/scripts/serp_kill_log.py +74 -0
  401. package/scripts/single_flight.py +120 -0
  402. package/scripts/social_engines/__init__.py +10 -0
  403. package/scripts/social_engines/bilibili_engine.py +131 -0
  404. package/scripts/social_engines/hackernews_engine.py +113 -0
  405. package/scripts/social_engines/reddit_engine.py +172 -0
  406. package/scripts/social_engines/twitter_engine.py +397 -0
  407. package/scripts/social_engines/v2ex_engine.py +101 -0
  408. package/scripts/social_engines/weibo_engine.py +128 -0
  409. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  410. package/scripts/social_engines/zhihu_engine.py +139 -0
  411. package/scripts/social_research.py +201 -0
  412. package/scripts/sogou_weixin_browser.py +318 -0
  413. package/scripts/source_ledger.py +249 -0
  414. package/scripts/stage_timing.py +58 -0
  415. package/scripts/stats_cli.py +107 -0
  416. package/scripts/straggler_policy.py +60 -0
  417. package/scripts/stylometry_detector.py +231 -0
  418. package/scripts/sync_backends.py +353 -0
  419. package/scripts/sync_installs.py +29 -0
  420. package/scripts/template_cli.py +127 -0
  421. package/scripts/text_lexicon.py +72 -0
  422. package/scripts/tfidf_router.py +109 -37
  423. package/scripts/time_utils.py +233 -0
  424. package/scripts/topic_research_profiles.py +643 -0
  425. package/scripts/train.py +737 -0
  426. package/scripts/tweet.py +596 -0
  427. package/scripts/url_canon.py +217 -0
  428. package/scripts/url_optimize.py +56 -0
  429. package/scripts/url_safety.py +236 -0
  430. package/scripts/usage_log.py +116 -0
  431. package/scripts/usage_log_clean.py +113 -0
  432. package/scripts/v2ex_nodes.py +311 -0
  433. package/scripts/variant_recall.py +188 -0
  434. package/scripts/watch.py +205 -0
  435. package/scripts/wechat_article.py +258 -0
  436. package/scripts/wide_research.py +447 -0
  437. package/scripts/wx.py +349 -0
  438. package/scripts/x_article_fetch.py +141 -0
  439. package/scripts/x_search.py +234 -0
  440. package/scripts/x_trending.py +189 -0
  441. package/scripts/yaml_load.py +67 -0
  442. package/scripts/zhihu_quota.py +139 -0
  443. package/sub-skills/ego-search/SKILL.md +253 -0
  444. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  445. package/sub-skills/ego-search/scripts/merge.py +226 -0
  446. package/sub-skills/ego-search/scripts/quality.py +64 -0
  447. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  448. package/sub-skills/ego-search/scripts/safety.py +109 -0
  449. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  450. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  451. package/sub-skills/js-run/SKILL.md +67 -0
  452. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  453. package/sub-skills/js-run/scripts/shim.js +188 -0
  454. package/sub-skills/local-search/SKILL.md +50 -53
  455. package/sub-skills/local-search/bm25.py +186 -0
  456. package/sub-skills/local-search/cli_runner.py +331 -0
  457. package/sub-skills/local-search/config.yaml +152 -55
  458. package/sub-skills/local-search/engine_registry.py +91 -26
  459. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  460. package/sub-skills/local-search/local_search_adapter.py +13 -2
  461. package/sub-skills/local-search/parse_maps.yaml +40 -42
  462. package/sub-skills/local-search/parsers.py +328 -0
  463. package/sub-skills/local-search/search_v3.py +716 -242
  464. package/sub-skills/local-search/smart_router.py +47 -7
  465. package/sub-skills/local-seek/SKILL.md +110 -0
  466. package/sub-skills/local-seek/config/domains.yaml +20 -0
  467. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  468. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  469. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  470. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  471. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  472. package/sub-skills/local-seek/scripts/seek_rank.py +197 -0
@@ -0,0 +1,1053 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ http_client.py — 零依赖增强 HTTP 请求层
4
+
5
+ 替代 urllib 的轻量封装,提供 Hound 级别的搜索弹性:
6
+ 1. User-Agent 轮换池(模拟 Chrome/Safari/Firefox/Edge)
7
+ 2. Cookie Jar 积累(跨请求保持会话)
8
+ 3. 指数退避重试 + 429/503 Retry-After 尊重
9
+ 4. 请求间随机抖动延迟(避免被识别为 bot)
10
+ 5. curl subprocess fallback(需要更强反检测时)
11
+
12
+ 纯 stdlib 实现,零 pip 依赖。
13
+
14
+ 用法:
15
+ from http_client import HttpClient
16
+ client = HttpClient()
17
+ resp = client.get("https://example.com")
18
+ print(resp["status"], resp["text"][:200])
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import http.client
24
+ import http.cookiejar
25
+ import json
26
+ import os
27
+ import random
28
+ import re
29
+ import socket
30
+ import ssl
31
+ import subprocess
32
+ import threading
33
+ import time
34
+ import urllib.parse
35
+ from contextlib import contextmanager
36
+ from typing import Any
37
+
38
+ try:
39
+ from url_safety import check_url
40
+ except ImportError: # pragma: no cover
41
+ def check_url(url: str) -> tuple[bool, str]:
42
+ return True, ""
43
+
44
+
45
+ # ─── User-Agent 轮换池 ───────────────────────────────────────────────────────
46
+
47
+ # 模拟主流浏览器的完整请求头(不只是 UA 字符串,而是完整 header 集)
48
+ _UA_PROFILES = [
49
+ {
50
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
51
+ "Accept": "text/markdown,text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
52
+ "Accept-Language": "en-US,en;q=0.9",
53
+ "Accept-Encoding": "gzip, deflate",
54
+ "Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
55
+ "Sec-Ch-Ua-Mobile": "?0",
56
+ "Sec-Ch-Ua-Platform": '"macOS"',
57
+ "Sec-Fetch-Dest": "document",
58
+ "Sec-Fetch-Mode": "navigate",
59
+ "Sec-Fetch-Site": "none",
60
+ "Sec-Fetch-User": "?1",
61
+ "Upgrade-Insecure-Requests": "1",
62
+ },
63
+ {
64
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15",
65
+ "Accept": "text/markdown,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
66
+ "Accept-Language": "en-US,en;q=0.9",
67
+ "Accept-Encoding": "gzip, deflate",
68
+ },
69
+ {
70
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:142.0) Gecko/20100101 Firefox/142.0",
71
+ "Accept": "text/markdown,text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
72
+ "Accept-Language": "en-US,en;q=0.5",
73
+ "Accept-Encoding": "gzip, deflate",
74
+ "Upgrade-Insecure-Requests": "1",
75
+ },
76
+ {
77
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0",
78
+ "Accept": "text/markdown,text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
79
+ "Accept-Language": "en-US,en;q=0.9",
80
+ "Accept-Encoding": "gzip, deflate",
81
+ "Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"',
82
+ "Sec-Ch-Ua-Mobile": "?0",
83
+ "Sec-Ch-Ua-Platform": '"Windows"',
84
+ "Sec-Fetch-Dest": "document",
85
+ "Sec-Fetch-Mode": "navigate",
86
+ "Sec-Fetch-Site": "none",
87
+ "Upgrade-Insecure-Requests": "1",
88
+ },
89
+ ]
90
+
91
+
92
+ def _random_headers(extra: dict | None = None) -> dict:
93
+ """生成一组随机浏览器请求头。"""
94
+ profile = random.choice(_UA_PROFILES).copy()
95
+ if extra:
96
+ profile.update(extra)
97
+ return profile
98
+
99
+
100
+ def _charset_of(content_type: str) -> str:
101
+ """从 Content-Type 解析 charset;缺失或无法识别时回落 utf-8。
102
+
103
+ 未知编码必须先判定再解码:`bytes.decode("x-unknown-8bit")` 会抛 LookupError,
104
+ 而 LookupError 不是 OSError 子类,会被 HttpClient.get 的 `except Exception`
105
+ 吞掉并返回 status=0——一个正常的 200 响应被静默降级成「连接失败」,
106
+ 还被归因为 network。运维现实里 `charset=x-unknown-8bit`、
107
+ `charset=gb2312;` 这类脏头并不罕见。
108
+ """
109
+ import codecs
110
+ if "charset=" in content_type:
111
+ charset = content_type.split("charset=")[-1].strip().split(";")[0].strip().strip('"\'')
112
+ try:
113
+ codecs.lookup(charset)
114
+ return charset
115
+ except (LookupError, ValueError):
116
+ pass
117
+ return "utf-8"
118
+
119
+
120
+ def _decode_body(raw_body: bytes, content_type: str) -> str:
121
+ """按 Content-Type 的 charset 解码响应体,任何失败都不丢 body。"""
122
+ return raw_body.decode(_charset_of(content_type), errors="replace")
123
+
124
+
125
+ # ─── Retry-After 尊重(合规限速信号)────────────────────────────────────────
126
+
127
+ _RETRY_AFTER_MAX_WAIT = 10.0 # 服务器要求等待超过此秒数 → 放弃不重试
128
+
129
+
130
+ def retry_after_seconds(status: int, headers: dict | None,
131
+ max_wait: float = _RETRY_AFTER_MAX_WAIT) -> float | None:
132
+ """解析响应的 Retry-After 头,返回应等待的秒数。
133
+
134
+ 仅 429(速率限制)与 503(服务过载)携带的 Retry-After 是明确的
135
+ 「请等待后再请求」信号,其他状态码忽略该头。返回 None 表示不等待:
136
+ - 状态码非 429/503
137
+ - 无 Retry-After 头
138
+ - 头为 HTTP-date 形式或非数字(无法量化等待时间,保守放弃)
139
+ - 等待时间超过 max_wait(超出可接受阈值,直接放弃重试)
140
+ """
141
+ if status not in (429, 503) or not headers:
142
+ return None
143
+ ra = headers.get("Retry-After") or headers.get("retry-after")
144
+ if ra is None:
145
+ return None
146
+ try:
147
+ wait = float(str(ra).strip())
148
+ except ValueError:
149
+ return None
150
+ if wait > max_wait:
151
+ return None
152
+ return max(0.0, wait)
153
+
154
+
155
+ # ─── 主机域族节流(并发桶 + 最小间隔)──────────────────────────────────────
156
+ #
157
+ # 背景:fan-out 并行查询时,同一引擎可能被多个工作线程同时命中,聚合起来
158
+ # 对源站形成密集连击,换来 429 与封锁。配额表管的是「一天能用多少次」,
159
+ # 管不了「这一秒打了多少次」——这里补上后者。
160
+ #
161
+ # 两条原则:
162
+ # 1. 同一家源站的多个域名(主站 / API 域 / CDN 域)共享同一个桶。站点
163
+ # 感受到的压力来自「这一家」,不是一个个孤立域名;按域名分桶会让
164
+ # 主站限流而 CDN 域继续冲锋,压力算总账时必然超。
165
+ # 2. 并发桶与最小间隔缺一不可:只有并发上限、间隔为 0,请求会在瞬间
166
+ # 齐发;只有间隔、没有并发上限,前序请求变慢时在途请求照样堆积。
167
+ #
168
+ # 只对声明的域族生效,未声明的域名不受影响——免费 API 有明确配额契约,
169
+ # 不需要进程内节流;节流是给「按网页语义访问、对突发敏感」的源用的。
170
+
171
+ # (组名, 域名后缀元组),按声明顺序匹配,host 以任一后缀结尾即归入该组
172
+ _HOST_GROUP_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
173
+ ("zhihu", ("zhihu.com",)),
174
+ ("weibo", ("weibo.com", "weibo.cn", "sinaimg.cn", "sina.com.cn")),
175
+ ("xhs", ("xiaohongshu.com", "xhscdn.com")),
176
+ ("bilibili", ("bilibili.com", "bilivideo.com", "hdslb.com", "b23.tv")),
177
+ ("x", ("x.com", "twitter.com", "twimg.com", "t.co")),
178
+ ("douyin", ("douyin.com", "iesdouyin.com", "douyinpic.com", "douyinvod.com")),
179
+ ("baidu", ("baidu.com", "bdstatic.com", "bcebos.com")),
180
+ ("byted", ("bytedance.com", "toutiao.com", "ibyteimg.com")),
181
+ ("sogou", ("sogou.com",)),
182
+ ("bing", ("bing.com",)),
183
+ ("google", ("google.com", "gstatic.com")),
184
+ ("yandex", ("yandex.com", "yandex.ru")),
185
+ ("startpage", ("startpage.com",)),
186
+ ("mojeek", ("mojeek.com",)),
187
+ ("juejin", ("juejin.cn", "juejin.im")),
188
+ ("v2ex", ("v2ex.com",)),
189
+ ("linuxdo", ("linux.do",)),
190
+ )
191
+
192
+ # 每组默认节流参数:并发桶 / 相邻请求最小间隔。
193
+ # 经验起点:中文社交与内容平台风控最紧(2 并发 / 1s+);搜索引擎页
194
+ # 次之(2 并发 / 800ms);其余声明组放宽(3 并发 / 500ms)。
195
+ _HOST_GROUP_DEFAULTS: dict[str, tuple[int, int]] = {
196
+ "zhihu": (2, 1200),
197
+ "weibo": (2, 1200),
198
+ "xhs": (2, 1500),
199
+ "bilibili": (2, 1000),
200
+ "x": (2, 1500),
201
+ "douyin": (2, 1500),
202
+ "baidu": (3, 800),
203
+ "byted": (3, 800),
204
+ "sogou": (2, 1000),
205
+ "bing": (2, 800),
206
+ "google": (2, 1000),
207
+ "yandex": (2, 1000),
208
+ "startpage": (2, 1000),
209
+ "mojeek": (2, 800),
210
+ "juejin": (3, 500),
211
+ "v2ex": (3, 500),
212
+ "linuxdo": (3, 500),
213
+ }
214
+
215
+ _SPEC_OVERRIDE_CACHE: dict[str, tuple[int, int] | None] = {}
216
+ _SPEC_OVERRIDE_LOCK = threading.Lock()
217
+
218
+
219
+ def host_group_for(url: str) -> str | None:
220
+ """URL 属于哪个域族节流组;不在任何声明组返回 None(不限流)。"""
221
+ try:
222
+ host = (urllib.parse.urlparse(url).hostname or "").lower()
223
+ except ValueError:
224
+ return None
225
+ if not host:
226
+ return None
227
+ for group, suffixes in _HOST_GROUP_RULES:
228
+ for suffix in suffixes:
229
+ if host == suffix or host.endswith("." + suffix):
230
+ return group
231
+ return None
232
+
233
+
234
+ def register_spec_limit(engine: str, max_concurrency: Any, min_interval_ms: Any) -> None:
235
+ """引擎 spec 显式声明覆盖(max_concurrency / min_interval_ms)。
236
+
237
+ spec 值优先于域族默认;传非法值等于显式声明「不限流」(None),
238
+ 而非回落到域族默认——声明即契约。
239
+ """
240
+ def _norm(v: Any, default: int) -> int | None:
241
+ try:
242
+ n = int(v)
243
+ except (TypeError, ValueError):
244
+ return None
245
+ return n if n > 0 else None
246
+ conc = _norm(max_concurrency, 0) if max_concurrency is not None else None
247
+ interval = _norm(min_interval_ms, 0) if min_interval_ms is not None else None
248
+ with _SPEC_OVERRIDE_LOCK:
249
+ _SPEC_OVERRIDE_CACHE[engine] = (
250
+ (conc, interval) if conc or interval else None
251
+ )
252
+
253
+
254
+ def _limits_for(url: str, engine: str | None) -> tuple[int, int] | None:
255
+ """解析该请求应使用的 (并发上限, 最小间隔 ms);None = 不限流。"""
256
+ if engine:
257
+ with _SPEC_OVERRIDE_LOCK:
258
+ override = _SPEC_OVERRIDE_CACHE.get(engine)
259
+ if override:
260
+ return override
261
+ if override is None and engine in _SPEC_OVERRIDE_CACHE:
262
+ return None # 显式注册过且声明不限流
263
+ group = host_group_for(url)
264
+ if group is None:
265
+ return None
266
+ return _HOST_GROUP_DEFAULTS.get(group, (2, 800))
267
+
268
+
269
+ class _HostBucket:
270
+ """单个域族的节流桶:信号量(并发)+ 时间戳(最小间隔)。"""
271
+
272
+ def __init__(self, max_concurrency: int, min_interval_ms: int):
273
+ self._sem = threading.BoundedSemaphore(max_concurrency)
274
+ self._interval = max(0.0, min_interval_ms / 1000.0)
275
+ self._lock = threading.Lock()
276
+ self._last_dispatch = 0.0
277
+ self._active = 0 # 在途 lease() 数:LRU 淘汰只踢零活跃的桶(见 host_throttle)
278
+
279
+ def _wait_slot(self) -> None:
280
+ """等到本线程拿到「发起权」,并保证相邻发起间隔 >= interval。
281
+
282
+ 实现是取号-复核循环:在锁内检查「现在是否已到允许时刻」,
283
+ 到了就把下一允许时刻设为「**真实** now + interval」并返回;
284
+ 没到就按剩余时间睡一觉后**重新复核**。
285
+
286
+ 为什么必须复核,而不是一次算出 wait 就睡:
287
+ `time.sleep` 只保证「至少睡够」,实测过冲可达数十毫秒(GIL/
288
+ 调度)。若按「进入函数时的 now + wait」预约下一位,过冲会让
289
+ **实际**发起时刻晚于预约值,下一位仍按旧预约值唤醒——相邻两
290
+ 次真实发起就被过冲吃掉一段间隔。实测旧实现第 2 次间隔仅
291
+ 0.0001~0.044s(声明 60ms),节流退化为「只对首次生效」;测试
292
+ test_min_interval_spread 在 HEAD 即因此间歇失败,是既有缺陷。
293
+ 复核循环用真实时刻推进预约位,过冲只让等待变短、不让间隔变短。
294
+ """
295
+ if self._interval <= 0:
296
+ return
297
+ while True:
298
+ with self._lock:
299
+ now = time.monotonic()
300
+ if now >= self._last_dispatch:
301
+ # 拿到发起权:下一允许时刻按真实 now 推进,
302
+ # 保证与本次真实发起严格相隔一个 interval
303
+ self._last_dispatch = now + self._interval
304
+ return
305
+ wait = self._last_dispatch - now
306
+ # 锁外睡眠;醒来后重新复核(过冲/并发都会在这里被纠正)
307
+ time.sleep(wait)
308
+
309
+ @contextmanager
310
+ def lease(self):
311
+ """进入即拿并发名额并按最小间隔排期,退出释放。"""
312
+ self._sem.acquire()
313
+ with self._lock:
314
+ self._active += 1
315
+ try:
316
+ self._wait_slot()
317
+ yield
318
+ finally:
319
+ with self._lock:
320
+ self._active -= 1
321
+ self._sem.release()
322
+
323
+
324
+ _BUCKETS: dict[str, _HostBucket] = {}
325
+ _BUCKETS_LOCK = threading.Lock()
326
+ _MAX_BUCKETS = 100
327
+ _bucket_last_used: dict[str, float] = {}
328
+
329
+
330
+ @contextmanager
331
+ def host_throttle(url: str, engine: str | None = None):
332
+ """按 URL 的域族节流。无组 / 显式不限流 → 直接放行。
333
+
334
+ 桶键 = (组名, 并发上限, 间隔):同组同参的引擎共享一个桶;个别引擎
335
+ 用 spec 覆盖了更紧的参数时自成桶,与组内其余引擎互不干扰。
336
+ """
337
+ limits = _limits_for(url, engine)
338
+ if not limits:
339
+ yield None
340
+ return
341
+ max_conc, interval = limits
342
+ # spec 单值声明(只给并发或只给间隔)时另一个分量为 None——BoundedSemaphore(None)
343
+ # 会在内部 value<0 处抛 TypeError,补默认值(与域族默认 2 并发一致;间隔 0=不限)
344
+ max_conc = max_conc if max_conc else 2
345
+ interval = interval if interval is not None else 0
346
+ group = host_group_for(url)
347
+ bucket_key = f"{group}|{max_conc}|{interval}"
348
+ with _BUCKETS_LOCK:
349
+ bucket = _BUCKETS.get(bucket_key)
350
+ if bucket is None:
351
+ if len(_BUCKETS) >= _MAX_BUCKETS:
352
+ # LRU 淘汰,但只踢**零活跃**的桶:踢掉正被 lease() 持有的桶,
353
+ # 同 key 新桶与旧桶租户并行,该主机的并发上限与最小间隔瞬间
354
+ # 双份(限速击穿)。全部活跃时宁可靠桶数短暂越上限(桶键空间
355
+ # 受配置约束,有界),也不打破约束。
356
+ idle = [k for k in _bucket_last_used if _BUCKETS[k]._active == 0]
357
+ if idle:
358
+ evict_key = min(idle, key=_bucket_last_used.get)
359
+ del _BUCKETS[evict_key]
360
+ del _bucket_last_used[evict_key]
361
+ bucket = _HostBucket(max_conc, interval)
362
+ _BUCKETS[bucket_key] = bucket
363
+ _bucket_last_used[bucket_key] = time.monotonic()
364
+ with bucket.lease():
365
+ yield group
366
+
367
+
368
+ # ─── Cookie 管理 ─────────────────────────────────────────────────────────────
369
+
370
+ class _CookieManager:
371
+ """跨请求保持 Cookie 积累(Hound 暖会话机制)。"""
372
+
373
+ def __init__(self, persist_path: str | None = None):
374
+ self._jar = http.cookiejar.CookieJar()
375
+ self._persist_path = persist_path
376
+
377
+ def get_cookie_header(self, url: str) -> str:
378
+ """获取适用于指定 URL 的 Cookie 头。"""
379
+ parsed = urllib.parse.urlparse(url)
380
+ # 构建一个虚拟 request 对象让 cookiejar 提取
381
+ import urllib.request as _urllib_request
382
+ req = _urllib_request.Request(url)
383
+ self._jar.add_cookie_header(req)
384
+ return req.get_header("Cookie") or ""
385
+
386
+ def extract_from_response(self, url: str, response_headers: list[tuple[str, str]]) -> None:
387
+ """从响应头提取 Set-Cookie 并存入 jar。"""
388
+ parsed = urllib.parse.urlparse(url)
389
+ # 构建 mock request 让 cookiejar 能提取
390
+ import urllib.request as _urllib_request
391
+ req = _urllib_request.Request(url)
392
+ # 使用 http.cookiejar.extract_cookies 需要 response 对象
393
+ # 简化:手动解析 Set-Cookie
394
+ for name, value in response_headers:
395
+ if name.lower() == "set-cookie":
396
+ self._parse_and_store(url, value)
397
+
398
+ def _parse_and_store(self, url: str, set_cookie: str) -> None:
399
+ """解析 Set-Cookie 头并存入 jar。"""
400
+ try:
401
+ parsed = urllib.parse.urlparse(url)
402
+ # 用 MozillaCookieJar 的方式存储
403
+ cookie = http.cookiejar.Cookie(
404
+ version=0,
405
+ name="",
406
+ value="",
407
+ port=None,
408
+ port_specified=False,
409
+ domain=parsed.hostname or "",
410
+ domain_specified=True,
411
+ domain_initial_dot=False,
412
+ path="/",
413
+ path_specified=True,
414
+ secure=parsed.scheme == "https",
415
+ expires=None,
416
+ discard=True,
417
+ comment=None,
418
+ comment_url=None,
419
+ rest={},
420
+ rfc2109=False,
421
+ )
422
+ # 解析 name=value 部分
423
+ parts = set_cookie.split(";")
424
+ if parts:
425
+ nv = parts[0].strip()
426
+ if "=" in nv:
427
+ cookie.name, cookie.value = nv.split("=", 1)
428
+ cookie.domain = parsed.hostname or ""
429
+ self._jar.set_cookie(cookie)
430
+ except Exception as _exc:
431
+ # 每响应都跑:坏 Set-Cookie 留 debug 痕迹(此前是全仓最高频静默点)
432
+ import logging
433
+ logging.getLogger("unified_search").debug(
434
+ f"Set-Cookie 解析失败: {_exc!r}")
435
+
436
+ @property
437
+ def jar(self) -> http.cookiejar.CookieJar:
438
+ return self._jar
439
+
440
+
441
+ # ─── 主 HTTP 客户端 ──────────────────────────────────────────────────────────
442
+
443
+ class HttpClient:
444
+ """增强 HTTP 客户端:UA 轮换 + Cookie 积累 + 重试弹性。
445
+
446
+ 设计原则:
447
+ - 每次请求随机选择 UA profile(模拟不同浏览器)
448
+ - 自动积累 Cookie(跨请求保持会话状态)
449
+ - 429/503 尊重 Retry-After 头
450
+ - 指数退避重试(最多 3 次)
451
+ - 请求间随机抖动延迟(0.1-0.5s)
452
+ """
453
+
454
+ def __init__(self, timeout: float = 10.0, max_retries: int = 2,
455
+ jitter: bool = True, use_curl_fallback: bool = False):
456
+ self.timeout = timeout
457
+ self.max_retries = max_retries
458
+ self.jitter = jitter
459
+ self.use_curl_fallback = use_curl_fallback
460
+ self._cookies = _CookieManager()
461
+ self._last_request_time = 0.0
462
+
463
+ def get(self, url: str, extra_headers: dict | None = None,
464
+ follow_redirects: bool = True,
465
+ engine: str | None = None,
466
+ impersonate_profiles: list[str] | None = None) -> dict:
467
+ """发送 GET 请求,返回统一响应格式。
468
+
469
+ SSRF 防护:默认拒绝内网 / 私有地址目标(ARGO_ALLOW_PRIVATE_URLS=1
470
+ 显式放行)。校验失败返回 status=0 + error,不发起请求。
471
+ engine:调用方引擎 id,用于按引擎 spec 覆盖域族节流参数。
472
+
473
+ 返回:{
474
+ "status": int,
475
+ "headers": dict,
476
+ "text": str,
477
+ "url": str, # 最终 URL(跟随重定向后)
478
+ "elapsed_ms": int,
479
+ "from_cache": bool,
480
+ }
481
+ """
482
+ ok, reason = check_url(url)
483
+ if not ok:
484
+ return {"status": 0, "headers": {}, "text": "", "url": url,
485
+ "elapsed_ms": 0, "error": f"URL 被 SSRF 防护拦截: {reason}"}
486
+
487
+ self._apply_jitter()
488
+
489
+ last_error = None
490
+ # 域族节流包住整个重试循环:重试同样占用该源站的并发名额
491
+ with host_throttle(url, engine):
492
+ for attempt in range(self.max_retries + 1):
493
+ try:
494
+ if impersonate_profiles:
495
+ # TLS 指纹仿冒档:反爬按 ClientHello 判机器人时启用
496
+ # (2026-09-26 Baidu 实测:Python 原生指纹整 IP 被降级
497
+ # 为壳页,chrome/safari 仿冒同刻 17/12 条)。
498
+ resp = self.get_impersonated(
499
+ url, extra_headers=extra_headers,
500
+ timeout=self.timeout, profiles=impersonate_profiles)
501
+ else:
502
+ resp = self._do_get(url, extra_headers, follow_redirects)
503
+ # 429/503 + Retry-After:服务器明确要求等待 → 按其指示等待后重试
504
+ # (等待服务器说的时间,而非盲退避;无头/超阈值则直接返回不重试)
505
+ wait = retry_after_seconds(resp.get("status", 0),
506
+ resp.get("headers", {}))
507
+ if wait is not None and attempt < self.max_retries:
508
+ time.sleep(wait)
509
+ continue
510
+ return resp
511
+ except (socket.timeout, ConnectionError, OSError) as e:
512
+ last_error = e
513
+ if attempt < self.max_retries:
514
+ wait = self._backoff_delay(attempt)
515
+ time.sleep(wait)
516
+ except Exception as e:
517
+ last_error = e
518
+ break
519
+
520
+ return {"status": 0, "headers": {}, "text": "", "url": url,
521
+ "elapsed_ms": 0, "error": str(last_error)[:200]}
522
+
523
+ def _do_get(self, url: str, extra_headers: dict | None,
524
+ follow_redirects: bool) -> dict:
525
+ """实际执行 GET 请求(使用 http.client,不自动解压)。"""
526
+ start = time.time()
527
+ current_url = url
528
+ redirects_left = 5 if follow_redirects else 0
529
+
530
+ while True:
531
+ # 构建请求头
532
+ headers = _random_headers(extra_headers)
533
+ cookie_str = self._cookies.get_cookie_header(current_url)
534
+ if cookie_str:
535
+ headers["Cookie"] = cookie_str
536
+
537
+ # 解析 URL
538
+ parsed = urllib.parse.urlparse(current_url)
539
+ if not parsed.scheme:
540
+ current_url = "https://" + current_url
541
+ parsed = urllib.parse.urlparse(current_url)
542
+
543
+ # 使用 http.client(不自动解压,我们可以手动处理);出口经 net_proxy 调度
544
+ from net_proxy import request_selector, resolve_proxy
545
+ from conn_pool import discard as _pool_discard, give_back as _pool_give, borrow_via_net_proxy as _pool_borrow
546
+ proxy_url = resolve_proxy(current_url)
547
+ # 连接池:优先取同主机空闲连接,没有才经 net_proxy 新建(出口单点不变)
548
+ conn, _key, _reused = _pool_borrow(parsed, self.timeout, proxy_url)
549
+
550
+ path = parsed.path or "/"
551
+ if parsed.query:
552
+ path += "?" + parsed.query
553
+
554
+ for _try in (0, 1):
555
+ try:
556
+ conn.request("GET", request_selector(parsed, path, bool(proxy_url)),
557
+ headers=headers)
558
+ resp = conn.getresponse()
559
+ break
560
+ except (http.client.HTTPException, ConnectionError, OSError):
561
+ # 复用连接被服务端悄悄关闭(keep-alive 空闲超时)是最高频失效形态:丢弃换新重试一次,不占调用方重试预算
562
+ _pool_discard(_key, conn)
563
+ if not _reused or _try:
564
+ raise
565
+ conn, _key, _reused = _pool_borrow(parsed, self.timeout, proxy_url)
566
+ status = resp.status
567
+ resp_headers = dict(resp.getheaders())
568
+
569
+ # 提取 Cookie
570
+ self._cookies.extract_from_response(current_url, resp.getheaders())
571
+
572
+ # 跟随重定向(301/302/303/307/308):Location 相对/绝对都解析
573
+ if status in (301, 302, 303, 307, 308) and redirects_left > 0:
574
+ loc = resp.getheader("Location")
575
+ # 响应体读干才能还池;读失败(连接已坏)就地丢弃。
576
+ try:
577
+ resp.read()
578
+ _pool_give(_key, conn)
579
+ except Exception:
580
+ _pool_discard(_key, conn)
581
+ if not loc:
582
+ break
583
+ current_url = urllib.parse.urljoin(current_url, loc)
584
+ # 部分站点的 Location 是未编码的(zdic /hans/道):原始 UTF-8
585
+ # 字节被 http.client 按 latin-1 解码成乱码——先按 latin-1 还原
586
+ # UTF-8,再补编码残余非 ASCII 字符(%XX 序列原样保留)
587
+ if any(ord(ch) > 127 for ch in current_url):
588
+ try:
589
+ current_url = current_url.encode("latin-1").decode("utf-8")
590
+ except (UnicodeDecodeError, UnicodeEncodeError):
591
+ pass
592
+ current_url = "".join(
593
+ ch if ord(ch) < 128 else urllib.parse.quote(ch)
594
+ for ch in current_url
595
+ )
596
+ redirects_left -= 1
597
+ continue
598
+
599
+ # 读取 body
600
+ raw_body = resp.read()
601
+ # 还池 vs 关闭:服务端明示 Connection: close(will_close)时读完即废。
602
+ if getattr(resp, "will_close", False):
603
+ _pool_discard(_key, conn)
604
+ else:
605
+ _pool_give(_key, conn)
606
+
607
+ # 手动解压
608
+ encoding = resp.getheader("Content-Encoding", "")
609
+ if "gzip" in encoding:
610
+ import gzip
611
+ import io
612
+ raw_body = gzip.GzipFile(fileobj=io.BytesIO(raw_body)).read()
613
+ elif "br" in encoding:
614
+ try:
615
+ import brotli
616
+ raw_body = brotli.decompress(raw_body)
617
+ except ImportError:
618
+ # brotli 不可用 → curl fallback
619
+ return self.get_with_curl(url, extra_headers)
620
+
621
+ # 解码
622
+ content_type = resp.getheader("Content-Type", "")
623
+ text = _decode_body(raw_body, content_type)
624
+
625
+ elapsed = int((time.time() - start) * 1000)
626
+
627
+ return {
628
+ "status": status,
629
+ "headers": resp_headers,
630
+ "text": text,
631
+ "url": current_url,
632
+ "elapsed_ms": elapsed,
633
+ "from_cache": False,
634
+ }
635
+
636
+ def _apply_jitter(self) -> None:
637
+ """请求间随机抖动延迟。"""
638
+ if not self.jitter:
639
+ return
640
+ now = time.time()
641
+ elapsed = now - self._last_request_time
642
+ if elapsed < 0.1:
643
+ delay = random.uniform(0.05, 0.3)
644
+ time.sleep(delay)
645
+ self._last_request_time = time.time()
646
+
647
+ @staticmethod
648
+ def _backoff_delay(attempt: int) -> float:
649
+ """指数退避 + 随机抖动。"""
650
+ base = min(2 ** attempt, 8) # 1s, 2s, 4s, 8s cap
651
+ return base + random.uniform(0, 0.5)
652
+
653
+ def get_with_curl(self, url: str, extra_headers: dict | None = None) -> dict:
654
+ """curl subprocess fallback(更强的反检测能力)。
655
+
656
+ curl 的 TLS 指纹与 Python urllib 不同,某些网站对 curl 更友好。
657
+ 重定向由 Python 侧安全跟随(每跳校验),curl 自身不跟随。
658
+ """
659
+ ok, reason = check_url(url)
660
+ if not ok:
661
+ return {"status": 0, "headers": {}, "text": "", "url": url,
662
+ "elapsed_ms": 0, "error": f"URL 被 SSRF 防护拦截: {reason}"}
663
+
664
+ start = time.time()
665
+ headers = _random_headers(extra_headers)
666
+
667
+ final_url = self._safe_follow_redirects(url, headers)
668
+ if final_url is None:
669
+ return {"status": 0, "headers": {}, "text": "", "url": url,
670
+ "elapsed_ms": int((time.time() - start) * 1000),
671
+ "error": "重定向目标被 SSRF 防护拦截"}
672
+
673
+ cmd = ["curl", "-s", "--max-redirs", "0", "--max-time",
674
+ str(int(self.timeout)),
675
+ "-w", "\\n%{http_code}\\n%{url_effective}"]
676
+ # 出口调度(issue #13):curl 原生只认标准小写 env,argo 级配置
677
+ # (ARGO_PROXY/rules)须显式 -x 下发
678
+ try:
679
+ from net_proxy import resolve_proxy
680
+ _px = resolve_proxy(final_url)
681
+ if _px:
682
+ cmd.extend(["-x", _px])
683
+ except Exception:
684
+ pass
685
+ for k, v in headers.items():
686
+ cmd.extend(["-H", f"{k}: {v}"])
687
+ cmd.append(final_url)
688
+
689
+ try:
690
+ r = subprocess.run(cmd, capture_output=True, text=True,
691
+ encoding="utf-8", errors="replace",
692
+ timeout=self.timeout + 5)
693
+ output = r.stdout.strip()
694
+ lines = output.rsplit("\n", 2)
695
+ if len(lines) >= 2:
696
+ text = lines[0] if len(lines) == 2 else "\n".join(lines[:-2])
697
+ status = int(lines[-2]) if lines[-2].isdigit() and lines[-2].isascii() else 0
698
+ final_url = lines[-1]
699
+ else:
700
+ text = output
701
+ status = 0
702
+ final_url = url
703
+
704
+ elapsed = int((time.time() - start) * 1000)
705
+ return {"status": status, "headers": {}, "text": text, "url": final_url,
706
+ "elapsed_ms": elapsed, "from_cache": False, "via_curl": True}
707
+ except Exception as e:
708
+ return {"status": 0, "headers": {}, "text": "", "url": url,
709
+ "elapsed_ms": 0, "error": str(e)[:200]}
710
+
711
+ def _curl_proxies(self, url: str) -> dict | None:
712
+ """curl_cffi 的 proxies 参数;出口解析唯一来源在 net_proxy。"""
713
+ try:
714
+ from net_proxy import resolve_proxy
715
+ p = resolve_proxy(url)
716
+ if not p:
717
+ return None
718
+ scheme = urllib.parse.urlparse(url).scheme or "https"
719
+ return {scheme: p}
720
+ except Exception:
721
+ return None
722
+
723
+ def get_impersonated(self, url: str, extra_headers: dict | None = None,
724
+ timeout: float | None = None,
725
+ profiles: list[str] | None = None) -> dict:
726
+ """TLS 指纹伪造请求(curl_cffi impersonate)。
727
+
728
+ 原理:urllib/curl 的 TLS ClientHello 指纹与真实浏览器不同,
729
+ 反爬站点(Cloudflare 等)凭指纹即可判定机器人并直接 403。
730
+ curl_cffi 可逐字节模拟 Chrome/Safari/Firefox 的 TLS 指纹,
731
+ 在不启动浏览器的情况下通过指纹检测。
732
+
733
+ - 指纹轮换:按 profiles 顺序尝试,直到成功
734
+ - SSRF 防护:重定向逐跳校验(与 get_with_curl 一致)
735
+ - 失败返回 status=0 + error,不抛异常
736
+
737
+ 返回格式与 get() 一致,另附 `impersonate`(成功所用指纹)。
738
+ """
739
+ ok, reason = check_url(url)
740
+ if not ok:
741
+ return {"status": 0, "headers": {}, "text": "", "url": url,
742
+ "elapsed_ms": 0, "error": f"URL 被 SSRF 防护拦截: {reason}"}
743
+
744
+ try:
745
+ from curl_cffi import requests as cr
746
+ except ImportError:
747
+ return {"status": 0, "headers": {}, "text": "", "url": url,
748
+ "elapsed_ms": 0, "error": "curl_cffi not installed"}
749
+
750
+ start = time.time()
751
+ _timeout = timeout if timeout is not None else self.timeout
752
+ headers = _random_headers(extra_headers)
753
+ profiles = profiles or ["chrome", "safari", "firefox"]
754
+ current = url
755
+
756
+ for _ in range(5): # 最多 5 跳重定向
757
+ ok, _reason = check_url(current)
758
+ if not ok:
759
+ return {"status": 0, "headers": {}, "text": "", "url": current,
760
+ "elapsed_ms": int((time.time() - start) * 1000),
761
+ "error": f"重定向目标被 SSRF 防护拦截: {_reason}"}
762
+
763
+ last_err = None
764
+ last_resp = None # 最后一次非重定向响应(用于全指纹被拒时上报)
765
+ for fp in profiles:
766
+ try:
767
+ resp = cr.get(current, impersonate=fp, headers=headers,
768
+ timeout=_timeout, allow_redirects=False,
769
+ proxies=self._curl_proxies(current))
770
+ status = resp.status_code
771
+ if status in (301, 302, 303, 307, 308):
772
+ location = resp.headers.get("Location", "")
773
+ if not location:
774
+ return {"status": status, "headers": dict(resp.headers),
775
+ "text": resp.text, "url": current,
776
+ "elapsed_ms": int((time.time() - start) * 1000),
777
+ "from_cache": False, "impersonate": fp}
778
+ current = urllib.parse.urljoin(current, location)
779
+ break # 进入下一跳
780
+ last_resp = (status, dict(resp.headers), resp.text)
781
+ if status in (429, 503):
782
+ # 明确停止信号(速率限制/服务过载)→ 与 TLS 指纹无关,
783
+ # 轮换指纹是徒劳的 bot 行为。立即返回并标记 stop_signal,
784
+ # 交由上层(fetch_v3 主链)停止升级重链。
785
+ return {"status": status, "headers": last_resp[1],
786
+ "text": last_resp[2], "url": current,
787
+ "elapsed_ms": int((time.time() - start) * 1000),
788
+ "from_cache": False, "impersonate": fp,
789
+ "stop_signal": True}
790
+ if status >= 400:
791
+ # 该指纹被反爬拒绝(403 等),轮换下一个指纹
792
+ continue
793
+ return {"status": status, "headers": last_resp[1],
794
+ "text": last_resp[2], "url": current,
795
+ "elapsed_ms": int((time.time() - start) * 1000),
796
+ "from_cache": False, "impersonate": fp}
797
+ except Exception as e:
798
+ last_err = e
799
+ continue
800
+ else:
801
+ if last_resp is not None:
802
+ # 全部指纹均被拒 → 返回最后一次响应,交由上层降级
803
+ status, hdrs, text = last_resp
804
+ return {"status": status, "headers": hdrs, "text": text,
805
+ "url": current,
806
+ "elapsed_ms": int((time.time() - start) * 1000),
807
+ "from_cache": False,
808
+ "impersonate": profiles[-1]}
809
+ # 全部指纹请求异常
810
+ return {"status": 0, "headers": {}, "text": "", "url": current,
811
+ "elapsed_ms": int((time.time() - start) * 1000),
812
+ "error": f"TLS 指纹请求失败: {str(last_err)[:150]}"}
813
+
814
+ return {"status": 0, "headers": {}, "text": "", "url": current,
815
+ "elapsed_ms": int((time.time() - start) * 1000),
816
+ "error": "重定向超过 5 跳"}
817
+
818
+ def post(self, url: str, body: dict | None = None,
819
+ extra_headers: dict | None = None,
820
+ follow_redirects: bool = True,
821
+ json_body: bool = True,
822
+ engine: str | None = None) -> dict:
823
+ """发送 POST 请求(anysearch 等 JSON-RPC/JSON API 用)。
824
+
825
+ 复用 get() 的 UA 轮换、Cookie 积累、重试退避、Retry-After 尊重、
826
+ SSRF 防护与域族节流。返回格式与 get() 一致。body:dict → json.dumps
827
+ (json_body=True)或 urlencode(json_body=False);None 则不发送 body。
828
+ """
829
+ ok, reason = check_url(url)
830
+ if not ok:
831
+ return {"status": 0, "headers": {}, "text": "", "url": url,
832
+ "elapsed_ms": 0, "error": f"URL 被 SSRF 防护拦截: {reason}"}
833
+ self._apply_jitter()
834
+ last_error = None
835
+ payload = None
836
+ if body is not None:
837
+ if json_body:
838
+ payload = json.dumps(body).encode("utf-8")
839
+ else:
840
+ payload = urllib.parse.urlencode(body).encode("utf-8")
841
+ with host_throttle(url, engine):
842
+ for attempt in range(self.max_retries + 1):
843
+ try:
844
+ resp = self._do_post(url, payload, extra_headers, follow_redirects)
845
+ wait = retry_after_seconds(resp.get("status", 0),
846
+ resp.get("headers", {}))
847
+ if wait is not None and attempt < self.max_retries:
848
+ time.sleep(wait)
849
+ continue
850
+ return resp
851
+ except (socket.timeout, ConnectionError, OSError) as e:
852
+ last_error = e
853
+ if attempt < self.max_retries:
854
+ time.sleep(self._backoff_delay(attempt))
855
+ except Exception as e:
856
+ last_error = e
857
+ break
858
+ return {"status": 0, "headers": {}, "text": "", "url": url,
859
+ "elapsed_ms": 0, "error": str(last_error)[:200]}
860
+
861
+ def _do_post(self, url: str, payload: bytes | None,
862
+ extra_headers: dict | None, follow_redirects: bool) -> dict:
863
+ """实际执行 POST(http.client;连接复用与失效重借与 _do_get 同构)。
864
+
865
+ 2026-10-18:此前 POST 每次直连再 close,绕开 conn_pool——而 anysearch
866
+ 正是 POST 引擎。实测同连接第二次请求省 320ms(anysearch)/ 561ms
867
+ (octen);长驻 MCP/daemon 进程本可整段省掉,却每次重付代理后的 TLS。
868
+ """
869
+ start = time.time()
870
+ current_url = url
871
+ redirects_left = 5 if follow_redirects else 0
872
+ while True:
873
+ headers = _random_headers(extra_headers)
874
+ cookie_str = self._cookies.get_cookie_header(current_url)
875
+ if cookie_str:
876
+ headers["Cookie"] = cookie_str
877
+ parsed = urllib.parse.urlparse(current_url)
878
+ if not parsed.scheme:
879
+ current_url = "https://" + current_url
880
+ parsed = urllib.parse.urlparse(current_url)
881
+ from net_proxy import request_selector, resolve_proxy
882
+ from conn_pool import (borrow_via_net_proxy as _pool_borrow,
883
+ discard as _pool_discard,
884
+ give_back as _pool_give)
885
+ proxy_url = resolve_proxy(current_url)
886
+ conn, _key, _reused = _pool_borrow(parsed, self.timeout, proxy_url)
887
+ path = parsed.path or "/"
888
+ if parsed.query:
889
+ path += "?" + parsed.query
890
+ for _try in (0, 1):
891
+ try:
892
+ conn.request("POST", request_selector(parsed, path, bool(proxy_url)),
893
+ body=payload, headers=headers)
894
+ resp = conn.getresponse()
895
+ break
896
+ except (http.client.HTTPException, ConnectionError, OSError):
897
+ # 与 GET 同口径:复用连接被服务端悄悄关闭时丢弃换新重试一次,
898
+ # 不占调用方重试预算
899
+ _pool_discard(_key, conn)
900
+ if not _reused or _try:
901
+ raise
902
+ conn, _key, _reused = _pool_borrow(parsed, self.timeout, proxy_url)
903
+ status = resp.status
904
+ resp_headers = dict(resp.getheaders())
905
+ self._cookies.extract_from_response(current_url, resp.getheaders())
906
+ if status in (301, 302, 303, 307, 308) and redirects_left > 0:
907
+ loc = resp.getheader("Location")
908
+ # 响应体读干才能还池;读失败(连接已坏)就地丢弃
909
+ try:
910
+ resp.read()
911
+ _pool_give(_key, conn)
912
+ except Exception:
913
+ _pool_discard(_key, conn)
914
+ if not loc:
915
+ break
916
+ current_url = urllib.parse.urljoin(current_url, loc)
917
+ redirects_left -= 1
918
+ continue
919
+ raw_body = resp.read()
920
+ if getattr(resp, "will_close", False):
921
+ _pool_discard(_key, conn)
922
+ else:
923
+ _pool_give(_key, conn)
924
+ encoding = resp.getheader("Content-Encoding", "")
925
+ if "gzip" in encoding:
926
+ import gzip, io
927
+ raw_body = gzip.GzipFile(fileobj=io.BytesIO(raw_body)).read()
928
+ elif "br" in encoding:
929
+ try:
930
+ import brotli
931
+ raw_body = brotli.decompress(raw_body)
932
+ except ImportError:
933
+ return self._do_post_fallback(url, payload, extra_headers)
934
+ content_type = resp.getheader("Content-Type", "")
935
+ text = _decode_body(raw_body, content_type)
936
+ return {"status": status, "headers": resp_headers, "text": text,
937
+ "url": current_url,
938
+ "elapsed_ms": int((time.time() - start) * 1000),
939
+ "from_cache": False}
940
+
941
+ def _do_post_fallback(self, url: str, payload: bytes | None,
942
+ extra_headers: dict | None) -> dict:
943
+ """POST 的 curl fallback(brotli 不可用时)。"""
944
+ headers = _random_headers(extra_headers)
945
+ cmd = ["curl", "-s", "--max-time", str(int(self.timeout)), "-X", "POST",
946
+ "-w", "\\n%{http_code}\\n%{url_effective}"]
947
+ # 出口调度(issue #13):与 GET 路径同一判据——此前这条分支漏了
948
+ # resolve_proxy,「须代理出网」环境里 POST 必然直连失败(2026-09-28 复核)
949
+ try:
950
+ from net_proxy import resolve_proxy
951
+ _px = resolve_proxy(url)
952
+ if _px:
953
+ cmd.extend(["-x", _px])
954
+ except Exception:
955
+ pass
956
+ for k, v in headers.items():
957
+ cmd.extend(["-H", f"{k}: {v}"])
958
+ if payload is not None:
959
+ cmd.extend(["--data-binary", "@-"])
960
+ cmd.append(url)
961
+ try:
962
+ import subprocess
963
+ # payload 恒为 bytes(post() 已 encode),故不用 text 模式:
964
+ # text=True 收 bytes input 会 TypeError;stdout 手动按 UTF-8 解
965
+ # (网页字节流本就 UTF-8,显式解也修掉 Windows GBK 乱码)
966
+ r = subprocess.run(cmd, input=payload, capture_output=True,
967
+ timeout=self.timeout + 5)
968
+ output = (r.stdout or b"").decode("utf-8", errors="replace").strip()
969
+ lines = output.rsplit("\n", 2)
970
+ if len(lines) >= 2:
971
+ text = lines[0] if len(lines) == 2 else "\n".join(lines[:-2])
972
+ status = int(lines[-2]) if lines[-2].isdigit() and lines[-2].isascii() else 0
973
+ else:
974
+ text, status = output, 0
975
+ return {"status": status, "headers": {}, "text": text, "url": url,
976
+ "elapsed_ms": 0, "from_cache": False}
977
+ except Exception as e:
978
+ return {"status": 0, "headers": {}, "text": "", "url": url,
979
+ "elapsed_ms": 0, "error": str(e)[:200]}
980
+
981
+ def _safe_follow_redirects(self, url: str, headers: dict,
982
+ max_redirects: int = 5) -> str | None:
983
+ """逐跳安全跟随重定向:每跳目标都过 SSRF 校验。
984
+
985
+ 返回最终 URL;任一跳被拦截或超过跳数返回 None。
986
+ """
987
+ current = url
988
+ for _ in range(max_redirects):
989
+ ok, _reason = check_url(current)
990
+ if not ok:
991
+ return None
992
+ parsed = urllib.parse.urlparse(current)
993
+ if parsed.scheme not in ("http", "https"):
994
+ return None
995
+ try:
996
+ from net_proxy import open_connection, request_selector, resolve_proxy
997
+ proxy_url = resolve_proxy(current)
998
+ conn, via_proxy = open_connection(parsed, self.timeout, proxy_url)
999
+ path = parsed.path or "/"
1000
+ if parsed.query:
1001
+ path += "?" + parsed.query
1002
+ conn.request("HEAD", request_selector(parsed, path, via_proxy),
1003
+ headers=headers)
1004
+ resp = conn.getresponse()
1005
+ status = resp.status
1006
+ location = resp.getheader("Location")
1007
+ conn.close()
1008
+ except Exception:
1009
+ return None
1010
+ if status not in (301, 302, 303, 307, 308) or not location:
1011
+ return current
1012
+ current = urllib.parse.urljoin(current, location)
1013
+ return None
1014
+
1015
+
1016
+ # ─── 便捷函数 ────────────────────────────────────────────────────────────────
1017
+
1018
+ def fetch_url(url: str, timeout: float = 10.0, max_retries: int = 2) -> dict:
1019
+ """一次性 GET 请求的便捷函数。"""
1020
+ client = HttpClient(timeout=timeout, max_retries=max_retries)
1021
+ return client.get(url)
1022
+
1023
+
1024
+ # ─── CLI 测试 ────────────────────────────────────────────────────────────────
1025
+
1026
+ if __name__ == "__main__":
1027
+ import sys
1028
+ url = sys.argv[1] if len(sys.argv) > 1 else "https://httpbin.org/get"
1029
+
1030
+ print(f"=== Testing HttpClient: {url} ===")
1031
+ client = HttpClient(timeout=10, max_retries=1)
1032
+ resp = client.get(url)
1033
+
1034
+ print(f"Status: {resp['status']}")
1035
+ print(f"Elapsed: {resp['elapsed_ms']}ms")
1036
+ print(f"Text length: {len(resp['text'])}")
1037
+ if resp.get("text"):
1038
+ try:
1039
+ data = json.loads(resp["text"])
1040
+ print(f"Server saw UA: {data.get('headers', {}).get('User-Agent', 'N/A')[:60]}")
1041
+ except json.JSONDecodeError:
1042
+ print(f"First 200 chars: {resp['text'][:200]}")
1043
+
1044
+ # 测试 curl fallback
1045
+ print(f"\n=== Testing curl fallback ===")
1046
+ resp2 = client.get_with_curl(url)
1047
+ print(f"Status: {resp2['status']}, via_curl: {resp2.get('via_curl')}")
1048
+ if resp2.get("text"):
1049
+ try:
1050
+ data = json.loads(resp2["text"])
1051
+ print(f"Server saw UA: {data.get('headers', {}).get('User-Agent', 'N/A')[:60]}")
1052
+ except json.JSONDecodeError:
1053
+ pass