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,336 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""serp_guard — SERP / 模糊搜索结果的垃圾结果守卫(判定规则单点)。
|
|
3
|
+
|
|
4
|
+
事故背景(dsh-free-search issue #38/#35 同款事故,argo 的 local_* 引擎同样
|
|
5
|
+
暴露):html 引擎解析的是无 API 的网页 SERP,上游偶发把缓存 SERP / 与查询
|
|
6
|
+
完全无关的结果页当正常页返回;模糊搜索 API(people_daily 的 isFuzzy=True)
|
|
7
|
+
匹配不到时则回整页默认 feed。两种形态都是「答非所问但看起来正常」,解析层
|
|
8
|
+
照常抽出标题摘要,垃圾条目静默混进聚合,调用方把「答非所问」当成「网上
|
|
9
|
+
没有」——失败伪装成功是红线,这类混入是它的隐形变体。
|
|
10
|
+
|
|
11
|
+
裁决口径(全部量化,无模糊地带):
|
|
12
|
+
|
|
13
|
+
1. query token:CJK 连续段取相邻二元组、拉丁/数字连续段取小写词。
|
|
14
|
+
token 少于 2 个时恒 False(无裁决权)——单 token 的巧合重叠率太高,
|
|
15
|
+
拦了误杀大于漏放。单字 CJK 查询(如「猫」)只有 0 个二元组,双字
|
|
16
|
+
CJK 查询(如「猫粮」)只有 1 个,都天然落在无裁决区。
|
|
17
|
+
2. results 只取前 5 条;每条取 title+snippet。URL 不参与:缓存/跳转
|
|
18
|
+
链接的 URL 常回显 query 参数(uddg=、q=),会正好把垃圾页放行,
|
|
19
|
+
标题+摘要才是「这页在答什么」的信号。
|
|
20
|
+
3. 前 5 条里没有任何一条覆盖 query token 的 20% → 整页判垃圾(True)。
|
|
21
|
+
只判「有没有一个词命中」不够:实测「2026年新能源汽车销量数据」经
|
|
22
|
+
local_bing 返回的五条全是 2026 年节假日/维基页面,只命中「2026」一个
|
|
23
|
+
数字 token,旧判据(有交集即放行)把整页垃圾放了过去,而核心词
|
|
24
|
+
「新能源汽车销量数据」全部缺席。覆盖率 1/9 远低于门槛。
|
|
25
|
+
中文查询在存在 CJK 二元组时**不把纯数字 token 计入分母**:年份是最
|
|
26
|
+
容易被无关页面顺带命中的词,不能拿它当相关性证据。
|
|
27
|
+
|
|
28
|
+
为什么调用方对判垃圾应整页丢弃而不是过滤后返回幸存者:前 5 条全无关说明
|
|
29
|
+
这一页整体答非所问(缓存页/错路由),幸存的一两条是同一页的噪声,过滤
|
|
30
|
+
返回等于给垃圾留门,宁缺勿假。调用方(engines_base)判垃圾后返回 [] 且
|
|
31
|
+
不记 note_failure——结果无关不是引擎故障,不该进熔断归因。
|
|
32
|
+
|
|
33
|
+
ARGO_SERP_GUARD=0 整体关闭守卫(逃生门:规则误杀时第一反应是关闸复现,
|
|
34
|
+
而不是删代码)。
|
|
35
|
+
|
|
36
|
+
不 import search_rank:本模块处在 engines_base 的冷启动热路径上,search_rank
|
|
37
|
+
较重且有环风险;分词口径按其 _tokens/_bigrams 的语义在本模块最小复刻
|
|
38
|
+
(CJK 二元组 + 小写拉丁词),不共享代码。
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
from typing import Any
|
|
47
|
+
from urllib.parse import urlparse
|
|
48
|
+
|
|
49
|
+
# CJK 连续段 / 小写拉丁数字词(findall 前文本已 lower())
|
|
50
|
+
_RUN_RE = re.compile(r"[\u4e00-\u9fff]+|[a-z0-9]+")
|
|
51
|
+
_GUARD_WINDOW = 5 # 只看前 5 条:SERP 的「答非所问」看头部即可下结论
|
|
52
|
+
_MIN_QUERY_TOKENS = 2 # query token 少于此数 → 无裁决权,恒不拦
|
|
53
|
+
_MIN_COVERAGE = 0.2 # 单条结果至少要覆盖 20% 的 query token 才算「在答这题」
|
|
54
|
+
_SWITCH_ENV = "ARGO_SERP_GUARD"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def guard_enabled() -> bool:
|
|
58
|
+
"""逃生门:仅 ARGO_SERP_GUARD=0 关闭,其余取值(含未设置)均开启。"""
|
|
59
|
+
return os.environ.get(_SWITCH_ENV, "") != "0"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_tokens(text: str) -> set[str]:
|
|
63
|
+
"""提取 token:CJK 相邻二元组 + 小写拉丁/数字词(与 search_rank 同口径)。"""
|
|
64
|
+
tokens: set[str] = set()
|
|
65
|
+
for run in _RUN_RE.findall((text or "").lower()):
|
|
66
|
+
if "\u4e00" <= run[0] <= "\u9fff":
|
|
67
|
+
tokens.update(run[i:i + 2] for i in range(len(run) - 1))
|
|
68
|
+
else:
|
|
69
|
+
tokens.add(run)
|
|
70
|
+
return tokens
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _result_text(item: object) -> str:
|
|
74
|
+
"""单条结果的相关性文本 = title + snippet;非 dict 或字段缺失按空处理。"""
|
|
75
|
+
if not isinstance(item, dict):
|
|
76
|
+
return ""
|
|
77
|
+
return f"{item.get('title') or ''} {item.get('snippet') or ''}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _query_tokens(query: str) -> set[str]:
|
|
81
|
+
"""query token;中文查询里纯数字不参与(年份不是相关性证据)。
|
|
82
|
+
|
|
83
|
+
有 CJK 二元组说明查询主体是中文短语,此时「2026」这类数字是最容易被
|
|
84
|
+
无关页面顺带命中的 token(节假日表、维基年表都带年份),留在分母里会
|
|
85
|
+
把覆盖率算高、把垃圾页放行。纯拉丁查询(如「2026 GDP」)没有这个问题,
|
|
86
|
+
数字是有信息的,照常保留。
|
|
87
|
+
"""
|
|
88
|
+
tokens = _extract_tokens(query)
|
|
89
|
+
if any("\u4e00" <= ch <= "\u9fff" for tok in tokens for ch in tok):
|
|
90
|
+
tokens = {tok for tok in tokens if not tok.isdigit()}
|
|
91
|
+
return tokens
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_junk_serp(query: str, results: list[dict]) -> bool:
|
|
95
|
+
"""判一页 SERP 结果是否整体答非所问(口径见模块 docstring)。
|
|
96
|
+
|
|
97
|
+
返回 True 表示整页判垃圾,调用方应丢弃全部条目;返回 False 表示不拦
|
|
98
|
+
(含 query 无裁决权、results 为空、守卫被关闭——三种都不拦)。
|
|
99
|
+
"""
|
|
100
|
+
if not guard_enabled():
|
|
101
|
+
return False
|
|
102
|
+
q_tokens = _query_tokens(query if isinstance(query, str) else "")
|
|
103
|
+
if len(q_tokens) < _MIN_QUERY_TOKENS:
|
|
104
|
+
return False
|
|
105
|
+
if not isinstance(results, list):
|
|
106
|
+
return False
|
|
107
|
+
head = [r for r in results[:_GUARD_WINDOW] if r is not None]
|
|
108
|
+
if not head:
|
|
109
|
+
return False
|
|
110
|
+
for item in head:
|
|
111
|
+
hit = _extract_tokens(_result_text(item)) & q_tokens
|
|
112
|
+
if len(hit) / len(q_tokens) >= _MIN_COVERAGE:
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── SERP/跳转 URL 判定(自 evidence.py 迁入,2026-10-01)────────────────────
|
|
118
|
+
# 迁入原因:这组判定是纯 URL 规则,但原宿主 evidence.py 会把 content_signals
|
|
119
|
+
# 等证据质量栈一起拖进 import(实测 ~9 ms/进程),而它的调用方
|
|
120
|
+
# (evidence_loop.gate_results、search_pipeline 的 SERP 过滤)在**每次搜索
|
|
121
|
+
# 必经**的路径上——缓存命中也照付。本模块只依赖 os/re/json/urllib.parse,
|
|
122
|
+
# 处在 engines_base 冷启动热路径上,轻量有既有纪律保证(见模块 docstring)。
|
|
123
|
+
# evidence.py 保留同名转出,既有调用方(ab_eval_p0p1 等)不受影响。
|
|
124
|
+
_BACKENDS_DIR = os.path.normpath(os.path.join(
|
|
125
|
+
os.path.dirname(os.path.abspath(__file__)), "..", "backends"))
|
|
126
|
+
_CN_OVERRIDES: "dict[str, Any] | None" = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_cn_source_types() -> dict[str, Any]:
|
|
130
|
+
global _CN_OVERRIDES
|
|
131
|
+
if _CN_OVERRIDES is not None:
|
|
132
|
+
return _CN_OVERRIDES
|
|
133
|
+
path = os.path.join(_BACKENDS_DIR, "source_types_cn.json")
|
|
134
|
+
try:
|
|
135
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
136
|
+
_CN_OVERRIDES = json.load(f)
|
|
137
|
+
except Exception:
|
|
138
|
+
_CN_OVERRIDES = {}
|
|
139
|
+
return _CN_OVERRIDES
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _normalize_domain(url: str) -> str:
|
|
143
|
+
if not url:
|
|
144
|
+
return ""
|
|
145
|
+
try:
|
|
146
|
+
host = urlparse(url).netloc.lower()
|
|
147
|
+
except Exception:
|
|
148
|
+
return ""
|
|
149
|
+
if "@" in host:
|
|
150
|
+
host = host.rsplit("@", 1)[1]
|
|
151
|
+
if ":" in host:
|
|
152
|
+
host = host.split(":", 1)[0]
|
|
153
|
+
if host.startswith("www."):
|
|
154
|
+
host = host[4:]
|
|
155
|
+
return host
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _query_has_param(query: str, name: str) -> bool:
|
|
159
|
+
"""query 参数名完整匹配:只在开头或 & 后出现才算。
|
|
160
|
+
|
|
161
|
+
子串匹配会把百度网盘分享的 ?pwd=x 连坐进 wd=(pwd 恰以 wd= 结尾,
|
|
162
|
+
实测误杀)——参数名只可能出现在 query 开头或 & 之后。
|
|
163
|
+
"""
|
|
164
|
+
return query.startswith(name + "=") or ("&" + name + "=") in query
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_serp_or_jump_url(url: str) -> bool:
|
|
168
|
+
"""是否为搜索引擎结果页 / 跳转壳(不可当信源正文)。"""
|
|
169
|
+
if not url:
|
|
170
|
+
return True
|
|
171
|
+
low = url.lower()
|
|
172
|
+
host = _normalize_domain(url)
|
|
173
|
+
|
|
174
|
+
# 白名单:百度子域(百家号、网盘等)不是 SERP,即使 path 为 /s 也不应误杀。
|
|
175
|
+
# 必须最先检查,否则 serp_url_patterns 中的 baidu.com/s? 会误匹配
|
|
176
|
+
# baijiahao.baidu.com/s?id= 百家号文章页。
|
|
177
|
+
_serp_whitelist = ("baijiahao.baidu.com", "pan.baidu.com", "tieba.baidu.com",
|
|
178
|
+
"zhidao.baidu.com", "wenku.baidu.com")
|
|
179
|
+
host_bare = host[4:] if host.startswith("www.") else host
|
|
180
|
+
if host in _serp_whitelist or host_bare in _serp_whitelist:
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
cfg = _load_cn_source_types()
|
|
184
|
+
for pat in cfg.get("serp_url_patterns") or []:
|
|
185
|
+
if pat.lower() in low:
|
|
186
|
+
return True
|
|
187
|
+
# 显式搜索/跳转模式
|
|
188
|
+
if re.search(r"(?:^|\.)baidu\.com/s\?", low):
|
|
189
|
+
return True
|
|
190
|
+
if "baidu.com/link" in low or "baidu.com/baidu.php" in low:
|
|
191
|
+
return True
|
|
192
|
+
if "sogou.com/link" in low:
|
|
193
|
+
return True
|
|
194
|
+
if "google.com/url?" in low or "google.com.hk/url?" in low:
|
|
195
|
+
return True
|
|
196
|
+
if re.search(r"(bing|google|google\.com\.hk)\.com/search\?", low):
|
|
197
|
+
return True
|
|
198
|
+
if "weixin.sogou.com/weixin" in low:
|
|
199
|
+
return True
|
|
200
|
+
try:
|
|
201
|
+
path = urlparse(url).path or ""
|
|
202
|
+
query = urlparse(url).query or ""
|
|
203
|
+
except Exception:
|
|
204
|
+
path, query = "", ""
|
|
205
|
+
# serp 域名只从 source_types_cn.json 的 serp_host_markers 读。此前这里
|
|
206
|
+
# 还并列写了一份字面量集合,两处各写一份正是漏网的成因:google.co.jp /
|
|
207
|
+
# yahoo.co.jp / duckduckgo.com 的结果页会被当成正文信源(authority 拿
|
|
208
|
+
# 到正常分、并进入最终输出),而 google.com 被拦——同一个概念两种判定。
|
|
209
|
+
#
|
|
210
|
+
# 判定用**后缀匹配**而非等值:同一搜索引擎的入口域名很多(yahoo.co.jp /
|
|
211
|
+
# search.yahoo.co.jp、duckduckgo.com / lite.duckduckgo.com、brave.com /
|
|
212
|
+
# search.brave.com),而 _normalize_domain 只去 www.,子域会原样保留。
|
|
213
|
+
# 等值比较下每上一个新入口就得往表里再补一条——那正是这份表原本在漏的。
|
|
214
|
+
serp_hosts = tuple(cfg.get("serp_host_markers") or ())
|
|
215
|
+
_hit = any(
|
|
216
|
+
host_bare == m or host == m
|
|
217
|
+
or host_bare.endswith("." + m) or host.endswith("." + m)
|
|
218
|
+
for m in serp_hosts
|
|
219
|
+
)
|
|
220
|
+
if _hit:
|
|
221
|
+
# path 只精确比较:此前 `path.startswith("/s")` 会把 pan.baidu.com/s/<id>
|
|
222
|
+
# 网盘分享正文链连坐误杀(实测;中文资源类查询的常见结果)。SERP 的
|
|
223
|
+
# path 恰为 /s,上方精确元组已覆盖,不需要前缀匹配。query 参数走
|
|
224
|
+
# _query_has_param 完整匹配(?pwd= 不再喂进 wd=)。
|
|
225
|
+
if path in ("", "/", "/s", "/web", "/search") \
|
|
226
|
+
or "link" in path or "search" in path \
|
|
227
|
+
or _query_has_param(query, "q") or _query_has_param(query, "wd"):
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def drop_shell_urls(results: list) -> list:
|
|
233
|
+
"""过滤结果列表里的 SERP/跳转壳条目——壳判定的唯一过滤出口。
|
|
234
|
+
|
|
235
|
+
为什么必须是本模块的共用函数而不是各处内联:壳判定有多个消费点
|
|
236
|
+
(充分性判定 query_signals——壳不算「够数」;恢复链 L0——壳不算
|
|
237
|
+
「交付」,否则误判恢复成功;L3 恢复结果收口——主链的壳过滤跑在
|
|
238
|
+
恢复链之前,恢复产出是唯一可能把壳带回最终输出的入口)。判定规则
|
|
239
|
+
演进过多次(百度子域白名单、网盘 /s/<id> 误杀),每处内联一份就多
|
|
240
|
+
一处漏改点;2026-10-11 热榜壳链事故的根因之一正是充分性与漏斗
|
|
241
|
+
两处口径漂移。无 url 的结构化快照(行情/天气)不受影响。
|
|
242
|
+
"""
|
|
243
|
+
kept = []
|
|
244
|
+
for r in results:
|
|
245
|
+
if not isinstance(r, dict):
|
|
246
|
+
continue
|
|
247
|
+
u = r.get("url")
|
|
248
|
+
if isinstance(u, str) and u and is_serp_or_jump_url(u):
|
|
249
|
+
continue
|
|
250
|
+
kept.append(r)
|
|
251
|
+
return kept
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── 内容农场与 AI 机翻垃圾站判定(2026-10-23)─────────────────────────────
|
|
255
|
+
#
|
|
256
|
+
# 依据 source_types_cn.json 的 content_farm_markers 与结构化特征模式,
|
|
257
|
+
# 识别并拦截低质搬运镜像、机翻爬虫站及流水线批量 AI 假稿。
|
|
258
|
+
|
|
259
|
+
_FARM_CONNECTORS = ("首先", "其次", "然后", "接下来", "最后", "综上所述")
|
|
260
|
+
_FARM_AI_PHRASES = ("业内人士表示", "专家分析", "消费者认为", "看完这篇你就懂了",
|
|
261
|
+
"看完你就懂了", "建议收藏", "综上所述")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def is_content_farm(url: str = "", title: str = "", snippet: str = "") -> bool:
|
|
265
|
+
"""判定某条目是否属于内容农场 / 机翻爬虫 / 批量流水线 AI 废话。
|
|
266
|
+
|
|
267
|
+
2026-10-23 接线前用 5 条正例 + 4 条边界标定,修正两处:
|
|
268
|
+
- 序数连接词单独出现**不判农场**:正常安装指南同样含「首先/其次/然后/
|
|
269
|
+
接下来/最后」(实测 5 个),旧判据 `hit_conn >= 4` 把它误杀了。现在
|
|
270
|
+
要求同时出现 ≥1 个 AI 套话(流水线稿的伴生特征)。
|
|
271
|
+
- 「自费 N 元实测」不再要求标题带「最新」:实测真实的虚假实测标题是
|
|
272
|
+
「自费5000元实测10款耳机」,不带「最新」,旧判据漏召。
|
|
273
|
+
"""
|
|
274
|
+
if not guard_enabled():
|
|
275
|
+
return False
|
|
276
|
+
cfg = _load_cn_source_types()
|
|
277
|
+
host = _normalize_domain(url)
|
|
278
|
+
host_bare = host[4:] if host.startswith("www.") else host
|
|
279
|
+
farm_markers = tuple(cfg.get("content_farm_markers") or ())
|
|
280
|
+
if any(host_bare == m or host == m or host_bare.endswith("." + m) or host.endswith("." + m)
|
|
281
|
+
for m in farm_markers):
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
low_url = (url or "").lower()
|
|
285
|
+
for pat in cfg.get("content_farm_url_patterns") or []:
|
|
286
|
+
if pat.lower() in low_url:
|
|
287
|
+
# 仅在非白名单顶级域下生效
|
|
288
|
+
if any(host.endswith(tld) for tld in (".top", ".xyz", ".site", ".cc", ".icu", ".work")):
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
# 标题模式:虚假实测 / 利益诱饵 + 年份刷新
|
|
292
|
+
t = (title or "").strip()
|
|
293
|
+
if t:
|
|
294
|
+
if re.search(r"自费\s*\d+\s*元?实测", t):
|
|
295
|
+
return True
|
|
296
|
+
if re.search(r"\d{4}\s*最新推荐\s*\d+\s*款.*看完这篇你就懂了", t):
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
# 摘要/正文模式:高度僵化的模板流水线
|
|
300
|
+
body = f"{t} {snippet or ''}".strip()
|
|
301
|
+
if body:
|
|
302
|
+
hit_conn = sum(1 for w in _FARM_CONNECTORS if w in body)
|
|
303
|
+
hit_ai = sum(1 for w in _FARM_AI_PHRASES if w in body)
|
|
304
|
+
if hit_conn >= 4 and hit_ai >= 1:
|
|
305
|
+
return True
|
|
306
|
+
if hit_ai >= 2 and hit_conn >= 1:
|
|
307
|
+
return True
|
|
308
|
+
if hit_ai >= 3:
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def is_unusable_url(url: str, title: str = "", snippet: str = "") -> bool:
|
|
315
|
+
"""是否为不可作为信源引用的条目(SERP壳 或 确凿内容农场)。"""
|
|
316
|
+
if is_serp_or_jump_url(url):
|
|
317
|
+
return True
|
|
318
|
+
return is_content_farm(url, title, snippet)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def filter_content_farms(results: list) -> list:
|
|
322
|
+
"""过滤结果列表里的内容农场条目。"""
|
|
323
|
+
if not results or not guard_enabled():
|
|
324
|
+
return results or []
|
|
325
|
+
kept = []
|
|
326
|
+
for r in results:
|
|
327
|
+
if not isinstance(r, dict):
|
|
328
|
+
continue
|
|
329
|
+
u = r.get("url") or ""
|
|
330
|
+
t = r.get("title") or ""
|
|
331
|
+
s = r.get("snippet") or ""
|
|
332
|
+
if is_content_farm(u, t, s):
|
|
333
|
+
continue
|
|
334
|
+
kept.append(r)
|
|
335
|
+
return kept
|
|
336
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""serp_kill_log — serp_guard 整页误杀的运行内账本(2026-10-10 方案甲)。
|
|
3
|
+
|
|
4
|
+
为什么单开一个模块:engines_base 是祖父文件、行数顶在登记上限,尺寸门的
|
|
5
|
+
哲学是「要加东西先拆模块」。_serp_guard_apply 的判定逻辑随本账本一起搬来,
|
|
6
|
+
engines_base 留薄委托保持原导入面不变。
|
|
7
|
+
|
|
8
|
+
背景:_serp_guard_apply 对整页垃圾返回 [] 是「诚实空」(结果无关不进熔断
|
|
9
|
+
归因、不伪装成成功),但对调用方等价于「引擎没搜到」——误杀类缺陷(搜狗
|
|
10
|
+
跳转壳 10-06、百家号 10-08)都在这里静默发生过。击杀先入账本,search 层
|
|
11
|
+
执行完 drain 进 errors:可见、可归因、可用 ARGO_SERP_GUARD=0 复核。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# 判定规则与逃生门(ARGO_SERP_GUARD=0)在 serp_guard 单点;这里只登记
|
|
19
|
+
# 哪些引擎接了守卫(解析上游 HTML 页 / 模糊搜索接口最易被缓存 SERP 或
|
|
20
|
+
# 默认 feed 静默命中)。people_daily 的 isFuzzy=True 在匹配不到时返回整页
|
|
21
|
+
# 最新 feed(issue #18 复现 A),与 local_* 同属「答非所问但形态正常」。
|
|
22
|
+
SERP_GUARD_ENGINES = frozenset({
|
|
23
|
+
"local_bing", "local_baidu", "local_sogou", "people_daily",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
# 进程级 + 锁。并发搜索下 drain 可能拿到彼此的条目——条目只含引擎名与
|
|
27
|
+
# 条数、无查询内容,跨运行误挂是无害的信息性噪音。
|
|
28
|
+
_LOCK = threading.Lock()
|
|
29
|
+
_KILLS: list[dict[str, Any]] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def serp_guard_apply(engine_name: str, query: str,
|
|
33
|
+
results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
34
|
+
"""守卫接线:命中引擎且整页判垃圾 → 记账后返回 [](诚实空语义不变)。"""
|
|
35
|
+
if not results or engine_name not in SERP_GUARD_ENGINES:
|
|
36
|
+
return results
|
|
37
|
+
try:
|
|
38
|
+
from serp_guard import is_junk_serp
|
|
39
|
+
except ImportError:
|
|
40
|
+
return results # fail-open:守卫自身故障不放大成搜索故障
|
|
41
|
+
if is_junk_serp(query, results):
|
|
42
|
+
with _LOCK:
|
|
43
|
+
_KILLS.append({"engine": engine_name, "results": len(results)})
|
|
44
|
+
return []
|
|
45
|
+
return results
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def drain_serp_guard_kills() -> list[dict[str, Any]]:
|
|
49
|
+
"""取走并清空本进程累计的整页击杀记录(search 层专用)。"""
|
|
50
|
+
with _LOCK:
|
|
51
|
+
kills = list(_KILLS)
|
|
52
|
+
_KILLS.clear()
|
|
53
|
+
return kills
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def clean_engine_lists(raw_results: dict[str, list[Any]],
|
|
57
|
+
query: str) -> list[list[dict[str, Any]]]:
|
|
58
|
+
"""raw_results{engine → results} → 去 error + SERP 守卫后的非空列表。
|
|
59
|
+
|
|
60
|
+
守卫必须同时覆盖缓存命中:per-engine 缓存走 engine_dispatch,不经过
|
|
61
|
+
engines.search,旧条目里的默认 feed/垃圾 SERP 会以 `ok-cached` 直接
|
|
62
|
+
返回(issue #18 的 14ms 复现)。融合前按引擎整页判定一次,缓存与网络
|
|
63
|
+
两条路径就收敛到同一口径;engines.search 里的守卫保留,因为它还要
|
|
64
|
+
阻止垃圾结果影响早停判定。
|
|
65
|
+
"""
|
|
66
|
+
out: list[list[dict[str, Any]]] = []
|
|
67
|
+
for eng, res in (raw_results or {}).items():
|
|
68
|
+
goods = [r for r in (res or []) if isinstance(r, dict) and "error" not in r]
|
|
69
|
+
if not goods:
|
|
70
|
+
continue
|
|
71
|
+
goods = serp_guard_apply(eng, query, goods)
|
|
72
|
+
if goods:
|
|
73
|
+
out.append(goods)
|
|
74
|
+
return out
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""single_flight.py — 进程内单飞合并:并发相同引擎调用只打一次上游。
|
|
3
|
+
|
|
4
|
+
设计:
|
|
5
|
+
- 线程并发(argo 执行模型是 ThreadPoolExecutor,非 asyncio)
|
|
6
|
+
- key = 引擎 + 规范化查询 + 有效参数指纹;同 key 并发调用合并为一次
|
|
7
|
+
leader 执行,followers 等待 leader 结果
|
|
8
|
+
- leader 失败/超时 → followers 降级为独立调用(避免一次瞬时故障
|
|
9
|
+
变成 N 相关故障)
|
|
10
|
+
- 执行完成后不存储:下一个调用者是新 leader(命中缓存层)
|
|
11
|
+
- ARGO_TOOL_CALL_COALESCE=0 可关闭;leaders/coalesced 计数供观测
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Callable, TypeVar
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
# follower 等待上限:leader 卡死时避免全体阻塞等待
|
|
24
|
+
_DEFAULT_WAIT_TIMEOUT_S = 60.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _enabled() -> bool:
|
|
28
|
+
from engine_env import env_flag
|
|
29
|
+
# 收编到 env_flag(2026-09-29):同 conn_pool——统一真值表 + env 文件可配
|
|
30
|
+
return env_flag("ARGO_TOOL_CALL_COALESCE", default=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SingleFlightCoalescer:
|
|
34
|
+
"""线程版单飞合并器(供引擎执行层复用)。"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, name: str = "") -> None:
|
|
37
|
+
self.name = name
|
|
38
|
+
self._cond = threading.Condition()
|
|
39
|
+
self._inflight: dict[str, tuple[threading.Event, list, float]] = {}
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self.leaders = 0
|
|
42
|
+
self.coalesced = 0
|
|
43
|
+
|
|
44
|
+
def _register_leader(self, key: str) -> None:
|
|
45
|
+
with self._lock:
|
|
46
|
+
self._inflight[key] = (threading.Event(), [], time.time())
|
|
47
|
+
self.leaders += 1
|
|
48
|
+
|
|
49
|
+
def _deregister(self, key: str) -> None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._inflight.pop(key, None)
|
|
52
|
+
|
|
53
|
+
def run(
|
|
54
|
+
self,
|
|
55
|
+
key: str,
|
|
56
|
+
fn: Callable[[], T],
|
|
57
|
+
*,
|
|
58
|
+
wait_timeout: float = _DEFAULT_WAIT_TIMEOUT_S,
|
|
59
|
+
) -> T:
|
|
60
|
+
if not key or not _enabled():
|
|
61
|
+
return fn()
|
|
62
|
+
with self._cond:
|
|
63
|
+
entry = self._inflight.get(key)
|
|
64
|
+
leader = entry is None
|
|
65
|
+
if leader:
|
|
66
|
+
self._register_leader(key)
|
|
67
|
+
else:
|
|
68
|
+
self.coalesced += 1
|
|
69
|
+
if not leader:
|
|
70
|
+
event, slots, started = entry # type: ignore[assignment]
|
|
71
|
+
waited = 0.0
|
|
72
|
+
while not event.is_set():
|
|
73
|
+
if waited >= wait_timeout:
|
|
74
|
+
break # 超时:降级独立调用
|
|
75
|
+
step = min(1.0, wait_timeout - waited)
|
|
76
|
+
event.wait(timeout=step)
|
|
77
|
+
waited += step
|
|
78
|
+
if event.is_set() and slots and not isinstance(slots[0], Exception):
|
|
79
|
+
return slots[0] # type: ignore[return-value]
|
|
80
|
+
# leader 失败或超时 → 独立执行(韧性)
|
|
81
|
+
return fn()
|
|
82
|
+
try:
|
|
83
|
+
result = fn()
|
|
84
|
+
except BaseException as exc: # noqa: BLE001
|
|
85
|
+
self._publish(key, exc)
|
|
86
|
+
self._deregister(key)
|
|
87
|
+
raise
|
|
88
|
+
self._publish(key, result)
|
|
89
|
+
self._deregister(key)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
def _publish(self, key: str, value: Any) -> None:
|
|
93
|
+
with self._cond:
|
|
94
|
+
entry = self._inflight.get(key)
|
|
95
|
+
if entry is None:
|
|
96
|
+
return
|
|
97
|
+
event, slots, _ = entry
|
|
98
|
+
with self._lock:
|
|
99
|
+
if slots:
|
|
100
|
+
slots[0] = value
|
|
101
|
+
else:
|
|
102
|
+
slots.append(value)
|
|
103
|
+
event.set()
|
|
104
|
+
self._cond.notify_all()
|
|
105
|
+
|
|
106
|
+
def stats(self) -> dict[str, int]:
|
|
107
|
+
with self._lock:
|
|
108
|
+
return {
|
|
109
|
+
"leaders": self.leaders,
|
|
110
|
+
"coalesced": self.coalesced,
|
|
111
|
+
"inflight": len(self._inflight),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# 单例:引擎执行层共用
|
|
116
|
+
_engine_coalescer = SingleFlightCoalescer("engines")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def engine_coalescer() -> SingleFlightCoalescer:
|
|
120
|
+
return _engine_coalescer
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""B站搜索引擎
|
|
3
|
+
|
|
4
|
+
使用 B站公开搜索 API(无需登录可搜索视频)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
# 出口调度唯一入口(issue #13 同类修复):urlopen 原生只认标准 HTTP(S)_PROXY
|
|
14
|
+
# 环境变量,**不认** config.yaml 的 network.proxy —— 裸用会在「必须经代理才能
|
|
15
|
+
# 出网」的环境里整源连不上。本文件在 scripts/ 子目录,父目录不在 sys.path 上。
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
19
|
+
from net_proxy import open_url # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _http_get_with_retry(url: str, headers: dict, timeout: int = 10, max_retries: int = 2):
|
|
23
|
+
"""带重试的 HTTP GET,尊重 429 + Retry-After。
|
|
24
|
+
|
|
25
|
+
仅使用 stdlib,不引入第三方依赖。
|
|
26
|
+
返回 (body_bytes, status_code)。失败时抛出最后一次异常。
|
|
27
|
+
"""
|
|
28
|
+
for attempt in range(max_retries + 1):
|
|
29
|
+
try:
|
|
30
|
+
req = urllib.request.Request(url, headers=headers)
|
|
31
|
+
with open_url(req, timeout=timeout) as resp:
|
|
32
|
+
return resp.read(), resp.status
|
|
33
|
+
except urllib.error.HTTPError as e:
|
|
34
|
+
if e.code == 429 and attempt < max_retries:
|
|
35
|
+
retry_after = int(e.headers.get("Retry-After", "5"))
|
|
36
|
+
time.sleep(min(retry_after, 30))
|
|
37
|
+
continue
|
|
38
|
+
raise
|
|
39
|
+
except (urllib.error.URLError, OSError):
|
|
40
|
+
if attempt < max_retries:
|
|
41
|
+
time.sleep(2 ** attempt + 0.5)
|
|
42
|
+
continue
|
|
43
|
+
raise
|
|
44
|
+
return b"", 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def search(query: str, n: int = 5) -> list[dict]:
|
|
48
|
+
"""通过 B站搜索 API 搜索"""
|
|
49
|
+
encoded = urllib.parse.quote(query)
|
|
50
|
+
url = (
|
|
51
|
+
f"https://api.bilibili.com/x/web-interface/search/all/v2"
|
|
52
|
+
f"?keyword={encoded}&page=1&pagesize={n}"
|
|
53
|
+
)
|
|
54
|
+
headers = {
|
|
55
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
56
|
+
"Referer": "https://www.bilibili.com"
|
|
57
|
+
}
|
|
58
|
+
try:
|
|
59
|
+
body, _ = _http_get_with_retry(url, headers, timeout=10, max_retries=2)
|
|
60
|
+
data = json.loads(body.decode("utf-8"))
|
|
61
|
+
return _parse_bilibili_response(data, n)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
# 吞异常返空会让 MCP 社交搜索把网络故障静默报成「平台成功、0 结果」
|
|
64
|
+
# (mcp_handlers 的 err=None 设计依赖引擎抛异常或返 error 占位,
|
|
65
|
+
# 见 tests/test_regression_p0p1.py「零结果应以 errors 提示」契约)。
|
|
66
|
+
# 对齐 zhihu_engine 范式:失败必须带着 error 字段浮上来。
|
|
67
|
+
return [{"error": f"bilibili {type(e).__name__}: {e}", "source": "bilibili"}]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_bilibili_response(data: dict, n: int) -> list[dict]:
|
|
71
|
+
"""解析 B站 API 响应"""
|
|
72
|
+
results = []
|
|
73
|
+
data_data = data.get("data", {})
|
|
74
|
+
for result_type in data_data.get("result", []):
|
|
75
|
+
if result_type.get("result_type") != "video":
|
|
76
|
+
continue
|
|
77
|
+
for item in result_type.get("data", [])[:n]:
|
|
78
|
+
title = item.get("title", "")
|
|
79
|
+
# 清理 HTML 标签
|
|
80
|
+
import re
|
|
81
|
+
title = re.sub(r'<[^>]+>', '', title)
|
|
82
|
+
results.append({
|
|
83
|
+
"title": title[:100] + ("..." if len(title) > 100 else ""),
|
|
84
|
+
"url": f"https://www.bilibili.com/video/{item.get('bvid', '')}",
|
|
85
|
+
"snippet": (item.get("description", "") or title)[:300],
|
|
86
|
+
"source": "bilibili",
|
|
87
|
+
"score": max(1.0 - len(results) * 0.1, 0.1),
|
|
88
|
+
"social_meta": {
|
|
89
|
+
"platform": "bilibili",
|
|
90
|
+
"content_type": "video",
|
|
91
|
+
"author": item.get("author", ""),
|
|
92
|
+
"duration": item.get("duration", ""),
|
|
93
|
+
"play_count": item.get("play", 0),
|
|
94
|
+
"danmu_count": item.get("video_review", 0),
|
|
95
|
+
"like_count": item.get("like", 0),
|
|
96
|
+
"bvid": item.get("bvid", ""),
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
if len(results) >= n:
|
|
100
|
+
break
|
|
101
|
+
if len(results) >= n:
|
|
102
|
+
break
|
|
103
|
+
return results
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
import argparse
|
|
108
|
+
parser = argparse.ArgumentParser(description="Bilibili search engine")
|
|
109
|
+
parser.add_argument("action", nargs="?", default="search")
|
|
110
|
+
parser.add_argument("query", nargs="?")
|
|
111
|
+
parser.add_argument("-n", type=int, default=5)
|
|
112
|
+
parser.add_argument("--json", action="store_true")
|
|
113
|
+
args = parser.parse_args()
|
|
114
|
+
|
|
115
|
+
if not args.query:
|
|
116
|
+
print("[]")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
results = search(args.query, args.n)
|
|
120
|
+
if args.json:
|
|
121
|
+
print(json.dumps(results, ensure_ascii=False))
|
|
122
|
+
else:
|
|
123
|
+
for i, r in enumerate(results, 1):
|
|
124
|
+
print(f"### {i}. {r['title']}")
|
|
125
|
+
print(f"- **URL**: {r['url']}")
|
|
126
|
+
print(f"- {r['snippet'][:200]}")
|
|
127
|
+
print()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|