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,1804 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ fetch_v3.py — 抓取降级链(零外部依赖,纯 stdlib + 系统 Chrome)
4
+
5
+ 吸收 Hound 的页面交互能力,但不引入 Playwright/Patchright 依赖:
6
+ 第零级:AI 友好变体探测({url}.md 直出 + 站点根 /llms.txt,诚实身份;
7
+ ARGO_FETCH_MD_VARIANT=0 关闭)
8
+ 第一级:增强 HTTP(UA 轮换 + Cookie 积累 + 重试弹性)
9
+ 第一级A2:移动端 UA 分支(客户端形态分流型反爬;门控站单次直连 +
10
+ per-host 身份记忆;ARGO_FETCH_MOBILE=0 关闭)
11
+ 第一级B:TLS 指纹伪造(curl_cffi impersonate,多指纹轮换,免起浏览器)
12
+ 第一级C:r.jina.ai 阅读器(keyless 免费层,远端 JS 渲染转 markdown;
13
+ 仅公网 URL,ARGO_FETCH_JINA=0 关闭)
14
+ 第二级:Chrome CDP 驱动(页面交互/JS 渲染/CF 绕过)
15
+ 第三级:内容质量评估(content_ok/page_type/quality_score)
16
+
17
+ 对比 fetch_v2:
18
+ - fetch_v2: urllib + Hound subprocess(需要 master_fetch 包)
19
+ - fetch_v3: http_client(stdlib) + curl_cffi(可选) + chrome_cdp(stdlib+系统Chrome) → 完全自主
20
+
21
+ TLS 指纹伪造层(第一级B):
22
+ 指纹检测型反爬(Cloudflare 等)凭 TLS ClientHello 判 bot,urllib 直接 403。
23
+ curl_cffi 可逐字节模拟 Chrome/Safari/Firefox 指纹,免起浏览器即可通过。
24
+ 开关:ARGO_FETCH_IMPERSONATE=0 关闭,默认开启。
25
+
26
+ 用法:
27
+ from fetch_v3 import fetch_v3, fetch_page_v3
28
+ result = fetch_v3("https://example.com", actions=[{"click": "#btn"}])
29
+ # 兼容旧接口
30
+ result = fetch_page_v3("https://example.com", max_chars=3000)
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import re
38
+ import signal
39
+ import sys
40
+ import time
41
+ import traceback
42
+ import urllib.parse
43
+ import uuid
44
+ from html.parser import HTMLParser
45
+ from pathlib import Path
46
+ from typing import Any
47
+ from urllib.parse import urlparse
48
+
49
+ # 确保能导入同目录模块
50
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
51
+ if SCRIPT_DIR not in sys.path:
52
+ sys.path.insert(0, SCRIPT_DIR)
53
+
54
+ # 渲染层(第二级A):TinyFish 直连渲染,独立模块以保持本文件聚焦主链调度
55
+ import fetch_render_tinyfish as _render_tinyfish
56
+
57
+ # 质量信号层(第三级):来源分类 / 页面类型 / 质量分 / 内容安全
58
+ import fetch_quality as _quality
59
+
60
+ # 本地状态目录唯一来源(env ARGO_STATE_DIR → config cache.db_path 父目录 → 旧路径)
61
+ import argo_paths as _paths
62
+ from net_proxy import open_url # 出口调度唯一入口(issue #13 同类修复)
63
+ from engine_env import env_flag # 布尔开关统一判断(见 env_flag 的说明)
64
+ from cli_io import dumps
65
+ from fetch_jsrun import _jsrun_challenge_fetch, _jsrun_enabled # js-run 轻量车道(拆出独立模块)
66
+
67
+
68
+ # ─── 内容提取器(复用 fetch.py 的逻辑,增强版)──────────────────────────────
69
+
70
+ _SKIP_TAGS = {"script", "style", "nav", "header", "footer", "aside", "noscript", "iframe"}
71
+
72
+ class ContentExtractor(HTMLParser):
73
+ """从 HTML 提取正文文本(基于文本密度排序)。"""
74
+
75
+ def __init__(self):
76
+ super().__init__()
77
+ self._in_skip = 0
78
+ self._blocks: list[tuple[float, str]] = []
79
+ self._current: list[str] = []
80
+ self.title = ""
81
+ self._in_title = False
82
+
83
+ def handle_starttag(self, tag, attrs):
84
+ if tag in _SKIP_TAGS:
85
+ self._in_skip += 1
86
+ if tag == "title":
87
+ self._in_title = True
88
+
89
+ def handle_endtag(self, tag):
90
+ if tag in _SKIP_TAGS:
91
+ self._in_skip = max(0, self._in_skip - 1)
92
+ if tag == "title":
93
+ self._in_title = False
94
+ if tag in ("p", "div", "article", "section", "li", "h1", "h2", "h3", "h4", "td", "blockquote"):
95
+ text = "".join(self._current).strip()
96
+ if len(text) > 20:
97
+ density = len(text.replace(" ", "")) / max(len(text), 1)
98
+ self._blocks.append((density, text))
99
+ self._current = []
100
+
101
+ def handle_data(self, data):
102
+ if self._in_title:
103
+ self.title += data
104
+ if self._in_skip == 0:
105
+ self._current.append(data)
106
+
107
+
108
+ def _cut(text: str, limit: int) -> str:
109
+ """按上限裁剪;limit <= 0 表示不限量(取全文)。"""
110
+ if not text:
111
+ return text
112
+ return text[:limit] if limit and limit > 0 else text
113
+
114
+
115
+ def extract_content(html: str, max_chars: int = 8000) -> tuple[str, str]:
116
+ """从 HTML 提取正文和标题。
117
+
118
+ P0 增强:readability 密度法为主(链接密度惩罚 + 标签权重 + 容器归并,
119
+ 保持文档顺序);返回空时回退旧密度排序实现(链接列表页等无正文场景
120
+ 不至于丢掉旧行为保底的结果)。
121
+ """
122
+ try:
123
+ from readability_extract import extract_readability
124
+ content, title = extract_readability(html, max_chars=max_chars)
125
+ if content.strip():
126
+ return _cut(content, max_chars), title.strip()
127
+ except Exception:
128
+ pass
129
+ ext = ContentExtractor()
130
+ try:
131
+ ext.feed(html)
132
+ except Exception:
133
+ pass
134
+ ext._blocks.sort(key=lambda x: x[0], reverse=True)
135
+ content = "\n\n".join(text for _, text in ext._blocks[:10])
136
+ return _cut(content, max_chars), ext.title.strip()
137
+
138
+
139
+ # ─── 降级检测 ────────────────────────────────────────────────────────────────
140
+
141
+ # 强特征:与格式无关,任何来源(含 Markdown 正文)出现都说明这是反爬挑战页
142
+ _CF_STRONG = re.compile(
143
+ r"checking your browser|cf-browser-verification|cf_chl_opt|ray id|"
144
+ r"challenge-platform|please verify you are a human",
145
+ re.IGNORECASE,
146
+ )
147
+ # 弱特征:裸词 cloudflare。只在 HTML 里有判别力——Markdown 正文里正常
148
+ # 提到 Cloudflare 是家常便饭(blog.cloudflare.com 通篇都是),不能据此判壳
149
+ _CF_WEAK = re.compile(r"cloudflare", re.IGNORECASE)
150
+ # 壳特征的扫描窗口:只在开头这段里找。
151
+ # 挑战页一定在顶部自报家门,而大页面中途总会出现裸词——实测 Wikipedia 的
152
+ # HTML 首个命中在第 241,552 字符、Astro 在第 78,009 字符,都在脚本与脚注里。
153
+ # 扫全文的后果是「页面越长越像反爬壳」:同一站点把 --max-chars 调大就会
154
+ # 悄悄换掉抓取方式(Wikipedia 因此从 http 升级到 Chrome CDP,正文也从
155
+ # 32,789 字变成另一份 68,143 字的 CDP 产物),预算改变了内容来源,
156
+ # 这正是「预算引发的错误」最难察觉的一种。
157
+ _CF_SCAN_CHARS = 32_768
158
+ # HTML 路径的壳判据=强特征 ∪ 弱特征
159
+ _CF_MARKERS = re.compile(
160
+ r"checking your browser|cf-browser-verification|cf_chl_opt|ray id|"
161
+ r"challenge-platform|please verify you are a human|cloudflare",
162
+ re.IGNORECASE,
163
+ )
164
+
165
+ _JS_MARKERS = re.compile(
166
+ r"enable javascript|javascript is required|javascript to run this app|"
167
+ r"you need to enable javascript|requires javascript",
168
+ re.IGNORECASE,
169
+ )
170
+
171
+ _EMPTY_SHELL = re.compile(
172
+ r"^[\s\n]*<html[^>]*>[\s\n]*<head>.*?</head>[\s\n]*<body>[\s\n]*</body>[\s\n]*</html>[\s\n]*$",
173
+ re.IGNORECASE | re.DOTALL,
174
+ )
175
+
176
+
177
+ # 已产出 Markdown 的抓取方式:内容已由站点或阅读器转好,没有 HTML 壳可言。
178
+ _MARKDOWN_METHODS = frozenset({"http_md", "md_variant", "llms_txt",
179
+ "jina", "jina_reader", "parallel",
180
+ "parallel_mcp", "tinyfish"})
181
+
182
+
183
+ def _needs_browser(result: dict) -> bool:
184
+ """判断是否需要升级到浏览器抓取。
185
+
186
+ 长度判据排在 Markdown 豁免之前:「内容过短」是内容量判据,与格式无关,
187
+ 安全网必须留着。
188
+
189
+ Markdown 豁免只放行**弱**特征(裸词 cloudflare),不放行**强**特征。
190
+ 两边的代价不对称:
191
+ - 豁免过宽:读者代理返回的挑战页会被当成正文收下,且不再升级浏览器
192
+ (实测夹具:本应调 browser 拿到真正文,豁免后直接返回「Just a
193
+ moment... Checking your browser」并标 content_ok=True)
194
+ - 豁免过窄:正文里提到 Cloudflare 就被判壳,白跑整条升级链
195
+ 强特征(checking your browser / ray id / challenge-platform …)在任何
196
+ 来源里都只可能是挑战页,不该被格式豁免掉;弱特征才会误伤正文。
197
+ """
198
+ if not result.get("success"):
199
+ return True
200
+ content = result.get("content", "")
201
+ # 有效文本长度:去所有空白(含换行/制表/空格)后判断。
202
+ # 原实现用 content.strip() 只去首尾空白,对「首尾有文字、中间全空白」
203
+ # 的 JS 渲染壳页(如 quotes.toscrape.com/js 返回 119 字符但有效文本
204
+ # 仅 16 字符)会误判为「内容足够」而不升级浏览器。
205
+ import re as _re
206
+ if not content or len(_re.sub(r'\s+', '', content)) < 100:
207
+ return True
208
+ if result.get("fetch_method") in _MARKDOWN_METHODS:
209
+ return bool(_CF_STRONG.search(content))
210
+ html = result.get("html", "")
211
+ # 空壳 HTML
212
+ if html and _EMPTY_SHELL.search(html):
213
+ return True
214
+ # CF 挑战:弱特征只看开头窗口,强特征扫全文(强特征在任何位置都只可能是
215
+ # 挑战页,且长度有限,不会因页面变长而误中)
216
+ window = (html or content)[:_CF_SCAN_CHARS]
217
+ if _CF_MARKERS.search(window) or _CF_STRONG.search(content):
218
+ return True
219
+ # JS 要求
220
+ if len(content) < 300 and _JS_MARKERS.search(html):
221
+ return True
222
+ return False
223
+
224
+
225
+ def _impersonate_enabled() -> bool:
226
+ """TLS 指纹伪造层开关:ARGO_FETCH_IMPERSONATE=0 关闭,默认开启。"""
227
+ return env_flag("ARGO_FETCH_IMPERSONATE")
228
+
229
+
230
+ # ─── 第零级:AI 友好变体探测({url}.md 直出)────────────────────────────────
231
+
232
+ _MD_SKIP_EXTS = {".html", ".htm", ".php", ".jsp", ".asp", ".aspx", ".shtml",
233
+ ".pdf", ".xml", ".json", ".png", ".jpg", ".jpeg", ".gif",
234
+ ".webp", ".svg", ".css", ".js", ".zip", ".gz", ".mp4", ".mp3"}
235
+
236
+ # ─── Markdown 响应校验(协商与 .md 变体共用同一组判据)────────────────────
237
+ #
238
+ # 2026-09 实测:声称 text/markdown 的响应里每 5 个就有 1 个是假货——
239
+ # docs.docker.com 200 + text/markdown + 16 字节占位页(正文只有一行标题)
240
+ # docs.gitbook.com 200 + text/markdown + 完整 HTML 错误页(302 Found)
241
+ # supabase / prisma 404 + text/markdown + 错误提示
242
+ # nextjs.org 308 + text/markdown + "Redirecting...",正文来自别的 URL
243
+ # 单看 Content-Type 会把噪声灌进语料,故统一在此收口。
244
+
245
+ _MD_MIN_CHARS = 200 # 长度地板:低于此视为占位页/跳转页
246
+ # 内容协商 Accept:markdown 优先、html 次之、其余兜底。实测 37 个站点,
247
+ # 不支持者一律原样返回 HTML,无 406 风险,故可作为主路径的默认请求头。
248
+ _ACCEPT_NEGOTIATE = "text/markdown, text/html;q=0.8, */*;q=0.5"
249
+ # 「这是一份 HTML 文档」的判据:只看文档级标记。不能用任意标签——
250
+ # react.dev 返回的真 Markdown 内嵌 <Intro>/JSX 代码示例,拿 <div 当判据
251
+ # 会把真 Markdown 误判成 HTML(实测假阴性)。
252
+ _MD_DOC_MARKERS = re.compile(r"<\s*(!doctype|html[\s>]|head[\s>]|body[\s>])",
253
+ re.IGNORECASE)
254
+ # 错误页/占位页正文特征(不区分大小写,允许前置 # 标题号)
255
+ _MD_ERROR_PAGE = re.compile(
256
+ r"^\s*(?:#+\s*)?(?:404|403|500|502|not\s+found|redirecting|access\s+denied|"
257
+ r"just\s+a\s+moment|please\s+verify\s+you\s+are\s+a\s+human)\b",
258
+ re.IGNORECASE)
259
+
260
+
261
+ def _md_variant_enabled() -> bool:
262
+ """.md 变体探测开关:ARGO_FETCH_MD_VARIANT=0 关闭,默认开启。"""
263
+ return env_flag("ARGO_FETCH_MD_VARIANT")
264
+
265
+
266
+ def _negotiate_enabled() -> bool:
267
+ """内容协商开关:ARGO_FETCH_MD_NEGOTIATE=0 关闭,默认开启。
268
+
269
+ 协商不是额外探测——它复用主路径那一次 GET 的请求头,故零额外请求、
270
+ 零额外延迟。实测 37 个站点支持者得干净 Markdown、不支持者原样返回
271
+ HTML,无一例失败(不存在 406 风险),因此默认常开。
272
+ """
273
+ return env_flag("ARGO_FETCH_MD_NEGOTIATE")
274
+
275
+
276
+ def _is_markdown_ctype(ctype: str) -> bool:
277
+ """Content-Type 是否可能承载 Markdown。
278
+
279
+ text/plain 一并纳入:react.dev/learn 实测返回 text/plain 而正文是真
280
+ Markdown(带 frontmatter),只认 text/markdown 会漏掉它。
281
+ """
282
+ ctype = (ctype or "").lower()
283
+ return "markdown" in ctype or ctype.startswith("text/plain")
284
+
285
+
286
+ def _looks_like_html_document(text: str) -> bool:
287
+ """判断正文是否是一份 HTML 文档(而非内嵌片段的正经 Markdown)。
288
+
289
+ 只用一条判据:开头窗口里出现文档级标记(`<!doctype`/`<html`/`<head`/`<body>`)。
290
+ 真实网页响应一定在开头自报家门,因此这条足够拦住实测到的全部假货
291
+ (gitbook 的 HTML 错误页、Cloudflare 的 HTML 外壳、404 页)。
292
+
293
+ 先后试过两条更"聪明"的二级判据,都被真实站点证伪,记在这里免得重蹈:
294
+ - 数标签个数:合法 Markdown 内嵌少量 HTML 就会被顶穿(8 行表格=33 个闭标签)
295
+ - 数标签字符占比:Markdown 里的 JSX 代码示例属性极长,实测 mintlify 的
296
+ 单页占比达 51.9%,而它返回的是百分之百的真 Markdown;
297
+ blog.cloudflare.com 同样因此被误拒,从一次 GET 掉到外部阅读器。
298
+ 结论:想从「Markdown 里混着 HTML」中分辨「这是一份 HTML 文档」,
299
+ 结构统计不可靠;以开头自报家门为准,宁可漏判(退回常规 HTML 路径,只是
300
+ 少省一点)也不误收(把 HTML 当正文交出去,结构尽失且不再升级)。
301
+ """
302
+ return bool(_MD_DOC_MARKERS.search(text[:4096]))
303
+
304
+
305
+ def _looks_like_markdown(text: str) -> bool:
306
+ """嗅探响应体是否为可用 Markdown 正文(排除伪装成 .md 的 HTML 壳)。"""
307
+ if not text or len(text.strip()) < 120:
308
+ return False
309
+ if _looks_like_html_document(text):
310
+ return False
311
+ return True
312
+
313
+
314
+ def _tinyfish_enabled() -> bool:
315
+ """tinyfish 免费渲染层开关(直连 api.fetch.tinyfish.ai,需 TINYFISH_API_KEY):
316
+ ARGO_FETCH_TINYFISH=0 关闭,默认开启。
317
+
318
+ 仅在http/指纹均失败或命中 JS/反爬壳时启用,结果失败自动回退,
319
+ 不改变正常抓取路径。
320
+
321
+ 实现委托给 fetch_render_tinyfish(渲染层已独立成模块)。
322
+ """
323
+ return _render_tinyfish.enabled()
324
+
325
+
326
+ def _md_variant_url(url: str) -> str | None:
327
+ """返回可探测的 .md 变体 URL;不适合探测时返回 None。
328
+
329
+ 只对无扩展名路径(文档站页面形态)追加 .md;站点根(/)不放 .md 探测
330
+ (根级 AI 友好文件是 llms.txt,由 _llms_txt_url 负责),已带扩展名或
331
+ 带查询串的动态地址跳过,避免无意义请求。
332
+ """
333
+ try:
334
+ parsed = urlparse(url)
335
+ except Exception:
336
+ return None
337
+ if parsed.scheme not in ("http", "https"):
338
+ return None
339
+ path = parsed.path or "/"
340
+ ext = os.path.splitext(path)[1].lower()
341
+ if ext or parsed.query or path in ("", "/"):
342
+ return None
343
+ return url.rstrip("/") + ".md"
344
+
345
+
346
+ def _llms_txt_url(url: str) -> str | None:
347
+ """返回站点根 /llms.txt 候选;仅对站点根路径(文档站门户形态)探测。
348
+
349
+ llms.txt(2024 起、规范 v1.8.0)是站点自述的 markdown 索引:H1 站名 +
350
+ 分节资源清单,头部开发者文档站采用中。非根路径不探测——该文件只存在于
351
+ 站点根,对深层页面探测是无意义请求。
352
+ """
353
+ try:
354
+ parsed = urlparse(url)
355
+ except Exception:
356
+ return None
357
+ if parsed.scheme not in ("http", "https"):
358
+ return None
359
+ if parsed.path not in ("", "/") or parsed.query:
360
+ return None
361
+ return f"{parsed.scheme}://{parsed.netloc}/llms.txt"
362
+
363
+
364
+ # llms-full.txt(llms.txt v2 规范伴生文件)明确禁用(2026-09-29):全站
365
+ # 正文合并成单文件,实测最大站点千万 token 量级——一次抓取撑爆 agent 上
366
+ # 下文窗口且无索引结构。守卫让「以后有人按规范补探测」直接踩刹车。
367
+ _BANNED_AI_VARIANTS = frozenset({"llms-full.txt"})
368
+
369
+
370
+ def _is_banned_ai_variant(url: str) -> bool:
371
+ """候选 URL 是否指向禁用的 AI 变体(按路径末段判,防查询串伪装)。"""
372
+ try:
373
+ path = urlparse(url).path
374
+ except Exception:
375
+ return False
376
+ return path.rstrip("/").rsplit("/", 1)[-1] in _BANNED_AI_VARIANTS
377
+
378
+
379
+ def _ai_variant_candidates(url: str) -> list[tuple[str, str]]:
380
+ """第零级探测候选清单(按优先序):页面 .md 直出 → 站点根 llms.txt。"""
381
+ out: list[tuple[str, str]] = []
382
+ md = _md_variant_url(url)
383
+ if md:
384
+ out.append((md, "md_variant"))
385
+ llms = _llms_txt_url(url)
386
+ if llms:
387
+ out.append((llms, "llms_txt"))
388
+ # token 炸弹守卫:全站合并类变体永不进候选(见 _BANNED_AI_VARIANTS)
389
+ return [(u, k) for u, k in out if not _is_banned_ai_variant(u)]
390
+
391
+
392
+ def _negotiated_markdown(resp: dict) -> str | None:
393
+ """从一次普通 GET 的响应里取出经校验的 Markdown 正文;不适用返回 None。
394
+
395
+ 四道门,逐条对应实测到的四类污染源:状态码 200 → 长度地板 → 非 HTML
396
+ 文档 → 非错误页特征。任一不过即视为普通 HTML 响应、走原有链路,因此
397
+ 不支持的站点行为完全不变。
398
+ """
399
+ if resp.get("status") != 200 or not resp.get("text"):
400
+ return None
401
+ ctype = ""
402
+ for k, v in (resp.get("headers") or {}).items():
403
+ if str(k).lower() == "content-type":
404
+ ctype = str(v)
405
+ break
406
+ if not _is_markdown_ctype(ctype):
407
+ return None
408
+ text = resp["text"]
409
+ if len(text.strip()) < _MD_MIN_CHARS:
410
+ return None
411
+ if _looks_like_html_document(text):
412
+ return None
413
+ if _MD_ERROR_PAGE.match(text.lstrip()):
414
+ return None
415
+ return text
416
+
417
+
418
+ def _markdown_title(content: str) -> str:
419
+ """从 Markdown 正文取标题:先认 frontmatter 的 title,再认首行 H1。
420
+
421
+ 顺序不能反、范围不能放:全文 search `^#` 会把代码块里的 `# 注释` 当成
422
+ 标题——实测一篇 frontmatter 写明 `title: Install Guide` 的文章,标题被
423
+ 代码注释覆盖成 `install dependencies`。H1 只认「跳过 frontmatter 后的
424
+ 首个非空行」,正是 Markdown 的 H1 语义位置。
425
+ """
426
+ m = re.search(r"^title:\s*(.+)$", content[:2000], re.MULTILINE)
427
+ if m:
428
+ return m.group(1).strip().strip("\"'")[:200]
429
+ body = re.sub(r"\A---\n.*?\n---\n", "", content, count=1, flags=re.DOTALL)
430
+ m = re.match(r"\s*#\s+(.+?)\s*$", body, re.MULTILINE)
431
+ return m.group(1)[:200] if m else ""
432
+
433
+
434
+ def _markdown_result(url: str, text: str, max_chars: int, method: str,
435
+ final_url: str = "") -> dict:
436
+ """用已就绪的 Markdown 直接构建结果。
437
+
438
+ 关键:不走 extract_content——Markdown 喂进 HTML 提取器会被当纯文本
439
+ 重新切块,结构(标题/表格/围栏)全丢。协商命中的价值正在于保留站点
440
+ 自己那份结构,故此处原样透传。
441
+ """
442
+ content = _cut(text, max_chars)
443
+ # 标题从完整正文里取:裁剪窗口小于标题搜索窗(2,000 字)时,
444
+ # 用交付视图取标题会因窗口不足而取不到。
445
+ title = _markdown_title(text)
446
+ result = {
447
+ "url": url,
448
+ "content": content,
449
+ "html": "",
450
+ "title": title,
451
+ "length": len(content),
452
+ "success": True,
453
+ "error": None,
454
+ "fetch_method": method,
455
+ }
456
+ _archive_truncated(url, text, content, "text", result, primary=True)
457
+ if final_url and final_url.rstrip("/") != url.rstrip("/"):
458
+ # 内容来自重定向后的地址:如实标注,避免「URL 是 A、正文是 B」而无声
459
+ result["final_url"] = final_url
460
+ return result
461
+
462
+
463
+ def _md_variant_wanted(result: dict) -> bool:
464
+ """主请求结果是否还值得回探 AI 友好变体。
465
+
466
+ 已经拿到 Markdown(协商命中)就没有再探的必要——两者的产物同类,
467
+ 再探只是白多一次请求。
468
+ """
469
+ if not result.get("success"):
470
+ return True
471
+ return result.get("fetch_method") not in _MARKDOWN_METHODS
472
+
473
+
474
+ def _md_variant_fetch(url: str, max_chars: int = 8000,
475
+ timeout: float = 8.0) -> dict | None:
476
+ """探测 AI 友好变体({url}.md 直出 / 站点根 llms.txt)。命中返回结果,未命中 None。
477
+
478
+ 背景:头部开发者文档站自发提供 .md 直出与 llms.txt,对 Agent 返回干净
479
+ 正文(诚实身份即可获取)。探测只多一两个 GET,命中即跳过整个反爬降级链;
480
+ 未命中静默放弃,走原有链路。返回的 result.url 保持为原始请求 URL
481
+ (llms.txt 描述的是站点,不是探测端点自身)。
482
+ """
483
+ candidates = _ai_variant_candidates(url)
484
+ if not candidates:
485
+ return None
486
+ for probe_url, kind in candidates:
487
+ try:
488
+ from http_client import HttpClient
489
+ client = HttpClient(timeout=min(timeout, 5.0), max_retries=0,
490
+ jitter=False)
491
+ resp = client.get(probe_url, extra_headers={
492
+ "User-Agent": "argo-fetch-v3/1.0 (+local-research; md-variant)",
493
+ "Accept": "text/markdown, text/plain;q=0.9, */*;q=0.8",
494
+ })
495
+ except Exception:
496
+ continue
497
+ if resp.get("status", 0) != 200 or not resp.get("text"):
498
+ continue
499
+ headers = resp.get("headers") or {}
500
+ ctype = ""
501
+ for k, v in headers.items():
502
+ if str(k).lower() == "content-type":
503
+ ctype = str(v).lower()
504
+ break
505
+ if "html" in ctype:
506
+ continue
507
+ text = resp["text"]
508
+ if not _looks_like_markdown(text):
509
+ continue
510
+ return _markdown_result(url, text, max_chars, kind)
511
+ return None
512
+
513
+
514
+ # ─── 第一级A2:移动端 UA 分支(客户端形态分流)──────────────────────────────
515
+
516
+ _MOBILE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
517
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 "
518
+ "Mobile/15E148 Safari/604.1")
519
+
520
+
521
+ def _mobile_branch_enabled() -> bool:
522
+ """移动端 UA 分支开关:ARGO_FETCH_MOBILE=0 关闭,默认开启。"""
523
+ return env_flag("ARGO_FETCH_MOBILE")
524
+
525
+
526
+ # 客户端形态分流已知站点:这些站对真机 UA 直接返回 SSR 数据,而桌面 UA 首发
527
+ # 会触发风控、连坐紧随其后的移动端请求(2026-08 实测冷却 ≥8s),因此必须
528
+ # 移动优先而非失败后重试。可用 ARGO_MOBILE_FIRST_HOSTS 追加(逗号分隔)。
529
+ _MOBILE_FIRST_HOSTS = ("douyin.com", "iesdouyin.com")
530
+
531
+
532
+ def _mobile_first_host(url: str) -> bool:
533
+ """URL 是否命中「移动优先」主机清单。"""
534
+ extra = [h.strip().lower() for h in os.environ.get(
535
+ "ARGO_MOBILE_FIRST_HOSTS", "").split(",") if h.strip()]
536
+ host = (urlparse(url).hostname or "").lower()
537
+ for marker in list(_MOBILE_FIRST_HOSTS) + extra:
538
+ if host == marker or host.endswith("." + marker):
539
+ return True
540
+ return False
541
+
542
+
543
+ # ─── 身份记忆(per-host 成功档位,跨进程小文件)─────────────────────────────
544
+ #
545
+ # 首次访问未知分流站:桌面失败 → 移动成功(2 次触碰);记忆生效后(TTL 24h)
546
+ # 直接移动首发(1 次触碰)。实测依据:抖音类站点按 IP 递进限速,请求节奏比
547
+ # 身份选择重要一个数量级,省掉的每次试错都是真实的封禁风险。
548
+ _IDENTITY_TTL = 86400
549
+ _IDENTITY_PATH = os.environ.get(
550
+ "ARGO_IDENTITY_MEMORY",
551
+ str(_paths.state_path("fetch-identity.json")))
552
+ _identity_mem: dict[str, float] = {}
553
+ _identity_loaded = False
554
+ _identity_dirty = False
555
+ _identity_last_flush = 0.0
556
+ _IDENTITY_FLUSH_INTERVAL = 30.0 # 最多每 30s 写一次文件
557
+
558
+
559
+ def _identity_load() -> None:
560
+ global _identity_loaded, _identity_mem
561
+ if _identity_loaded:
562
+ return
563
+ _identity_loaded = True
564
+ try:
565
+ with open(_IDENTITY_PATH, encoding="utf-8") as f: # 写侧 atomic_write_text 恒 UTF-8,读侧同编码
566
+ raw = json.load(f)
567
+ now = time.time()
568
+ mem: dict[str, float] = {}
569
+ for h, t in raw.items():
570
+ try:
571
+ exp = float(t)
572
+ if exp > now:
573
+ mem[str(h)] = exp
574
+ except (TypeError, ValueError):
575
+ continue # 单条脏数据不拖垮整表(旧版本/手改字段)
576
+ _identity_mem = mem
577
+ except Exception:
578
+ _identity_mem = {}
579
+
580
+
581
+ def _identity_remember_mobile(host: str) -> None:
582
+ """记录「该 host 移动端身份成功过」,最多每 30s 写一次文件。失败静默(纯增益层)。"""
583
+ global _identity_dirty, _identity_last_flush
584
+ if not host:
585
+ return
586
+ _identity_load()
587
+ if len(_identity_mem) >= 512: # 有界:内存表防长驻进程无限增长(磁盘侧有 TTL)
588
+ _identity_mem.pop(next(iter(_identity_mem)), None)
589
+ _identity_mem[host] = time.time() + _IDENTITY_TTL
590
+ _identity_dirty = True
591
+ now = time.monotonic()
592
+ if _identity_last_flush > 0.0 and now - _identity_last_flush < _IDENTITY_FLUSH_INTERVAL:
593
+ return
594
+ _identity_last_flush = now
595
+ try:
596
+ # 原子写走 argo_paths 唯一来源(唯一 tmp 名);旧实现固定 `.tmp` 名,
597
+ # 并发抓取进程会互相搬走临时文件,身份记忆静默丢失。
598
+ _paths.atomic_write_json(Path(_IDENTITY_PATH), _identity_mem, indent=None)
599
+ _identity_dirty = False
600
+ except Exception:
601
+ pass
602
+
603
+
604
+ def _identity_is_mobile(host: str) -> bool:
605
+ """该 host 近 24h 内移动身份是否成功过。"""
606
+ if not host:
607
+ return False
608
+ _identity_load()
609
+ return _identity_mem.get(host, 0) > time.time()
610
+
611
+
612
+ def _mobile_http_fetch(url: str, max_chars: int = 8000,
613
+ timeout: float = 8.0) -> dict:
614
+ """移动端 UA 抓取:客户端形态分流型站点对真机 UA 返回 SSR 数据。
615
+
616
+ 实测(2026-08):抖音 iesdouyin 分享页对 iPhone UA 返回含 _ROUTER_DATA
617
+ 的服务端渲染数据页;桌面/AI bot UA 一律收到 acrawler 风控 JS 壳。
618
+ 该类分流与 TLS 指纹无关,stdlib 即可通过,放在 TLS 伪造层之前。
619
+ """
620
+ try:
621
+ from url_safety import check_url
622
+ ok, reason = check_url(url)
623
+ if not ok:
624
+ return _make_result(url, "", 0, "http_mobile", ok=False,
625
+ error=f"URL 被 SSRF 防护拦截: {reason}")
626
+ except ImportError:
627
+ pass
628
+ try:
629
+ from http_client import HttpClient
630
+ client = HttpClient(timeout=min(timeout, 5.0), max_retries=1,
631
+ jitter=False)
632
+ resp = client.get(url, extra_headers={"User-Agent": _MOBILE_UA})
633
+ except ImportError:
634
+ import urllib.request
635
+ req = urllib.request.Request(url, headers={"User-Agent": _MOBILE_UA})
636
+ try:
637
+ with open_url(req, timeout=timeout) as r:
638
+ text = r.read().decode("utf-8", errors="replace")
639
+ return _make_result(url, text, max_chars, "http_mobile")
640
+ except Exception as e:
641
+ return _make_result(url, "", 0, "http_mobile", ok=False,
642
+ error=str(e)[:100])
643
+ if resp.get("status", 0) >= 400:
644
+ result = _make_result(url, "", 0, "http_mobile", ok=False,
645
+ error=f"HTTP {resp.get('status')}")
646
+ _mark_stop_signal(result, resp)
647
+ return result
648
+ if not resp.get("text"):
649
+ return _make_result(url, "", 0, "http_mobile", ok=False,
650
+ error="empty response")
651
+ result = _make_result(url, resp["text"], max_chars, "http_mobile")
652
+ _mark_stop_signal(result, resp)
653
+ return result
654
+
655
+
656
+ def _mark_stop_signal(result: dict, resp: dict) -> dict:
657
+ """把 429/503 明确停止信号记录到结果,供主链检查使用。
658
+
659
+ 429(速率限制)与 503(服务过载)是服务器明确的「请停止」信号,
660
+ 与请求方式(UA/TLS 指纹/浏览器)无关。收到后不应升级重链,
661
+ 否则等于无视服务器指示、持续放大目标站点负载。
662
+ """
663
+ status = resp.get("status", 0)
664
+ result["status"] = status
665
+ if status in (429, 503):
666
+ result["stop_signal"] = True
667
+ return result
668
+
669
+
670
+ # ─── 第一级:增强 HTTP ───────────────────────────────────────────────────────
671
+
672
+ def _http_fetch(url: str, max_chars: int = 8000, timeout: float = 8.0,
673
+ allow_markdown: bool = True) -> dict:
674
+ """使用 http_client(UA 轮换 + Cookie 积累)抓取。
675
+
676
+ allow_markdown:是否在这次请求上带内容协商 Accept。协商复用本次 GET,
677
+ **不额外发请求**——命中则直接拿到站点自己的 Markdown(结构完整、跳过
678
+ 整个反爬降级链),未命中则该响应就是原本要抓的 HTML,行为与从前一致。
679
+ 需要 raw HTML 的调用方(crawl/extract 取链接)置 False。
680
+
681
+ Markdown 命中只认「校验通过」的结果(见 _negotiated_markdown 四道门),
682
+ 声称 text/markdown 却是占位页/错误页/404 的响应一律按 HTML 处理。
683
+ """
684
+ try:
685
+ from url_safety import check_url
686
+ ok, reason = check_url(url)
687
+ if not ok:
688
+ return _make_result(url, "", 0, "http", ok=False,
689
+ error=f"URL 被 SSRF 防护拦截: {reason}")
690
+ except ImportError:
691
+ pass
692
+ negotiate = allow_markdown and _negotiate_enabled()
693
+ extra = {"Accept": _ACCEPT_NEGOTIATE} if negotiate else None
694
+ try:
695
+ from http_client import HttpClient
696
+ client = HttpClient(timeout=timeout, max_retries=1, jitter=False)
697
+ resp = client.get(url, extra_headers=extra)
698
+ except ImportError:
699
+ # fallback 到 urllib
700
+ import urllib.request
701
+ req = urllib.request.Request(url, headers={
702
+ "User-Agent": "argo-fetch-v3/1.0 (+local-research)",
703
+ })
704
+ try:
705
+ with open_url(req, timeout=timeout) as r:
706
+ text = r.read().decode("utf-8", errors="replace")
707
+ return _make_result(url, text, max_chars, "http")
708
+ except Exception as e:
709
+ return _make_result(url, "", 0, "http", ok=False, error=str(e)[:100])
710
+
711
+ if resp.get("status", 0) >= 400:
712
+ result = _make_result(url, "", 0, "http", ok=False,
713
+ error=f"HTTP {resp.get('status')}")
714
+ _mark_stop_signal(result, resp)
715
+ return result
716
+ if not resp.get("text"):
717
+ return _make_result(url, "", 0, "http", ok=False, error="empty response")
718
+
719
+ if negotiate:
720
+ text = _negotiated_markdown(resp)
721
+ if text is not None:
722
+ return _markdown_result(url, text, max_chars, "http_md",
723
+ final_url=resp.get("url", ""))
724
+
725
+ return _make_result(url, resp["text"], max_chars, "http")
726
+
727
+
728
+ def _tls_spoof_fetch(url: str, max_chars: int = 8000, timeout: float = 8.0) -> dict:
729
+ """TLS 指纹伪造层:curl_cffi impersonate 多指纹轮换抓取。
730
+
731
+ 针对指纹检测型反爬(Cloudflare 等直接按 TLS ClientHello 判 bot),
732
+ urllib/curl 原生指纹与真实浏览器不同会被 403。本层用 curl_cffi
733
+ 逐字节模拟 Chrome/Safari/Firefox 指纹,免起浏览器即可通过。
734
+
735
+ 指纹轮换顺序:chrome → safari → firefox(safari 对部分站点更友好)。
736
+ """
737
+ try:
738
+ from url_safety import check_url
739
+ ok, reason = check_url(url)
740
+ if not ok:
741
+ return _make_result(url, "", 0, "tls_spoof", ok=False,
742
+ error=f"URL 被 SSRF 防护拦截: {reason}")
743
+ except ImportError:
744
+ pass
745
+
746
+ try:
747
+ from http_client import HttpClient
748
+ client = HttpClient(timeout=timeout, max_retries=1, jitter=False)
749
+ resp = client.get_impersonated(url, timeout=timeout)
750
+ except ImportError:
751
+ return _make_result(url, "", 0, "tls_spoof", ok=False,
752
+ error="http_client not available")
753
+
754
+ if resp.get("status", 0) >= 400:
755
+ result = _make_result(url, "", 0, "tls_spoof", ok=False,
756
+ error=f"HTTP {resp.get('status')}")
757
+ _mark_stop_signal(result, resp)
758
+ return result
759
+ if not resp.get("text"):
760
+ return _make_result(url, "", 0, "tls_spoof", ok=False,
761
+ error=resp.get("error", "empty response"))
762
+
763
+ result = _make_result(url, resp["text"], max_chars, "tls_spoof")
764
+ result["impersonate"] = resp.get("impersonate", "")
765
+ return result
766
+
767
+
768
+ def _wayback_fetch(url: str, max_chars: int = 8000, timeout: float = 12.0) -> dict:
769
+ """Wayback Machine 快照回退:CDX API 查最新快照 → 抓取。
770
+
771
+ 用于 HTTP 失败 / 空内容 / 疑似被删页面的保底,返回统一输出格式。
772
+ """
773
+ try:
774
+ from http_client import HttpClient
775
+ client = HttpClient(timeout=timeout, max_retries=1, jitter=False)
776
+ # CDX 查询最新快照
777
+ cdx_url = (
778
+ "https://web.archive.org/cdx/search/cdx"
779
+ f"?url={urllib.parse.quote(url, safe='')}"
780
+ "&output=json&limit=1&sort=reverse"
781
+ )
782
+ resp = client.get(cdx_url)
783
+ if not resp.get("text"):
784
+ return _make_result(url, "", 0, "wayback", ok=False,
785
+ error="wayback cdx empty")
786
+ data = json.loads(resp["text"])
787
+ if not data or len(data) < 2:
788
+ return _make_result(url, "", 0, "wayback", ok=False,
789
+ error="wayback no snapshot")
790
+ timestamp = data[1][1]
791
+ snapshot_url = f"https://web.archive.org/web/{timestamp}/{url}"
792
+ snap = client.get(snapshot_url)
793
+ if not snap.get("text"):
794
+ return _make_result(url, "", 0, "wayback", ok=False,
795
+ error="wayback snapshot empty")
796
+ result = _make_result(url, snap["text"], max_chars, "wayback")
797
+ result["snapshot_url"] = snapshot_url
798
+ result["snapshot_ts"] = timestamp
799
+ return result
800
+ except Exception as e:
801
+ return _make_result(url, "", 0, "wayback", ok=False,
802
+ error=f"wayback error: {str(e)[:100]}")
803
+
804
+
805
+ def _archive_truncated(url: str, text: str, delivered: str, kind: str,
806
+ extra: dict, primary: bool = False) -> None:
807
+ """内容被裁时把完整版存进全文存档,并就地补上可回读的字段。
808
+
809
+ 只裁不存会让「截断」变成不可追溯的数据销毁:默认档 8,000 字抓一个
810
+ 十一万字的页面,被裁掉的 93% 既无副本也无线索,事后无法复核结论是否
811
+ 落在丢掉的那一段里。存档失败不影响本次交付(见 fulltext_store.save)。
812
+
813
+ primary 标识「这条就是交付视图」。`truncated` / `full_length` 只由它写:
814
+ 同一次抓取里正文与 HTML 都会各自被裁,若都往同名键上写,后写的 HTML
815
+ 会覆盖正文的数字——实测把 32,789 字的正文报成 533,612 字节的 HTML。
816
+ 非主视图的截断另起 `_truncated` 后缀的键,不抢占语义。
817
+ """
818
+ cut = bool(text) and len(text) > len(delivered)
819
+ if primary:
820
+ extra["full_length"] = len(text)
821
+ extra["truncated"] = cut
822
+ else:
823
+ extra[f"{kind}_truncated"] = cut
824
+ if not cut:
825
+ return
826
+ try:
827
+ from fulltext_store import save as _save_fulltext
828
+ path = _save_fulltext(url, text, kind)
829
+ if path:
830
+ extra[f"full_{kind}_path"] = path
831
+ if primary:
832
+ extra["full_text_path"] = path
833
+ except Exception:
834
+ pass
835
+
836
+
837
+ def _make_result(url: str, html: str, max_chars: int,
838
+ method: str, ok: bool = True, error: str | None = None,
839
+ title: str = "") -> dict:
840
+ """构建统一输出格式。
841
+
842
+ 取全文 → 裁交付视图 → 被裁部分归档。`content` 仍受 max_chars 约束
843
+ (token 预算不变),但完整正文不再当场销毁。
844
+ """
845
+ full_content, extracted_title = (
846
+ extract_content(html, 0) if ok and html else ("", ""))
847
+ if not title:
848
+ title = extracted_title
849
+ content = _cut(full_content, max_chars)
850
+ extra: dict = {}
851
+ _archive_truncated(url, full_content, content, "text", extra, primary=True)
852
+ full_html = html or ""
853
+ delivered_html = _cut(full_html, max_chars * 2)
854
+ # HTML 同样会被预算裁掉,而结构性提取(表格 / Meta / JSON-LD)全靠它——
855
+ # 裁掉就等于那些表格从此再也取不到。与正文分开存,互不覆盖。
856
+ if full_html:
857
+ _archive_truncated(url, full_html, delivered_html, "html", extra)
858
+ result = {
859
+ "url": url,
860
+ "content": content,
861
+ "html": delivered_html,
862
+ "title": title,
863
+ "length": len(content),
864
+ "success": ok,
865
+ "error": error,
866
+ "fetch_method": method,
867
+ }
868
+ # 元数据必须从**完整 HTML** 里提:delivered_html 已按 max_chars*2 截断,
869
+ # 链接通常在正文中后段,截断后再解析只剩 head 里的 meta(2026-10-22 实测)。
870
+ if ok and html:
871
+ try:
872
+ from article_meta import extract_article_meta
873
+ result.update(extract_article_meta(html, url))
874
+ except Exception:
875
+ pass
876
+ result.update(extra)
877
+ return result
878
+
879
+
880
+ # ─── 第一级C:r.jina.ai 阅读器(keyless 免费层,远端 JS 渲染转 markdown)─────
881
+
882
+ _JINA_PRIVATE_HOST = re.compile(
883
+ r"^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|0\.)")
884
+
885
+
886
+ def _jina_enabled() -> bool:
887
+ """r.jina.ai 阅读器级开关:ARGO_FETCH_JINA=0 关闭,默认开启。"""
888
+ return env_flag("ARGO_FETCH_JINA")
889
+
890
+
891
+ def _is_public_host(host: str) -> bool:
892
+ """r.jina.ai 是第三方代理:只把公网 URL 交给它。
893
+
894
+ 内网/本机/裸 IP 一律跳过——把内部地址送出第三方 = 拓扑泄露;
895
+ 域名形 IP(全数字)与含冒号的 IPv6 字面量同判为裸地址。
896
+ """
897
+ if not host:
898
+ return False
899
+ if host == "localhost" or host.endswith((".local", ".internal", ".lan")):
900
+ return False
901
+ if _JINA_PRIVATE_HOST.match(host):
902
+ return False
903
+ if host.replace(".", "").isdigit() or ":" in host:
904
+ return False
905
+ return "." in host
906
+
907
+
908
+ def _jina_reader_fetch(url: str, max_chars: int = 8000,
909
+ timeout: float = 8.0) -> dict | None:
910
+ """r.jina.ai 阅读器(keyless 免费层)。
911
+
912
+ 定位:HTTP/TLS 直连全败后的免浏览器快速路径——jina 在远端渲染并返回
913
+ markdown,命中则免去 CDP 冷启动。免费层有速率限制,失败/429 返回 None
914
+ 静默放行走 Wayback/浏览器。调用方须先过 _is_public_host(第三方代理边界)。
915
+ """
916
+ try:
917
+ from http_client import HttpClient
918
+ client = HttpClient(timeout=min(timeout, 10.0), max_retries=0,
919
+ jitter=False)
920
+ resp = client.get(f"https://r.jina.ai/{url}", extra_headers={
921
+ "User-Agent": "argo-fetch-v3/1.0 (+local-research; jina-reader)",
922
+ "Accept": "text/plain",
923
+ })
924
+ except Exception:
925
+ return None
926
+ if resp.get("status", 0) != 200 or not resp.get("text"):
927
+ return None
928
+ raw = resp["text"]
929
+ title = ""
930
+ m = re.search(r"^Title:\s*(.+)$", raw[:2000], re.MULTILINE)
931
+ if m:
932
+ title = m.group(1).strip()[:200]
933
+ if "Markdown Content:" in raw:
934
+ raw = raw.split("Markdown Content:", 1)[1].lstrip()
935
+ if not _looks_like_markdown(raw):
936
+ return None
937
+ result = _make_result(url, "", max_chars, "jina_reader")
938
+ result["content"] = raw[:max_chars]
939
+ result["length"] = len(result["content"])
940
+ result["title"] = title
941
+ return result
942
+
943
+
944
+ # ─── 第一级D:Parallel 免费 MCP web_fetch(keyless 云端渲染+提取)────────────
945
+
946
+ _PARALLEL_MCP_URL = "https://search.parallel.ai/mcp"
947
+
948
+
949
+ def _parallel_mcp_enabled() -> bool:
950
+ return env_flag("ARGO_FETCH_PARALLEL")
951
+
952
+
953
+ def _parallel_session_id() -> str:
954
+ """免费层限流按 session_id 关联:一次生成、持久复用(官方建议)。
955
+
956
+ 状态目录不可写时退化为随机 id——仍唯一但不稳定,只损失限流关联的
957
+ 连续性,不影响功能。
958
+ """
959
+ try:
960
+ from argo_paths import state_path
961
+ p = state_path("parallel_session_id.txt")
962
+ if p.exists() and p.read_text(encoding="utf-8").strip():
963
+ return p.read_text(encoding="utf-8").strip()
964
+ sid = uuid.uuid4().hex
965
+ p.parent.mkdir(parents=True, exist_ok=True)
966
+ p.write_text(sid, encoding="utf-8")
967
+ return sid
968
+ except Exception:
969
+ return uuid.uuid4().hex
970
+
971
+
972
+ def _parallel_mcp_fetch(url: str, max_chars: int = 8000,
973
+ timeout: float = 25.0) -> dict:
974
+ """Parallel 免费 MCP web_fetch(keyless,full_content markdown)。
975
+
976
+ 定位:jina 同级的免浏览器快速路径——云端 JS 渲染 + 正文提取,
977
+ full_content=true 拿整页 markdown(官方警告长文可达数万 token,
978
+ 故截到 max_chars)。无账号无 key(2026-09-14 实测无状态直调成立),
979
+ 免费层限流按 session_id 关联。失败静默返回 success=False 放行后级。
980
+ 调用方须先过 _is_public_host(第三方代理边界)。
981
+ """
982
+ body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
983
+ "params": {"name": "web_fetch", "arguments": {
984
+ "urls": [url],
985
+ "objective": "Extract the main page content",
986
+ "full_content": True,
987
+ "session_id": _parallel_session_id(),
988
+ }}}
989
+ try:
990
+ from http_client import HttpClient
991
+ client = HttpClient(timeout=min(timeout, 25.0), max_retries=0,
992
+ jitter=False)
993
+ resp = client.post(_PARALLEL_MCP_URL, body=body, extra_headers={
994
+ "Content-Type": "application/json",
995
+ "Accept": "application/json, text/event-stream",
996
+ })
997
+ except Exception as e:
998
+ return {"url": url, "title": "", "content": "", "html": "", "length": 0,
999
+ "success": False, "error": f"parallel_mcp: {e}",
1000
+ "fetch_method": "parallel_mcp"}
1001
+ if resp.get("status", 0) != 200 or not resp.get("text"):
1002
+ return {"url": url, "title": "", "content": "", "html": "", "length": 0,
1003
+ "success": False, "error": f"parallel_mcp: HTTP {resp.get('status')}",
1004
+ "fetch_method": "parallel_mcp"}
1005
+ try:
1006
+ data = json.loads(resp["text"])
1007
+ except (ValueError, TypeError):
1008
+ return {"url": url, "title": "", "content": "", "html": "", "length": 0,
1009
+ "success": False, "error": "parallel_mcp: 响应非 JSON",
1010
+ "fetch_method": "parallel_mcp"}
1011
+ # MCP 错误显式记录(fetch 链语义:失败放行后级,但 error 留痕可归因)
1012
+ r = data.get("result") or {}
1013
+ mcp_err = None
1014
+ if r.get("isError"):
1015
+ blk = (r.get("content") or [{}])
1016
+ mcp_err = (blk[0].get("text", "")[:200] if isinstance(blk[0], dict)
1017
+ else str(blk[0])[:200])
1018
+ elif isinstance(data.get("error"), dict):
1019
+ mcp_err = str(data["error"].get("message") or data["error"])[:200]
1020
+ inner: dict = {}
1021
+ for block in (r.get("content") or []):
1022
+ text = block.get("text", "") if isinstance(block, dict) else str(block)
1023
+ try:
1024
+ parsed = json.loads(text)
1025
+ except (ValueError, TypeError):
1026
+ continue
1027
+ if isinstance(parsed, dict) and parsed.get("results"):
1028
+ inner = parsed
1029
+ break
1030
+ items = inner.get("results") or []
1031
+ if not items:
1032
+ return {"url": url, "title": "", "content": "", "html": "", "length": 0,
1033
+ "success": False,
1034
+ "error": f"parallel_mcp: {mcp_err or '上游无结果'}",
1035
+ "fetch_method": "parallel_mcp"}
1036
+ item = items[0]
1037
+ # full_content=true → 整页 markdown;缺省(上游变更/未开启)退回 excerpts 拼接
1038
+ raw = str(item.get("full_content") or "") or "\n\n".join(
1039
+ str(e) for e in (item.get("excerpts") or []))
1040
+ result = _make_result(url, "", max_chars, "parallel_mcp")
1041
+ result["content"] = raw[:max_chars]
1042
+ result["length"] = len(result["content"])
1043
+ result["title"] = str(item.get("title") or "")[:200]
1044
+ if mcp_err:
1045
+ result["parallel_mcp_note"] = mcp_err
1046
+ return result
1047
+
1048
+
1049
+ # ─── 第二级A:tinyfish 直连渲染(Markdown 直出,含 JS 执行)─────────────
1050
+ # 实现见 fetch_render_tinyfish(独立模块):markdown-only 渲染,
1051
+ # 不产 raw html,故 need_html 场景由主链跳过本层。
1052
+
1053
+ def _tinyfish_fetch(url: str, max_chars: int = 8000, timeout: float = 8.0) -> dict:
1054
+ """委托给 fetch_render_tinyfish.fetch(渲染层已拆为独立模块)。"""
1055
+ return _render_tinyfish.fetch(url, max_chars=max_chars, timeout=timeout)
1056
+
1057
+
1058
+ # ─── 第二级:Chrome CDP 浏览器 ───────────────────────────────────────────────
1059
+ # 实现拆在 fetch_browser(渲染 + actions + 登录态 profile 车道,A2):
1060
+ # fetch_v3 在模块体量门禁的祖父清单里,只能减不能增。保留 `_browser_fetch`
1061
+ # 名字绑定(存量测试 monkeypatch 的是这个名字)。
1062
+ from fetch_browser import browser_fetch as _browser_fetch
1063
+
1064
+
1065
+ # ─── 第三级:质量评估 ─────────────────────────────────────────────────────────
1066
+ # 实现见 fetch_quality(独立模块):来源分类 / 页面类型 / 质量分 / 内容安全。
1067
+
1068
+ def _assess_quality(result: dict) -> dict:
1069
+ """计算内容质量信号(委托 fetch_quality.assess)。"""
1070
+ return _quality.assess(result)
1071
+
1072
+
1073
+ # 兼容别名:存量测试与调用方直接引用这些私有名,委托到 fetch_quality 保留。
1074
+ _detect_page_type = _quality._detect_page_type
1075
+ _compute_quality = _quality._compute_quality
1076
+ _classify_domain = _quality._classify_domain
1077
+
1078
+
1079
+ # ─── 主入口 ──────────────────────────────────────────────────────────────────
1080
+
1081
+ # RFC 3986 语法型规范化:unreserved 字符集与默认端口
1082
+ _UNRESERVED = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
1083
+ "0123456789-._~")
1084
+ _DEFAULT_PORTS = {"http": "80", "https": "443", "ftp": "21", "ws": "80", "wss": "443"}
1085
+ _PCT_RE = re.compile(r"%([0-9A-Fa-f]{2})")
1086
+
1087
+
1088
+ def _remove_dot_segments(path: str) -> str:
1089
+ """RFC 3986 §6.2.2.3:移除 . 与 .. 路径段(纯语法,不触碰语义)。"""
1090
+ if not path:
1091
+ return path
1092
+ out: list[str] = []
1093
+ for seg in path.split("/"):
1094
+ if seg == ".":
1095
+ continue
1096
+ if seg == "..":
1097
+ if out and out[-1] not in ("", ".."):
1098
+ out.pop()
1099
+ continue
1100
+ out.append(seg)
1101
+ joined = "/".join(out)
1102
+ if path.endswith("/") and not joined.endswith("/"):
1103
+ joined += "/"
1104
+ if path.startswith("/") and not joined.startswith("/"):
1105
+ joined = "/" + joined
1106
+ return joined
1107
+
1108
+
1109
+ def normalize_url(url: str) -> str:
1110
+ """RFC 3986 §6.2.2 / §6.2.3 语法型与协议型规范化。
1111
+
1112
+ 只做**等价改写**(同一个资源的不同写法归一),不做语义改写,因此可以
1113
+ 安全地用在缓存键与去重上:
1114
+
1115
+ · scheme / host 小写(§6.2.2.1)—— 大小写不同不会访问到不同资源,
1116
+ 但会让同一页被判成两条,缓存与去重都失效
1117
+ · 解码 unreserved 百分号编码(§6.2.2.2):%7E→~、%41→A
1118
+ · 移除点段(§6.2.2.3):/a/./b/../c → 该去哪去哪
1119
+ · 去掉默认端口(§6.2.3):http:80、https:443
1120
+ · 空路径补 /(§6.2.3):https://x.com 与 https://x.com/ 是同一个资源
1121
+
1122
+ 不动的部分:query 与 fragment 原样保留(只有既有的追踪参数清理会碰它们),
1123
+ percent 编码中的保留字符不解码——那会改变语义。
1124
+ """
1125
+ try:
1126
+ parsed = urllib.parse.urlsplit(url)
1127
+ except Exception:
1128
+ return url
1129
+ if not parsed.scheme or not parsed.netloc:
1130
+ return url
1131
+
1132
+ scheme = parsed.scheme.lower()
1133
+ netloc = parsed.netloc
1134
+
1135
+ # host 小写、userinfo 保留原样、端口按需剥离
1136
+ userinfo, _, hostport = netloc.rpartition("@")
1137
+ host, sep, port = hostport.partition(":")
1138
+ host = host.lower()
1139
+ if port and port == _DEFAULT_PORTS.get(scheme):
1140
+ port = ""
1141
+ netloc = (userinfo + "@" if userinfo else "") + host + (sep + port if port else "")
1142
+
1143
+ # unreserved 解码 + 保留字符的百分号编码统一大写十六进制
1144
+ def _fix_pct(m: re.Match) -> str:
1145
+ ch = chr(int(m.group(1), 16))
1146
+ return ch if ch in _UNRESERVED else "%" + m.group(1).upper()
1147
+
1148
+ path = _PCT_RE.sub(_fix_pct, parsed.path)
1149
+ path = _remove_dot_segments(path) or "/"
1150
+ if not path.startswith("/"):
1151
+ path = "/" + path
1152
+
1153
+ return urllib.parse.urlunsplit(
1154
+ (scheme, netloc, path, parsed.query, parsed.fragment))
1155
+
1156
+
1157
+ def _optimize_url(url: str) -> str:
1158
+ """URL 优化:Reddit 重写、追踪参数清理等。"""
1159
+ try:
1160
+ from url_optimize import optimize_url
1161
+ return optimize_url(url)
1162
+ except ImportError:
1163
+ return url
1164
+
1165
+
1166
+ def _cache_content_too_short(hit: dict, max_chars: int) -> bool:
1167
+ """缓存正文是否因写入时 max_chars 更小而不够本次使用。
1168
+
1169
+ 缓存存的是「写入时按 max_chars 裁剪后」的正文(不是完整副本),所以
1170
+ 本次请求更大时必须视为 miss 回源,否则调用方会静默拿到被截短的正文
1171
+ (实测:--max-chars 60000 命中 8000 字缓存,正文反而比首次更短)。
1172
+ 与 search 侧 _max_results 柔性命中同构:不够用就 miss 去升级,够用才截断。
1173
+
1174
+ 老条目(修复前写入,无 _max_chars)长度不可信,退回按 content 实际
1175
+ 长度判断;首轮回源后会写入带 _max_chars 的条目,之后即精确判定。
1176
+ """
1177
+ if max_chars is None:
1178
+ return False
1179
+ if max_chars <= 0:
1180
+ # 0/负数表示「要全文」,而缓存从不存全文(只存裁剪后的副本)→ 必然不够。
1181
+ # 早先这里写成 `if not max_chars: return False`,于是要全文的请求
1182
+ # 会安安静静拿回一份 8,000 字的缓存副本,长度还显示成 8,000。
1183
+ return True
1184
+ content = hit.get("content") or ""
1185
+ cached_max = hit.get("_max_chars")
1186
+ if cached_max is None:
1187
+ return max_chars > len(content)
1188
+ try:
1189
+ return max_chars > int(cached_max)
1190
+ except (TypeError, ValueError):
1191
+ return max_chars > len(content)
1192
+
1193
+
1194
+ def _strip_html_unless_needed(result: dict, need_html: bool) -> dict:
1195
+ """模型/CLI 默认只要正文。html 只在调用方声明 need_html(extract/crawl)时保留。
1196
+
1197
+ 写缓存已经丢掉 html;若不在公共返回里剥掉,缓存未命中的第一次会把最多
1198
+ max_chars*2 的生 HTML 泄漏给 MCP/CLI。与「内部字段不进模型可见输出」同构。
1199
+ """
1200
+ if not need_html and isinstance(result, dict):
1201
+ result.pop("html", None)
1202
+ return result
1203
+
1204
+
1205
+ def fetch_v3(url: str, max_chars: int = 8000, timeout: float = 8.0,
1206
+ use_browser_fallback: bool = True,
1207
+ actions: list[dict] | None = None,
1208
+ force_browser: bool = False,
1209
+ skip_cache: bool = False,
1210
+ need_html: bool = False,
1211
+ deadline_s: float | None = None) -> dict:
1212
+ """多级抓取降级链主函数(逐级升级,受全局 deadline 约束)。
1213
+
1214
+ 执行顺序:
1215
+ 第零级:{url}.md 变体 + 站点根 /llms.txt 探测(AI 友好直出,命中即跳过整条反爬链)
1216
+ 第一级:增强 HTTP(UA 轮换 + Cookie 积累 + Accept: text/markdown 内容协商,
1217
+ 协商命中即得站点自己的 Markdown,fetch_method=http_md);
1218
+ 客户端形态分流型站点移动 UA 首发
1219
+ 第一级B:TLS 指纹伪造(curl_cffi impersonate,指纹检测型反爬)
1220
+ 第一级C:r.jina.ai 阅读器(keyless 免费层,仅公网 URL,markdown-only)
1221
+ 第一级D:Parallel 免费 MCP web_fetch(keyless 免费层,仅公网 URL,markdown-only)
1222
+ 第二级A:tinyfish 直连渲染(markdown-only,需 TINYFISH_API_KEY;need_html 或开关关闭时跳过)
1223
+ 第二级B:Wayback 快照 + Chrome CDP 浏览器(自动降级或 actions 触发)
1224
+ 登录态车道:站点有 argo auth 持久 profile 时跳过匿名链直进浏览器
1225
+ (结果标 login_state_used,不读写 URL 缓存;ARGO_AUTH_FETCH=0 关闭)
1226
+ 第三级:质量评估(content_ok/page_type/quality_score)
1227
+
1228
+ 全局 deadline:单次 fetch_v3 总耗时上限 = deadline_s 参数(调用方显式
1229
+ 预算,如 verify 核验档);未传时读 ARGO_FETCH_DEADLINE_S(默认 60,
1230
+ 可设 0 关闭)。降级是「延迟换成功率」的交易,延迟必须有一等公民约束——
1231
+ 逐级独立超时的加法无上限(8+8+8+12+8+15≈59s+),会击穿 MCP 客户端
1232
+ 工具超时。每级升级前检查剩余预算,耗尽即停链返回当前最优结果
1233
+ (失败结果 + deadline_exhausted 标记),不再无限叠加。
1234
+
1235
+ URL 级缓存:无 actions 的成功结果写入 SearchCache(L1+L2),正文按本次
1236
+ max_chars 裁剪并记录 _max_chars;后续请求更大 max_chars 时判为 miss 回源
1237
+ 重抓,避免静默返回被截短的正文。
1238
+ need_html:调用方需要原始 HTML(如爬取提取链接)时置 True,会跳过 tinyfish/
1239
+ jina/Parallel(仅产 markdown)、停用内容协商,并且**自动绕过 URL 缓存**——
1240
+ 缓存有意不存 html 大字段(省空间),命中只会拿到空 html,让结构化提取
1241
+ 静默全空。这条约束放在这里,是因为要求每个调用方自己记得配 skip_cache,
1242
+ 迟早有人漏;漏了的症状是「开关看着接上了,结果永远为空」。
1243
+ """
1244
+ try:
1245
+ from url_safety import check_url
1246
+ ok, reason = check_url(url)
1247
+ if not ok:
1248
+ return _strip_html_unless_needed({
1249
+ "url": url, "title": "", "content": "",
1250
+ "html": "", "length": 0, "success": False,
1251
+ "error": f"URL 被 SSRF 防护拦截: {reason}",
1252
+ "fetch_method": "blocked",
1253
+ }, need_html)
1254
+ except ImportError:
1255
+ pass
1256
+
1257
+ # URL 优化(Reddit 重写、追踪参数清理)
1258
+ url = _optimize_url(url)
1259
+
1260
+ # robots.txt 尊重(合规检查):被禁路径直接拒绝,抓取失败放行
1261
+ try:
1262
+ from robots_guard import robots_blocked
1263
+ if robots_blocked(url, timeout=min(timeout, 5.0)):
1264
+ result = _make_result(url, "", 0, "robots_blocked", ok=False,
1265
+ error="robots.txt 禁止抓取")
1266
+ result = _assess_quality(result)
1267
+ result["cached"] = False
1268
+ return _strip_html_unless_needed(result, need_html)
1269
+ except ImportError:
1270
+ pass
1271
+
1272
+ # 登录态车道(A2):站点有 argo auth 持久 profile → 跳过匿名链直进浏览器。
1273
+ # 匿名 HTTP 对已登录站只会拿到「登出态」内容——那是成功的假象,比失败更糟。
1274
+ # 判断本体在 browser_auth(含开关与 use_browser_fallback=False 让路);
1275
+ # 未认证时零改动,不破默认路径。
1276
+ auth_profile = None
1277
+ try:
1278
+ from browser_auth import auth_profile_for_fetch
1279
+ auth_profile = auth_profile_for_fetch(url, allow_browser_lane=use_browser_fallback)
1280
+ except Exception:
1281
+ auth_profile = None
1282
+ if auth_profile:
1283
+ force_browser = True
1284
+ skip_cache = True
1285
+
1286
+ # 有 actions → 强制浏览器模式,且不读缓存
1287
+ if actions:
1288
+ force_browser = True
1289
+ skip_cache = True
1290
+
1291
+ # 需原始 HTML → 不读缓存(缓存不存 html,命中必得空 html)。
1292
+ # 与 actions 同理:这是取数的前提条件,不是调用方的可选项。
1293
+ if need_html:
1294
+ skip_cache = True
1295
+
1296
+ # 读 URL 缓存
1297
+ if max_chars is not None and max_chars <= 0:
1298
+ # 要全文的请求:缓存只存裁剪副本,读写都不该经过它
1299
+ skip_cache = True
1300
+
1301
+ if not skip_cache and not force_browser:
1302
+ try:
1303
+ from cache import SearchCache
1304
+ hit = SearchCache().get_fetch(url)
1305
+ # 缓存正文是按写入时 max_chars 裁剪的,本次要得更多 → 判 miss 走抓取链
1306
+ if hit and hit.get("success") and not _cache_content_too_short(hit, max_chars):
1307
+ # 缓存内容可能比本次 max_chars 更长 → 截断
1308
+ out = {k: v for k, v in hit.items() if not str(k).startswith("_")}
1309
+ content = out.get("content") or ""
1310
+ if max_chars and len(content) > max_chars:
1311
+ out["content"] = content[:max_chars]
1312
+ out["length"] = len(out["content"])
1313
+ # 评分口径变了 → 条目里的旧分不再成立,就地重算。
1314
+ # 正文仍在条目里(它才是贵的那部分),为一个公式改动去重新
1315
+ # 联网不值得。html 未被缓存,结构修正项拿不到,故标注口径来源,
1316
+ # 让调用方知道这个分与新鲜抓取的分不是同一种计算。
1317
+ try:
1318
+ _v = (_quality.QUALITY_FORMULA_VERSION
1319
+ if hasattr(_quality, "QUALITY_FORMULA_VERSION") else 0)
1320
+ if (out.get("quality_breakdown") or {}).get("version") != _v:
1321
+ out = _quality.assess(out)
1322
+ out["quality_basis"] = "rescored"
1323
+ except Exception:
1324
+ pass
1325
+ out["cached"] = True
1326
+ out["cache_level"] = hit.get("_cache_level", "L?")
1327
+ out["url"] = url
1328
+ return _strip_html_unless_needed(out, need_html)
1329
+ except Exception:
1330
+ pass
1331
+
1332
+ # X Article 正文提取:通过 FxTwitter API 获取 Draft.js blocks 并重建为 Markdown
1333
+ if not force_browser:
1334
+ try:
1335
+ from x_article_fetch import is_x_article_url, fetch_x_article
1336
+ if is_x_article_url(url):
1337
+ result = fetch_x_article(url, max_chars=max_chars, timeout=timeout)
1338
+ if result and result.get("success"):
1339
+ result["cached"] = False
1340
+ return _strip_html_unless_needed(result, need_html)
1341
+ except ImportError:
1342
+ pass
1343
+
1344
+ if force_browser:
1345
+ result = _browser_fetch(url, max_chars, timeout=15.0, actions=actions,
1346
+ auth_profile=auth_profile)
1347
+ else:
1348
+ # 全局 deadline:所有降级升级动作共用的总预算(秒)。
1349
+ # 调用方显式传 deadline_s 优先;未传时读 ARGO_FETCH_DEADLINE_S
1350
+ # (默认 60,MCP 客户端工具超时的安全下限;=0 关闭)。
1351
+ if deadline_s is None:
1352
+ try:
1353
+ deadline_s = float(os.environ.get("ARGO_FETCH_DEADLINE_S", "60") or 60)
1354
+ except ValueError:
1355
+ deadline_s = 60.0
1356
+ t_chain0 = time.monotonic()
1357
+ deadline_hit = {"flag": False}
1358
+
1359
+ def _budget_left() -> float:
1360
+ """剩余降级预算;耗尽时置标记并返回 -1(调用方停止升级)。"""
1361
+ if deadline_s <= 0:
1362
+ return 1.0
1363
+ left = deadline_s - (time.monotonic() - t_chain0)
1364
+ if left <= 0:
1365
+ deadline_hit["flag"] = True
1366
+ return -1.0
1367
+ return left
1368
+
1369
+ def _level_timeout(requested: float) -> float:
1370
+ """本降级级的 timeout:不超过剩余总预算。
1371
+
1372
+ **每一级都必须过这道口**。历史 bug(2026-09-19 复现):只有
1373
+ 后加的 jina / Parallel 两级写了 `min(..., max(_budget_left(), 1.0))`,
1374
+ 而 mobile UA / TLS 指纹 / wayback / tinyfish / browser 五级都拿
1375
+ 原始的 timeout(8s / 8s / 12s / 8s / 15s)。预算只剩 0.1s 时,
1376
+ 这五级仍会各自跑满自己的超时,实测**可击穿总预算 34.9s**——
1377
+ 而 ARGO_FETCH_DEADLINE_S 存在的全部理由就是兜住 MCP 客户端
1378
+ 的工具超时(默认 60s),击穿等于让这条兜底失效。
1379
+
1380
+ `deadline_s <= 0` 表示 deadline 被显式关闭(ARGO_FETCH_DEADLINE_S=0),
1381
+ 此时 `_budget_left()` 返回的是「放行」哨兵 1.0 而不是真实剩余量,
1382
+ 必须原样放行——照抄 min() 会把关闭状态下的每一级都压成 1s。
1383
+
1384
+ 下限 1.0s:预算所剩无几时,0 秒超时的请求本身没有意义(连不上
1385
+ 也读不到),留 1s 让它要么快速成功、要么快速失败。代价是总墙钟
1386
+ 的上界不是 deadline 本身,而是 **deadline + 5s**(预算耗尽后仍有
1387
+ 至多 5 个降级级各留 1s 下限)。这个上界是刻意的:比「击穿 34.9s」
1388
+ 小一个量级,又不必给每一级单独定义「多小的预算算不值得试」。
1389
+ """
1390
+ if deadline_s <= 0:
1391
+ return requested
1392
+ return min(requested, max(_budget_left(), 1.0))
1393
+
1394
+ host = (urlparse(url).hostname or "").lower()
1395
+ gated = (_mobile_branch_enabled()
1396
+ and (_mobile_first_host(url) or _identity_is_mobile(host)))
1397
+ result = None
1398
+ # AI 友好变体探测({url}.md 直出 / 站点根 llms.txt)**排在主请求之后**。
1399
+ # 协商折在主请求里不额外花往返,而探测无论命中与否都要先付一次串行
1400
+ # 请求(实测未命中白等 571–1,246 ms)。主请求先走,只在没拿到 Markdown
1401
+ # 时才回探变体——已知的 .md 专有站点仍能拿到,其余站点少一次往返。
1402
+ if result is None:
1403
+ # 第一级:客户端形态分流型站点(如抖音)直接以移动端 UA 首发——
1404
+ # 桌面 UA 首发会触发风控并连坐后续移动请求,顺序不可颠倒。
1405
+ if gated:
1406
+ result = _mobile_http_fetch(url, max_chars, timeout)
1407
+ if result.get("success") and not _needs_browser(result):
1408
+ result["ua_profile"] = "mobile"
1409
+ _identity_remember_mobile(host)
1410
+ else:
1411
+ # 第一级:HTTP(带内容协商;crawl/extract 要 raw HTML 时关闭)
1412
+ result = _http_fetch(url, max_chars, timeout,
1413
+ allow_markdown=not need_html)
1414
+
1415
+ # 主请求未得到 Markdown → 回探 AI 友好变体。
1416
+ # 门控站(抖音一类)跳过:少一次主机触碰,保住单次直连窗口。
1417
+ # need_html 一并关掉:它和 tinyfish/jina/Parallel 同类(只产 markdown),
1418
+ # 漏在这里会让 extract 拿到空 html 却 success=True(2026-09-19 实测)。
1419
+ if (result is not None and not gated and not need_html
1420
+ and _md_variant_enabled() and _md_variant_wanted(result)):
1421
+ md = _md_variant_fetch(url, max_chars, timeout)
1422
+ if md is not None:
1423
+ md["md_variant"] = True
1424
+ md["http_fallback"] = True
1425
+ result = md
1426
+
1427
+ # 明确停止信号(429/503)→ 不再升级 TLS/wayback/CDP。
1428
+ # 限速/过载与请求方式无关,继续升级重链 = 无视服务器指示放大负载。
1429
+ if not result.get("stop_signal") and _budget_left() > 0:
1430
+ # 第一级A2:移动端 UA 分支(客户端形态分流型反爬)。实测抖音
1431
+ # iesdouyin 对真机 UA 返回 SSR 数据、对桌面/AI UA 一律风控壳;
1432
+ # 该类分流与 TLS 指纹无关,stdlib 免费尝试即可,命中则免去
1433
+ # TLS 伪造与浏览器冷启动。ARGO_FETCH_MOBILE=0 关闭。
1434
+ if (_mobile_branch_enabled() and not gated
1435
+ and result.get("fetch_method") != "http_mobile"
1436
+ and (not result.get("success")
1437
+ or _needs_browser(result))):
1438
+ mob = _mobile_http_fetch(url, max_chars,
1439
+ _level_timeout(timeout))
1440
+ if (mob.get("success") and not mob.get("stop_signal")
1441
+ and not _needs_browser(mob)):
1442
+ mob["ua_profile"] = "mobile"
1443
+ mob["http_fallback"] = True
1444
+ result = mob
1445
+ _identity_remember_mobile(host)
1446
+
1447
+ if not result.get("stop_signal") and _budget_left() > 0:
1448
+ # 第一级B:TLS 指纹伪造(HTTP 失败或疑似指纹拦截时)
1449
+ # 指纹检测型反爬对 urllib 直接 403,TLS 层免起浏览器即可通过,
1450
+ # 避免不必要的 CDP 冷启动。门控站跳过:单次直连原则,
1451
+ # 连击直连只会加重按 IP 的递进限速,失败直接交 Wayback/浏览器。
1452
+ if (not gated and _impersonate_enabled()
1453
+ and (not result.get("success")
1454
+ or _needs_browser(result))):
1455
+ spoof = _tls_spoof_fetch(url, max_chars,
1456
+ _level_timeout(timeout))
1457
+ if spoof.get("stop_signal"):
1458
+ result = spoof
1459
+ elif spoof.get("success"):
1460
+ spoof["http_fallback"] = True
1461
+ result = spoof
1462
+
1463
+ if not result.get("stop_signal") and _budget_left() > 0:
1464
+ # 第一级C:r.jina.ai 阅读器(keyless 免费层,远端 JS 渲染)。
1465
+ # HTTP/TLS 直连全败时的免浏览器快速路径;markdown-only 同
1466
+ # tinyfish,need_html 场景跳过;第三方代理只接公网 URL。
1467
+ if (not gated and not need_html and _jina_enabled()
1468
+ and _is_public_host(host)
1469
+ and (not result.get("success")
1470
+ or _needs_browser(result))):
1471
+ # 超时受剩余预算约束:deadline 场景本级不得击穿总预算
1472
+ jina = _jina_reader_fetch(
1473
+ url, max_chars, timeout=_level_timeout(timeout))
1474
+ if jina is not None and jina.get("success"):
1475
+ jina["http_fallback"] = True
1476
+ result = jina
1477
+
1478
+ if not result.get("stop_signal") and _budget_left() > 0:
1479
+ # 第一级D:Parallel 免费 MCP web_fetch(keyless 云端渲染+提取)。
1480
+ # jina 同级的免浏览器快速路径,markdown-only 同 tinyfish,
1481
+ # need_html 场景跳过;免费层限流按持久 session_id 关联;
1482
+ # full_content 整页较慢,超时受剩余预算约束。ARGO_FETCH_PARALLEL=0 关闭。
1483
+ if (not gated and not need_html and _parallel_mcp_enabled()
1484
+ and _is_public_host(host)
1485
+ and (not result.get("success")
1486
+ or _needs_browser(result))):
1487
+ pm = _parallel_mcp_fetch(
1488
+ url, max_chars,
1489
+ timeout=min(_level_timeout(timeout), 25.0))
1490
+ if pm.get("success") and not _needs_browser(pm):
1491
+ pm["http_fallback"] = True
1492
+ result = pm
1493
+
1494
+ if not result.get("stop_signal") and _budget_left() > 0:
1495
+ # 第二级:Wayback 快照回退(HTTP 失败 / 内容空 / 疑似被删页面)
1496
+ if not result.get("success"):
1497
+ wb = _wayback_fetch(url, max_chars,
1498
+ timeout=_level_timeout(
1499
+ min(timeout * 1.5, 12.0)))
1500
+ if wb.get("success"):
1501
+ wb["http_fallback"] = True
1502
+ result = wb
1503
+
1504
+ # 第三级:浏览器降级(HTTP 失败或疑似 CF/JS 壳);预算耗尽不再升级
1505
+ if use_browser_fallback and _needs_browser(result) and _budget_left() > 0:
1506
+ # js-run 轻量车道:挑战页先试 V8 算通行证;退避预算随链级 deadline
1507
+ if _jsrun_enabled() and not need_html and result.get("html"):
1508
+ _bo = min(15.0, _budget_left()) if deadline_s > 0 else None
1509
+ jr = _jsrun_challenge_fetch(
1510
+ url, result["html"], max_chars,
1511
+ _level_timeout(min(timeout, 5.0)), backoff_budget_s=_bo)
1512
+ if jr.get("success"):
1513
+ jr["http_fallback"] = True
1514
+ result = jr
1515
+ # tinyfish 免费渲染(返回 clean Markdown,含 JS 执行)优先于本地 Chrome;
1516
+ # 只产 markdown 无 raw html,爬取(need_html)跳过,失败自动回退。
1517
+ if result.get("fetch_method") != "jsrun_challenge" and _tinyfish_enabled() and not need_html:
1518
+ # tinyfish 免费渲染(返回 clean Markdown,含 JS 执行)优先于本地 Chrome;
1519
+ # 只产 markdown 无 raw html,爬取(need_html)跳过,失败自动回退。
1520
+ tf = _tinyfish_fetch(url, max_chars,
1521
+ _level_timeout(timeout))
1522
+ if tf.get("success") and len(
1523
+ (tf.get("content") or "").strip()) >= 100:
1524
+ tf["http_fallback"] = True
1525
+ result = tf
1526
+ # tinyfish 未命中(关闭/缺 key/失败/短内容)才起本地 Chrome——
1527
+ # 内容过短的成功响应与失败同样需要继续降级
1528
+ if result.get("fetch_method") != _render_tinyfish.TINYFISH_METHOD:
1529
+ browser_result = _browser_fetch(
1530
+ url, max_chars, timeout=_level_timeout(15.0))
1531
+ if browser_result.get("success") or not result.get("success"):
1532
+ browser_result["http_fallback"] = True
1533
+ result = browser_result
1534
+
1535
+ if deadline_hit["flag"]:
1536
+ result["deadline_exhausted"] = True
1537
+
1538
+ # 第三级:质量评估
1539
+ result = _assess_quality(result)
1540
+
1541
+ # 文章级元数据(链接/作者/站点名/摘要):与正文同源于本次 HTML,一次解析
1542
+ # 拿全;调用方不必为了顺着引用继续取证而重抓页面。缓存一起存(链接上限
1543
+ # 30 条,体积可控),输出侧由 CLI/MCP 决定是否展示。
1544
+ if result.get("success") and result.get("html"):
1545
+ try:
1546
+ from article_meta import extract_article_meta
1547
+ for _mk, _mv in extract_article_meta(result["html"], url).items():
1548
+ result.setdefault(_mk, _mv)
1549
+ except Exception:
1550
+ pass
1551
+
1552
+ # 写 URL 缓存(不存大块 html,省空间)
1553
+ if not skip_cache and result.get("success"):
1554
+ try:
1555
+ from cache import SearchCache, FETCH_DEFAULT_TTL, FETCH_EVIDENCE_KEY
1556
+ to_store = {
1557
+ k: v for k, v in result.items()
1558
+ if k not in ("html",) and not str(k).startswith("_")
1559
+ }
1560
+ # 记录本次裁剪上限:后续请求更大 max_chars 时据此判缓存不够用
1561
+ # (读缓存侧 _cache_content_too_short)。下划线字段读时会被过滤,
1562
+ # 不会泄漏给调用方。
1563
+ to_store["_max_chars"] = max_chars
1564
+ # 按内容类型粗略 TTL:新闻短、文档长
1565
+ ttl = FETCH_DEFAULT_TTL
1566
+ st = (result.get("source_type") or result.get("page_type") or "")
1567
+ if st in ("news", "realtime"):
1568
+ ttl = 600
1569
+ elif st in ("docs", "documentation", "reference"):
1570
+ ttl = 86400
1571
+ # 证据分与正文写进**同一个条目、同一个 try 块**。
1572
+ # 分成两处写过(独立 kind、各自 try)会失配:登录态内容被
1573
+ # assert_cacheable 拒绝时正文没落盘,证据却照样写成功,于是
1574
+ # 「已核验」标记挂在一份不存在的正文上,verify 从此永久跳过它。
1575
+ try:
1576
+ from evidence_loop import extract_fetch_evidence
1577
+ ev = extract_fetch_evidence(result)
1578
+ if ev:
1579
+ to_store[FETCH_EVIDENCE_KEY] = {
1580
+ k: v for k, v in ev.items() if k != "url"}
1581
+ except Exception:
1582
+ pass
1583
+ SearchCache().set_fetch(url, to_store, ttl=ttl)
1584
+ except Exception:
1585
+ pass
1586
+
1587
+ result["cached"] = False
1588
+ return _strip_html_unless_needed(result, need_html)
1589
+
1590
+
1591
+ def fetch_page_v3(url: str, max_chars: int = 3000,
1592
+ timeout: int = 8, raw: bool = False) -> dict:
1593
+ """兼容 fetch.py 的 fetch_page() 签名,支持透明替换。
1594
+
1595
+ raw=True 时跳过 URL 缓存:缓存写入时有意丢弃 html 大字段(省空间),
1596
+ 缓存命中只会返回空 html,结构化提取(tables/meta/jsonld)会全空。
1597
+ 需要原始 HTML 的场景必须重新抓取,才能拿到完整页面。
1598
+ """
1599
+ result = fetch_v3(url, max_chars=max_chars, timeout=float(timeout),
1600
+ skip_cache=raw, need_html=raw)
1601
+ out = {
1602
+ "url": result["url"],
1603
+ "content": result["content"],
1604
+ "length": result["length"],
1605
+ "success": result["success"],
1606
+ "error": result.get("error", ""),
1607
+ }
1608
+ if raw:
1609
+ out["html"] = result.get("html", "")
1610
+ return out
1611
+
1612
+
1613
+ # ─── 聚焦提取(--focus:BM25 段落聚焦,省 token)──────────────────────────────
1614
+ # 语义来源在 focus_extract.apply_focus(CLI 与 MCP 共用同一份裁剪契约),
1615
+ # 此处只做接线——两处必须走同一实现,否则迟早再次分叉。
1616
+
1617
+ def _focus_fetch_chars(max_chars: int, query: str) -> int:
1618
+ """聚焦场景的抓取额度(契约在 focus_extract,此处只做容错接线)。"""
1619
+ try:
1620
+ from focus_extract import focus_fetch_chars
1621
+ except ImportError:
1622
+ return max_chars
1623
+ return focus_fetch_chars(max_chars, query)
1624
+
1625
+
1626
+ def _apply_focus_to_result(result: dict, query: str, top_k: int = 5,
1627
+ max_chars: int | None = None) -> dict:
1628
+ """对成功结果做 BM25 聚焦裁剪(失败结果不动,语义与 MCP 侧一致)。"""
1629
+ if not query or not result.get("success"):
1630
+ return result
1631
+ try:
1632
+ from focus_extract import apply_focus
1633
+ except ImportError:
1634
+ return result
1635
+ return apply_focus(result, query, top_k=top_k, max_chars=max_chars)
1636
+
1637
+
1638
+ # ─── 全文回读(--full / --offset / --limit)──────────────────────────────────
1639
+
1640
+ def _read_archived(url: str) -> str | None:
1641
+ """读本地全文存档,不联网。"""
1642
+ try:
1643
+ from fulltext_store import load as _load_fulltext
1644
+ return _load_fulltext(url, "text")
1645
+ except Exception:
1646
+ return None
1647
+
1648
+
1649
+ def _full_view(url: str, args) -> dict:
1650
+ """取全文视图:优先用存档(本地、零延迟),没有存档才按全量重新抓一次。
1651
+
1652
+ 存在的意义:Agent 上下文放不下整篇,但结论可能落在被裁掉的那一段。
1653
+ 有了这条通道,「先看 8,000 字摘要、需要时再翻全文」才成立。
1654
+ """
1655
+ from_archive = True
1656
+ text = _read_archived(url)
1657
+ if text is None:
1658
+ from_archive = False
1659
+ r = fetch_v3(url, max_chars=0, timeout=args.timeout,
1660
+ force_browser=args.browser,
1661
+ use_browser_fallback=not args.no_fallback)
1662
+ text = r.get("content", "") or ""
1663
+ else:
1664
+ r = {"success": True, "error": None, "title": ""}
1665
+ start = max(args.offset or 0, 0)
1666
+ end = start + args.limit if args.limit and args.limit > 0 else None
1667
+ out = dict(r)
1668
+ out.update({
1669
+ "url": url,
1670
+ "content": text[start:end],
1671
+ "length": len(text[start:end]),
1672
+ "total_length": len(text),
1673
+ "offset": start,
1674
+ "full_source": "archive" if from_archive else "refetch",
1675
+ "fetch_method": "fulltext" if from_archive else r.get("fetch_method", "full"),
1676
+ })
1677
+ return out
1678
+
1679
+
1680
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
1681
+
1682
+ # 人类可读输出的默认显示上限(字符)。--limit 管的是交付窗口,这里是显示上限;
1683
+ # 显式 --full / --limit 时按调用方要求放开,截断时打印可翻页的提示。
1684
+ _DISPLAY_CHARS = 2000
1685
+
1686
+
1687
+ def build_parser():
1688
+ """CLI 参数表(独立成函数以便测试校验旗标契约)。
1689
+
1690
+ `--use-browser` 与 `--browser` 同义:文档两处都写过前者,入口若只认后者,
1691
+ 用户照文档敲命令即报错。两个名字都收,避免再出现「文档有、代码无」。
1692
+ """
1693
+ import argparse
1694
+ p = argparse.ArgumentParser(description="Argo fetch v3 — 四级抓取(零依赖)")
1695
+ p.add_argument("url", help="目标 URL")
1696
+ p.add_argument("--max-chars", type=int, default=8000)
1697
+ p.add_argument("--timeout", type=float, default=8.0)
1698
+ p.add_argument("--browser", "--use-browser", dest="browser",
1699
+ action="store_true",
1700
+ help="强制使用浏览器(--use-browser 同义)")
1701
+ p.add_argument("--no-fallback", action="store_true", help="禁用浏览器降级")
1702
+ p.add_argument("--actions", type=str,
1703
+ help="页面交互 JSON(如 '[{\"click\":\"#btn\"}]')")
1704
+ p.add_argument("--focus", type=str, default="",
1705
+ help="BM25 聚焦关键词:只返回相关段落,省 token")
1706
+ p.add_argument("--focus-top", type=int, default=5,
1707
+ help="--focus 无段落超阈值时的回退保留段落数(默认 5)")
1708
+ p.add_argument("--full", action="store_true",
1709
+ help="取全文:优先读本地全文存档,没有则按抓取全量取回,"
1710
+ "不受 --max-chars 限制(用于复核被截断的部分)")
1711
+ p.add_argument("--offset", type=int, default=0,
1712
+ help="从正文第 N 个字符开始输出(配合 --limit 翻页读长文)")
1713
+ p.add_argument("--limit", type=int, default=0,
1714
+ help="本次输出字符数上限;0 表示不额外限制")
1715
+ p.add_argument("--include-links", action="store_true",
1716
+ help="JSON 摘要附带正文链接(默认只给 links_count,省 token)")
1717
+ p.add_argument("--json", action="store_true",
1718
+ help="只输出 JSON 摘要(缺省还会附人类可读的正文段;"
1719
+ "Usage 一直把 --json 列为 Common flags,此前却报"
1720
+ " unrecognized arguments)")
1721
+ return p
1722
+
1723
+
1724
+ if __name__ == "__main__":
1725
+ args = build_parser().parse_args()
1726
+
1727
+ actions = None
1728
+ if args.actions:
1729
+ actions = json.loads(args.actions)
1730
+
1731
+ # 聚焦时按放大额度抓取——否则 focus 只能在文档头部选段(见
1732
+ # focus_extract.focus_fetch_chars),选完再裁回 args.max_chars。
1733
+ if args.full or args.offset or args.limit:
1734
+ r = _full_view(args.url, args)
1735
+ else:
1736
+ r = fetch_v3(args.url,
1737
+ max_chars=_focus_fetch_chars(args.max_chars, args.focus),
1738
+ timeout=args.timeout,
1739
+ force_browser=args.browser,
1740
+ use_browser_fallback=not args.no_fallback,
1741
+ actions=actions)
1742
+
1743
+ focus_requested = bool(args.focus)
1744
+ pre_focus_len = r.get("length", 0)
1745
+ r = _apply_focus_to_result(r, args.focus, top_k=args.focus_top,
1746
+ max_chars=args.max_chars)
1747
+
1748
+ # 输出摘要
1749
+ summary = {k: r[k] for k in ("success", "fetch_method", "content_ok",
1750
+ "quality_score", "page_type", "source_type",
1751
+ "is_official", "length", "url") if k in r}
1752
+ # 截断必须可见:只给 8,000 字却不说明「还有多少没给」,调用方会当全文用
1753
+ for k in ("truncated", "full_length", "full_text_path", "total_length",
1754
+ "offset", "full_source", "final_url",
1755
+ "login_state_used", "cache_eligible"):
1756
+ if k in r:
1757
+ summary[k] = r[k]
1758
+ if focus_requested:
1759
+ # 显式回报聚焦是否真的生效:正文过短时 focus_applied=False,
1760
+ # 不谎报「已省 token」
1761
+ summary["focus_applied"] = bool(r.get("focus_applied"))
1762
+ if r.get("error"):
1763
+ summary["error"] = r["error"]
1764
+ if r.get("links"):
1765
+ summary["links_count"] = len(r["links"])
1766
+ if args.include_links:
1767
+ summary["links"] = r["links"]
1768
+ print(dumps(summary))
1769
+ # __main__ 块是模块级代码,不能用 return 短路——用 if 包住人类可读段
1770
+ if not args.json:
1771
+ if r.get("title"):
1772
+ print(f"\nTitle: {r['title']}")
1773
+ if focus_requested:
1774
+ print(f"\n[focus] query={args.focus!r} applied={bool(r.get('focus_applied'))} "
1775
+ f"chars={pre_focus_len} → {r.get('length', 0)}")
1776
+ print(f"\n--- CONTENT ({r['length']} chars) ---")
1777
+ # 默认档保持 2000 字显示上限(护住 agent 上下文),但显式 --full /
1778
+ # --limit 时必须按调用方要求显示完整交付窗口:此前这行硬编码
1779
+ # [:2000],与 --limit 的语义(本次输出字符数上限,0 表示不限)冲突,
1780
+ # 且表头写的是交付长度,于是「说 8000 字、只给 2000 字、不说明还剩
1781
+ # 多少」——调用方会把截断正文当全文用(证据核验场景尤其危险)。
1782
+ delivered = r.get("content", "") or ""
1783
+ show_all = bool(args.full) or bool(args.limit and args.limit > 0)
1784
+ shown = delivered if show_all else delivered[:_DISPLAY_CHARS]
1785
+ print(shown)
1786
+ if len(shown) < len(delivered):
1787
+ # 两个字段是不同代码路径写的同一事实:fetch_v3 路径给 full_length,
1788
+ # _full_view 路径给 total_length,故这里按可用者兜底。
1789
+ full_len = r.get("full_length") or r.get("total_length") or len(delivered)
1790
+ if focus_requested:
1791
+ # --focus 之后 content 是重排后的段落抽取,不再是正文的连续窗口,
1792
+ # 按「本页起点 + 本页长度」推 --offset 会指向无关区间。宁可不给
1793
+ # 翻页建议,也不能给错的。
1794
+ print(f"\n[truncated] 已显示 {len(shown)}/{len(delivered)} 字"
1795
+ f"(页面全文 {full_len} 字)。当前为 --focus 抽取结果,"
1796
+ f"非正文连续窗口;要完整正文请用 --full")
1797
+ else:
1798
+ # --offset 是相对正文全文的绝对偏移(见 _full_view),所以下一页
1799
+ # 要从「本页起点 + 本页长度」继续,否则已翻过页的调用方会原地打转。
1800
+ next_offset = int(r.get("offset") or 0) + len(shown)
1801
+ print(f"\n[truncated] 已显示 {len(shown)}/{len(delivered)} 字"
1802
+ f"(页面全文 {full_len} 字)。"
1803
+ f"继续读:--offset {next_offset} --limit {_DISPLAY_CHARS};"
1804
+ f"全文:--full")