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.
- package/LICENSE +201 -21
- package/README.en.md +735 -0
- package/README.es.md +777 -0
- package/README.ja.md +777 -0
- package/README.ko.md +777 -0
- package/README.md +778 -320
- package/SKILL.md +135 -0
- package/assets/readme/hero.svg +121 -0
- package/assets/readme/made-with-beautify.svg +10 -0
- package/assets/readme/proof-routes.svg +73 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2356 -96
- package/backends/engine_registry.yaml +3428 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2488 -160
- package/backends/source_types_cn.json +109 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +772 -0
- package/bin/argo.js +102 -39
- package/config.yaml +5139 -367
- package/cordis.patch.yml +21 -0
- package/data/countries.json +40 -0
- package/data/lang_matrix/lang_capability.json +842 -0
- package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
- package/data/regions_cn.json +1 -0
- package/docs/ADDING_NEW_ENGINE.md +184 -0
- package/docs/ARGO_INTRO.md +124 -0
- package/docs/DATA_SOURCE_GAPS.md +81 -0
- package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
- package/docs/ENGINE_CATALOG.md +575 -0
- package/docs/MCP_SETUP.md +108 -0
- package/docs/PLATFORM_PATHS.md +111 -0
- package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
- package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
- package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
- package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
- package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
- package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
- package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
- package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
- package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
- package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
- package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
- package/docs/SEARCH_ARCHIVE.md +115 -0
- package/docs/assets/architecture.svg +104 -0
- package/docs/assets/hero.svg +98 -0
- package/docs/engines/bangumi.md +27 -0
- package/docs/engines/biorxiv.md +27 -0
- package/docs/engines/clawhub.md +27 -0
- package/docs/engines/crt_sh.md +27 -0
- package/docs/engines/datacite.md +27 -0
- package/docs/engines/deps_dev.md +27 -0
- package/docs/engines/douban_movie.md +27 -0
- package/docs/engines/endoflife.md +27 -0
- package/docs/engines/flk_law.md +27 -0
- package/docs/engines/fxtwitter.md +27 -0
- package/docs/engines/google_news.md +27 -0
- package/docs/engines/gov_regulations.md +27 -0
- package/docs/engines/guqian.md +27 -0
- package/docs/engines/hackernews.md +27 -0
- package/docs/engines/iplant.md +27 -0
- package/docs/engines/keenable.md +27 -0
- package/docs/engines/met_museum.md +27 -0
- package/docs/engines/nasa_images.md +27 -0
- package/docs/engines/netease_music.md +27 -0
- package/docs/engines/npm.md +27 -0
- package/docs/engines/nvd.md +27 -0
- package/docs/engines/openreview.md +27 -0
- package/docs/engines/openstd.md +27 -0
- package/docs/engines/parallel_free.md +27 -0
- package/docs/engines/people_daily.md +27 -0
- package/docs/engines/realtime_index.md +27 -0
- package/docs/engines/sec_edgar.md +27 -0
- package/docs/engines/seltz.md +27 -0
- package/docs/engines/skillsmp.md +27 -0
- package/docs/engines/sspai.md +27 -0
- package/docs/engines/std_samr.md +27 -0
- package/docs/engines/train.md +27 -0
- package/docs/engines/un_comtrade.md +27 -0
- package/docs/engines/wikisource.md +27 -0
- package/docs/engines/zdic.md +27 -0
- package/docs/engines/zenodo.md +27 -0
- package/docs/engines/zhihu_hot_app.md +27 -0
- package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
- package/engines/README.md +10 -0
- package/engines/_template_cli.yaml +39 -0
- package/engines/_template_http.yaml +37 -0
- package/engines/plugins/_template_plugin.py +50 -0
- package/engines/specs/_cisa_kev.yaml +54 -0
- package/engines/specs/_osv.yaml +63 -0
- package/engines/specs/aljazeera.yaml +43 -0
- package/engines/specs/antara.yaml +43 -0
- package/engines/specs/artic.yaml +20 -0
- package/engines/specs/aviation_weather.yaml +39 -0
- package/engines/specs/bangumi.yaml +21 -0
- package/engines/specs/biorxiv.yaml +24 -0
- package/engines/specs/carbon_intensity.yaml +32 -0
- package/engines/specs/cfets.yaml +41 -0
- package/engines/specs/cinii.yaml +44 -0
- package/engines/specs/clawhub.yaml +49 -0
- package/engines/specs/cleveland.yaml +19 -0
- package/engines/specs/cn_ai_news.yaml +43 -0
- package/engines/specs/core.yaml +35 -0
- package/engines/specs/crt_sh.yaml +38 -0
- package/engines/specs/ct24.yaml +43 -0
- package/engines/specs/datacite.yaml +27 -0
- package/engines/specs/datagov_uk.yaml +41 -0
- package/engines/specs/ddgs_images.yaml +45 -0
- package/engines/specs/deepseek_search.yaml +74 -0
- package/engines/specs/deezer.yaml +20 -0
- package/engines/specs/defillama.yaml +38 -0
- package/engines/specs/deps_dev.yaml +21 -0
- package/engines/specs/digup.yaml +36 -0
- package/engines/specs/doi.yaml +39 -0
- package/engines/specs/douban_movie.yaml +22 -0
- package/engines/specs/dw_news.yaml +47 -0
- package/engines/specs/egov_law.yaml +21 -0
- package/engines/specs/elpais.yaml +43 -0
- package/engines/specs/endoflife.yaml +21 -0
- package/engines/specs/energy_charts.yaml +21 -0
- package/engines/specs/factcheck_org.yaml +52 -0
- package/engines/specs/faz.yaml +43 -0
- package/engines/specs/federal_register.yaml +57 -0
- package/engines/specs/firecrawl.yaml +53 -0
- package/engines/specs/flk_law.yaml +24 -0
- package/engines/specs/folha.yaml +43 -0
- package/engines/specs/france24.yaml +44 -0
- package/engines/specs/full_fact.yaml +47 -0
- package/engines/specs/fxtwitter.yaml +36 -0
- package/engines/specs/gbfs_nyc.yaml +32 -0
- package/engines/specs/gdacs.yaml +21 -0
- package/engines/specs/gdelt.yaml +64 -0
- package/engines/specs/gleif.yaml +55 -0
- package/engines/specs/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -0
- package/engines/specs/hkex.yaml +40 -0
- package/engines/specs/hurriyet.yaml +43 -0
- package/engines/specs/iplant.yaml +25 -0
- package/engines/specs/jikan.yaml +24 -0
- package/engines/specs/jolpica.yaml +20 -0
- package/engines/specs/k10plus.yaml +20 -0
- package/engines/specs/keenable.yaml +44 -0
- package/engines/specs/keenable_free.yaml +36 -0
- package/engines/specs/lefigaro.yaml +43 -0
- package/engines/specs/listenbrainz.yaml +19 -0
- package/engines/specs/matichon.yaml +43 -0
- package/engines/specs/met_museum.yaml +21 -0
- package/engines/specs/nasa_images.yaml +45 -0
- package/engines/specs/netease_music.yaml +38 -0
- package/engines/specs/nhk.yaml +43 -0
- package/engines/specs/nhtsa_vpic.yaml +20 -0
- package/engines/specs/noaa_swpc.yaml +20 -0
- package/engines/specs/nvd.yaml +38 -0
- package/engines/specs/obis.yaml +20 -0
- package/engines/specs/opencitations.yaml +52 -0
- package/engines/specs/openf1.yaml +21 -0
- package/engines/specs/openfoodfacts.yaml +45 -0
- package/engines/specs/openligadb.yaml +20 -0
- package/engines/specs/openreview.yaml +37 -0
- package/engines/specs/openstd.yaml +21 -0
- package/engines/specs/people_daily.yaml +22 -0
- package/engines/specs/realtime_index.yaml +34 -0
- package/engines/specs/ror.yaml +20 -0
- package/engines/specs/satnogs.yaml +20 -0
- package/engines/specs/sec_edgar.yaml +46 -0
- package/engines/specs/shfe.yaml +39 -0
- package/engines/specs/skillsmp.yaml +50 -0
- package/engines/specs/sogou_weixin_browser.yaml +30 -0
- package/engines/specs/soilgrids.yaml +25 -0
- package/engines/specs/sspai.yaml +36 -0
- package/engines/specs/stackexchange.yaml +46 -0
- package/engines/specs/std_samr.yaml +21 -0
- package/engines/specs/tass.yaml +43 -0
- package/engines/specs/tavily_free.yaml +56 -0
- package/engines/specs/tle_mirror.yaml +21 -0
- package/engines/specs/train.yaml +37 -0
- package/engines/specs/tvmaze.yaml +20 -0
- package/engines/specs/twitter_syndication.yaml +31 -0
- package/engines/specs/un_comtrade.yaml +23 -0
- package/engines/specs/unpaywall.yaml +54 -0
- package/engines/specs/vnpress.yaml +43 -0
- package/engines/specs/weather.yaml +32 -0
- package/engines/specs/who_don.yaml +21 -0
- package/engines/specs/who_gho.yaml +20 -0
- package/engines/specs/wikimedia_commons.yaml +34 -0
- package/engines/specs/wikisource.yaml +22 -0
- package/engines/specs/worms.yaml +19 -0
- package/engines/specs/x_trending.yaml +34 -0
- package/engines/specs/yna.yaml +43 -0
- package/engines/specs/ynet.yaml +43 -0
- package/engines/specs/yuanbao.yaml +93 -0
- package/engines/specs/zbmath.yaml +40 -0
- package/engines/specs/zdic.yaml +22 -0
- package/engines/specs/zenodo.yaml +27 -0
- package/engines/specs/zhihu_hot_app.yaml +35 -0
- package/mcp/clients.yaml +106 -0
- package/package.json +55 -12
- package/packages/dsh-plugin/README.md +152 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1533 -0
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +527 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +749 -0
- package/packages/dsh-plugin/dsh/runner.js +666 -0
- package/packages/dsh-plugin/dsh/wide-research.js +878 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +406 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +1382 -0
- package/references/query-templates.md +96 -0
- package/references/query-templates.yaml +118 -0
- package/scripts/ab_eval.py +219 -0
- package/scripts/ab_eval_p0p1.py +266 -0
- package/scripts/adaptive.py +294 -70
- package/scripts/answer.py +154 -0
- package/scripts/archive_run.py +591 -0
- package/scripts/argo_engine_registry.py +172 -0
- package/scripts/argo_paths.py +728 -0
- package/scripts/article.py +136 -0
- package/scripts/article_meta.py +131 -0
- package/scripts/arxiv_source.py +201 -0
- package/scripts/badcase_ledger.py +252 -0
- package/scripts/batch_probe.py +241 -0
- package/scripts/benchmark.py +19 -0
- package/scripts/bounded_run.py +144 -0
- package/scripts/browser_auth.py +323 -0
- package/scripts/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1066 -67
- package/scripts/cache_guard.py +271 -0
- package/scripts/cache_key_vdom.py +56 -0
- package/scripts/cache_similarity.py +170 -0
- package/scripts/caliber_isolation.py +195 -0
- package/scripts/calibrate_lowquality.py +210 -0
- package/scripts/candidate_envelope.py +366 -0
- package/scripts/chrome_cdp.py +871 -0
- package/scripts/ci_drift_gate.py +90 -0
- package/scripts/circuit_breaker.py +389 -0
- package/scripts/citations.py +334 -0
- package/scripts/cite_cli.py +59 -0
- package/scripts/cjk_tokens.py +95 -0
- package/scripts/clarify.py +47 -24
- package/scripts/cli_io.py +240 -0
- package/scripts/config.py +683 -45
- package/scripts/config_cache.py +402 -0
- package/scripts/conn_pool.py +220 -0
- package/scripts/content_boundary.py +46 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +751 -0
- package/scripts/crawl.py +74 -35
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +982 -0
- package/scripts/engine_env.py +539 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +738 -0
- package/scripts/engine_policy.py +218 -0
- package/scripts/engine_requires.py +190 -0
- package/scripts/engine_status.py +322 -0
- package/scripts/engine_validate.py +638 -0
- package/scripts/engines.py +799 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +310 -0
- package/scripts/engines_builders_batch10.py +204 -0
- package/scripts/engines_builders_batch11.py +174 -0
- package/scripts/engines_builders_batch12.py +103 -0
- package/scripts/engines_builders_batch13.py +340 -0
- package/scripts/engines_builders_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -0
- package/scripts/engines_builders_batch9.py +1468 -0
- package/scripts/engines_builders_cn.py +2283 -0
- package/scripts/engines_builders_data.py +2923 -0
- package/scripts/engines_builders_data_macro.py +775 -0
- package/scripts/engines_builders_feeds.py +255 -0
- package/scripts/engines_builders_intl.py +1239 -0
- package/scripts/engines_builders_keyless.py +111 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1359 -0
- package/scripts/evidence.py +620 -166
- package/scripts/evidence_loop.py +624 -0
- package/scripts/evidence_tier.py +298 -0
- package/scripts/except_sets.py +25 -0
- package/scripts/extract.py +55 -16
- package/scripts/fact_align.py +207 -0
- package/scripts/fact_crosscheck.py +230 -0
- package/scripts/fetch.py +25 -12
- package/scripts/fetch_browser.py +87 -0
- package/scripts/fetch_jsrun.py +122 -0
- package/scripts/fetch_quality.py +225 -0
- package/scripts/fetch_render_tinyfish.py +117 -0
- package/scripts/fetch_v3.py +1804 -0
- package/scripts/focus_extract.py +350 -0
- package/scripts/fulltext_store.py +169 -0
- package/scripts/fxtwitter_api.py +325 -0
- package/scripts/gen_engine_catalog.py +364 -0
- package/scripts/gen_native_tools.py +91 -0
- package/scripts/gen_routing_snapshot.py +195 -0
- package/scripts/health_check.py +140 -402
- package/scripts/health_probe.py +23 -8
- package/scripts/hot_state.py +134 -0
- package/scripts/http_client.py +1053 -0
- package/scripts/image_ops.py +430 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +166 -0
- package/scripts/install.sh +221 -0
- package/scripts/job.py +1186 -0
- package/scripts/jump_resolver.py +235 -0
- package/scripts/lang_capability.py +150 -0
- package/scripts/lang_detect.py +289 -0
- package/scripts/lang_matrix_probe.py +329 -0
- package/scripts/lang_pref.py +311 -0
- package/scripts/link_source.py +340 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +402 -0
- package/scripts/macro_countries.py +97 -0
- package/scripts/matrix_search_eval.py +1187 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +492 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +124 -0
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +125 -419
- package/scripts/mcp_setup.py +431 -0
- package/scripts/mcp_tools.py +331 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/mcp_warm.py +127 -0
- package/scripts/min_version_scan.py +335 -0
- package/scripts/net_proxy.py +258 -0
- package/scripts/network_aware.py +152 -0
- package/scripts/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/paper.py +274 -0
- package/scripts/pdf_extract.py +518 -0
- package/scripts/phrase_filter.py +66 -0
- package/scripts/plan.py +422 -0
- package/scripts/postprocess_signals.py +150 -0
- package/scripts/pre-commit +48 -0
- package/scripts/query_enhance.py +157 -0
- package/scripts/query_operators.py +196 -0
- package/scripts/query_rewriter.py +116 -7
- package/scripts/query_signals.py +300 -0
- package/scripts/query_templates.py +167 -0
- package/scripts/query_understanding.py +411 -0
- package/scripts/query_variants.py +298 -0
- package/scripts/quota.py +633 -76
- package/scripts/rank_signals.py +775 -0
- package/scripts/ranking_eval.py +319 -0
- package/scripts/readability_extract.py +433 -0
- package/scripts/recompute.py +415 -0
- package/scripts/recovery.py +616 -0
- package/scripts/recovery_l0.py +160 -0
- package/scripts/recovery_runner.py +150 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +658 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +607 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +169 -0
- package/scripts/research_gates.py +272 -0
- package/scripts/research_report.py +209 -0
- package/scripts/research_strategy.py +37 -0
- package/scripts/research_work_packages.py +195 -0
- package/scripts/result_lang.py +311 -0
- package/scripts/retry_policy.py +76 -0
- package/scripts/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +243 -0
- package/scripts/route_combo.py +917 -0
- package/scripts/route_domains.py +341 -0
- package/scripts/route_lang.py +605 -0
- package/scripts/route_log.py +63 -0
- package/scripts/route_policy.py +250 -0
- package/scripts/rrf_classic.py +66 -0
- package/scripts/screenshot.py +73 -0
- package/scripts/search.py +886 -406
- package/scripts/search_attribution.py +185 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +640 -0
- package/scripts/search_entry.py +395 -0
- package/scripts/search_output.py +690 -0
- package/scripts/search_pipeline.py +926 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +129 -16
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +391 -0
- package/scripts/serp_guard.py +336 -0
- package/scripts/serp_kill_log.py +74 -0
- package/scripts/single_flight.py +120 -0
- package/scripts/social_engines/__init__.py +10 -0
- package/scripts/social_engines/bilibili_engine.py +131 -0
- package/scripts/social_engines/hackernews_engine.py +113 -0
- package/scripts/social_engines/reddit_engine.py +172 -0
- package/scripts/social_engines/twitter_engine.py +397 -0
- package/scripts/social_engines/v2ex_engine.py +101 -0
- package/scripts/social_engines/weibo_engine.py +128 -0
- package/scripts/social_engines/xiaohongshu_engine.py +137 -0
- package/scripts/social_engines/zhihu_engine.py +139 -0
- package/scripts/social_research.py +201 -0
- package/scripts/sogou_weixin_browser.py +318 -0
- package/scripts/source_ledger.py +249 -0
- package/scripts/stage_timing.py +58 -0
- package/scripts/stats_cli.py +107 -0
- package/scripts/straggler_policy.py +60 -0
- package/scripts/stylometry_detector.py +231 -0
- package/scripts/sync_backends.py +353 -0
- package/scripts/sync_installs.py +29 -0
- package/scripts/template_cli.py +127 -0
- package/scripts/text_lexicon.py +72 -0
- package/scripts/tfidf_router.py +109 -37
- package/scripts/time_utils.py +233 -0
- package/scripts/topic_research_profiles.py +643 -0
- package/scripts/train.py +737 -0
- package/scripts/tweet.py +596 -0
- package/scripts/url_canon.py +217 -0
- package/scripts/url_optimize.py +56 -0
- package/scripts/url_safety.py +236 -0
- package/scripts/usage_log.py +116 -0
- package/scripts/usage_log_clean.py +113 -0
- package/scripts/v2ex_nodes.py +311 -0
- package/scripts/variant_recall.py +188 -0
- package/scripts/watch.py +205 -0
- package/scripts/wechat_article.py +258 -0
- package/scripts/wide_research.py +447 -0
- package/scripts/wx.py +349 -0
- package/scripts/x_article_fetch.py +141 -0
- package/scripts/x_search.py +234 -0
- package/scripts/x_trending.py +189 -0
- package/scripts/yaml_load.py +67 -0
- package/scripts/zhihu_quota.py +139 -0
- package/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/scripts/ego_search.py +800 -0
- package/sub-skills/ego-search/scripts/merge.py +226 -0
- package/sub-skills/ego-search/scripts/quality.py +64 -0
- package/sub-skills/ego-search/scripts/runtime.py +172 -0
- package/sub-skills/ego-search/scripts/safety.py +109 -0
- package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
- package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
- package/sub-skills/js-run/SKILL.md +67 -0
- package/sub-skills/js-run/scripts/jsrun.py +112 -0
- package/sub-skills/js-run/scripts/shim.js +188 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +186 -0
- package/sub-skills/local-search/cli_runner.py +331 -0
- package/sub-skills/local-search/config.yaml +152 -55
- package/sub-skills/local-search/engine_registry.py +91 -26
- package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
- package/sub-skills/local-search/local_search_adapter.py +13 -2
- package/sub-skills/local-search/parse_maps.yaml +40 -42
- package/sub-skills/local-search/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +716 -242
- package/sub-skills/local-search/smart_router.py +47 -7
- package/sub-skills/local-seek/SKILL.md +110 -0
- package/sub-skills/local-seek/config/domains.yaml +20 -0
- package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
- package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
- package/sub-skills/local-seek/scripts/seek.py +1465 -0
- package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
- package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
- package/sub-skills/local-seek/scripts/seek_rank.py +197 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""rank_signals.py — 相关性/完整性算子与 SEO 对抗信号(纯函数层)。
|
|
3
|
+
|
|
4
|
+
2026-09-27 从 search_rank.py 拆出(该文件因本次改造触及 1000 行硬上限)。
|
|
5
|
+
拆分理由不是「文件太长」,而是**职责本就独立**:本模块只回答「这条结果
|
|
6
|
+
与该查询有多匹配、这份内容有多可信」,不碰融合、去重、语言偏好、截断等
|
|
7
|
+
排序流程——那些留在 search_rank.py。两者是「打分」与「调度」的分工。
|
|
8
|
+
|
|
9
|
+
设计约束(与 search_rank 一致):
|
|
10
|
+
- 零外部依赖,只用 stdlib + re,单次调用微秒级
|
|
11
|
+
- 纯函数,无全局可变状态(延迟编译的正则除外,只写一次)
|
|
12
|
+
- 所有开关走 ARGO_* 环境变量,默认开启,=0 退回旧行为
|
|
13
|
+
|
|
14
|
+
文献依据见 research/argo-低质内容检测调研.md;实证数据见
|
|
15
|
+
tests/test_relevance_cjk.py 的用例注释。
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from engine_env import get_env
|
|
26
|
+
except ImportError: # pragma: no cover - 独立导入时的兜底
|
|
27
|
+
def get_env(name):
|
|
28
|
+
if isinstance(name, str):
|
|
29
|
+
return os.environ.get(name)
|
|
30
|
+
for n in name:
|
|
31
|
+
v = os.environ.get(n)
|
|
32
|
+
if v:
|
|
33
|
+
return v
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _env_flag(name: str, default: bool = True) -> bool:
|
|
38
|
+
"""读 ARGO_* 开关:未设置取 default,=0/off/no/false 判 False。"""
|
|
39
|
+
try:
|
|
40
|
+
v = get_env(name)
|
|
41
|
+
except Exception:
|
|
42
|
+
v = os.environ.get(name)
|
|
43
|
+
if v is None or str(v).strip() == "":
|
|
44
|
+
return default
|
|
45
|
+
return str(v).strip().lower() not in ("0", "off", "no", "false", "disable", "disabled")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# 中文停用字:高频但无区分度。内容信号模块(content_signals._ZH_STOPCHARS)
|
|
49
|
+
# 有一份同表副本——刻意独立维护以避开反向依赖(search_rank / rank_signals
|
|
50
|
+
# 在 import 链上游),两表分叉由 tests/test_relevance_cjk.py 的一致性用例发现。
|
|
51
|
+
CJK_STOPCHARS: frozenset[str] = frozenset(
|
|
52
|
+
"的了是和与或在有为被把对从到就都也还很更最之其此这那一个"
|
|
53
|
+
"个们我你他她它上下中里外前后时和及等所可能够会要"
|
|
54
|
+
"怎幺么样如选哪个多少何呢吗吧啊哦呀嘛"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── 相关性算子(2026-09-27 重写)─────────────────────────────────────────────
|
|
59
|
+
#
|
|
60
|
+
# 旧实现把查询与文档都按「中文单字」切分后算覆盖率,三个结构性缺陷:
|
|
61
|
+
#
|
|
62
|
+
# 1. **关键词堆砌得分更高**。「颈椎病 枕头 推荐」切成 颈/椎/病/枕/头/推/荐
|
|
63
|
+
# 七个单字后,堆砌标题「颈椎枕头推荐_颈椎病枕头怎么选_枕头推荐颈椎病」
|
|
64
|
+
# 能全覆盖拿 0.650,而正常表述「颈椎病患者如何选择合适的枕头」只有
|
|
65
|
+
# 0.464 —— 奖励的正是内容农场唯一要刷的指标。
|
|
66
|
+
# 2. **单字命中即得分**。任何含「股」「价」二字的页面,在「贵州茅台股价」
|
|
67
|
+
# 这个查询上白拿 2/6 覆盖率,而「贵州」「茅台」作为一个词与单字「价」
|
|
68
|
+
# 等权。
|
|
69
|
+
# 3. **堆砌的长标题反而拉低不了分**。覆盖率的分母是查询 token 数、不惩罚
|
|
70
|
+
# 文档侧的冗余,所以「把查询词重复十遍」与「自然用一次」同分。
|
|
71
|
+
#
|
|
72
|
+
# 新算子:查询侧取**字符二元组(CJK)+ 小写词(拉丁数字)**,文档侧同样
|
|
73
|
+
# 处理,用**非对称覆盖率**(分母只算查询单元)。停用字不参与,避免
|
|
74
|
+
# 「怎么选」这类无信息词撑起分数。堆砌由 _title_stuffing_penalty 单独处理。
|
|
75
|
+
|
|
76
|
+
_WORD_RE = None
|
|
77
|
+
_CJK_RUN_RE = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def relevance_units(text: str) -> list[str]:
|
|
81
|
+
"""相关性比对单元(有序、含重复):CJK 字符二元组 + 小写拉丁/数字词。
|
|
82
|
+
|
|
83
|
+
**返回 list 而非 set**:`title_stuffing_penalty` 要数「同一单元在标题里
|
|
84
|
+
出现了几次」,set 会把重复折叠掉,堆砌检测就永不触发。需要集合语义的
|
|
85
|
+
调用方自己 `set(...)`。
|
|
86
|
+
|
|
87
|
+
与 serp_guard._extract_tokens 同口径(CJK bigram + 拉丁词),但那里是
|
|
88
|
+
「有无交集」的粗判,这里要算覆盖率,故独立成实现——serp_guard 刻意
|
|
89
|
+
不 import search_rank(冷启动热路径),与本模块也不共享代码。
|
|
90
|
+
"""
|
|
91
|
+
global _WORD_RE, _CJK_RUN_RE
|
|
92
|
+
if _WORD_RE is None:
|
|
93
|
+
_WORD_RE = re.compile(r"[a-zA-Z0-9]+")
|
|
94
|
+
_CJK_RUN_RE = re.compile(r"[\u4e00-\u9fff]+")
|
|
95
|
+
low = (text or "").lower()
|
|
96
|
+
units: list[str] = []
|
|
97
|
+
for run in _CJK_RUN_RE.findall(low):
|
|
98
|
+
# 先剔停用字再取 bigram:否则「怎么选枕头」里的字会与相邻字组成
|
|
99
|
+
# bigram 混进单元集合。
|
|
100
|
+
kept = [ch for ch in run if ch not in CJK_STOPCHARS]
|
|
101
|
+
if len(kept) == 1:
|
|
102
|
+
units.append(kept[0]) # 单字残段:补一个单元,避免整段丢失
|
|
103
|
+
else:
|
|
104
|
+
units.extend(kept[i] + kept[i + 1] for i in range(len(kept) - 1))
|
|
105
|
+
units.extend(_WORD_RE.findall(low))
|
|
106
|
+
return units
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def relevance_v2_enabled() -> bool:
|
|
110
|
+
"""相关性算子开关:ARGO_RELEVANCE_V2=0 退回旧单字覆盖率。"""
|
|
111
|
+
return _env_flag("ARGO_RELEVANCE_V2")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _coverage(units: set[str], title: str, snippet: str) -> tuple[float, float]:
|
|
115
|
+
"""返回 (title 覆盖率, snippet 覆盖率)。"""
|
|
116
|
+
t_units = set(relevance_units(title))
|
|
117
|
+
s_units = set(relevance_units(snippet))
|
|
118
|
+
t_cov = len(units & t_units) / len(units) if units else 0.0
|
|
119
|
+
s_cov = len(units & s_units) / len(units) if units else 0.0
|
|
120
|
+
return t_cov, s_cov
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def keyword_density(text: str, units: set[str]) -> float:
|
|
124
|
+
"""查询词密度:命中单元数 / 文档单元总数(0-1)。
|
|
125
|
+
|
|
126
|
+
**这个量与「命中次数」是不同的事,是本模块 2026-09-27 最重要的修正。**
|
|
127
|
+
|
|
128
|
+
原先只按「命中次数 / 查询单元数」判堆砌,那个体现在两个真实场景上都错:
|
|
129
|
+
|
|
130
|
+
① 触底太早。原惩罚下限 0.35,而实测堆砌样本普遍是 **5-8 倍**超额——
|
|
131
|
+
5 倍时惩罚正好触底,于是「5 倍堆砌」和「20 倍堆砌」拿到同一个系数。
|
|
132
|
+
内容农场只会越刷越多,不会刚好停在惩罚拐点上。
|
|
133
|
+
|
|
134
|
+
② 更根本的是:只看**标题**会漏掉正文堆砌,而只看**次数**会误杀正常文。
|
|
135
|
+
实测把正文并进来算「次数」:11 条 stuffing 样本里能罚到 7 条,
|
|
136
|
+
但 4 条正常 SEO 长文也被罚 3 条(专家评测/步骤文/FAQ 都会在每段
|
|
137
|
+
复述主题词,这很正常)。**次数分不出好坏,密度可以。**
|
|
138
|
+
|
|
139
|
+
标定数据(tests/golden/lowquality_calibration.json,25 条对照):
|
|
140
|
+
|
|
141
|
+
整体密度 AUC = 0.896 ← 本仓现有信号里最强(clickbait 0.685)
|
|
142
|
+
正文密度 AUC = 0.753
|
|
143
|
+
标题密度 AUC = 0.698
|
|
144
|
+
|
|
145
|
+
阈值取 **0.22** 而非 F1 最优点 0.08:0.08 虽 F1=0.839,但会误杀全部
|
|
146
|
+
4 条 SEO 对照组(正常文章本来就会提查询词)。0.22 是**零误杀前提下
|
|
147
|
+
的 F1 最优**(TP=9 / FP=0 / FN=5)。宁可漏放也不误伤:误伤的是正常
|
|
148
|
+
内容农场式 SEO 优化站,漏放的还有来源侧信号兜着。
|
|
149
|
+
"""
|
|
150
|
+
if not units or not text:
|
|
151
|
+
return 0.0
|
|
152
|
+
doc_units = relevance_units(text)
|
|
153
|
+
if not doc_units:
|
|
154
|
+
return 0.0
|
|
155
|
+
hits = sum(1 for u in doc_units if u in units)
|
|
156
|
+
return hits / len(doc_units)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# 关键词密度惩罚的门槛与下限(标定得出,见 keyword_density 的 docstring)
|
|
160
|
+
STUFFING_DENSITY_THRESHOLD = 0.22
|
|
161
|
+
STUFFING_DENSITY_FLOOR = 0.35
|
|
162
|
+
|
|
163
|
+
# 密度判据的**最小文档长度**(以相关性单元计)。与 serp_guard 的
|
|
164
|
+
# _MIN_QUERY_TOKENS 同一条纪律:比率型判据在分母太小时必然失真。
|
|
165
|
+
# 实测踩到的例子:标题「讨论:某技术选型」+ snippet「短」(1 个字),
|
|
166
|
+
# 合并单元只有 5 个而命中 3 个 → 密度 0.60,越过 0.22 阈值,
|
|
167
|
+
# 于是一条正常的共识条目被当成堆砌压掉,挤掉了多引擎共识排序
|
|
168
|
+
# (tests/test_ranking_contract.py 的不变式 2 当场变红)。
|
|
169
|
+
# 短 snippet 是 SERP 的常态(各家摘要长度不一),不是农场特征,故按长度
|
|
170
|
+
# 豁免而不是调高阈值——调高阈值会连带放过真正的短堆砌页
|
|
171
|
+
# (标定集里 stuff_low_query_echo 的 snippet 就很短)。
|
|
172
|
+
STUFFING_MIN_UNITS = 20
|
|
173
|
+
|
|
174
|
+
# 「关键词流」判据:空格分隔的词条串。
|
|
175
|
+
#
|
|
176
|
+
# 只看 **snippet**,不看合并文本——实测金标用例的标题是
|
|
177
|
+
# 「青藏高原 形成成因:板块 碰撞 的 结果」,标题里有「:」,拿合并文本判
|
|
178
|
+
# 会因为这一个标点而整条豁免掉,守卫直接失效(第一版就踩了这个)。
|
|
179
|
+
# 真正说明问题的是**摘要**:SERP 摘要被上游抽成词条时才会出现
|
|
180
|
+
# 「零标点 + 多空格」,而标题里的标点与摘要的抽取方式无关。
|
|
181
|
+
_KW_STREAM_MIN_SPACES = 2
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _looks_like_keyword_stream(snippet: str) -> bool:
|
|
185
|
+
"""摘要是否是被抽成词条的形态(多空格、零标点)。"""
|
|
186
|
+
if not snippet:
|
|
187
|
+
return False
|
|
188
|
+
if re.search(r"[。,、!?;:,.!?;:]", snippet):
|
|
189
|
+
return False
|
|
190
|
+
return snippet.count(" ") + snippet.count(" ") >= _KW_STREAM_MIN_SPACES
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def stuffing_density_penalty(title: str, snippet: str,
|
|
194
|
+
units: set[str]) -> float:
|
|
195
|
+
"""按整体关键词密度给堆砌折扣(≤1,只降不升)。
|
|
196
|
+
|
|
197
|
+
判据用 **title + snippet 合并后的整体密度**,不是标题密度:农场页的
|
|
198
|
+
堆砌主要发生在正文(标题被 SEO 插件限长),而正常长文的高密度来自
|
|
199
|
+
「每个小节都在讲这个主题」——那是内容的合理形态。
|
|
200
|
+
"""
|
|
201
|
+
if not units or not (title or snippet):
|
|
202
|
+
return 1.0
|
|
203
|
+
text = f"{title or ''} {snippet or ''}"
|
|
204
|
+
# 长度不足不判:比率型判据在分母太小时必然失真(见 STUFFING_MIN_UNITS)
|
|
205
|
+
if len(relevance_units(text)) < STUFFING_MIN_UNITS:
|
|
206
|
+
return 1.0
|
|
207
|
+
# 「关键词流」形态不判:SERP 摘要常被上游抽成空格分隔的词条
|
|
208
|
+
# (实测金标用例「青藏高原 形成成因 印度 板块 与 欧亚 板块 碰撞 隆起」)。
|
|
209
|
+
# 这种文本的每个 token 都是查询词,密度天然接近 1.0——但那是**上游抽取
|
|
210
|
+
# 方式**的产物,不是内容农场特征。判它错等于惩罚所有被抽成词条的正常
|
|
211
|
+
# 来源(金标 floor MRR 1.0 → 0.5 就是这么掉的)。
|
|
212
|
+
if _looks_like_keyword_stream(snippet or ""):
|
|
213
|
+
return 1.0
|
|
214
|
+
density = keyword_density(text, units)
|
|
215
|
+
if density < STUFFING_DENSITY_THRESHOLD:
|
|
216
|
+
return 1.0
|
|
217
|
+
# 密度 0.22 → ×1.0(不罚),线性降到 0.60 → ×0.35(地板)。
|
|
218
|
+
# 0.60 是实测最高的两条(query_echo 0.833、punctuation_spray 0.947)
|
|
219
|
+
# 之外的稳健上限,地板 0.35 与旧实现一致,避免一次改太多。
|
|
220
|
+
span = 0.60 - STUFFING_DENSITY_THRESHOLD
|
|
221
|
+
severity = min(1.0, (density - STUFFING_DENSITY_THRESHOLD) / span)
|
|
222
|
+
return round(1.0 - (1.0 - STUFFING_DENSITY_FLOOR) * severity, 4)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def title_stuffing_penalty(title: str, units: set[str]) -> float:
|
|
226
|
+
"""标题堆砌惩罚:同一查询单元在标题里重复出现时的折扣系数。
|
|
227
|
+
|
|
228
|
+
内容农场的标题形态是「颈椎枕头推荐_颈椎病枕头怎么选_枕头推荐颈椎病」——
|
|
229
|
+
查询单元全部命中,但重复了五六遍。覆盖率看不见这种堆砌(分子封顶在
|
|
230
|
+
查询单元数),所以必须单独量「文档侧冗余」:命中次数 / 查询单元数。
|
|
231
|
+
|
|
232
|
+
⚠️ 2026-09-27 降级说明:本函数只按**标题**算,且下限 0.35 会在 5 倍
|
|
233
|
+
超额时就触底。扩充标定集后实测:
|
|
234
|
+
- 只看标题会漏掉「标题正常、正文堆砌」的形态;
|
|
235
|
+
- 放宽下限会连带误杀正常长文(次数罚不出好坏,见 keyword_density)。
|
|
236
|
+
故它的角色从「主判据」降为「标题这一处的补充」,整体密度判据见
|
|
237
|
+
stuffing_density_penalty。保留本函数是因为标题堆砌是独立可解释的信号,
|
|
238
|
+
且在「同一域刷标题」的场景下仍有效。
|
|
239
|
+
"""
|
|
240
|
+
if not units or not title:
|
|
241
|
+
return 1.0
|
|
242
|
+
t_units_list = relevance_units(title)
|
|
243
|
+
if not t_units_list:
|
|
244
|
+
return 1.0
|
|
245
|
+
hits = sum(1 for u in t_units_list if u in units)
|
|
246
|
+
if hits <= len(units):
|
|
247
|
+
return 1.0
|
|
248
|
+
excess = hits / max(len(units), 1)
|
|
249
|
+
return round(max(0.35, 1.0 - 0.30 * (excess - 1.0)), 4)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def score_relevance(query_units: set[str], title: str, snippet: str) -> float:
|
|
253
|
+
"""相关性:查询单元在 title/snippet 的覆盖率 × 标题堆砌惩罚。
|
|
254
|
+
|
|
255
|
+
非对称覆盖率——分母只算查询侧单元,文档侧的冗长不摊薄分数(这是
|
|
256
|
+
「长文档天然占优」的对立面)。堆砌由 title_stuffing_penalty 单独处理。
|
|
257
|
+
"""
|
|
258
|
+
if not query_units:
|
|
259
|
+
return 0.5
|
|
260
|
+
if not relevance_v2_enabled():
|
|
261
|
+
# 逃生门:旧单字覆盖率。注意调用方需相应地传单字集合
|
|
262
|
+
# (见 search_rank.local_five_dim_rerank 的开关分支)。
|
|
263
|
+
t_tokens = set(re.compile(r"[\u4e00-\u9fff]|[a-zA-Z0-9]+")
|
|
264
|
+
.findall((title or "").lower()))
|
|
265
|
+
s_tokens = set(re.compile(r"[\u4e00-\u9fff]|[a-zA-Z0-9]+")
|
|
266
|
+
.findall((snippet or "").lower()))
|
|
267
|
+
title_cov = len(query_units & t_tokens) / len(query_units)
|
|
268
|
+
snip_cov = len(query_units & s_tokens) / len(query_units)
|
|
269
|
+
return round(min(1.0, 0.65 * title_cov + 0.35 * snip_cov), 4)
|
|
270
|
+
t_cov, s_cov = _coverage(query_units, title, snippet)
|
|
271
|
+
raw = 0.65 * t_cov + 0.35 * s_cov
|
|
272
|
+
# 两个堆砌判据取**更严**的一个(min),不是相乘:相乘会双重折同一类
|
|
273
|
+
# 滥用(同一份文本既标题堆砌又高密度时白吃两次罚),且让「两个信号
|
|
274
|
+
# 都不强但都沾边」的情形过度惩罚。取 min 的语义是「哪条判据更确信
|
|
275
|
+
# 这是滥用,就用哪条」。
|
|
276
|
+
penalty = min(title_stuffing_penalty(title, query_units),
|
|
277
|
+
stuffing_density_penalty(title, snippet, query_units))
|
|
278
|
+
return round(min(1.0, raw * penalty), 4)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ── 完整性维度(2026-09-27 重写:QSDM 信息/噪声比)───────────────────────────
|
|
282
|
+
#
|
|
283
|
+
# 旧实现 0.6×snippet 长度 + 0.2×有数字 + 0.2×有标题。长度占大头,而「把
|
|
284
|
+
# 摘要写长、塞进数字」正是内容农场唯一需要刷的指标——实测 SEO 软文得
|
|
285
|
+
# 0.556、正经来源只得 0.239(2.3 倍反向激励)。
|
|
286
|
+
#
|
|
287
|
+
# 换成 Bendersky/Croft/Diao, WSDM 2011(QSDM)的质量特征族。选它的理由:
|
|
288
|
+
# 该论文的核心结果是这套特征**在几乎无 spam 的 GOV2 上也显著有效**
|
|
289
|
+
# (MRR +8%、nDCG@5 +9%、Wikipedia 页检索率 5 倍),说明它抓的是普适的
|
|
290
|
+
# 文档质量而非可被针对性绕过的「spam 特征」。
|
|
291
|
+
#
|
|
292
|
+
# 论文要求特征「线性时间可算、可 aggregate」,以下全部满足。
|
|
293
|
+
|
|
294
|
+
COMPLETENESS_IDEAL_TITLE_CHARS = (8, 40)
|
|
295
|
+
|
|
296
|
+
_STRUCTURE_MARK_RE = None
|
|
297
|
+
_TITLE_SEP_RE = None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def completeness_v2_enabled() -> bool:
|
|
301
|
+
"""完整性算子开关:ARGO_COMPLETENESS_V2=0 退回旧长度启发式。"""
|
|
302
|
+
return _env_flag("ARGO_COMPLETENESS_V2")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def title_quality_term(title: str) -> float:
|
|
306
|
+
"""标题质量项(0.2-1.0):长度规范度 × 分隔式堆砌惩罚。
|
|
307
|
+
|
|
308
|
+
两道独立扣分正交叠加:
|
|
309
|
+
① 长度:<8 字信息不足、>40 字快速衰减(论文实测长标题更可能是 spam)
|
|
310
|
+
② 分隔符:标题被切成 3 段以上短语 → SEO 堆砌形态
|
|
311
|
+
自然标题两项都不触发。
|
|
312
|
+
"""
|
|
313
|
+
global _TITLE_SEP_RE
|
|
314
|
+
t = (title or "").strip()
|
|
315
|
+
if not t:
|
|
316
|
+
return 0.0
|
|
317
|
+
tlen = len(t)
|
|
318
|
+
lo, hi = COMPLETENESS_IDEAL_TITLE_CHARS
|
|
319
|
+
if tlen < lo:
|
|
320
|
+
length_term = tlen / lo
|
|
321
|
+
elif tlen <= hi:
|
|
322
|
+
length_term = 1.0
|
|
323
|
+
else:
|
|
324
|
+
# 超出每 10 字扣 0.2,下限 0.2(比初版 20 字/0.15 更陡)
|
|
325
|
+
length_term = max(0.2, 1.0 - 0.2 * ((tlen - hi) / 10.0))
|
|
326
|
+
|
|
327
|
+
if _TITLE_SEP_RE is None:
|
|
328
|
+
# 中文逗号/顿号/竖线/分号/方括号 + 英文逗号/竖线
|
|
329
|
+
_TITLE_SEP_RE = re.compile(r"[,,、||;;【】\[\]]")
|
|
330
|
+
segs = len([s for s in _TITLE_SEP_RE.split(t) if s.strip()])
|
|
331
|
+
# 2 段以内视为自然;3 段起每多一段扣 0.12,下限 0.5
|
|
332
|
+
sep_term = 1.0 if segs <= 2 else max(0.5, 1.0 - 0.12 * (segs - 2))
|
|
333
|
+
|
|
334
|
+
return max(0.2, length_term * sep_term)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def score_completeness(title: str, snippet: str) -> float:
|
|
338
|
+
"""完整性:QSDM 式信息/噪声比(标题质量 + 实义密度 + 数字密度 + 结构)。
|
|
339
|
+
|
|
340
|
+
与旧版的关键差别:**长度不再直接给分**,只通过标题质量与实义密度间接
|
|
341
|
+
体现,且两个方向都能扣分。长而空的软文摘要因此拿不到高分。
|
|
342
|
+
"""
|
|
343
|
+
if not completeness_v2_enabled():
|
|
344
|
+
length = len(snippet or "")
|
|
345
|
+
length_score = min(length / 200.0, 1.0)
|
|
346
|
+
has_digit = 1.0 if any(c.isdigit() for c in (snippet or "")) else 0.0
|
|
347
|
+
has_title = 1.0 if (title or "").strip() else 0.0
|
|
348
|
+
return round(min(1.0, 0.6 * length_score + 0.2 * has_digit + 0.2 * has_title), 4)
|
|
349
|
+
|
|
350
|
+
global _STRUCTURE_MARK_RE
|
|
351
|
+
if _STRUCTURE_MARK_RE is None:
|
|
352
|
+
# 可读性/结构标记:列表、序号、定义、对比、步骤(中英)
|
|
353
|
+
_STRUCTURE_MARK_RE = re.compile(
|
|
354
|
+
r"(?:^|\n)\s*(?:\d+[.、)]|[一二三四五六七八九十][.、)]|[-*•])"
|
|
355
|
+
r"|(?:是指|定义为|包括|分为|例如|比如|综上|因此|however|defined as|such as)",
|
|
356
|
+
re.I,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
s = snippet or ""
|
|
360
|
+
title_term = title_quality_term(title)
|
|
361
|
+
|
|
362
|
+
# 实义密度(fracVisText 的文本级近似):去空白后非标点字符占比
|
|
363
|
+
stripped = "".join(s.split())
|
|
364
|
+
if not stripped:
|
|
365
|
+
density_term = 0.0
|
|
366
|
+
digit_term = 0.0
|
|
367
|
+
struct_term = 0.0
|
|
368
|
+
else:
|
|
369
|
+
substantive = sum(1 for c in stripped if c.isalnum())
|
|
370
|
+
density_term = substantive / len(stripped)
|
|
371
|
+
|
|
372
|
+
# 数字密度**归一化**:有数字只是入场券,占比过多(堆砌「10款/20款/
|
|
373
|
+
# 90%/5000元」的软文话术)同样扣分。2%-15% 为合理区间。
|
|
374
|
+
digits = sum(1 for c in stripped if c.isdigit())
|
|
375
|
+
dr = digits / len(stripped)
|
|
376
|
+
if dr == 0:
|
|
377
|
+
digit_term = 0.35 # 无数字:信息量偏低但不等于低质
|
|
378
|
+
elif dr <= 0.15:
|
|
379
|
+
digit_term = 1.0
|
|
380
|
+
elif dr <= 0.30:
|
|
381
|
+
digit_term = 0.7
|
|
382
|
+
else:
|
|
383
|
+
digit_term = 0.4
|
|
384
|
+
|
|
385
|
+
struct_term = 1.0 if _STRUCTURE_MARK_RE.search(s) else 0.4
|
|
386
|
+
|
|
387
|
+
return round(min(1.0, 0.35 * title_term + 0.30 * density_term
|
|
388
|
+
+ 0.20 * digit_term + 0.15 * struct_term), 4)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ── 域级聚合惩罚(2026-09-27 新增)───────────────────────────────────────────
|
|
392
|
+
#
|
|
393
|
+
# 仿 Google Helpful Content Update 的 site-wide 信号。现有 authority 只看
|
|
394
|
+
# 域名查表,**不看在这次查询里这个域名实际返回了什么**。实测「空气净化器
|
|
395
|
+
# 推荐」返回 5 条结果全部来自 zhuanlan.zhihu.com,且标题清一色是
|
|
396
|
+
# 「2026年9月最新实测」「自费5000元实测20款」的软文形态。
|
|
397
|
+
#
|
|
398
|
+
# 本函数是该思想在**单次查询粒度**上的落地(argo 无跨查询的站点级统计):
|
|
399
|
+
# 只惩罚「高度集中」的情形(单一域名占比过半),正常的多结果场景不触发。
|
|
400
|
+
#
|
|
401
|
+
# 与 MMR/新颖性的分工:新颖性按**内容**去冗余(bigram 相似度),本函数按
|
|
402
|
+
# **来源**去集中,两者正交——同一个域名可以发多篇不同内容(都被压),
|
|
403
|
+
# 同一篇内容也可以跨多个域名转载(由 minhash + novelty 负责)。
|
|
404
|
+
|
|
405
|
+
# 进程内缓存:纯函数(只读输入列表),同进程内重复调用共享结果。
|
|
406
|
+
# 键用 tuple(urls) 而非整个列表——列表不可哈希,且 url 序列足以区分不同结果集。
|
|
407
|
+
# 用 OrderedDict 实现 LRU,上限 128 条防止无界增长(一次搜索最多几十个 URL,
|
|
408
|
+
# 128 足够覆盖同进程内的重复调用)。
|
|
409
|
+
from collections import OrderedDict
|
|
410
|
+
_DOMAIN_PENALTY_CACHE: OrderedDict[tuple, dict[str, float]] = OrderedDict()
|
|
411
|
+
_DOMAIN_PENALTY_CACHE_MAX = 128
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def domain_penalty_enabled() -> bool:
|
|
415
|
+
"""域级聚合惩罚开关:ARGO_DOMAIN_CONCENTRATION=0 关闭。"""
|
|
416
|
+
return _env_flag("ARGO_DOMAIN_CONCENTRATION")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def host_of(url: str) -> str:
|
|
420
|
+
"""取规范化 host(www 折叠为裸域)。"""
|
|
421
|
+
if not url:
|
|
422
|
+
return ""
|
|
423
|
+
try:
|
|
424
|
+
host = urlparse(url).netloc.lower().split(":", 1)[0]
|
|
425
|
+
except Exception:
|
|
426
|
+
return ""
|
|
427
|
+
return host[4:] if host.startswith("www.") else host
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def domain_concentration_penalty(results: list[dict[str, Any]]) -> dict[str, float]:
|
|
431
|
+
"""按「同一域名的占比」给出惩罚系数(乘子 ≤1,只降不升)。
|
|
432
|
+
|
|
433
|
+
返回 {host: factor};不触发时返回空表(调用方据此完全跳过,保证
|
|
434
|
+
「无惩罚时与旧实现逐位一致」这条既有纪律)。
|
|
435
|
+
|
|
436
|
+
2026-09-27 加 lru_cache:纯函数(只读输入列表),同进程内重复调用
|
|
437
|
+
(rerank / selection / evidence 打分)共享结果。键用 (tuple(urls),) 而非
|
|
438
|
+
整个列表——列表不可哈希,且 url 序列足以区分不同结果集。
|
|
439
|
+
"""
|
|
440
|
+
if not results:
|
|
441
|
+
return {}
|
|
442
|
+
# 缓存键:只取 url 列表(可哈希),避免整个 dict 列表不可哈希的问题
|
|
443
|
+
cache_key = tuple(str(r.get("url") or "") for r in results if isinstance(r, dict))
|
|
444
|
+
if not cache_key:
|
|
445
|
+
return {}
|
|
446
|
+
cached = _DOMAIN_PENALTY_CACHE.get(cache_key)
|
|
447
|
+
if cached is not None:
|
|
448
|
+
# LRU:命中时移到末尾(最新使用)
|
|
449
|
+
_DOMAIN_PENALTY_CACHE.move_to_end(cache_key)
|
|
450
|
+
return cached
|
|
451
|
+
from collections import Counter
|
|
452
|
+
counts: Counter = Counter()
|
|
453
|
+
for url in cache_key:
|
|
454
|
+
host = host_of(url)
|
|
455
|
+
if host:
|
|
456
|
+
counts[host] += 1
|
|
457
|
+
total = sum(counts.values())
|
|
458
|
+
if total < 3 or not counts:
|
|
459
|
+
# 结果太少时占比噪声太大(2 条里 1 条就是 50%),不判定
|
|
460
|
+
if len(_DOMAIN_PENALTY_CACHE) >= _DOMAIN_PENALTY_CACHE_MAX:
|
|
461
|
+
_DOMAIN_PENALTY_CACHE.popitem(last=False)
|
|
462
|
+
_DOMAIN_PENALTY_CACHE[cache_key] = {}
|
|
463
|
+
return {}
|
|
464
|
+
if len(counts) == 1:
|
|
465
|
+
# 全部结果来自同一域名:这不是「某站从多源里占榜」,而是「本次召回
|
|
466
|
+
# 本来就只有一个来源」(单引擎查询、或该域确实是唯一相关源)。
|
|
467
|
+
# 罚它等于对所有单源结果无条件降权——既打不出多样性(没有别的源
|
|
468
|
+
# 可让位),又破坏了「无融合信息时与旧实现确定性」这条既有不变式
|
|
469
|
+
# (由 tests/test_ranking_contract.py 锁定)。故只在**多域并存**时判定。
|
|
470
|
+
if len(_DOMAIN_PENALTY_CACHE) >= _DOMAIN_PENALTY_CACHE_MAX:
|
|
471
|
+
_DOMAIN_PENALTY_CACHE.popitem(last=False)
|
|
472
|
+
_DOMAIN_PENALTY_CACHE[cache_key] = {}
|
|
473
|
+
return {}
|
|
474
|
+
penalty: dict[str, float] = {}
|
|
475
|
+
for host, n in counts.items():
|
|
476
|
+
share = n / total
|
|
477
|
+
if share <= 0.5:
|
|
478
|
+
continue
|
|
479
|
+
# 占比过半起罚:0.5 → 1.0(不罚),1.0 → 0.75(满罚 25%)
|
|
480
|
+
# 用意是「打散占榜」而非「删除结果」——同域仍可按内容质量排序。
|
|
481
|
+
penalty[host] = round(1.0 - 0.25 * (share - 0.5) / 0.5, 4)
|
|
482
|
+
# LRU 写入:超限时淘汰最久未使用的条目
|
|
483
|
+
if len(_DOMAIN_PENALTY_CACHE) >= _DOMAIN_PENALTY_CACHE_MAX:
|
|
484
|
+
_DOMAIN_PENALTY_CACHE.popitem(last=False)
|
|
485
|
+
_DOMAIN_PENALTY_CACHE[cache_key] = penalty
|
|
486
|
+
return penalty
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ── 域级源保底分(config.yaml 声明驱动)─────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
_SCORE_FLOORS_CACHE: dict[str, dict[str, dict[str, float]]] | None = None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ── 跨域内容同质(2026-09-27 新增)────────────────────────────────────────────
|
|
495
|
+
#
|
|
496
|
+
# 补 Google「Scaled content abuse」政策(2024-03 起)对应的信号:
|
|
497
|
+
# "Creating many pages where the content makes little or no sense..."
|
|
498
|
+
# "Scraping feeds... including through automated transformations like
|
|
499
|
+
# synonymizing, translating, or other obfuscation techniques"
|
|
500
|
+
#
|
|
501
|
+
# 与上面 domain_concentration_penalty 的分工(两者正交,不是加强关系):
|
|
502
|
+
# - 域级惩罚管「**一个**站占榜」:同一 host 占比过半
|
|
503
|
+
# - 本信号管「**很多**站各发一篇同款」:host 各不相同、内容高度相似
|
|
504
|
+
# 后者正是前者的结构性盲区:几十个小站发同一篇软文时,每个 host 都只占
|
|
505
|
+
# 1/N,域级惩罚一条都不触发。
|
|
506
|
+
#
|
|
507
|
+
# 为什么这条比继续在内容上加信号更值得做:ACL 2024「AI News Content
|
|
508
|
+
# Farms Are Easy to Make and Hard to Detect」(arXiv 2406.12128) 的结论是
|
|
509
|
+
# 内容农场对**基于内容**的检测器天然鲁棒——改写成本极低。来源侧的证据
|
|
510
|
+
# (同一批内容出现在互不相干的域名下)不随改写而消失,判别力更稳。
|
|
511
|
+
#
|
|
512
|
+
# 复用 search_rank._content_similarity(minhash + Jaccard 兜底)而非另造
|
|
513
|
+
# 指纹:同一套口径,避免「去重用一套、惩罚用另一套」导致的自相矛盾——
|
|
514
|
+
# 同一对内容不会既被判为近重复而删除、又被判为不相似而不罚。
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def cross_domain_homogeneity_enabled() -> bool:
|
|
518
|
+
"""跨域同质惩罚开关:ARGO_CROSS_DOMAIN_HOMOGENEITY=0 关闭。"""
|
|
519
|
+
return _env_flag("ARGO_CROSS_DOMAIN_HOMOGENEITY")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _result_content_sig(r: dict[str, Any]) -> str:
|
|
523
|
+
"""结果的内容签名:title + snippet。取不到就返回空串(不参与判定)。"""
|
|
524
|
+
parts = [str(r.get("title") or ""), str(r.get("snippet") or "")]
|
|
525
|
+
return " ".join(p for p in parts if p).strip()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def cross_domain_homogeneity_penalty(
|
|
529
|
+
results: list[dict[str, Any]],
|
|
530
|
+
similarity_threshold: float = 0.82,
|
|
531
|
+
min_cluster: int = 3,
|
|
532
|
+
) -> dict[str, float]:
|
|
533
|
+
"""同质内容簇的降权系数(按 url 索引,乘子 ≤1,只降不升)。
|
|
534
|
+
|
|
535
|
+
判据:一条结果与**其它域名**下的结果内容相似度 ≥ 阈值,且这样的同伙
|
|
536
|
+
总数 ≥ min_cluster(含自己),就认定为「同质簇」。
|
|
537
|
+
|
|
538
|
+
三个刻意的保守设计(每条都对应一类误伤):
|
|
539
|
+
1. **只降不升**,且强度按「簇内排名」递减——簇里最像原创源的那条(分
|
|
540
|
+
最高者)几乎不受罚,罚的是随大流。farm 的危害不是「存在」而是
|
|
541
|
+
「占榜」,把位次让出来就达到了目的。
|
|
542
|
+
2. **要求 ≥2 个不同 host**:同域多篇不是「跨域」问题,那是
|
|
543
|
+
domain_concentration_penalty 的职责,两者不重复计罚。
|
|
544
|
+
3. **相似度阈值走既有 minhash 口径**(0.82 对齐 minhash_dedupe 的
|
|
545
|
+
0.85 略松一点,因为惩罚比删除温和,可以更早介入)。
|
|
546
|
+
|
|
547
|
+
正常场景不触发:多域并存的正常结果,内容本就该不同;真出现同一新闻
|
|
548
|
+
多家转载时,簇内只有 2-3 条且相似度中等,且最像原创的那条不受罚。
|
|
549
|
+
"""
|
|
550
|
+
if not results:
|
|
551
|
+
return {}
|
|
552
|
+
try:
|
|
553
|
+
similarity_threshold = float(similarity_threshold)
|
|
554
|
+
min_cluster = int(min_cluster)
|
|
555
|
+
except (TypeError, ValueError):
|
|
556
|
+
return {}
|
|
557
|
+
if not (0.0 < similarity_threshold <= 1.0) or min_cluster < 2:
|
|
558
|
+
return {}
|
|
559
|
+
|
|
560
|
+
# 延迟导入:search_rank 在 import 链上游,rank_signals 不能反向依赖它
|
|
561
|
+
try:
|
|
562
|
+
from search_rank import _content_similarity
|
|
563
|
+
except Exception:
|
|
564
|
+
return {}
|
|
565
|
+
|
|
566
|
+
rows: list[tuple[int, str, str, str]] = [] # (原始下标, host, sig, url)
|
|
567
|
+
for i, r in enumerate(results):
|
|
568
|
+
if not isinstance(r, dict):
|
|
569
|
+
continue
|
|
570
|
+
host = host_of(r.get("url") or "")
|
|
571
|
+
sig = _result_content_sig(r)
|
|
572
|
+
if not host or len(sig) < 40:
|
|
573
|
+
# 过短的签名(标题+摘要不足 40 字符)相似度噪声太大,不判定
|
|
574
|
+
continue
|
|
575
|
+
rows.append((i, host, sig, str(r.get("url") or "")))
|
|
576
|
+
|
|
577
|
+
if len(rows) < min_cluster:
|
|
578
|
+
return {}
|
|
579
|
+
|
|
580
|
+
penalty: dict[str, float] = {}
|
|
581
|
+
for ai, ahost, asig, aurl in rows:
|
|
582
|
+
mates = 0
|
|
583
|
+
for bi, bhost, bsig, _ in rows:
|
|
584
|
+
if bi == ai or bhost == ahost:
|
|
585
|
+
continue # 只数异域同伙
|
|
586
|
+
try:
|
|
587
|
+
if _content_similarity(asig, bsig) >= similarity_threshold:
|
|
588
|
+
mates += 1
|
|
589
|
+
except Exception:
|
|
590
|
+
continue
|
|
591
|
+
if mates + 1 < min_cluster:
|
|
592
|
+
continue
|
|
593
|
+
# 同伙越多罚得越重,但留 0.45 下限:单点判据的假阳性代价高于漏放,
|
|
594
|
+
# 且不把任何一条打到 0——最终排序还会叠加其它维度。
|
|
595
|
+
severity = min(1.0, (mates + 1 - min_cluster) / float(max(min_cluster, 1)))
|
|
596
|
+
penalty[aurl] = round(1.0 - 0.55 * severity, 4)
|
|
597
|
+
return penalty
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def combined_source_penalties(results: list[dict[str, Any]]) -> tuple[
|
|
601
|
+
dict[str, float], dict[str, float]]:
|
|
602
|
+
"""两个来源侧惩罚的合取:返回 (按 host 的域级系数, 按 url 的跨域同质系数)。
|
|
603
|
+
|
|
604
|
+
合并成一个入口有两个理由:
|
|
605
|
+
- 调用点(search_rank)只关心「把所有来源侧惩罚都算出来」,不必知道
|
|
606
|
+
现在有几类、各自的键是什么——再加一类惩罚时不必改调用点;
|
|
607
|
+
- 开关判断也收在这里,调用点不再出现 if/else 分支。
|
|
608
|
+
|
|
609
|
+
两类惩罚键不同(host vs url)故分开返回,调用点各自查各自的表。
|
|
610
|
+
"""
|
|
611
|
+
domain_pen: dict[str, float] = (
|
|
612
|
+
domain_concentration_penalty(results) if domain_penalty_enabled() else {})
|
|
613
|
+
cross_pen: dict[str, float] = (
|
|
614
|
+
cross_domain_homogeneity_penalty(results)
|
|
615
|
+
if cross_domain_homogeneity_enabled() else {})
|
|
616
|
+
return domain_pen, cross_pen
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def domain_score_floors() -> dict[str, dict[str, dict[str, float]]]:
|
|
620
|
+
"""域级源保底分(config.yaml 各域的 score_floors),进程内缓存。
|
|
621
|
+
|
|
622
|
+
这些分值是「域对源的先验信任」,属于引擎/域声明而非排序算法——
|
|
623
|
+
此前硬编码在 local_five_dim_rerank 里,每接一个新源都可能要改排序
|
|
624
|
+
代码(2026-09-13 审查 P1-2)。声明形态:
|
|
625
|
+
|
|
626
|
+
score_floors:
|
|
627
|
+
sina_quote: {relevance: 1.0, authority: 0.85, freshness: 0.85}
|
|
628
|
+
|
|
629
|
+
生效时机:relevance 在相关性评分后立即生效;authority/freshness 仅在
|
|
630
|
+
evidence 评分可用时生效(无 evidence 时两维本就恒 0.5,保底无意义,
|
|
631
|
+
与旧实现逐位一致)。源匹配按「/」切分成员判断(rrf 合并源
|
|
632
|
+
"local_bing/sina_quote" 也要吃到保底)。
|
|
633
|
+
"""
|
|
634
|
+
global _SCORE_FLOORS_CACHE
|
|
635
|
+
if _SCORE_FLOORS_CACHE is None:
|
|
636
|
+
try:
|
|
637
|
+
from config import load_config
|
|
638
|
+
floors: dict[str, dict[str, dict[str, float]]] = {}
|
|
639
|
+
for d in (load_config().get("domains") or []):
|
|
640
|
+
if isinstance(d, dict) and d.get("score_floors"):
|
|
641
|
+
floors[d["name"]] = {
|
|
642
|
+
str(src): dict(fl)
|
|
643
|
+
for src, fl in d["score_floors"].items()
|
|
644
|
+
if isinstance(fl, dict)
|
|
645
|
+
}
|
|
646
|
+
_SCORE_FLOORS_CACHE = floors
|
|
647
|
+
except Exception:
|
|
648
|
+
_SCORE_FLOORS_CACHE = {}
|
|
649
|
+
return _SCORE_FLOORS_CACHE
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ── 离散权威等级(0-4) ──────────────────────────────────────────────────────
|
|
653
|
+
#
|
|
654
|
+
# 对齐 DeepSeek Search 的 authority 语义(0-4,≥3 极权威,-1 未知)。为什么
|
|
655
|
+
# 需要这一层:连续 authority 分对 Agent 是弱信号——不知道该在哪切;离散等级
|
|
656
|
+
# 才能直接当闸门用(≥3 才进事实链)。与既有 authority_tier
|
|
657
|
+
# (high/medium/low/very_low)同源不同形:tier 是内部排序语义,level 是对外
|
|
658
|
+
# 可解释语义,两者共用同一条 authority 连续分,不产生第二套评分。
|
|
659
|
+
|
|
660
|
+
def authority_level(authority_score: Any, upstream_authority: Any = None) -> int:
|
|
661
|
+
"""0-4 离散权威等级;上游原生 0-4 优先,-1/不可解析回退自有映射。
|
|
662
|
+
|
|
663
|
+
自有映射阈值按 2026-10-17 实测分布校准(score_authority 在真实 URL 上
|
|
664
|
+
的输出):官方/一手(gov/arxiv/mdn/github/eastmoney)0.85-1.0 → 4;
|
|
665
|
+
知名第三方(百科/优质站)0.7-0.85 → 3;一般可信 0.5-0.7 → 2;UGC/低可信
|
|
666
|
+
0.3-0.5 → 1;SERP/跳转链/未知 <0.3 → 0。
|
|
667
|
+
"""
|
|
668
|
+
if upstream_authority not in (None, ""):
|
|
669
|
+
try:
|
|
670
|
+
v = int(float(str(upstream_authority).strip()))
|
|
671
|
+
if 0 <= v <= 4:
|
|
672
|
+
return v
|
|
673
|
+
except (TypeError, ValueError):
|
|
674
|
+
pass # 上游值不可解析(含 -1 未知)→ 走自有映射
|
|
675
|
+
try:
|
|
676
|
+
s = float(authority_score)
|
|
677
|
+
except (TypeError, ValueError):
|
|
678
|
+
return 0
|
|
679
|
+
if s >= 0.85:
|
|
680
|
+
return 4
|
|
681
|
+
if s >= 0.7:
|
|
682
|
+
return 3
|
|
683
|
+
if s >= 0.5:
|
|
684
|
+
return 2
|
|
685
|
+
if s >= 0.3:
|
|
686
|
+
return 1
|
|
687
|
+
return 0
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# ── 模糊近重复判定(rapidfuzz,可选依赖)─────────────────────────────────────
|
|
691
|
+
#
|
|
692
|
+
# 背景(2026-10-16 实测):minhash_dedupe 的字符集 Jaccard 对「同文换站后缀/
|
|
693
|
+
# 语序」的内容对给分低(「X - 知乎」vs「X | 知乎专栏」,token 集合差一个后缀
|
|
694
|
+
# 词),实测 15 个真实结果池漏合 3 条跨站镜像条目。rapidfuzz token_set_ratio
|
|
695
|
+
# 补这一带;只在其 minhash 分落在 [band_lo, threshold) 的近似命中带内启用
|
|
696
|
+
# ——带外不调(实测每查询 0-6 次调用、<2.5ms;全量逐对 800 条要 2.34s,
|
|
697
|
+
# 热路径付不起,故必须有这道闸)。
|
|
698
|
+
#
|
|
699
|
+
# 形态与 curl_cffi 一致:可选依赖,缺失时静默回退纯 minhash 判定
|
|
700
|
+
# (本函数返回 False,调用方行为与接入前逐位一致);ARGO_DEDUP_FUZZY=0 可
|
|
701
|
+
# 整体关闭。阈值可用 ARGO_DEDUP_FUZZY_THR / ARGO_DEDUP_FUZZY_BAND 覆盖。
|
|
702
|
+
|
|
703
|
+
_RF_FUZZ: Any = None
|
|
704
|
+
_RF_TRIED = False
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ── 标题站后缀归一化(零依赖)───────────────────────────────────────────────
|
|
708
|
+
#
|
|
709
|
+
# 跨站同文对的差异几乎全是结尾的站归属后缀(「- 知乎」「| 知乎专栏」
|
|
710
|
+
# 「_百度百家号」):不剥掉时字符集 Jaccard 被一个后缀词拉低(真重复对
|
|
711
|
+
# minhash 实测 0.375-0.75,都不过 0.85 阈值)。剥掉再算 minhash,同文对
|
|
712
|
+
# 相似度自然升过阈值——不需要第二套相似度度量。
|
|
713
|
+
#
|
|
714
|
+
# 边界(2026-10-16 实测):只剥「结尾处 分隔符+已知站名(+≤6 字尾缀)」,
|
|
715
|
+
# 「我在知乎的日子」「CSDN 编程指北」这类站名在中间/开头的不动;
|
|
716
|
+
# 纯「- 知乎」标题剥为空(无内容可留,交由后续空签判据处理)。
|
|
717
|
+
|
|
718
|
+
_SITE_SUFFIX_RE = None
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def normalize_title_suffix(title: str) -> str:
|
|
722
|
+
"""剥掉结尾的站归属后缀(「X - 知乎」→「X」);无后缀原样返回。"""
|
|
723
|
+
global _SITE_SUFFIX_RE
|
|
724
|
+
if not title:
|
|
725
|
+
return title
|
|
726
|
+
if _SITE_SUFFIX_RE is None:
|
|
727
|
+
import re as _re
|
|
728
|
+
_SITE_SUFFIX_RE = _re.compile(
|
|
729
|
+
r"\s*[-_|·—–::]\s*"
|
|
730
|
+
r"(知乎|知乎专栏|百度百家号|腾讯新闻|网易|新浪|搜狐|头条|简书|CSDN|掘金|"
|
|
731
|
+
r"博客园|oschina|开源中国|bilibili|哔哩哔哩|微信公众号|微信|微博|小红书|"
|
|
732
|
+
r"豆瓣|贴吧|虎嗅|36氪|少数派)"
|
|
733
|
+
r"[一-龥A-Za-z]{0,6}\s*$"
|
|
734
|
+
)
|
|
735
|
+
return _SITE_SUFFIX_RE.sub("", title)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _rf_token_set_ratio(a: str, b: str) -> float:
|
|
739
|
+
"""懒加载 rapidfuzz 的 token_set_ratio(×100 归一到 0-1);缺失返回 -1。"""
|
|
740
|
+
global _RF_FUZZ, _RF_TRIED
|
|
741
|
+
if not _RF_TRIED:
|
|
742
|
+
_RF_TRIED = True
|
|
743
|
+
try:
|
|
744
|
+
from rapidfuzz import fuzz as _fuzz
|
|
745
|
+
_RF_FUZZ = _fuzz.token_set_ratio
|
|
746
|
+
except Exception:
|
|
747
|
+
_RF_FUZZ = None
|
|
748
|
+
if _RF_FUZZ is None:
|
|
749
|
+
return -1.0
|
|
750
|
+
try:
|
|
751
|
+
return float(_RF_FUZZ(a, b)) / 100.0
|
|
752
|
+
except Exception:
|
|
753
|
+
return -1.0
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def fuzzy_near_dup(sig_a: str, sig_b: str, minhash_sim: float,
|
|
757
|
+
band_lo: float = 0.55, threshold: float = 0.88) -> bool:
|
|
758
|
+
"""近似命中带内的模糊近重复判定(minhash 未过阈值时的第二判据)。
|
|
759
|
+
|
|
760
|
+
minhash_sim 是调用方已算出的字符集相似度;落在 [band_lo, threshold)
|
|
761
|
+
才继续问 rapidfuzz,低于 band_lo 直接 False(省掉绝大部分逐对调用)。
|
|
762
|
+
rapidfuzz 缺失、开关关闭、判据异常,一律 False(回退纯 minhash 语义)。
|
|
763
|
+
"""
|
|
764
|
+
if not sig_a or not sig_b:
|
|
765
|
+
return False
|
|
766
|
+
if not _env_flag("ARGO_DEDUP_FUZZY"):
|
|
767
|
+
return False
|
|
768
|
+
try:
|
|
769
|
+
band = float(get_env("ARGO_DEDUP_FUZZY_BAND") or band_lo)
|
|
770
|
+
thr = float(get_env("ARGO_DEDUP_FUZZY_THR") or threshold)
|
|
771
|
+
except (TypeError, ValueError):
|
|
772
|
+
band, thr = band_lo, threshold
|
|
773
|
+
if not (band <= minhash_sim < thr):
|
|
774
|
+
return False
|
|
775
|
+
return _rf_token_set_ratio(sig_a, sig_b) >= thr
|