argo-search 1.0.1 → 2.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (463) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +822 -0
  3. package/README.es.md +864 -0
  4. package/README.ja.md +864 -0
  5. package/README.ko.md +864 -0
  6. package/README.md +867 -320
  7. package/SKILL.md +135 -0
  8. package/assets/readme/hero.svg +121 -0
  9. package/assets/readme/made-with-beautify.svg +10 -0
  10. package/assets/readme/proof-routes.svg +73 -0
  11. package/assets/readme/settings-keys.png +0 -0
  12. package/assets/readme/settings-tools.png +0 -0
  13. package/assets/readme/why-better.svg +104 -0
  14. package/assets/readme/workflow.svg +83 -0
  15. package/backends/domain_profiles.json +2308 -96
  16. package/backends/engine_registry.yaml +3314 -503
  17. package/backends/query_synonyms_cn.json +35 -0
  18. package/backends/quota_profiles.json +2409 -161
  19. package/backends/source_types_cn.json +83 -0
  20. package/backends/text_lexicon.json +44 -0
  21. package/bin/argo +703 -0
  22. package/bin/argo.js +71 -39
  23. package/config.yaml +4976 -363
  24. package/cordis.patch.yml +21 -0
  25. package/data/countries.json +40 -0
  26. package/data/lang_matrix/lang_capability.json +842 -0
  27. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  28. package/data/regions_cn.json +1 -0
  29. package/docs/ADDING_NEW_ENGINE.md +184 -0
  30. package/docs/ARGO_INTRO.md +124 -0
  31. package/docs/DATA_SOURCE_GAPS.md +81 -0
  32. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  33. package/docs/ENGINE_CATALOG.md +564 -0
  34. package/docs/MCP_SETUP.md +106 -0
  35. package/docs/PLATFORM_PATHS.md +111 -0
  36. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  37. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  38. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  39. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  40. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  41. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  42. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  43. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  44. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  45. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  46. package/docs/SEARCH_ARCHIVE.md +115 -0
  47. package/docs/assets/architecture.svg +104 -0
  48. package/docs/assets/hero.svg +98 -0
  49. package/docs/engines/bangumi.md +27 -0
  50. package/docs/engines/biorxiv.md +27 -0
  51. package/docs/engines/clawhub.md +27 -0
  52. package/docs/engines/crt_sh.md +27 -0
  53. package/docs/engines/datacite.md +27 -0
  54. package/docs/engines/deps_dev.md +27 -0
  55. package/docs/engines/douban_movie.md +27 -0
  56. package/docs/engines/endoflife.md +27 -0
  57. package/docs/engines/flk_law.md +27 -0
  58. package/docs/engines/fxtwitter.md +27 -0
  59. package/docs/engines/google_news.md +27 -0
  60. package/docs/engines/gov_regulations.md +27 -0
  61. package/docs/engines/guqian.md +27 -0
  62. package/docs/engines/hackernews.md +27 -0
  63. package/docs/engines/iplant.md +27 -0
  64. package/docs/engines/keenable.md +27 -0
  65. package/docs/engines/met_museum.md +27 -0
  66. package/docs/engines/nasa_images.md +27 -0
  67. package/docs/engines/netease_music.md +27 -0
  68. package/docs/engines/npm.md +27 -0
  69. package/docs/engines/nvd.md +27 -0
  70. package/docs/engines/openreview.md +27 -0
  71. package/docs/engines/openstd.md +27 -0
  72. package/docs/engines/parallel_free.md +27 -0
  73. package/docs/engines/people_daily.md +27 -0
  74. package/docs/engines/realtime_index.md +27 -0
  75. package/docs/engines/sec_edgar.md +27 -0
  76. package/docs/engines/seltz.md +27 -0
  77. package/docs/engines/skillsmp.md +27 -0
  78. package/docs/engines/sspai.md +27 -0
  79. package/docs/engines/std_samr.md +27 -0
  80. package/docs/engines/train.md +27 -0
  81. package/docs/engines/un_comtrade.md +27 -0
  82. package/docs/engines/wikisource.md +27 -0
  83. package/docs/engines/zdic.md +27 -0
  84. package/docs/engines/zenodo.md +27 -0
  85. package/docs/engines/zhihu_hot_app.md +27 -0
  86. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  87. package/engines/README.md +10 -0
  88. package/engines/_template_cli.yaml +39 -0
  89. package/engines/_template_http.yaml +37 -0
  90. package/engines/plugins/_template_plugin.py +50 -0
  91. package/engines/specs/_cisa_kev.yaml +54 -0
  92. package/engines/specs/_osv.yaml +63 -0
  93. package/engines/specs/aljazeera.yaml +43 -0
  94. package/engines/specs/antara.yaml +43 -0
  95. package/engines/specs/artic.yaml +20 -0
  96. package/engines/specs/aviation_weather.yaml +39 -0
  97. package/engines/specs/bangumi.yaml +21 -0
  98. package/engines/specs/biorxiv.yaml +24 -0
  99. package/engines/specs/carbon_intensity.yaml +32 -0
  100. package/engines/specs/cinii.yaml +44 -0
  101. package/engines/specs/clawhub.yaml +49 -0
  102. package/engines/specs/cleveland.yaml +19 -0
  103. package/engines/specs/cn_ai_news.yaml +43 -0
  104. package/engines/specs/core.yaml +35 -0
  105. package/engines/specs/crt_sh.yaml +38 -0
  106. package/engines/specs/ct24.yaml +43 -0
  107. package/engines/specs/datacite.yaml +27 -0
  108. package/engines/specs/datagov_uk.yaml +41 -0
  109. package/engines/specs/ddgs_images.yaml +45 -0
  110. package/engines/specs/deepseek_search.yaml +74 -0
  111. package/engines/specs/deezer.yaml +20 -0
  112. package/engines/specs/deps_dev.yaml +21 -0
  113. package/engines/specs/digup.yaml +36 -0
  114. package/engines/specs/doi.yaml +39 -0
  115. package/engines/specs/douban_movie.yaml +22 -0
  116. package/engines/specs/dw_news.yaml +47 -0
  117. package/engines/specs/egov_law.yaml +21 -0
  118. package/engines/specs/elpais.yaml +43 -0
  119. package/engines/specs/endoflife.yaml +21 -0
  120. package/engines/specs/energy_charts.yaml +21 -0
  121. package/engines/specs/factcheck_org.yaml +52 -0
  122. package/engines/specs/faz.yaml +43 -0
  123. package/engines/specs/federal_register.yaml +57 -0
  124. package/engines/specs/firecrawl.yaml +53 -0
  125. package/engines/specs/flk_law.yaml +24 -0
  126. package/engines/specs/folha.yaml +43 -0
  127. package/engines/specs/france24.yaml +44 -0
  128. package/engines/specs/full_fact.yaml +47 -0
  129. package/engines/specs/fxtwitter.yaml +31 -0
  130. package/engines/specs/gbfs_nyc.yaml +32 -0
  131. package/engines/specs/gdacs.yaml +21 -0
  132. package/engines/specs/gdelt.yaml +64 -0
  133. package/engines/specs/google_news.yaml +22 -0
  134. package/engines/specs/gov_regulations.yaml +38 -0
  135. package/engines/specs/guardian_rss.yaml +59 -0
  136. package/engines/specs/hurriyet.yaml +43 -0
  137. package/engines/specs/iplant.yaml +25 -0
  138. package/engines/specs/jikan.yaml +24 -0
  139. package/engines/specs/jolpica.yaml +20 -0
  140. package/engines/specs/k10plus.yaml +20 -0
  141. package/engines/specs/keenable.yaml +44 -0
  142. package/engines/specs/lefigaro.yaml +43 -0
  143. package/engines/specs/listenbrainz.yaml +19 -0
  144. package/engines/specs/matichon.yaml +43 -0
  145. package/engines/specs/met_museum.yaml +21 -0
  146. package/engines/specs/nasa_images.yaml +45 -0
  147. package/engines/specs/netease_music.yaml +38 -0
  148. package/engines/specs/nhk.yaml +43 -0
  149. package/engines/specs/nhtsa_vpic.yaml +20 -0
  150. package/engines/specs/noaa_swpc.yaml +20 -0
  151. package/engines/specs/nvd.yaml +38 -0
  152. package/engines/specs/obis.yaml +20 -0
  153. package/engines/specs/opencitations.yaml +52 -0
  154. package/engines/specs/openf1.yaml +21 -0
  155. package/engines/specs/openfoodfacts.yaml +45 -0
  156. package/engines/specs/openligadb.yaml +20 -0
  157. package/engines/specs/openreview.yaml +37 -0
  158. package/engines/specs/openstd.yaml +21 -0
  159. package/engines/specs/people_daily.yaml +22 -0
  160. package/engines/specs/realtime_index.yaml +34 -0
  161. package/engines/specs/ror.yaml +20 -0
  162. package/engines/specs/satnogs.yaml +20 -0
  163. package/engines/specs/sec_edgar.yaml +46 -0
  164. package/engines/specs/skillsmp.yaml +50 -0
  165. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  166. package/engines/specs/soilgrids.yaml +25 -0
  167. package/engines/specs/sspai.yaml +36 -0
  168. package/engines/specs/stackexchange.yaml +46 -0
  169. package/engines/specs/std_samr.yaml +21 -0
  170. package/engines/specs/tass.yaml +43 -0
  171. package/engines/specs/tle_mirror.yaml +21 -0
  172. package/engines/specs/train.yaml +37 -0
  173. package/engines/specs/tvmaze.yaml +20 -0
  174. package/engines/specs/twitter_syndication.yaml +31 -0
  175. package/engines/specs/un_comtrade.yaml +23 -0
  176. package/engines/specs/unpaywall.yaml +54 -0
  177. package/engines/specs/vnpress.yaml +43 -0
  178. package/engines/specs/weather.yaml +32 -0
  179. package/engines/specs/who_don.yaml +21 -0
  180. package/engines/specs/who_gho.yaml +20 -0
  181. package/engines/specs/wikimedia_commons.yaml +34 -0
  182. package/engines/specs/wikisource.yaml +22 -0
  183. package/engines/specs/worms.yaml +19 -0
  184. package/engines/specs/x_trending.yaml +34 -0
  185. package/engines/specs/yna.yaml +43 -0
  186. package/engines/specs/ynet.yaml +43 -0
  187. package/engines/specs/zbmath.yaml +40 -0
  188. package/engines/specs/zdic.yaml +22 -0
  189. package/engines/specs/zenodo.yaml +27 -0
  190. package/engines/specs/zhihu_hot_app.yaml +35 -0
  191. package/mcp/clients.yaml +74 -0
  192. package/package.json +51 -12
  193. package/packages/dsh-plugin/README.md +134 -0
  194. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  195. package/packages/dsh-plugin/dsh/client.js +1244 -0
  196. package/packages/dsh-plugin/dsh/index.js +2290 -0
  197. package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
  198. package/packages/dsh-plugin/package.json +38 -0
  199. package/packages/dsh-plugin/test/client.test.js +232 -0
  200. package/packages/dsh-plugin/test/config.test.js +30 -0
  201. package/packages/dsh-plugin/test/index.test.js +1338 -0
  202. package/references/query-templates.md +96 -0
  203. package/references/query-templates.yaml +118 -0
  204. package/scripts/ab_eval.py +219 -0
  205. package/scripts/ab_eval_p0p1.py +266 -0
  206. package/scripts/adaptive.py +271 -70
  207. package/scripts/answer.py +154 -0
  208. package/scripts/archive_run.py +591 -0
  209. package/scripts/argo_engine_registry.py +172 -0
  210. package/scripts/argo_paths.py +728 -0
  211. package/scripts/article.py +136 -0
  212. package/scripts/arxiv_source.py +201 -0
  213. package/scripts/badcase_ledger.py +252 -0
  214. package/scripts/batch_probe.py +241 -0
  215. package/scripts/benchmark.py +19 -0
  216. package/scripts/bounded_run.py +144 -0
  217. package/scripts/browser_auth.py +323 -0
  218. package/scripts/burstiness_detector.py +147 -0
  219. package/scripts/cache.py +1078 -67
  220. package/scripts/cache_guard.py +271 -0
  221. package/scripts/cache_key_vdom.py +56 -0
  222. package/scripts/cache_similarity.py +170 -0
  223. package/scripts/caliber_isolation.py +195 -0
  224. package/scripts/calibrate_lowquality.py +210 -0
  225. package/scripts/candidate_envelope.py +366 -0
  226. package/scripts/chrome_cdp.py +871 -0
  227. package/scripts/circuit_breaker.py +389 -0
  228. package/scripts/citations.py +334 -0
  229. package/scripts/cite_cli.py +59 -0
  230. package/scripts/cjk_tokens.py +95 -0
  231. package/scripts/clarify.py +47 -24
  232. package/scripts/cli_io.py +240 -0
  233. package/scripts/config.py +683 -45
  234. package/scripts/config_cache.py +402 -0
  235. package/scripts/conn_pool.py +220 -0
  236. package/scripts/content_security.py +685 -0
  237. package/scripts/content_signals.py +730 -0
  238. package/scripts/crawl.py +74 -35
  239. package/scripts/daemon.py +866 -0
  240. package/scripts/digup_bridge.py +214 -0
  241. package/scripts/engine_admission.py +366 -0
  242. package/scripts/engine_dispatch.py +1019 -0
  243. package/scripts/engine_env.py +534 -0
  244. package/scripts/engine_failure.py +411 -0
  245. package/scripts/engine_families.py +732 -0
  246. package/scripts/engine_policy.py +218 -0
  247. package/scripts/engine_requires.py +190 -0
  248. package/scripts/engine_status.py +322 -0
  249. package/scripts/engine_validate.py +638 -0
  250. package/scripts/engines.py +778 -294
  251. package/scripts/engines_base.py +1521 -0
  252. package/scripts/engines_builders.py +298 -0
  253. package/scripts/engines_builders_batch10.py +159 -0
  254. package/scripts/engines_builders_batch11.py +174 -0
  255. package/scripts/engines_builders_batch12.py +103 -0
  256. package/scripts/engines_builders_batch13.py +340 -0
  257. package/scripts/engines_builders_batch9.py +1468 -0
  258. package/scripts/engines_builders_cn.py +2283 -0
  259. package/scripts/engines_builders_data.py +2923 -0
  260. package/scripts/engines_builders_data_macro.py +775 -0
  261. package/scripts/engines_builders_feeds.py +255 -0
  262. package/scripts/engines_builders_intl.py +1239 -0
  263. package/scripts/engines_builders_search.py +440 -0
  264. package/scripts/engines_builders_tech.py +1360 -0
  265. package/scripts/evidence.py +588 -162
  266. package/scripts/evidence_loop.py +592 -0
  267. package/scripts/evidence_tier.py +298 -0
  268. package/scripts/except_sets.py +25 -0
  269. package/scripts/extract.py +55 -16
  270. package/scripts/fact_align.py +207 -0
  271. package/scripts/fact_crosscheck.py +230 -0
  272. package/scripts/fetch.py +25 -12
  273. package/scripts/fetch_browser.py +87 -0
  274. package/scripts/fetch_jsrun.py +122 -0
  275. package/scripts/fetch_quality.py +225 -0
  276. package/scripts/fetch_render_tinyfish.py +117 -0
  277. package/scripts/fetch_v3.py +1779 -0
  278. package/scripts/focus_extract.py +350 -0
  279. package/scripts/fulltext_store.py +169 -0
  280. package/scripts/fxtwitter_api.py +325 -0
  281. package/scripts/gen_engine_catalog.py +364 -0
  282. package/scripts/gen_native_tools.py +91 -0
  283. package/scripts/gen_routing_snapshot.py +184 -0
  284. package/scripts/health_check.py +140 -402
  285. package/scripts/health_probe.py +23 -8
  286. package/scripts/hot_state.py +134 -0
  287. package/scripts/http_client.py +1053 -0
  288. package/scripts/image_ops.py +430 -0
  289. package/scripts/image_vision/vision_probe +0 -0
  290. package/scripts/image_vision/vision_probe.swift +126 -0
  291. package/scripts/install.ps1 +148 -0
  292. package/scripts/install.sh +206 -0
  293. package/scripts/job.py +1186 -0
  294. package/scripts/jump_resolver.py +235 -0
  295. package/scripts/lang_capability.py +150 -0
  296. package/scripts/lang_detect.py +289 -0
  297. package/scripts/lang_matrix_probe.py +329 -0
  298. package/scripts/lang_pref.py +311 -0
  299. package/scripts/link_source.py +340 -0
  300. package/scripts/local_engines.py +321 -0
  301. package/scripts/local_image.py +970 -0
  302. package/scripts/local_seek.py +366 -0
  303. package/scripts/macro_countries.py +82 -0
  304. package/scripts/matrix_search_eval.py +1105 -0
  305. package/scripts/mcp_diag.py +8 -3
  306. package/scripts/mcp_handlers.py +1074 -0
  307. package/scripts/mcp_handlers_surface.py +157 -0
  308. package/scripts/mcp_launch.sh +122 -0
  309. package/scripts/mcp_server.py +118 -417
  310. package/scripts/mcp_setup.py +407 -0
  311. package/scripts/mcp_tools.py +330 -0
  312. package/scripts/mcp_transport.py +191 -0
  313. package/scripts/min_version_scan.py +335 -0
  314. package/scripts/net_proxy.py +258 -0
  315. package/scripts/network_aware.py +152 -0
  316. package/scripts/paper.py +274 -0
  317. package/scripts/pdf_extract.py +518 -0
  318. package/scripts/phrase_filter.py +66 -0
  319. package/scripts/plan.py +422 -0
  320. package/scripts/postprocess_signals.py +150 -0
  321. package/scripts/pre-commit +48 -0
  322. package/scripts/query_enhance.py +157 -0
  323. package/scripts/query_operators.py +196 -0
  324. package/scripts/query_rewriter.py +116 -7
  325. package/scripts/query_signals.py +300 -0
  326. package/scripts/query_templates.py +167 -0
  327. package/scripts/query_understanding.py +411 -0
  328. package/scripts/query_variants.py +298 -0
  329. package/scripts/quota.py +488 -76
  330. package/scripts/rank_signals.py +775 -0
  331. package/scripts/ranking_eval.py +319 -0
  332. package/scripts/readability_extract.py +433 -0
  333. package/scripts/recompute.py +415 -0
  334. package/scripts/recovery.py +616 -0
  335. package/scripts/recovery_l0.py +160 -0
  336. package/scripts/redskill/redskill_engine.py +332 -0
  337. package/scripts/regression_p0p1.py +318 -0
  338. package/scripts/replay_eval.py +640 -0
  339. package/scripts/rerank_tfidf.py +225 -0
  340. package/scripts/research.py +591 -403
  341. package/scripts/research_cli.py +361 -0
  342. package/scripts/research_dossier.py +492 -0
  343. package/scripts/research_expand.py +146 -0
  344. package/scripts/research_gates.py +272 -0
  345. package/scripts/research_report.py +209 -0
  346. package/scripts/research_strategy.py +37 -0
  347. package/scripts/research_work_packages.py +195 -0
  348. package/scripts/result_lang.py +311 -0
  349. package/scripts/robots_guard.py +259 -0
  350. package/scripts/route.py +921 -254
  351. package/scripts/route_bangs.py +40 -0
  352. package/scripts/route_cache.py +225 -0
  353. package/scripts/route_combo.py +910 -0
  354. package/scripts/route_domains.py +328 -0
  355. package/scripts/route_lang.py +605 -0
  356. package/scripts/route_log.py +63 -0
  357. package/scripts/route_policy.py +250 -0
  358. package/scripts/rrf_classic.py +66 -0
  359. package/scripts/screenshot.py +73 -0
  360. package/scripts/search.py +897 -405
  361. package/scripts/search_attribution.py +88 -0
  362. package/scripts/search_benchmark.py +407 -0
  363. package/scripts/search_cli.py +627 -0
  364. package/scripts/search_entry.py +389 -0
  365. package/scripts/search_output.py +606 -0
  366. package/scripts/search_pipeline.py +1077 -0
  367. package/scripts/search_rank.py +991 -0
  368. package/scripts/search_types.py +27 -12
  369. package/scripts/seek_locator.py +68 -0
  370. package/scripts/semantic_evidence.py +215 -0
  371. package/scripts/serp_guard.py +228 -0
  372. package/scripts/serp_kill_log.py +51 -0
  373. package/scripts/single_flight.py +120 -0
  374. package/scripts/social_engines/__init__.py +10 -0
  375. package/scripts/social_engines/bilibili_engine.py +131 -0
  376. package/scripts/social_engines/hackernews_engine.py +113 -0
  377. package/scripts/social_engines/reddit_engine.py +172 -0
  378. package/scripts/social_engines/twitter_engine.py +397 -0
  379. package/scripts/social_engines/v2ex_engine.py +101 -0
  380. package/scripts/social_engines/weibo_engine.py +128 -0
  381. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  382. package/scripts/social_engines/zhihu_engine.py +139 -0
  383. package/scripts/social_research.py +201 -0
  384. package/scripts/sogou_weixin_browser.py +318 -0
  385. package/scripts/source_ledger.py +249 -0
  386. package/scripts/stage_timing.py +58 -0
  387. package/scripts/stats_cli.py +95 -0
  388. package/scripts/straggler_policy.py +60 -0
  389. package/scripts/stylometry_detector.py +231 -0
  390. package/scripts/sync_backends.py +353 -0
  391. package/scripts/sync_installs.py +29 -0
  392. package/scripts/template_cli.py +127 -0
  393. package/scripts/text_lexicon.py +72 -0
  394. package/scripts/tfidf_router.py +109 -37
  395. package/scripts/time_utils.py +233 -0
  396. package/scripts/topic_research_profiles.py +643 -0
  397. package/scripts/train.py +737 -0
  398. package/scripts/tweet.py +596 -0
  399. package/scripts/url_canon.py +217 -0
  400. package/scripts/url_optimize.py +56 -0
  401. package/scripts/url_safety.py +236 -0
  402. package/scripts/usage_log.py +116 -0
  403. package/scripts/usage_log_clean.py +113 -0
  404. package/scripts/v2ex_nodes.py +311 -0
  405. package/scripts/variant_recall.py +188 -0
  406. package/scripts/watch.py +205 -0
  407. package/scripts/wechat_article.py +258 -0
  408. package/scripts/wide_research.py +402 -0
  409. package/scripts/wx.py +349 -0
  410. package/scripts/x_article_fetch.py +141 -0
  411. package/scripts/x_search.py +234 -0
  412. package/scripts/x_trending.py +189 -0
  413. package/scripts/yaml_load.py +67 -0
  414. package/sub-skills/ego-search/SKILL.md +253 -0
  415. package/sub-skills/ego-search/references/browser-runtime.md +43 -0
  416. package/sub-skills/ego-search/references/install.md +67 -0
  417. package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
  418. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  419. package/sub-skills/ego-search/scripts/merge.py +226 -0
  420. package/sub-skills/ego-search/scripts/quality.py +64 -0
  421. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  422. package/sub-skills/ego-search/scripts/safety.py +109 -0
  423. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  424. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  425. package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
  426. package/sub-skills/js-run/SKILL.md +67 -0
  427. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  428. package/sub-skills/js-run/scripts/shim.js +188 -0
  429. package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
  430. package/sub-skills/js-run/tests/test_jsrun.py +165 -0
  431. package/sub-skills/local-search/SKILL.md +50 -53
  432. package/sub-skills/local-search/bm25.py +162 -0
  433. package/sub-skills/local-search/config.yaml +152 -55
  434. package/sub-skills/local-search/engine_registry.py +91 -26
  435. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  436. package/sub-skills/local-search/local_search_adapter.py +13 -2
  437. package/sub-skills/local-search/parse_maps.yaml +40 -42
  438. package/sub-skills/local-search/search_v3.py +1182 -65
  439. package/sub-skills/local-search/smart_router.py +47 -7
  440. package/sub-skills/local-seek/SKILL.md +110 -0
  441. package/sub-skills/local-seek/config/domains.yaml +20 -0
  442. package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
  443. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
  444. package/sub-skills/local-seek/references/git-integration.md +54 -0
  445. package/sub-skills/local-seek/references/remote-handoff.md +46 -0
  446. package/sub-skills/local-seek/references/rg-recipes.md +96 -0
  447. package/sub-skills/local-seek/references/strategies.md +52 -0
  448. package/sub-skills/local-seek/references/structural-search.md +59 -0
  449. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  450. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  451. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  452. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  453. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  454. package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
  455. package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
  456. package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
  457. package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
  458. package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
  459. package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
  460. package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
  461. package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
  462. package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
  463. package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
@@ -0,0 +1,730 @@
1
+ """Argo 内容质量信号模块 — 内联移植自 Hound envelope.py。
2
+
3
+ 4 个纯计算信号 + 1 个整合入口,全部基于 stdlib + re:
4
+ classify_source 域名权威分类
5
+ compute_freshness 时效性(年龄 + stale)
6
+ detect_page_type 页面结构类型
7
+ compute_content_quality 内容质量评分
8
+ score_clickbait 标题党检测(2026-09-27 新增)
9
+ score_title_body_consistency 标题-正文一致性(2026-09-27 新增)
10
+ score_template_repetition 句法模板重复率(2026-09-27 新增)
11
+ 主入口:analyze_fetch_result(url, html, content, metadata) -> dict。
12
+ 零外部依赖,单次调用微秒级。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import functools
18
+ from datetime import datetime, date, timezone
19
+ from typing import Any
20
+ from urllib.parse import urlparse
21
+ from cli_io import dumps
22
+
23
+ # ── 全局常量 ────────────────────────────────────────────────────────
24
+
25
+ STALE_DAYS = 365
26
+
27
+ # 判定为 article 的最小正文字数(正文充分性阈值)。
28
+ # 供 detect_page_type 的 markdown-only 回退使用,避免与 fetch 判定漂移。
29
+ MIN_ARTICLE_CHARS = 200
30
+
31
+ _NEWS_DOMAINS = (
32
+ "nytimes.com", "bbc.com", "bbc.co.uk", "reuters.com", "theguardian.com",
33
+ "washingtonpost.com", "bloomberg.com", "apnews.com", "aljazeera.com",
34
+ "cnbc.com", "ft.com", "economist.com", "techcrunch.com", "theverge.com",
35
+ "arstechnica.com", "wired.com", "nature.com", "science.org",
36
+ )
37
+ _QA_DOMAINS = (
38
+ "stackoverflow.com", "stackexchange.com", "serverfault.com",
39
+ "superuser.com", "mathoverflow.com", "askubuntu.com",
40
+ )
41
+ _GITHUB_DOMAINS = (
42
+ "github.com", "raw.githubusercontent.com", "gist.github.com",
43
+ )
44
+
45
+ _DATE_FORMATS = (
46
+ "%Y-%m-%d", "%Y/%m/%d", "%B %d, %Y", "%b %d, %Y",
47
+ "%d %B %Y", "%d %b %Y",
48
+ )
49
+
50
+ _FORUM_MARKERS = (
51
+ "phpbb", "discourse", 'class="forum', 'id="forum',
52
+ 'class="thread', 'class="post-body', 'class="message-body', "data-post-id",
53
+ )
54
+ _QA_MARKERS = (
55
+ "stackoverflow", "stackexchange", 'class="question',
56
+ 'class="answer', "data-answerid", "data-questionid",
57
+ )
58
+ _DOCS_MARKERS = (
59
+ "mkdocs", "docusaurus", "readthedocs", "sphinx-document",
60
+ "algolia-docsearch", "md-nav", "theme-doc", 'class="rst-content"',
61
+ "wy-nav-side",
62
+ )
63
+ _PAYWALL_MARKERS = (
64
+ "subscribe to continue", "subscribe to read",
65
+ "this article is for subscribers",
66
+ "create a free account to continue", "sign in to continue reading",
67
+ "you've reached your free article limit", "subscriber-only content",
68
+ "premium content", "paywall",
69
+ )
70
+
71
+ _META_REFRESH_RE = re.compile(
72
+ r'<meta\b[^>]*?http-equiv=["\']refresh["\'][^>]*?content=["\'][^"\']*url=',
73
+ re.IGNORECASE,
74
+ )
75
+ _JS_REDIRECT_RE = re.compile(
76
+ r'(?:location\.href\s*=|location\.replace|window\.location\s*=)',
77
+ re.IGNORECASE,
78
+ )
79
+ _ANCHOR_RE = re.compile(r'<a\b[^>]*?href=["\']([^"\']+)["\']', re.IGNORECASE)
80
+ _STRIP_BLOCK_RE = re.compile(
81
+ r'<(nav|header|footer|aside|script|style|noscript)\b[^>]*>.*?</\1>',
82
+ re.IGNORECASE | re.DOTALL,
83
+ )
84
+ _ARTICLE_TAG_RE = re.compile(r'<article\b', re.IGNORECASE)
85
+ _TAG_RE = re.compile(r"<[^>]+>", re.DOTALL)
86
+ _WS_RE = re.compile(r"\s+")
87
+
88
+
89
+ # ── 1. 域名权威分类 ─────────────────────────────────────────────────
90
+
91
+ def classify_source(url: str) -> dict:
92
+ """URL 域名权威分类。
93
+
94
+ 返回 {"source_type", "is_official"}。
95
+ source_type: gov/edu/github/news/blog/forum/qa/docs-site/ecommerce/unknown
96
+ is_official: 仅对 .gov/.edu/github/厂商 docs 判定 True。
97
+ """
98
+ if not url:
99
+ return {"source_type": "unknown", "is_official": False}
100
+ try:
101
+ host = urlparse(url).netloc.lower()
102
+ except Exception:
103
+ return {"source_type": "unknown", "is_official": False}
104
+ if "@" in host:
105
+ host = host.rsplit("@", 1)[1]
106
+ if ":" in host:
107
+ host = host.split(":", 1)[0]
108
+ if not host:
109
+ return {"source_type": "unknown", "is_official": False}
110
+
111
+ if host.endswith(".gov") or host == "gov" or ".gov." in host:
112
+ return {"source_type": "gov", "is_official": True}
113
+ if host.endswith(".edu") or host.endswith(".ac.uk") or re.search(r"\.ac\.[a-z]{2}$", host):
114
+ return {"source_type": "edu", "is_official": True}
115
+ if host in _GITHUB_DOMAINS or host.endswith(".github.io"):
116
+ return {"source_type": "github", "is_official": True}
117
+ if host.startswith(("docs.", "developer.", "developers.")):
118
+ return {"source_type": "docs-site", "is_official": True}
119
+ if host in _QA_DOMAINS or host.endswith(".stackexchange.com") or host.endswith(".stackoverflow.com"):
120
+ return {"source_type": "qa", "is_official": False}
121
+ if any(m in host for m in ("forum", "forums", "community", "discourse", "board")):
122
+ return {"source_type": "forum", "is_official": False}
123
+ if host in ("reddit.com", "www.reddit.com", "old.reddit.com", "new.reddit.com") or host.endswith(".reddit.com"):
124
+ return {"source_type": "forum", "is_official": False}
125
+ if (
126
+ host.startswith("blog.")
127
+ or host in ("medium.com", "wordpress.com", "substack.com")
128
+ or host.endswith((".substack.com", ".medium.com"))
129
+ ):
130
+ return {"source_type": "blog", "is_official": False}
131
+ if host.startswith(("shop.", "store.")) or host in ("amazon.com", "ebay.com") or host.endswith(".shop"):
132
+ return {"source_type": "ecommerce", "is_official": False}
133
+ if any(host == d or host.endswith("." + d) for d in _NEWS_DOMAINS):
134
+ return {"source_type": "news", "is_official": False}
135
+ return {"source_type": "unknown", "is_official": False}
136
+
137
+
138
+ # ── 2. 时效性 ────────────────────────────────────────────────────────
139
+
140
+ def _parse_date(s: str) -> date | None:
141
+ """解析日期:ISO 8601 (含 Z/offset)、压缩 YYYYMMDD、英文格式。"""
142
+ if not s or not s.strip():
143
+ return None
144
+ s = s.strip()
145
+ if re.fullmatch(r"\d{8}", s):
146
+ try:
147
+ return datetime.strptime(s, "%Y%m%d").date()
148
+ except ValueError:
149
+ return None
150
+ for cand in (s, s[:10]):
151
+ try:
152
+ return datetime.fromisoformat(cand.replace("Z", "+00:00")).date()
153
+ except ValueError:
154
+ continue
155
+ for fmt in _DATE_FORMATS:
156
+ for cand in (s, s[:32]):
157
+ try:
158
+ return datetime.strptime(cand, fmt).date()
159
+ except ValueError:
160
+ continue
161
+ return None
162
+
163
+
164
+ def compute_freshness(meta_dates: dict, fetched_at: str = None) -> dict:
165
+ """计算内容年龄。
166
+
167
+ 输入 {"published_time", "modified_time", "date"};
168
+ 偏好 modified > published > date;
169
+ 返回 {"content_age_days", "is_stale"},无日期返回 (-1, False)。
170
+ """
171
+ if not meta_dates:
172
+ return {"content_age_days": -1, "is_stale": False}
173
+ date_str = (
174
+ meta_dates.get("modified_time")
175
+ or meta_dates.get("published_time")
176
+ or meta_dates.get("date")
177
+ or ""
178
+ )
179
+ content_date = _parse_date(date_str) if date_str else None
180
+ if content_date is None:
181
+ return {"content_age_days": -1, "is_stale": False}
182
+ fetched_date = _parse_date(fetched_at) if fetched_at else datetime.now(timezone.utc).date()
183
+ delta = (fetched_date - content_date).days
184
+ if delta < 0:
185
+ return {"content_age_days": -1, "is_stale": False}
186
+ return {"content_age_days": delta, "is_stale": delta > STALE_DAYS}
187
+
188
+
189
+ # ── 3. 页面结构类型检测 ─────────────────────────────────────────────
190
+
191
+ def _count_content_links(html: str, host: str) -> int:
192
+ """统计主内容区同域链接数(剥除 nav/header/footer 等 chrome)。"""
193
+ stripped = _STRIP_BLOCK_RE.sub("", html)
194
+ count = 0
195
+ for m in _ANCHOR_RE.finditer(stripped):
196
+ href = (m.group(1) or "").strip()
197
+ if not href:
198
+ continue
199
+ low = href.lower()
200
+ if low.startswith(("#", "mailto:", "tel:", "javascript:")):
201
+ continue
202
+ if href.startswith(("/", "?")):
203
+ count += 1
204
+ continue
205
+ try:
206
+ h = urlparse(href).netloc.lower()
207
+ except Exception:
208
+ continue
209
+ if h and (h == host or h.endswith("." + host)):
210
+ count += 1
211
+ return count
212
+
213
+
214
+ def detect_page_type(html: str, url: str = "", content: str = "") -> dict:
215
+ """从 HTML 判断页面结构类型。
216
+
217
+ 返回 {"page_type", "confidence"}。
218
+ page_type: article/list/forum/qa/docs/paywall/redirect/unknown。
219
+ 错误推导信号(js_shell/auth_wall)不在此处检测——由上层错误回调 override。
220
+
221
+ content 参数用于 markdown-only 源(.md 变体 / tinyfish 渲染层)回退:
222
+ 这类源没有原始 HTML,docs / list / forum 等结构信号全部缺失,只能按
223
+ 正文字数判「正文充分」,confidence 相应压低(0.4)以反映判定降级。
224
+ """
225
+ if not html or not html.strip():
226
+ if content and len(content) > MIN_ARTICLE_CHARS:
227
+ return {"page_type": "article", "confidence": 0.4}
228
+ return {"page_type": "unknown", "confidence": 0.0}
229
+ low = html.lower()
230
+ # 跳转
231
+ if _META_REFRESH_RE.search(low):
232
+ return {"page_type": "redirect", "confidence": 0.9}
233
+ text_len_approx = len(re.sub(r"<[^>]+>", "", low))
234
+ if _JS_REDIRECT_RE.search(low) and text_len_approx < 500:
235
+ return {"page_type": "redirect", "confidence": 0.85}
236
+ # paywall
237
+ if any(m in low for m in _PAYWALL_MARKERS):
238
+ return {"page_type": "paywall", "confidence": 0.9}
239
+ # 结构化标记
240
+ if any(m in low for m in _QA_MARKERS):
241
+ return {"page_type": "qa", "confidence": 0.85}
242
+ if any(m in low for m in _FORUM_MARKERS):
243
+ return {"page_type": "forum", "confidence": 0.8}
244
+ if any(m in low for m in _DOCS_MARKERS):
245
+ return {"page_type": "docs", "confidence": 0.85}
246
+ # list 页:多同域链接 + 文本少
247
+ host = ""
248
+ if url:
249
+ try:
250
+ host = urlparse(url).netloc.lower().split(":", 1)[0]
251
+ except Exception:
252
+ host = ""
253
+ if host and not _ARTICLE_TAG_RE.search(low):
254
+ n_links = _count_content_links(html, host)
255
+ if n_links >= 20 and (text_len_approx < 1500 or text_len_approx / n_links < 200):
256
+ return {"page_type": "list", "confidence": 0.75}
257
+ # article
258
+ if _ARTICLE_TAG_RE.search(low):
259
+ return {"page_type": "article", "confidence": 0.8}
260
+ return {"page_type": "unknown", "confidence": 0.4}
261
+
262
+
263
+ # ── 4. 内容质量评分 ─────────────────────────────────────────────────
264
+
265
+ # GEO 实证:数字/定义/对比/how-to 与吸收深度正相关;纯 Q&A 格式无优势(甚至略负)
266
+ _NUM_RE = re.compile(
267
+ r"(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s*(?:%|%|亿|万|万亿|pct|bp|元|美元|吨|倍)"
268
+ r"|(?:环比|同比|较上[季年]度?)[^\n]{0,12}[+\-+-]?\d"
269
+ r"|Q[1-4]\b|20\d{2}\s*年",
270
+ re.I,
271
+ )
272
+ _DEF_RE = re.compile(
273
+ r"(?:是指|定义为|所谓|即指|指的是|是一种|可定义为"
274
+ r"|definition of|is defined as|refers to)",
275
+ re.I,
276
+ )
277
+ _CMP_RE = re.compile(
278
+ r"(?:对比|比较|相较|相比|环比|同比|分别|vs\.?|versus|versus|"
279
+ r"增持|减持|上升|下降|提升|回落|高于|低于|超过|不及)",
280
+ re.I,
281
+ )
282
+ _HOWTO_RE = re.compile(
283
+ r"(?:步骤|如何|怎么做|操作建议|方法如下|第[一二三四五六七八九十1-9][步、.]"
284
+ r"|step\s*\d|how to|tutorial)",
285
+ re.I,
286
+ )
287
+ _QA_FMT_RE = re.compile(
288
+ r"(?:^|\n)\s*(?:Q\s*[::]|A\s*[::]|问\s*[::]|答\s*[::])"
289
+ r"|class=[\"']question[\"']|class=[\"']answer[\"']"
290
+ r"|(?:怎么样|好不好|靠谱吗)\s*$",
291
+ re.I | re.M,
292
+ )
293
+ _DISCLOSE_RE = re.compile(
294
+ r"(?:截至|根据|数据显示|研究报告|披露|公告|季报|年报|来源[::])",
295
+ re.I,
296
+ )
297
+
298
+
299
+ @functools.lru_cache(maxsize=512)
300
+ def _score_evidence_density_cached(text: str, title: str = "") -> tuple:
301
+ """score_evidence_density 的缓存版本:纯函数(仅 regex + 长度判定,
302
+ 无时间/全局状态),返回元组以避免 lru_cache 缓存可变 dict 被调用侧
303
+ 意外修改——与 score_authority 的缓存形态统一。"""
304
+ body = f"{title or ''}\n{text or ''}"
305
+ has_numbers = bool(_NUM_RE.search(body))
306
+ has_definition = bool(_DEF_RE.search(body))
307
+ has_comparison = bool(_CMP_RE.search(body))
308
+ has_howto = bool(_HOWTO_RE.search(body))
309
+ has_disclose = bool(_DISCLOSE_RE.search(body))
310
+ is_qa_format = bool(_QA_FMT_RE.search(body)) or bool(
311
+ re.search(r"[??]\s*$", (title or "").strip())
312
+ )
313
+
314
+ score = 0.15
315
+ if has_numbers:
316
+ score += 0.22
317
+ if has_definition:
318
+ score += 0.18
319
+ if has_comparison:
320
+ score += 0.16
321
+ if has_howto:
322
+ score += 0.12
323
+ if has_disclose:
324
+ score += 0.08
325
+ if len(body) >= 80:
326
+ score += 0.05
327
+ if is_qa_format:
328
+ score -= 0.08 # GEO: 纯 Q&A 格式平均吸收略负
329
+
330
+ absorption = round(min(max(score, 0.0), 1.0), 3)
331
+ return (has_numbers, has_definition, has_comparison, has_howto,
332
+ has_disclose, is_qa_format, absorption)
333
+
334
+
335
+ def score_evidence_density(text: str, title: str = "") -> dict:
336
+ """证据密度评分(snippet 或正文均可)。
337
+
338
+ 第一性:Agent 需要的不是「被检索到」,而是「可抽取、可核对的证据块」。
339
+ 返回布尔特征 + absorption_score ∈ [0,1]。
340
+
341
+ 结果按 (text, title) 做进程内 lru_cache:rerank / selection / evidence
342
+ 打分 / compute_content_quality 间共享,避免同一 snippet 反复跑六次
343
+ 正则提取。调用契约不变(返回新 dict)。
344
+ """
345
+ has_numbers, has_definition, has_comparison, has_howto, \
346
+ has_disclose, is_qa_format, absorption = _score_evidence_density_cached(
347
+ text, title)
348
+ return {
349
+ "has_numbers": has_numbers,
350
+ "has_definition": has_definition,
351
+ "has_comparison": has_comparison,
352
+ "has_howto": has_howto,
353
+ "has_disclose": has_disclose,
354
+ "is_qa_format": is_qa_format,
355
+ "absorption_score": absorption,
356
+ }
357
+
358
+
359
+ def compute_content_quality(content: str, title: str = "") -> dict:
360
+ """去 HTML 后文本质量评分。
361
+
362
+ 返回 quality_score / content_ok / word_count / text_density / has_structure
363
+ + 证据密度字段(GEO 吸收信号)。
364
+ content_ok = quality_score > 0.3 且 word_count > 50。
365
+ """
366
+ clean = _TAG_RE.sub(" ", content or "")
367
+ clean = _WS_RE.sub(" ", clean).strip()
368
+ word_count = len(clean)
369
+ text_len = len(clean.replace(" ", ""))
370
+ raw_len = max(len(content or ""), 1)
371
+ text_density = text_len / raw_len
372
+ has_structure = bool(
373
+ re.search(r"</?(p|li|h[1-6]|pre|blockquote|section|div)\b", content or "", re.IGNORECASE)
374
+ )
375
+ # 长度分:50-1500 字线性增长(降权,避免 SEO 水文仅靠长度胜出)
376
+ length_score = min(max((word_count - 50) / 1000, 0.0), 1.0)
377
+ density_score = min(text_density / 0.5, 1.0)
378
+ structure_score = 0.2 if has_structure else 0.0
379
+ title_bonus = 0.0
380
+ if title:
381
+ title_words = [w for w in _WS_RE.split(title.lower()) if len(w) > 1]
382
+ if title_words:
383
+ hits = sum(1 for w in title_words if w in clean.lower())
384
+ title_bonus = min(hits / len(title_words), 1.0) * 0.1
385
+
386
+ evidence = score_evidence_density(clean, title)
387
+ # 权重:长度 0.2 + 密度 0.2 + 结构 0.2 + 证据 0.3 + 标题保持一致 0.1
388
+ quality_score = min(
389
+ length_score * 0.2
390
+ + density_score * 0.2
391
+ + structure_score
392
+ + evidence["absorption_score"] * 0.3
393
+ + title_bonus,
394
+ 1.0,
395
+ )
396
+ return {
397
+ "quality_score": round(quality_score, 3),
398
+ "content_ok": quality_score > 0.3 and word_count > 50,
399
+ "word_count": word_count,
400
+ "text_density": round(text_density, 3),
401
+ "has_structure": has_structure,
402
+ **evidence,
403
+ }
404
+
405
+
406
+ # ── 5. SEO/低质内容信号(2026-09-27 新增)───────────────────────────
407
+ #
408
+ # 三个互补信号,全部纯规则、零依赖,服务于「主题相关但质量低」的内容——
409
+ # 这类内容能骗过 serp_guard(token 交集高)也能骗过长度型完整性分。
410
+ #
411
+ # 文献依据(详见 research/argo-低质内容检测调研.md):
412
+ # - Chang & Huang, PACLIC 2024:中文标题党的四类语言特征,并明确警告
413
+ # 「单纯数标点会误杀」——故本实现要求悬念/夸张词与标点**共现**才算
414
+ # - Google Search Quality Rater Guidelines 4.0/5.0:把「title extremely
415
+ # misleading, shocking, or exaggerated」列为独立的最低质量判据
416
+ # - 百度《违规低质页面问题说明》:「资源内容不符」(内容与标题不一致)
417
+ # - Shaib et al., EMNLP 2024:模板重复率(模型 76% vs 人类 35%)
418
+
419
+ # 中文停用字:高频但无区分度。与 search_rank._CJK_STOPCHARS 同表——
420
+ # 两处独立维护是为了避开反向依赖(search_rank 在 import 链上游,
421
+ # content_signals 不能被它反向依赖),分叉由测试的一致性用例发现。
422
+ _ZH_STOPCHARS: frozenset[str] = frozenset(
423
+ "的了是和与或在有为被把对从到就都也还很更最之其此这那一个"
424
+ "个们我你他她它上下中里外前后时和及等所可能够会要"
425
+ "怎幺么样如选哪个多少何呢吗吧啊哦呀嘛"
426
+ )
427
+
428
+ # PACLIC 2024 的悬念词/夸张词表(原文四类里可直接规则化的两类)。
429
+ # 刻意保持小规模(<20 词):词表越长越容易误伤正常标题。
430
+ _ZH_SUSPENSE_WORDS = ("疑", "曝", "露", "公開", "揭秘", "内幕", "真相", "原来")
431
+ _ZH_EXAGGERATED_WORDS = ("震惊", "驚", "轟", "必看", "绝对", "史上最", "第一", "唯一",
432
+ "彻底", "惊呆", "炸了", "翻车", "逆天")
433
+ # 前指代词/列表数字:制造 curiosity gap 与 listicle 结构
434
+ _ZH_FORWARD_REFS = ("这", "那", "他", "她", "它", "此", "该")
435
+
436
+
437
+ def score_clickbait(title: str) -> dict:
438
+ """标题党打分(0-1,越高越像标题党)。
439
+
440
+ 2026-09-27 标定(tests/golden/lowquality_calibration.json,14 条好/坏对照)
441
+ 量化出两件事,直接决定了这个函数的形态:
442
+
443
+ 1. **纯词表对现代农场标题近乎无效**。标定集 7 条低质样本里 6 条得 0.00,
444
+ 只有 2020 年代的老式标题党(震惊!…99% 的人都不知道)被抓到。2026 年
445
+ 的 SEO 写法是「2026最新推荐10款,看完这篇你就懂了」——一个词表都不沾。
446
+ 2. **单纯换结构模式也不够**。试过疑问尾钩/数字开头/年份/比较声明/利益承诺/
447
+ 最高级/破折号/副标题八种模式,各自的区分度都在噪声附近,且
448
+ 「疑问尾钩」把正常的技术文标题("Python 性能优化从哪开始?")误伤。
449
+
450
+ 故现在的形态是**词汇与结构取并集、且两者都弱**:词表命中给 0.30/0.35,
451
+ 结构模式每个只给 0.08-0.12。理由见下——
452
+
453
+ 这个信号在标定集上 AUC 仅 0.643,**它本就该是弱信号**。它的价值不在
454
+ 独立判别(做不到),而在当它与 title_body / 来源侧证据**同时**命中时提高
455
+ 置信度。因此宁可漏放也不误杀:单信号误伤的代价(正常长文被判农场)
456
+ 高于漏放(反正还有别的信号兜着)。真要提升整体判别力,该加的是来源侧
457
+ 证据(见 rank_signals.cross_domain_homogeneity_penalty),不是在这里
458
+ 堆词表。
459
+
460
+ PACLIC 2024 的教训仍然生效:标点**不单独计分**,只作已有命中的放大器
461
+ (原文发现模型把「含感叹/疑问号的中文非标题党」过度泛化为标题党)。
462
+ """
463
+ t = (title or "").strip()
464
+ if not t:
465
+ return {"score": 0.0, "hits": [], "is_clickbait": False}
466
+
467
+ hits: list[str] = []
468
+ score = 0.0
469
+
470
+ # ── 词汇层:老式标题党仍能抓到,权重维持较高 ──
471
+ sus = [w for w in _ZH_SUSPENSE_WORDS if w in t]
472
+ exa = [w for w in _ZH_EXAGGERATED_WORDS if w in t]
473
+ if sus:
474
+ score += 0.30
475
+ hits.extend(sus)
476
+ if exa:
477
+ score += 0.35
478
+ hits.extend(exa)
479
+
480
+ # ── 结构层:现代农场写法,每个模式只给弱权重 ──
481
+ # 数字 listicle:「10款」「3款」等。不要求数字前有分隔符——「震惊!这3款…」
482
+ # 这种形态里数字紧跟量词,漏掉它就丢掉了最典型的一类现代标题党。
483
+ if re.search(r"\d+\s*(?:个|款|种|条|大|招|步|类)", t):
484
+ score += 0.12
485
+ hits.append("listicle")
486
+ # 分隔式堆砌:三段以上短语(内容农场标题的标准形态)
487
+ if len([s for s in re.split(r"[,,、||;;【】]", t) if s.strip()]) >= 3:
488
+ score += 0.12
489
+ hits.append("segmented")
490
+ # 伪新鲜:标题里的年份几乎从不参与正文判断,是农场刷新页的标志
491
+ if re.search(r"(?:19|20)\d{2}\s*年", t):
492
+ score += 0.08
493
+ hits.append("year_prefix")
494
+ # 利益承诺:「看完…你就会」「建议收藏」「一篇搞懂」
495
+ if re.search(r"(看完|建议收藏|建议收藏|一篇(?:讲|说|搞懂)|全解析|必看|建议收藏)", t):
496
+ score += 0.10
497
+ hits.append("benefit_promise")
498
+
499
+ # 标点:仅在已有命中时作**放大器**(PACLIC 的误杀教训)
500
+ if score > 0:
501
+ if "!" in t or "!" in t:
502
+ score += 0.12
503
+ hits.append("!")
504
+ if "?" in t or "?" in t:
505
+ score += 0.08
506
+ hits.append("?")
507
+ # 前指代词开头制造悬念
508
+ if t[0] in _ZH_FORWARD_REFS:
509
+ score += 0.10
510
+ hits.append("fwd_ref")
511
+
512
+ score = round(min(score, 1.0), 3)
513
+ return {"score": score, "hits": hits, "is_clickbait": score >= 0.5}
514
+
515
+
516
+ def score_title_body_consistency(title: str, content: str) -> dict:
517
+ """标题-正文一致性(0-1,越高越一致)。
518
+
519
+ 百度官方低质判据里的「资源内容不符」——标题承诺的主题在正文里找不到。
520
+ 纯规则实现:标题的信息单元(CJK bigram + 拉丁词)在正文里的覆盖率。
521
+ 标题党与洗稿站常在这里露馅:标题堆满热词,正文却是通用模板。
522
+ """
523
+ t = (title or "").strip()
524
+ if not t or not content:
525
+ return {"score": 0.5, "coverage": 0.0, "unmatched": [], "mismatch": False}
526
+ # 复用与 search_rank 同口径的单元划分(此处独立实现以避开循环导入:
527
+ # search_rank 已经在 import 链的上游,content_signals 不能被它反向依赖)
528
+ def _units(text: str) -> list[str]:
529
+ low = text.lower()
530
+ out: list[str] = []
531
+ for run in re.findall(r"[\u4e00-\u9fff]+", low):
532
+ kept = [ch for ch in run if ch not in _ZH_STOPCHARS]
533
+ if len(kept) == 1:
534
+ out.append(kept[0])
535
+ else:
536
+ out.extend(kept[i] + kept[i + 1] for i in range(len(kept) - 1))
537
+ out.extend(re.findall(r"[a-zA-Z0-9]+", low))
538
+ return out
539
+
540
+ tu = _units(t)
541
+ if not tu:
542
+ return {"score": 0.5, "coverage": 0.0, "unmatched": [], "mismatch": False}
543
+ # 误伤防护(2026-09-27):本信号只对**中文标题**判定。
544
+ # 理由:英文标题常是单个词或短短语("Gold" / "Test" / "Pricing"),
545
+ # 与正文的 bigram 交集天然稀疏,覆盖率恒低会误判为「文不对题」——
546
+ # 实测 tests/test_evidence_loop.py 的 mock(title="Test",正文为黄金行情
547
+ # 英文段落)覆盖率 0.0,直接把正常抓取压成 mismatch。
548
+ # 中文标题的信息单元密度高,覆盖率才有判别力;英文交给 clickbait 与
549
+ # template 两个信号处理。
550
+ if not re.search(r"[\u4e00-\u9fff]", t):
551
+ return {"score": 0.5, "coverage": 0.0, "unmatched": [], "mismatch": False,
552
+ "skipped": "non_cjk_title"}
553
+ # 标题过短(单元数 <3)时覆盖率噪声大,不判定
554
+ uniq_cjk = [u for u in dict.fromkeys(tu) if re.search(r"[\u4e00-\u9fff]", u)]
555
+ if len(uniq_cjk) < 3:
556
+ return {"score": 0.5, "coverage": 0.0, "unmatched": [], "mismatch": False,
557
+ "skipped": "title_too_short"}
558
+ body = set(_units(content))
559
+ uniq = list(dict.fromkeys(tu)) # 去重保序,避免长标题压倒短标题
560
+ hit = [u for u in uniq if u in body]
561
+ coverage = len(hit) / len(uniq)
562
+ unmatched = [u for u in uniq if u not in body][:8]
563
+ return {
564
+ "score": round(coverage, 3),
565
+ "coverage": round(coverage, 3),
566
+ "unmatched": unmatched,
567
+ # 阈值 0.5:过半数标题单元在正文里找不到 → 文不对题
568
+ "mismatch": coverage < 0.5,
569
+ }
570
+
571
+
572
+ def score_template_repetition(content: str) -> dict:
573
+ """句法模板重复率(0-1,越高越像批量生成/模板化内容)。
574
+
575
+ 依据 Shaib et al. (EMNLP 2024):LLM 生成文本的句法模板重复率显著高于
576
+ 人类(76% vs 35%),且微调后不被覆盖。此处用**粗粒度近似**(不做 POS
577
+ 标注,避免引入 NLP 工具):每句取「首二字 + 末二字 + 长度档位」作模板
578
+ 指纹,统计同一指纹在文内的重复率。
579
+
580
+ ⚠️ **2026-09-27 标定结论:这个近似在本仓的口径下没有判别力,勿依赖它。**
581
+
582
+ 标定集(14 条好/坏对照)实测 AUC = **0.500**——恰好等于随机猜测。逐条
583
+ 看:正常技术长文得 0.833,LLM 批量生成文本得 0.875,两者几乎不可分。
584
+ 原因不难理解:中文技术写作本身句式就高度平行(「X 通过 Y 实现 Z」、
585
+ 「需要注意的一点是」),首二字+末二字这个指纹在**人类**文本上同样高频
586
+ 重复。论文测的是英文 POS 模板,被搬到中文后失去了分辨力。
587
+
588
+ 又试了三个更贴近论文的廉价代理,全部落在噪声附近:
589
+ 标点序列模板 AUC=0.551 / 句首二字集中度 AUC=0.592 / 句长自相关 AUC=0.531
590
+
591
+ 结论:要做这件事需要真正的句法分析(POS 标注或依存句法),那会引入
592
+ 重依赖,与本仓「零外部依赖、单次调用微秒级」的纪律冲突。故**保留字段
593
+ 但降权到几乎不参与决策**(见 evidence_loop 的折扣系数),并在标定脚本
594
+ 里持续显示 AUC=0.5 作为「已知无效」的显式记录。将来若接入句法分析,
595
+ 这里是接入点,且标定集能立刻给出 before/after。
596
+ """
597
+ text = (content or "").strip()
598
+ if len(text) < 120:
599
+ return {"score": 0.0, "templates": 0, "sentences": 0, "repetition": 0.0}
600
+ # 分句:中英标点 + 换行
601
+ sents = [s.strip() for s in re.split(r"[。!?!?;;\n]+", text) if len(s.strip()) >= 6]
602
+ if len(sents) < 4:
603
+ return {"score": 0.0, "templates": len(sents), "sentences": len(sents),
604
+ "repetition": 0.0}
605
+ sigs: list[str] = []
606
+ for s in sents:
607
+ head = s[:2]
608
+ tail = s[-2:]
609
+ # 长度档位:粗化到 3 档,避免「模板相同但字数微差」被判为不同
610
+ bucket = "S" if len(s) < 25 else ("M" if len(s) < 60 else "L")
611
+ sigs.append(f"{head}|{tail}|{bucket}")
612
+ uniq = len(set(sigs))
613
+ repetition = 1.0 - uniq / len(sigs)
614
+ return {
615
+ "score": round(min(repetition, 1.0), 3),
616
+ "templates": uniq,
617
+ "sentences": len(sigs),
618
+ "repetition": round(repetition, 3),
619
+ }
620
+
621
+
622
+ # 中文停用字(与 search_rank._CJK_STOPCHARS 同表;此处独立维护以避开
623
+ # 反向依赖,两处若分叉由 tests/test_relevance_cjk.py 的一致性用例发现)
624
+
625
+
626
+ # ── 6. 整合入口 ─────────────────────────────────────────────────────
627
+
628
+ def analyze_fetch_result(url: str, html: str, content: str, metadata: dict | None = None) -> dict:
629
+ """综合所有信号返回完整质量信封。
630
+
631
+ 2026-09-29 新增:集成证据分层(evidence_tier)和来源账本(source_ledger),
632
+ 用于量化内容来源的可信度和新鲜度风险。
633
+ """
634
+ metadata = metadata or {}
635
+ title = metadata.get("title", "")
636
+
637
+ # 基础信号
638
+ source_info = classify_source(url)
639
+ quality_info = compute_content_quality(content, title)
640
+
641
+ # 证据分层
642
+ try:
643
+ from evidence_tier import assess_evidence_tier
644
+ has_date = bool(metadata.get("published_time") or metadata.get("modified_time"))
645
+ has_author = bool(metadata.get("author"))
646
+ has_citation = bool(re.search(r"(https?://|参考文献|references|资料来源)", content or "", re.IGNORECASE))
647
+ evidence = assess_evidence_tier(
648
+ url=url,
649
+ source_type=source_info.get("source_type", "unknown"),
650
+ is_official=source_info.get("is_official", False),
651
+ has_date=has_date,
652
+ has_author=has_author,
653
+ has_citation=has_citation,
654
+ content=content,
655
+ )
656
+ evidence_result = {
657
+ "tier": evidence.tier,
658
+ "tier_weight": evidence.tier_weight,
659
+ "notes": evidence.notes,
660
+ }
661
+ except ImportError:
662
+ evidence_result = {"tier": "unknown", "tier_weight": 0.0, "notes": []}
663
+
664
+ # 来源账本
665
+ try:
666
+ from source_ledger import SourceLedger, create_source_record
667
+ ledger = SourceLedger()
668
+ record = create_source_record(
669
+ url=url,
670
+ publisher=source_info.get("source_type", ""),
671
+ fetch_success=True,
672
+ http_status=200,
673
+ title=title,
674
+ content=content,
675
+ confidence_tier=evidence_result.get("tier", "D"),
676
+ )
677
+ ledger.add_source(record)
678
+ ledger_result = {
679
+ "freshness_risk": ledger.get_freshness_risk(url),
680
+ "access_risk": ledger.get_access_risk(url),
681
+ "overall_risk": ledger.get_overall_risk(url),
682
+ }
683
+ except ImportError:
684
+ ledger_result = {"freshness_risk": 0.0, "access_risk": 0.0, "overall_risk": 0.0}
685
+
686
+ # 文体特征检测(2026-09-29)
687
+ try:
688
+ from stylometry_detector import StylometryDetector
689
+ stylometry = StylometryDetector().score(content)
690
+ except ImportError:
691
+ stylometry = {"score": 0.0, "features": {}, "is_suspicious": False}
692
+
693
+ # 突发性检测(2026-09-29)
694
+ try:
695
+ from burstiness_detector import BurstinessDetector
696
+ burstiness = BurstinessDetector().score(content)
697
+ except ImportError:
698
+ burstiness = {"score": 0.0, "burstiness": {}, "is_suspicious": False}
699
+
700
+ return {
701
+ "source": source_info,
702
+ "freshness": compute_freshness(metadata),
703
+ "page_type": detect_page_type(html, url),
704
+ "quality": quality_info,
705
+ # SEO/低质信号(2026-09-27):只在有正文时才有意义,故随主信封一起算
706
+ "clickbait": score_clickbait(title),
707
+ "title_body": score_title_body_consistency(title, content),
708
+ "template": score_template_repetition(content),
709
+ # 证据分层 + 来源账本(2026-09-29)
710
+ "evidence_tier": evidence_result,
711
+ "source_ledger": ledger_result,
712
+ # 文体特征 + 突发性检测(2026-09-29)
713
+ "stylometry": stylometry,
714
+ "burstiness": burstiness,
715
+ }
716
+
717
+
718
+ if __name__ == "__main__":
719
+ test_url = "https://docs.python.org/3/library/asyncio.html"
720
+ body_text = "word " * 200
721
+ test_html = (
722
+ '<html><head><title>asyncio</title></head><body><article><h1>asyncio</h1>'
723
+ "<p>" + body_text + "</p>"
724
+ + '<a href="/library/a.html">a</a>' * 5
725
+ + '<nav><a href="/">home</a></nav>'
726
+ + "</article></body></html>"
727
+ )
728
+ test_content = body_text
729
+ test_meta = {"title": "asyncio", "published_time": "2024-01-15", "modified_time": "2025-06-01"}
730
+ print(dumps(analyze_fetch_result(test_url, test_html, test_content, test_meta)))