argo-search 1.0.1 → 2.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -21
- package/README.en.md +822 -0
- package/README.es.md +864 -0
- package/README.ja.md +864 -0
- package/README.ko.md +864 -0
- package/README.md +867 -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/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2308 -96
- package/backends/engine_registry.yaml +3314 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2409 -161
- package/backends/source_types_cn.json +83 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +703 -0
- package/bin/argo.js +71 -39
- package/config.yaml +4976 -363
- 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 +564 -0
- package/docs/MCP_SETUP.md +106 -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/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/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/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 +31 -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/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -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/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/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/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/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 +74 -0
- package/package.json +51 -12
- package/packages/dsh-plugin/README.md +134 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1244 -0
- package/packages/dsh-plugin/dsh/index.js +2290 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/test/client.test.js +232 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/index.test.js +1338 -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 +271 -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/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/burstiness_detector.py +147 -0
- package/scripts/cache.py +1078 -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/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_security.py +685 -0
- package/scripts/content_signals.py +730 -0
- package/scripts/crawl.py +74 -35
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +1019 -0
- package/scripts/engine_env.py +534 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +732 -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 +778 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +298 -0
- package/scripts/engines_builders_batch10.py +159 -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_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_search.py +440 -0
- package/scripts/engines_builders_tech.py +1360 -0
- package/scripts/evidence.py +588 -162
- package/scripts/evidence_loop.py +592 -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 +1779 -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 +184 -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 +0 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +206 -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_engines.py +321 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +366 -0
- package/scripts/macro_countries.py +82 -0
- package/scripts/matrix_search_eval.py +1105 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +1074 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +122 -0
- package/scripts/mcp_server.py +118 -417
- package/scripts/mcp_setup.py +407 -0
- package/scripts/mcp_tools.py +330 -0
- package/scripts/mcp_transport.py +191 -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/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 +488 -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/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +640 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +591 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +146 -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/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +225 -0
- package/scripts/route_combo.py +910 -0
- package/scripts/route_domains.py +328 -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 +897 -405
- package/scripts/search_attribution.py +88 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +627 -0
- package/scripts/search_entry.py +389 -0
- package/scripts/search_output.py +606 -0
- package/scripts/search_pipeline.py +1077 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +27 -12
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +215 -0
- package/scripts/serp_guard.py +228 -0
- package/scripts/serp_kill_log.py +51 -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 +95 -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 +402 -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/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/references/browser-runtime.md +43 -0
- package/sub-skills/ego-search/references/install.md +67 -0
- package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -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/ego-search/tests/test_query_normalize.py +68 -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/js-run/tests/fixtures/challenge_like.js +27 -0
- package/sub-skills/js-run/tests/test_jsrun.py +165 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +162 -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/search_v3.py +1182 -65
- 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/references/auto-behaviors.md +24 -0
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
- package/sub-skills/local-seek/references/git-integration.md +54 -0
- package/sub-skills/local-seek/references/remote-handoff.md +46 -0
- package/sub-skills/local-seek/references/rg-recipes.md +96 -0
- package/sub-skills/local-seek/references/strategies.md +52 -0
- package/sub-skills/local-seek/references/structural-search.md +59 -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 +193 -0
- package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
- package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
- package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
- package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
- package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
- package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
|
@@ -0,0 +1,1521 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""engines_base — 公共工具 + cli/http/html 构建器 + 通用解析。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
import serp_kill_log # noqa: E402 守卫接线+误杀账本(2026-10-10 拆出,stdlib 级轻量)
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from config import load_config, get_engines
|
|
26
|
+
except ImportError:
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
28
|
+
from config import load_config, get_engines
|
|
29
|
+
|
|
30
|
+
# 图片许可判定 / 字段收尾 / CLI 字段契约收敛都在 image_ops(体积门禁要求,
|
|
31
|
+
# 且同属「引擎输出 → 对外契约」这一层)。转发导入保持调用点不变。
|
|
32
|
+
from image_ops import ( # noqa: E402
|
|
33
|
+
CLI_PASSTHROUGH_FIELDS as _CLI_PASSTHROUGH_FIELDS,
|
|
34
|
+
_cli_result_row,
|
|
35
|
+
_license_allows_commercial,
|
|
36
|
+
finalize_image_fields,
|
|
37
|
+
image_output_map_fields,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_logger = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_logger():
|
|
44
|
+
"""延迟创建 logger,避免 import logging 的重量级 import 链(traceback→dataclasses→inspect)。"""
|
|
45
|
+
global _logger
|
|
46
|
+
if _logger is None:
|
|
47
|
+
import logging
|
|
48
|
+
_logger = logging.getLogger("unified_search.engines")
|
|
49
|
+
if not _logger.handlers:
|
|
50
|
+
_logger.setLevel(logging.WARNING)
|
|
51
|
+
_logger.addHandler(logging.StreamHandler(sys.stderr))
|
|
52
|
+
return _logger
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── 引擎级失败归因寄存器 ─────────────────────────────────────────────────────
|
|
56
|
+
# 进程内「引擎 → 最近一次失败归因」。归因必须在失败现场写入——这是唯一能同时
|
|
57
|
+
# 拿到引擎名、状态码、响应体的位置。HTML 引擎的反爬命中走「静默返回空」路径,
|
|
58
|
+
# 没有任何错误文本可供聚合层事后反推;不在这里寄存,封锁就永远伪装成空结果,
|
|
59
|
+
# 被封的引擎会被当成故障累计到熔断里(封错人)。
|
|
60
|
+
_FAIL_NOTES: dict[str, dict[str, Any]] = {}
|
|
61
|
+
_FAIL_NOTES_LOCK = threading.Lock()
|
|
62
|
+
|
|
63
|
+
# HTTPError 错误体的读取上限:只用于归因(下游截 400 字符),
|
|
64
|
+
# 无限读会把几十 MB 错误页吃进内存并写进磁盘状态
|
|
65
|
+
_HTTP_ERROR_BODY_CAP = 64 * 1024
|
|
66
|
+
|
|
67
|
+
# 归因细节里可能回显凭证(OpenAI 风格 "Incorrect API key: sk-…"),
|
|
68
|
+
# 且会持久化到 circuit_breaker.json / 出现在 --list-engines 输出。
|
|
69
|
+
# 复用 archive_run 的唯一脱敏实现,避免各写一份规则。
|
|
70
|
+
try:
|
|
71
|
+
from archive_run import redact_secrets as _redact_secrets
|
|
72
|
+
except Exception: # pragma: no cover - archive_run 不可用时退化为基本脱敏
|
|
73
|
+
_FALLBACK_BEARER_RE = re.compile(r'(Bearer\s+)[A-Za-z0-9._\-]+')
|
|
74
|
+
_FALLBACK_KEY_RE = re.compile(r'((?:api[_-]?key|token|secret)["\']?\s*[:=]\s*["\']?)[A-Za-z0-9._\-]{8,}')
|
|
75
|
+
|
|
76
|
+
def _redact_secrets(text: str) -> str:
|
|
77
|
+
"""archive_run 不可用时的兜底脱敏:至少覆盖 Bearer token 和 key=value 形式。"""
|
|
78
|
+
text = _FALLBACK_BEARER_RE.sub(r'\1***', text)
|
|
79
|
+
text = _FALLBACK_KEY_RE.sub(r'\1***', text)
|
|
80
|
+
return text
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def note_failure(engine: str, category: str, reason: str, detail: str = "") -> None:
|
|
84
|
+
"""记录引擎最近一次失败的归因(覆盖式:只留最后一次)。
|
|
85
|
+
|
|
86
|
+
detail 在写入前脱敏:这里聚合了引擎名、状态码与**响应体**,是唯一能把
|
|
87
|
+
上游回显的凭证带进持久化状态的位置(会写进 circuit_breaker.json 并出现在
|
|
88
|
+
--list-engines 输出)。脱敏在唯一的写入点做,消费者无需各自处理。
|
|
89
|
+
"""
|
|
90
|
+
if not engine:
|
|
91
|
+
return
|
|
92
|
+
with _FAIL_NOTES_LOCK:
|
|
93
|
+
_FAIL_NOTES[engine] = {
|
|
94
|
+
"category": category,
|
|
95
|
+
"reason": reason,
|
|
96
|
+
"detail": _redact_secrets(detail or "")[:200],
|
|
97
|
+
"ts": time.time(),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def pop_failure_note(engine: str) -> dict[str, Any] | None:
|
|
102
|
+
"""取走引擎的失败归因(读后即清,避免陈旧归因污染后续查询)。"""
|
|
103
|
+
with _FAIL_NOTES_LOCK:
|
|
104
|
+
return _FAIL_NOTES.pop(engine, None)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _lang_param(param: str, query: str) -> str:
|
|
108
|
+
"""按查询主语言返回引擎语言参数;表在 lang_detect 单来源维护。
|
|
109
|
+
|
|
110
|
+
弱信号查询(mixed/other)时注入 lang_pref 的 engine_lang(习惯/系统/中英基线),
|
|
111
|
+
强查询信号仍由 detect_language 主导,不因系统 locale 覆盖。
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
from lang_detect import engine_lang_param, detect_language
|
|
115
|
+
preferred = ""
|
|
116
|
+
try:
|
|
117
|
+
from lang_pref import effective_engine_lang
|
|
118
|
+
preferred = effective_engine_lang(detect_language(query))
|
|
119
|
+
except ImportError:
|
|
120
|
+
pass
|
|
121
|
+
return engine_lang_param(param, query, preferred_lang=preferred)
|
|
122
|
+
except ImportError:
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def safe_search(fn: Callable) -> Callable:
|
|
127
|
+
"""统一错误处理装饰器。所有异常返回 [],细粒度异常先于通用 Exception 匹配。"""
|
|
128
|
+
@functools.wraps(fn)
|
|
129
|
+
def wrapper(*args, **kwargs) -> list[dict[str, Any]]:
|
|
130
|
+
name = fn.__name__.replace("_engine", "").strip("_")
|
|
131
|
+
try:
|
|
132
|
+
return fn(*args, **kwargs)
|
|
133
|
+
except subprocess.TimeoutExpired:
|
|
134
|
+
_get_logger().warning(f"引擎 {name} 超时")
|
|
135
|
+
except FileNotFoundError as e:
|
|
136
|
+
_get_logger().warning(f"引擎 {name} 命令不存在: {e}")
|
|
137
|
+
except (urllib.error.URLError, urllib.error.HTTPError) as e:
|
|
138
|
+
_get_logger().warning(f"引擎 {name} HTTP 错误: {e}")
|
|
139
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
140
|
+
_get_logger().warning(f"引擎 {name} 解析错误: {e}")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
_get_logger().error(f"引擎 {name} 未预期异常: {type(e).__name__}: {e}", exc_info=True)
|
|
143
|
+
return []
|
|
144
|
+
return wrapper
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── 引擎内排序分(rank decay)─────────────────────────────────────────────────
|
|
148
|
+
# 2026-09-13 修复「硬编码常量 score」:多数 builder 给同一引擎返回的每条结果
|
|
149
|
+
# 写死同一个分(如全部 0.7),于是上游 API 自己的相关性排序在进 RRF 前就被
|
|
150
|
+
# 抹平了——融合层只剩「引擎内序位」这点信息,且 local_five_dim_rerank 的
|
|
151
|
+
# relevance 维度对同引擎结果无区分度。
|
|
152
|
+
#
|
|
153
|
+
# 约定:builder 返回的 list 顺序 = 上游 API 的相关性顺序(各实现均按 API 原序
|
|
154
|
+
# append,未重排)。因此不需要猜上游分数语义,只需把「位置」编码进 score:
|
|
155
|
+
# base —— 该引擎的质量档位(沿用原常量,保持引擎间相对权重不变);
|
|
156
|
+
# rank —— 在本次结果中的 0 基序位。
|
|
157
|
+
# 衰减用 1/(1+rank*DECAY):rank0=base、rank1≈0.97*base、rank4≈0.88*base,
|
|
158
|
+
# 单调递减且差距温和(避免位置噪声压过引擎档位差)。
|
|
159
|
+
#
|
|
160
|
+
# 上游本身给了真实分数的引擎(openalex/crossref 等)不要用本函数——直接透传
|
|
161
|
+
# 上游分更准;本函数只服务「原先写死常量」的场景。
|
|
162
|
+
RANK_DECAY = 0.03
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def rank_score(base: float, rank: int) -> float:
|
|
166
|
+
"""把常量基础分 + 结果序位转成单调递减的相关性分(保序、不夸大)。
|
|
167
|
+
|
|
168
|
+
base 保持引擎档位(跨引擎相对权重不变),rank 仅在引擎内做温和衰减。
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
b = float(base)
|
|
172
|
+
r = int(rank)
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
return float(base) if isinstance(base, (int, float)) else 0.7
|
|
175
|
+
# 边界加固:NaN/Inf 会一路传到融合层,而下游最终排序用 abs(score)
|
|
176
|
+
# (search.py 的 merged.sort),NaN 的比较结果恒 False 会让排序退化为
|
|
177
|
+
# 任意序;负分经 abs() 会**反转成最高分**。两者都不是调用方有意为之,
|
|
178
|
+
# 统一夹到 [0, 1] 并让非有限值退化为默认档位。
|
|
179
|
+
if not math.isfinite(b):
|
|
180
|
+
return 0.7
|
|
181
|
+
b = min(max(b, 0.0), 1.0)
|
|
182
|
+
if r <= 0:
|
|
183
|
+
return b
|
|
184
|
+
return round(b / (1.0 + r * RANK_DECAY), 4)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _run(cmd: list[str], timeout: float = 8, engine_name: str = "?") -> str:
|
|
188
|
+
"""执行命令,超时/异常不抛。"""
|
|
189
|
+
try:
|
|
190
|
+
r = subprocess.run(cmd, capture_output=True, text=True,
|
|
191
|
+
encoding="utf-8", errors="replace", timeout=timeout)
|
|
192
|
+
if r.returncode == 0:
|
|
193
|
+
return r.stdout
|
|
194
|
+
tail = (r.stderr or "").strip()[:200]
|
|
195
|
+
_get_logger().warning(f"引擎 {engine_name} 失败 (rc={r.returncode}): {tail}")
|
|
196
|
+
return r.stdout if r.stdout.strip() else ""
|
|
197
|
+
except subprocess.TimeoutExpired:
|
|
198
|
+
_get_logger().warning(f"引擎 {engine_name} 超时 (>{timeout}s)")
|
|
199
|
+
except FileNotFoundError as e:
|
|
200
|
+
_get_logger().error(f"引擎 {engine_name} CLI 缺失: {e}")
|
|
201
|
+
except Exception as e:
|
|
202
|
+
_get_logger().error(f"引擎 {engine_name} 异常: {type(e).__name__}: {e}")
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_PROC_UUID: str | None = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _proc_uuid() -> str:
|
|
210
|
+
"""进程级一次性 UUID(大写 hex):同进程跨请求一致、跨进程彼此不同。
|
|
211
|
+
|
|
212
|
+
服务「要一个稳定身份但不想要固定身份」的上游——典型如 Baidu BAIDUID:
|
|
213
|
+
固定值会随请求量被关联拉黑(2026-09-26 实测:同一写死值密集调用后整值
|
|
214
|
+
作废),随机新值 + 进程内一致性即可稳定出结果。{UUID} 在 env 占位符
|
|
215
|
+
替换**之前**落地,避免被 {ENV} 正则吞掉去查不存在的环境变量。
|
|
216
|
+
"""
|
|
217
|
+
global _PROC_UUID
|
|
218
|
+
if _PROC_UUID is None:
|
|
219
|
+
import uuid as _uuid
|
|
220
|
+
_PROC_UUID = _uuid.uuid4().hex.upper()
|
|
221
|
+
return _PROC_UUID
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resolve(template: list[str] | str, query: str, n: int, **extra: Any) -> list[str] | str:
|
|
225
|
+
"""替换模板占位符。"""
|
|
226
|
+
if isinstance(template, list):
|
|
227
|
+
return [_resolve(item, query, n, **extra) for item in template]
|
|
228
|
+
s = template.replace("{query}", query).replace("{n}", str(n))
|
|
229
|
+
s = s.replace("{TIMESTAMP}", str(int(time.time())))
|
|
230
|
+
if "{UUID}" in s:
|
|
231
|
+
s = s.replace("{UUID}", _proc_uuid())
|
|
232
|
+
for key, val in extra.items():
|
|
233
|
+
s = s.replace(f"{{{key}}}", str(val))
|
|
234
|
+
if s.startswith("~"):
|
|
235
|
+
s = str(Path.home() / s[1:])
|
|
236
|
+
# env 占位符:缺失时替换为空串而非保留字面量。
|
|
237
|
+
# 保留字面量会把 `Authorization: token {GITHUB_TOKEN}` 原样发出 → 401;
|
|
238
|
+
# 空串 + 调用方过滤空头 = 未配置 key 的引擎自动退化为匿名请求。
|
|
239
|
+
# 经 engine_env 按候选链解析(PLACEHOLDER_ALIASES:ARGO_ 推荐名优先 +
|
|
240
|
+
# 历史兼容名):os.environ 优先 + ~/.config/argo/env 热读保底
|
|
241
|
+
# (密钥轮换改文件即生效,无需重启)。
|
|
242
|
+
try:
|
|
243
|
+
from engine_env import PLACEHOLDER_ALIASES as _PA, get_env as _get_env
|
|
244
|
+
return re.sub(
|
|
245
|
+
r"\{([A-Z_][A-Z0-9_]*)\}",
|
|
246
|
+
lambda m: _get_env(
|
|
247
|
+
_PA.get(m.group(1), [f"ARGO_{m.group(1)}", m.group(1)]), ""
|
|
248
|
+
),
|
|
249
|
+
s,
|
|
250
|
+
)
|
|
251
|
+
except ImportError:
|
|
252
|
+
return re.sub(r"\{([A-Z_][A-Z0-9_]*)\}", lambda m: os.environ.get(m.group(1), ""), s)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_AUTH_PREFIXES = ("Bearer", "token", "Basic", "Key", "Api-Key", "X-Key", "Secret", "Appid")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _header_meaningful(v: Any) -> bool:
|
|
259
|
+
"""头值是否有意义:空值、纯空白、或认证前缀残留('Bearer '/ 'token ' 后无凭据)都过滤。
|
|
260
|
+
|
|
261
|
+
'{GITHUB_TOKEN}' 未配置时 _resolve 得到 'token '(前缀残留非空),
|
|
262
|
+
仅靠非空判断过滤不掉;必须按「认证前缀 + 空格 + 无凭据」精确识别,
|
|
263
|
+
且不能用 strip 预处理(会丢掉区分用的尾随空格)。
|
|
264
|
+
"""
|
|
265
|
+
if not v:
|
|
266
|
+
return False
|
|
267
|
+
raw = str(v)
|
|
268
|
+
if not raw.strip():
|
|
269
|
+
return False
|
|
270
|
+
for prefix in _AUTH_PREFIXES:
|
|
271
|
+
marker = prefix + " "
|
|
272
|
+
if raw.lower().startswith(marker.lower()):
|
|
273
|
+
if not raw[len(prefix):].strip():
|
|
274
|
+
return False
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _get_path(obj: Any, path: str) -> Any:
|
|
279
|
+
"""按点分路径取值,支持 list 下标(如 authors.0.name)。空路径返回 obj。"""
|
|
280
|
+
if path in ("", ".", "$", "[]", None):
|
|
281
|
+
return obj
|
|
282
|
+
cur = obj
|
|
283
|
+
for part in str(path).split("."):
|
|
284
|
+
if cur is None:
|
|
285
|
+
return None
|
|
286
|
+
if isinstance(cur, dict):
|
|
287
|
+
cur = cur.get(part)
|
|
288
|
+
elif isinstance(cur, (list, tuple)) and part.isdigit():
|
|
289
|
+
idx = int(part)
|
|
290
|
+
cur = cur[idx] if 0 <= idx < len(cur) else None
|
|
291
|
+
else:
|
|
292
|
+
return None
|
|
293
|
+
return cur
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _coerce_field(val: Any, *, max_len: int | None = None) -> str:
|
|
297
|
+
"""把 API 字段压成字符串:list 取首项、dict 丢弃、其余 str。"""
|
|
298
|
+
if val is None:
|
|
299
|
+
return ""
|
|
300
|
+
if isinstance(val, list):
|
|
301
|
+
if not val:
|
|
302
|
+
return ""
|
|
303
|
+
val = val[0]
|
|
304
|
+
if isinstance(val, dict):
|
|
305
|
+
return ""
|
|
306
|
+
s = str(val).strip()
|
|
307
|
+
if max_len is not None and len(s) > max_len:
|
|
308
|
+
return s[:max_len]
|
|
309
|
+
return s
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _format_url_template(template: str, item: dict) -> str:
|
|
313
|
+
"""支持 {field} 与 {a.b.c} 点分路径的 URL 模板。"""
|
|
314
|
+
def repl(m: re.Match) -> str:
|
|
315
|
+
return _coerce_field(_get_path(item, m.group(1)))
|
|
316
|
+
try:
|
|
317
|
+
return re.sub(r"\{([^}]+)\}", repl, template)
|
|
318
|
+
except Exception:
|
|
319
|
+
return ""
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _extract_items(data: Any, path: str) -> list:
|
|
323
|
+
"""从 JSON 按路径提取列表。path 为 . / $ / [] / 空 且 data 为 list 时直接返回。"""
|
|
324
|
+
if path in ("", ".", "$", "[]"):
|
|
325
|
+
return data if isinstance(data, list) else []
|
|
326
|
+
if not isinstance(data, dict):
|
|
327
|
+
return []
|
|
328
|
+
obj = _get_path(data, path)
|
|
329
|
+
if isinstance(obj, list):
|
|
330
|
+
return obj
|
|
331
|
+
# DBLP 等 API 在 h=1 时把 hit 收成对象而非数组
|
|
332
|
+
if isinstance(obj, dict):
|
|
333
|
+
return [obj]
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _make_field_parser(path: str, fields: dict[str, str], url_template: str | None = None,
|
|
338
|
+
max_items: int = 10) -> Callable:
|
|
339
|
+
"""构造声明式 parser。支持点分路径字段与 url_template(含嵌套占位)。"""
|
|
340
|
+
def parser(data: Any) -> list[dict[str, Any]]:
|
|
341
|
+
if isinstance(data, list) and path in ("", ".", "$", "[]"):
|
|
342
|
+
items = data
|
|
343
|
+
elif isinstance(data, dict):
|
|
344
|
+
items = _extract_items(data, path)
|
|
345
|
+
else:
|
|
346
|
+
items = []
|
|
347
|
+
results = []
|
|
348
|
+
for item in items:
|
|
349
|
+
if not isinstance(item, dict):
|
|
350
|
+
continue
|
|
351
|
+
r: dict[str, Any] = {}
|
|
352
|
+
for ok, ik in fields.items():
|
|
353
|
+
if not ik:
|
|
354
|
+
continue
|
|
355
|
+
raw = _get_path(item, ik) if "." in str(ik) else item.get(ik, "")
|
|
356
|
+
# 兼容顶层点分键名不存在时回退 get
|
|
357
|
+
if raw in ("", None) and "." not in str(ik):
|
|
358
|
+
raw = item.get(ik, "")
|
|
359
|
+
coerced = _coerce_field(raw, max_len=300 if ok == "snippet" else 500)
|
|
360
|
+
if coerced:
|
|
361
|
+
r[ok] = coerced
|
|
362
|
+
if url_template and (not r.get("url") or not str(r["url"]).startswith(("http://", "https://"))):
|
|
363
|
+
built = _format_url_template(url_template, item)
|
|
364
|
+
if built.startswith(("http://", "https://")):
|
|
365
|
+
r["url"] = built
|
|
366
|
+
if isinstance(r.get("url"), str) and r["url"].startswith("//"):
|
|
367
|
+
r["url"] = "https:" + r["url"]
|
|
368
|
+
if r.get("title") or r.get("url"):
|
|
369
|
+
results.append(r)
|
|
370
|
+
return results[:max_items]
|
|
371
|
+
return parser
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _build_cli_engine(spec: dict[str, Any]) -> Any:
|
|
375
|
+
cmd_template = spec.get("cmd", [])
|
|
376
|
+
search_args = spec.get("search_args", [])
|
|
377
|
+
env_overrides = spec.get("env", {})
|
|
378
|
+
output_format = spec.get("output_format", "") # "yaml" | ""(JSON/文本自动)
|
|
379
|
+
filter_args = spec.get("filter_args", {}) # {kwarg: [参数模板...]},kwargs 携带时追加
|
|
380
|
+
|
|
381
|
+
@safe_search
|
|
382
|
+
def _engine(query: str, n: int = 5, timeout: float = 8, mode: str = "fast", **kwargs) -> list[dict[str, Any]]:
|
|
383
|
+
cmd = _resolve(cmd_template, query, n, mode=mode, **kwargs)
|
|
384
|
+
args = _resolve(search_args, query, n, mode=mode, **kwargs)
|
|
385
|
+
if not cmd:
|
|
386
|
+
return []
|
|
387
|
+
# 跨平台解释器解析:Windows 一般没有 python3 这个可执行名
|
|
388
|
+
if isinstance(cmd, list) and cmd and cmd[0] == "python3":
|
|
389
|
+
import shutil # 仅 CLI 引擎解析解释器时需要,不进模块级导入链
|
|
390
|
+
py3 = shutil.which("python3") or shutil.which("python") or sys.executable
|
|
391
|
+
cmd[0] = py3
|
|
392
|
+
for key, tmpl in filter_args.items():
|
|
393
|
+
if kwargs.get(key) not in (None, ""):
|
|
394
|
+
args += _resolve(tmpl, query, n, mode=mode, **kwargs)
|
|
395
|
+
env = os.environ.copy()
|
|
396
|
+
env.update(env_overrides)
|
|
397
|
+
return _parse_text_output(_run(cmd + args, timeout=timeout, engine_name=spec.get("_name", "cli")),
|
|
398
|
+
spec.get("_name", "cli"), output_format=output_format, n=n)
|
|
399
|
+
return _engine
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _build_http_engine(spec: dict[str, Any]) -> Any:
|
|
403
|
+
"""统一 HTTP 引擎构造(GET/POST)。
|
|
404
|
+
|
|
405
|
+
GET 请求走 HttpClient(UA 轮换 + Cookie 积累 + 429/503 Retry-After 尊重 +
|
|
406
|
+
指数退避重试 + 重定向跟随 + 域族节流);POST 走 http_open(与 GET 共用同一
|
|
407
|
+
归因路径;POST 型引擎均为 JSON API,无进程内节流需求)。开关
|
|
408
|
+
ARGO_ENGINE_HTTP_CLIENT=0 可整体回退 GET 到 urllib(小范围试运行/诊断用)。
|
|
409
|
+
spec 显式声明的 max_concurrency / min_interval_ms 覆盖域族默认节流。
|
|
410
|
+
"""
|
|
411
|
+
url_template = spec.get("url", "")
|
|
412
|
+
headers = spec.get("headers", {"Content-Type": "application/json"})
|
|
413
|
+
query_param = spec.get("query_param", "q")
|
|
414
|
+
fmt = spec.get("format", "")
|
|
415
|
+
# format 的双重身份:它本来是**解析提示**(_parse_http_payload 用它区分
|
|
416
|
+
# xml/json 分支),但历史上被无条件当成查询参数拼进 URL。绝大多数 API
|
|
417
|
+
# 忽略未知参数所以没暴露问题,直到 local_pubmed 撞上 NCBI E-utilities——
|
|
418
|
+
# 那里 `format` 是**输出格式**参数且不接受 json,注入后同一条 URL 由
|
|
419
|
+
# HTTP 200 变 400(实测),引擎静默返回 0 结果。
|
|
420
|
+
# 默认仍注入(保持 47 个既有引擎行为不变),需要干净的引擎显式声明
|
|
421
|
+
# `format_is_query_param: false` 退出注入。改全局会动到全部 47 个源,
|
|
422
|
+
# 拿大范围风险换一个小修,故按引擎收敛。
|
|
423
|
+
format_is_query_param = spec.get("format_is_query_param", True)
|
|
424
|
+
timeout = spec.get("timeout", 8)
|
|
425
|
+
extra_params = spec.get("extra_params", {})
|
|
426
|
+
output_map = spec.get("output_map", {})
|
|
427
|
+
is_get = spec.get("method", "GET") == "GET"
|
|
428
|
+
body_template = spec.get("body", {})
|
|
429
|
+
eng = spec.get("_name", "")
|
|
430
|
+
# spec 显式节流覆盖(max_concurrency / min_interval_ms),声明即契约
|
|
431
|
+
if spec.get("max_concurrency") is not None or \
|
|
432
|
+
spec.get("min_interval_ms") is not None:
|
|
433
|
+
try:
|
|
434
|
+
from http_client import register_spec_limit
|
|
435
|
+
register_spec_limit(eng,
|
|
436
|
+
spec.get("max_concurrency"),
|
|
437
|
+
spec.get("min_interval_ms"))
|
|
438
|
+
except ImportError:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
@safe_search
|
|
442
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, depth: str = "fast", **kwargs) -> list[dict[str, Any]]:
|
|
443
|
+
to = _timeout or timeout
|
|
444
|
+
import urllib.parse as up
|
|
445
|
+
|
|
446
|
+
if is_get:
|
|
447
|
+
resolved_url = _resolve(url_template, query, n, **kwargs)
|
|
448
|
+
parts: list[str] = []
|
|
449
|
+
if query_param: # 空字符串表示该 API 不用查询参数名(仅 extra_params)
|
|
450
|
+
parts.append(f"{query_param}={up.quote(query)}")
|
|
451
|
+
if fmt and format_is_query_param:
|
|
452
|
+
parts.append(f"format={up.quote(str(fmt))}")
|
|
453
|
+
for k, v in extra_params.items():
|
|
454
|
+
# 语言参数动态化(v2.7):按查询主语言覆盖静态 setlang/hl/lang/mkt
|
|
455
|
+
if k in ("setlang", "hl", "lang", "uselang", "mkt"):
|
|
456
|
+
v = _lang_param(k, query) or v
|
|
457
|
+
parts.append(f"{k}={up.quote(_resolve(str(v), query, n, **kwargs))}")
|
|
458
|
+
if parts:
|
|
459
|
+
separator = "&" if "?" in resolved_url else "?"
|
|
460
|
+
full_url = resolved_url + separator + "&".join(parts)
|
|
461
|
+
else:
|
|
462
|
+
full_url = resolved_url
|
|
463
|
+
resolved_headers = {
|
|
464
|
+
k: v for k, v in (
|
|
465
|
+
(k, _resolve(v, query, n, **kwargs))
|
|
466
|
+
for k, v in headers.items()
|
|
467
|
+
) if _header_meaningful(v) # 过滤空/认证前缀残留头(未配置的 {ENV} 不发送)
|
|
468
|
+
}
|
|
469
|
+
# GET:HttpClient 渐进增强(UA 轮换/重试/重定向跟随/域族节流);
|
|
470
|
+
# 失败返回空(与 urllib 失败行为一致,不抛异常)
|
|
471
|
+
raw = _http_get_raw(full_url, resolved_headers, to, engine=eng)
|
|
472
|
+
if raw is None:
|
|
473
|
+
return []
|
|
474
|
+
return _parse_http_payload(raw, fmt, eng, n, output_map, spec)
|
|
475
|
+
else:
|
|
476
|
+
body: dict[str, Any] = {}
|
|
477
|
+
for k, v in body_template.items():
|
|
478
|
+
resolved = _resolve(str(v), query, n, **kwargs)
|
|
479
|
+
if k == "search_depth":
|
|
480
|
+
body[k] = depth
|
|
481
|
+
elif resolved.lower() == "true":
|
|
482
|
+
body[k] = True
|
|
483
|
+
elif resolved.lower() == "false":
|
|
484
|
+
body[k] = False
|
|
485
|
+
else:
|
|
486
|
+
try:
|
|
487
|
+
body[k] = int(resolved)
|
|
488
|
+
except ValueError:
|
|
489
|
+
try:
|
|
490
|
+
body[k] = float(resolved)
|
|
491
|
+
except ValueError:
|
|
492
|
+
body[k] = resolved
|
|
493
|
+
# 与 GET 路径保持一致:过滤空/认证前缀残留头;POST 默认走 HttpClient 连接池
|
|
494
|
+
resolved_headers = {k: v for k, v in ((k, _resolve(v, query, n, **kwargs))
|
|
495
|
+
for k, v in headers.items()) if _header_meaningful(v)}
|
|
496
|
+
from engine_env import env_flag
|
|
497
|
+
import urllib.request as _urllib_request
|
|
498
|
+
if env_flag("ARGO_ENGINE_HTTP_CLIENT") and getattr(_urllib_request.urlopen, "__module__", "") == "urllib.request":
|
|
499
|
+
from http_client import HttpClient
|
|
500
|
+
resp = HttpClient(timeout=to, max_retries=0, jitter=False).post(url_template, body=body, extra_headers=resolved_headers, engine=eng)
|
|
501
|
+
raw = (resp.get("text") or "") if resp.get("status") == 200 else ""
|
|
502
|
+
if not raw:
|
|
503
|
+
_note_http_failure(eng, resp.get("status") or 0, resp.get("text") or resp.get("error", ""))
|
|
504
|
+
return []
|
|
505
|
+
else:
|
|
506
|
+
req = _urllib_request.Request(url_template, data=json.dumps(body).encode("utf-8"), headers=resolved_headers)
|
|
507
|
+
try:
|
|
508
|
+
with http_open(req, timeout=to, engine=eng) as resp:
|
|
509
|
+
raw = resp.read().decode("utf-8")
|
|
510
|
+
except Exception as e:
|
|
511
|
+
_get_logger().warning(f"HTTP 引擎失败: {e}")
|
|
512
|
+
return []
|
|
513
|
+
return _parse_http_payload(raw, fmt, eng, n, output_map, spec)
|
|
514
|
+
return _engine
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _http_get_raw(url: str, headers: dict, timeout: float,
|
|
518
|
+
engine: str = "?", tls_profiles: list[str] | None = None) -> str | None:
|
|
519
|
+
"""GET 原始响应体(HttpClient 渐进增强;ARGO_ENGINE_HTTP_CLIENT=0 回退 urllib)。
|
|
520
|
+
|
|
521
|
+
返回响应文本;任何失败返回 None(调用方按「无结果」处理)。
|
|
522
|
+
失败时按状态码写入归因寄存器:限流(429)、封锁(403+拦截特征)、
|
|
523
|
+
网络(连接层)。归因供聚合层区分「引擎坏」与「被挡住」。
|
|
524
|
+
"""
|
|
525
|
+
from engine_env import env_flag
|
|
526
|
+
use_client = env_flag("ARGO_ENGINE_HTTP_CLIENT")
|
|
527
|
+
if use_client:
|
|
528
|
+
try:
|
|
529
|
+
from http_client import HttpClient
|
|
530
|
+
# max_retries=0:引擎内不做连接级重试——死源一次超时已耗尽预算,
|
|
531
|
+
# 重试把最坏代价翻倍(OSM 6s 声明实测 11.3s=两次尝试);重试语义
|
|
532
|
+
# 上移到调度层(hedged race 换引擎 / 熔断降权 / 串行救援链)
|
|
533
|
+
resp = HttpClient(timeout=timeout, max_retries=0, jitter=False).get(
|
|
534
|
+
url, extra_headers=headers, follow_redirects=True, engine=engine,
|
|
535
|
+
impersonate_profiles=tls_profiles,
|
|
536
|
+
)
|
|
537
|
+
status = resp.get("status") or 0
|
|
538
|
+
text = resp.get("text") or ""
|
|
539
|
+
if status == 200 and text:
|
|
540
|
+
return text
|
|
541
|
+
if status >= 400 or status == 0:
|
|
542
|
+
_get_logger().warning(
|
|
543
|
+
f"HTTP 引擎失败: status={status} {resp.get('error', '')[:120]}"
|
|
544
|
+
)
|
|
545
|
+
_note_http_failure(engine, status,
|
|
546
|
+
text or resp.get("error", ""))
|
|
547
|
+
return None
|
|
548
|
+
# 2xx/3xx 无 body:视为失败
|
|
549
|
+
return None
|
|
550
|
+
except Exception as e:
|
|
551
|
+
_get_logger().warning(f"HTTP 引擎失败(HttpClient): {type(e).__name__} {e}")
|
|
552
|
+
note_failure(engine, "network", "exception",
|
|
553
|
+
f"{type(e).__name__}: {e}")
|
|
554
|
+
return None
|
|
555
|
+
# 回退 urllib(原行为)。出口仍须经 net_proxy——否则设
|
|
556
|
+
# ARGO_ENGINE_HTTP_CLIENT=0 就顺带关掉了代理支持,在必须走代理的环境里
|
|
557
|
+
# 这条保底路径会一直连不上(issue #13 的形态)。
|
|
558
|
+
import http.client # except 元组求值需要;走到这里 urlopen 已加载过,零成本
|
|
559
|
+
try:
|
|
560
|
+
from net_proxy import open_url as _proxy_open
|
|
561
|
+
import urllib.request as _urllib_request
|
|
562
|
+
req = _urllib_request.Request(url, headers=headers)
|
|
563
|
+
with _proxy_open(req, timeout=timeout) as resp:
|
|
564
|
+
return resp.read().decode("utf-8")
|
|
565
|
+
except urllib.error.HTTPError as e:
|
|
566
|
+
body = ""
|
|
567
|
+
try:
|
|
568
|
+
body = e.read().decode("utf-8", errors="replace")[:400]
|
|
569
|
+
except (OSError, http.client.HTTPException):
|
|
570
|
+
pass
|
|
571
|
+
_get_logger().warning(f"HTTP 引擎失败: HTTP {e.code} {engine}")
|
|
572
|
+
_note_http_failure(engine, e.code, body)
|
|
573
|
+
return None
|
|
574
|
+
except Exception as e:
|
|
575
|
+
_get_logger().warning(f"HTTP 引擎失败: {e}")
|
|
576
|
+
note_failure(engine, "network", "exception", str(e))
|
|
577
|
+
return None
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _note_http_failure(engine: str, status: int, body: str) -> None:
|
|
581
|
+
"""按状态码 + 响应体特征归因,写入寄存器。
|
|
582
|
+
|
|
583
|
+
判定委托给 engine_failure.classify(唯一来源)。此前这里复刻了一份状态码
|
|
584
|
+
分支表,与 classify 在 97/201 个状态码上给出不同答案(503 一边说限流、
|
|
585
|
+
一边说上游改版),同一引擎的归因会随「你看哪个界面」而变。
|
|
586
|
+
"""
|
|
587
|
+
try:
|
|
588
|
+
from engine_failure import classify
|
|
589
|
+
except ImportError: # 模块缺失时退化为最小事实,不猜类别
|
|
590
|
+
note_failure(engine, "unknown", f"http-{status}", (body or "")[:120])
|
|
591
|
+
return
|
|
592
|
+
res = classify(status_code=status, output=(body or "")[:2000])
|
|
593
|
+
note_failure(engine, res["category"], res["reason"],
|
|
594
|
+
res.get("evidence") or (body or "")[:120])
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@contextmanager
|
|
598
|
+
def http_open(req: Any, timeout: float = 10.0, engine: str = ""):
|
|
599
|
+
"""执行单次 HTTP 请求并在失败现场归因——直连 `urllib.request.urlopen` 的替代。
|
|
600
|
+
|
|
601
|
+
背景:手写引擎构建器里曾有近百处直连 `urllib.request.urlopen`。HTTP 失败
|
|
602
|
+
只留下一行 warning 就变成空结果,既不写归因寄存器,也不出现在
|
|
603
|
+
`--list-engines --detail`——博查 AI Search 端点 `403 套餐额度不足` 就是这样
|
|
604
|
+
长期显示 ready 的。归因必须在失败现场做:这里同时拿得到引擎名、状态码与
|
|
605
|
+
响应体,聚合层事后无从反推。
|
|
606
|
+
|
|
607
|
+
与 urlopen 的关系是「等价替换」而非「另起一条路」:
|
|
608
|
+
- 成功路径行为完全一致:同样的响应对象、同样的字节流(gbk 等非 UTF-8
|
|
609
|
+
站点仍由调用方手工解码)、同样的异常传播。
|
|
610
|
+
- 失败时先归因再原样抛出,不吞异常、不改各站点既有的 except 分支——
|
|
611
|
+
`except HTTPError` / `except Exception` 的语义都保持原样。
|
|
612
|
+
- HTTPError 的响应体被归因读走后回挂一份 `BytesIO`,调用方 `e.read()`
|
|
613
|
+
仍能拿到错误体(博查的 `_bocha_http_error` 依赖它)。
|
|
614
|
+
|
|
615
|
+
归因分类委托 `_note_http_failure`(唯一来源=engine_failure.classify):
|
|
616
|
+
403+额度文案→rate_limited、403+登录文案→auth、429→rate_limited 等。
|
|
617
|
+
engine 传空串时不写寄存器(测试直调 builder 未标引擎名时保持惰性)。
|
|
618
|
+
"""
|
|
619
|
+
if isinstance(req, str):
|
|
620
|
+
import urllib.request as _urllib_request
|
|
621
|
+
req = _urllib_request.Request(req)
|
|
622
|
+
# 出口调度(issue #13):统一走 net_proxy.open_url——代理解析(argo 级
|
|
623
|
+
# rules / ARGO_PROXY / config url + 标准环境变量)的唯一来源。此前这里
|
|
624
|
+
# 自带一份 opener 拼装,与 fetch/job 等处的 urlopen 各写一份,于是 issue
|
|
625
|
+
# #13 只修了本函数覆盖的引擎路径,其余出口仍然直接调用。
|
|
626
|
+
import http.client # except 元组求值需要;走到这里 urlopen 已加载过,零成本
|
|
627
|
+
from net_proxy import open_url
|
|
628
|
+
try:
|
|
629
|
+
resp = open_url(req, timeout=timeout)
|
|
630
|
+
except urllib.error.HTTPError as e:
|
|
631
|
+
body_bytes = b""
|
|
632
|
+
try:
|
|
633
|
+
# 限长:错误页可能几十 MB,而调用方只用前 400 字符归因。
|
|
634
|
+
# 无上限会把整页读进内存,并经归因持久化进磁盘状态文件。
|
|
635
|
+
#
|
|
636
|
+
# KeyError 也要接(2026-10-17):Python 3.9 的 HTTPError 在
|
|
637
|
+
# fp=None 时(部分上游/mock 形态)不初始化文件基类,首次访问
|
|
638
|
+
# `read` 经 __getattr__ 抛 KeyError('file')。它既不是 OSError
|
|
639
|
+
# 也不是 HTTPException,漏接会把「诚实上报 403」退化成「未预期
|
|
640
|
+
# 异常静默空结果」(tests/test_bocha.py 的 403 可见性用例抓的
|
|
641
|
+
# 正是它——本该是 error 项,实际返回 0 条)。
|
|
642
|
+
body_bytes = e.read(_HTTP_ERROR_BODY_CAP) or b""
|
|
643
|
+
except (OSError, http.client.HTTPException, KeyError):
|
|
644
|
+
pass
|
|
645
|
+
_get_logger().warning(f"HTTP 引擎失败: HTTP {e.code} {engine}")
|
|
646
|
+
_note_http_failure(engine, e.code,
|
|
647
|
+
body_bytes.decode("utf-8", errors="replace")[:400])
|
|
648
|
+
_restore_error_body(e, body_bytes)
|
|
649
|
+
raise
|
|
650
|
+
except urllib.error.URLError as e:
|
|
651
|
+
_get_logger().warning(f"HTTP 引擎失败: {e}")
|
|
652
|
+
note_failure(engine, "network", "urlopen", str(e)[:160])
|
|
653
|
+
raise
|
|
654
|
+
except OSError as e:
|
|
655
|
+
_get_logger().warning(f"HTTP 引擎失败: {e}")
|
|
656
|
+
note_failure(engine, "network", "exception",
|
|
657
|
+
f"{type(e).__name__}: {e}")
|
|
658
|
+
raise
|
|
659
|
+
except Exception as e:
|
|
660
|
+
# http.client.HTTPException(BadStatusLine / IncompleteRead 等)与
|
|
661
|
+
# ValueError 既不是 URLError 也不是 OSError,不兜这一层就会穿透归因,
|
|
662
|
+
# 与该函数的契约不符,也比 _http_get_raw 的保底更窄。归因后原样抛出。
|
|
663
|
+
_get_logger().warning(f"HTTP 引擎失败: {type(e).__name__} {e}")
|
|
664
|
+
note_failure(engine, "network", "exception",
|
|
665
|
+
f"{type(e).__name__}: {e}")
|
|
666
|
+
raise
|
|
667
|
+
with resp:
|
|
668
|
+
yield resp
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _restore_error_body(e: urllib.error.HTTPError, body: bytes) -> None:
|
|
672
|
+
"""把归因时读走的错误体还回 HTTPError,调用方 `e.read()` 仍可用。
|
|
673
|
+
|
|
674
|
+
HTTPError 是 tempfile._TemporaryFileWrapper 的子孙,`read` 等文件方法在
|
|
675
|
+
首次访问时被绑定并缓存进实例 __dict__(见 _TemporaryFileWrapper.__getattr__)。
|
|
676
|
+
只改 `fp` / `file` 不改缓存,之后 `e.read()` 仍打到已耗尽的旧流上。所以这里
|
|
677
|
+
连同缓存一起重置。全部包在 try 里:这是尽力而为的兼容层,任何一步失败都不
|
|
678
|
+
应影响原异常的抛出。
|
|
679
|
+
"""
|
|
680
|
+
try:
|
|
681
|
+
restored = io.BytesIO(body)
|
|
682
|
+
e.fp = restored
|
|
683
|
+
e.file = restored
|
|
684
|
+
for attr in ("read", "readline", "readlines", "seek", "tell",
|
|
685
|
+
"close", "__iter__"):
|
|
686
|
+
e.__dict__.pop(attr, None)
|
|
687
|
+
except AttributeError:
|
|
688
|
+
pass
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _envelope_error(data: Any) -> str:
|
|
692
|
+
"""提取 HTTP 200 响应体里的业务错误(火山 ResponseMetadata.Error / 知乎
|
|
693
|
+
顶层 Code/Message 风格等)。
|
|
694
|
+
|
|
695
|
+
byted 免费配额耗尽(10406 Free quota exhausted)曾以 HTTP 200 + 空
|
|
696
|
+
WebResults 静默通过;zhihu 20001 Authorization failed 同理——错误藏在
|
|
697
|
+
200 响应体里,调用侧把「配额用完/鉴权失败」当「没结果」。
|
|
698
|
+
"""
|
|
699
|
+
if not isinstance(data, dict):
|
|
700
|
+
return ""
|
|
701
|
+
rm = data.get("ResponseMetadata")
|
|
702
|
+
if isinstance(rm, dict):
|
|
703
|
+
e = rm.get("Error")
|
|
704
|
+
if isinstance(e, dict) and (e.get("Code") or e.get("Message")):
|
|
705
|
+
return f"{e.get('Code')}: {e.get('Message')}"
|
|
706
|
+
# 顶层 Code/Message 封套(知乎 zhihu_search 等):Code 非 0 且有 Message。
|
|
707
|
+
# 成功码白名单含数值 200(部分 API 用 HTTP 语义的 200 表示成功);
|
|
708
|
+
# Code=0 无 Message 时不算错误(纯 Code 字段的成功封套)
|
|
709
|
+
code = data.get("Code")
|
|
710
|
+
msg = data.get("Message") or data.get("message")
|
|
711
|
+
if msg and code not in (0, None, "", "0", "OK", "ok", "success", 200):
|
|
712
|
+
return f"{code}: {msg}"
|
|
713
|
+
for key in ("error", "Error"):
|
|
714
|
+
e = data.get(key)
|
|
715
|
+
if isinstance(e, dict):
|
|
716
|
+
code = e.get("Code") or e.get("code") or ""
|
|
717
|
+
msg = e.get("Message") or e.get("message") or ""
|
|
718
|
+
if code or msg:
|
|
719
|
+
return f"{code}: {msg}" if code and msg else str(msg or code)
|
|
720
|
+
elif isinstance(e, str) and e:
|
|
721
|
+
return e
|
|
722
|
+
return ""
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _normalize_epoch(value: Any) -> str:
|
|
726
|
+
"""epoch 秒/毫秒 → `YYYY-MM-DD`,非 epoch 原样返回。
|
|
727
|
+
|
|
728
|
+
技能目录源的 updatedAt 单位不统一:SkillsMP 给秒(1786285295),ClawHub
|
|
729
|
+
给毫秒(1789594554485),相差 1000 倍。原样映射等于把一串数字当
|
|
730
|
+
published_at 交出去,既读不出日期也无法与别的源比较。
|
|
731
|
+
|
|
732
|
+
按量级判别而非按源声明:1e9~1e11 是秒(2001~5138 年),1e11~1e14 是
|
|
733
|
+
毫秒,两位差三个数量级,不存在擦边区间。范围外的值不猜,原样返回——
|
|
734
|
+
猜错会把一个可疑值变成看起来正常的值,比留着更糟。
|
|
735
|
+
"""
|
|
736
|
+
text = str(value).strip()
|
|
737
|
+
if not text.isdigit():
|
|
738
|
+
return text
|
|
739
|
+
num = int(text)
|
|
740
|
+
if 1_000_000_000 <= num < 100_000_000_000:
|
|
741
|
+
seconds = num
|
|
742
|
+
elif 100_000_000_000 <= num < 100_000_000_000_000:
|
|
743
|
+
seconds = num / 1000
|
|
744
|
+
else:
|
|
745
|
+
return text
|
|
746
|
+
try:
|
|
747
|
+
return datetime.fromtimestamp(seconds, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
748
|
+
except (OverflowError, OSError, ValueError):
|
|
749
|
+
return text
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _parse_http_payload(raw: str, fmt: str, eng: str, n: int,
|
|
753
|
+
output_map: dict, spec: dict) -> list[dict[str, Any]]:
|
|
754
|
+
"""HTTP 引擎响应体解析(GET/POST 共用)。"""
|
|
755
|
+
if fmt == "xml":
|
|
756
|
+
return _parse_xml(raw, eng)
|
|
757
|
+
try:
|
|
758
|
+
data = json.loads(raw)
|
|
759
|
+
except (json.JSONDecodeError, ValueError):
|
|
760
|
+
_get_logger().warning(f"HTTP 引擎解析失败: {eng} 非 JSON/XML 响应")
|
|
761
|
+
return []
|
|
762
|
+
# 业务错误封套优先于条数提取:强封套错误(ResponseMetadata.Error、顶层
|
|
763
|
+
# error 对象)表示本次调用失败,返回结果必然为空或不可信
|
|
764
|
+
env_err = _envelope_error(data)
|
|
765
|
+
if env_err:
|
|
766
|
+
_get_logger().warning(f"HTTP 引擎业务错误: {eng} {env_err[:120]}")
|
|
767
|
+
return [{"error": f"{eng} {env_err}", "source": eng}]
|
|
768
|
+
limit = max(1, int(n or 5))
|
|
769
|
+
# 专用 JSON 解析器优先(DDG Instant Answer / UAPI / Semantic Scholar 等)
|
|
770
|
+
custom = _CUSTOM_JSON_PARSERS.get(eng)
|
|
771
|
+
if custom:
|
|
772
|
+
return _ensure_engine_source(custom(data), eng)[:limit]
|
|
773
|
+
if output_map:
|
|
774
|
+
items_path = output_map.get("items", "")
|
|
775
|
+
# 根节点即为数组时(HF / dev.to / polymarket),items 用 "."
|
|
776
|
+
if isinstance(data, list) and not items_path:
|
|
777
|
+
items_path = "."
|
|
778
|
+
parsed = _make_field_parser(items_path, {
|
|
779
|
+
"title": output_map.get("item_title", "title"),
|
|
780
|
+
"url": output_map.get("item_url", "url"),
|
|
781
|
+
"snippet": output_map.get("item_summary", "snippet"),
|
|
782
|
+
"source": output_map.get("item_source", "source"),
|
|
783
|
+
"published_at": output_map.get("item_published_at", "published_at"),
|
|
784
|
+
# 可验证出处:技能目录源给的是市场页(skillUrl/canonicalUrl),
|
|
785
|
+
# 光有它只能再开一次浏览器。上游仓目录(SkillsMP 的 githubUrl)
|
|
786
|
+
# 或安装引用(ClawHub 的 install.reference)才是能核对、能安装的
|
|
787
|
+
# 那个地址,与市场页分开存。
|
|
788
|
+
"upstream": output_map.get("item_upstream", ""),
|
|
789
|
+
# 上游原生信号透传(DeepSeek search 接入):不覆盖 Argo 自己的
|
|
790
|
+
# authority/score(排序用),upstream_* 原名落盘,spec 不声明则不出现
|
|
791
|
+
"upstream_authority": output_map.get("item_upstream_authority", ""),
|
|
792
|
+
"upstream_score": output_map.get("item_upstream_score", ""),
|
|
793
|
+
# 可选图片字段:图源(nasa_images 等)声明 item_image 后,结果里多出
|
|
794
|
+
# image_url / image_license,供「搜到图 → 直接看图」用。路径须指向
|
|
795
|
+
# **字符串**(如 links.0.href)——指向 dict 会被 _coerce_field 丢成空串
|
|
796
|
+
# (NASA 的 links.0 是 {href, rel, render, ...})。
|
|
797
|
+
"image_url": output_map.get("item_image", ""),
|
|
798
|
+
"image_license": output_map.get("item_image_license", ""),
|
|
799
|
+
# 其余图片字段(许可链接/协议版本原料/像素尺寸)见 image_ops
|
|
800
|
+
**image_output_map_fields(output_map),
|
|
801
|
+
}, url_template=output_map.get("url_template"))(data)
|
|
802
|
+
# spec 级授权常量:授权不随条目变化的源(如 NASA 公版)在 spec 上写一次
|
|
803
|
+
_lic = spec.get("image_license")
|
|
804
|
+
for r in parsed:
|
|
805
|
+
r.setdefault("source", eng)
|
|
806
|
+
if r.get("published_at"):
|
|
807
|
+
r["published_at"] = _normalize_epoch(r["published_at"])
|
|
808
|
+
if _lic and r.get("image_url"):
|
|
809
|
+
r.setdefault("image_license", _lic)
|
|
810
|
+
if isinstance(r.get("snippet"), str) and len(r["snippet"]) > 300:
|
|
811
|
+
r["snippet"] = r["snippet"][:300]
|
|
812
|
+
# 图片元数据收尾(尺寸转 int / 许可归一 / 商用判据)——落在这个共同
|
|
813
|
+
# 出口而不是各 parser 里:图源有三条产出路径(声明式 output_map、
|
|
814
|
+
# 自定义 builder、通用兜底),只覆盖一条会让契约对一半结果生效。
|
|
815
|
+
finalize_image_fields(parsed, _lic or "")
|
|
816
|
+
# preserve_source(声明式 spec):保留 API 返回的真实来源标注
|
|
817
|
+
return _ensure_engine_source(
|
|
818
|
+
parsed, eng, preserve=bool(spec.get("preserve_source"))
|
|
819
|
+
)[:limit]
|
|
820
|
+
if isinstance(data, list):
|
|
821
|
+
return _ensure_engine_source(
|
|
822
|
+
_parse_generic({"results": data}, eng, limit), eng
|
|
823
|
+
)[:limit]
|
|
824
|
+
return _ensure_engine_source(_parse_generic(data, eng, limit), eng)[:limit]
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
# ── HTML 网页解析引擎 ─────────────────────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
def _load_parse_maps() -> dict:
|
|
830
|
+
"""加载 parse_maps.yaml(声明式 CSS 选择器映射)。"""
|
|
831
|
+
maps_path = Path(__file__).parent.parent / "sub-skills" / "local-search" / "parse_maps.yaml"
|
|
832
|
+
if not maps_path.exists():
|
|
833
|
+
return {}
|
|
834
|
+
try:
|
|
835
|
+
from yaml_load import load as _yaml_load
|
|
836
|
+
# UTF-8 读取由 yaml_load 内部固定(Windows GBK locale 会读崩 UTF-8 的
|
|
837
|
+
# YAML,与原实现显式 encoding="utf-8" 等价)
|
|
838
|
+
return _yaml_load(maps_path) or {}
|
|
839
|
+
except Exception:
|
|
840
|
+
return {}
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _detect_anti_bot(html: str) -> bool:
|
|
844
|
+
"""检测反爬/拦截页面。head 区扫通用标记 + 全文扫高特异性标记。"""
|
|
845
|
+
if not html:
|
|
846
|
+
return True
|
|
847
|
+
if len(html.strip()) < 500:
|
|
848
|
+
return True
|
|
849
|
+
# 通用标记只在前 2000 字符(head 区域)检测:captcha/challenge 这类词
|
|
850
|
+
# 出现在正文属合法语义,全文扫会误杀
|
|
851
|
+
anti_bot_head = [
|
|
852
|
+
"captcha", "challenge", "cf-browser-verification",
|
|
853
|
+
"access denied", "rate limit", "too many requests",
|
|
854
|
+
"checking your browser", "ddos-guard", "perimeterx",
|
|
855
|
+
]
|
|
856
|
+
lowered_head = html[:2000].lower()
|
|
857
|
+
for marker in anti_bot_head:
|
|
858
|
+
if marker in lowered_head:
|
|
859
|
+
return True
|
|
860
|
+
# 全文级高特异性标记:实测 DDG challenge 页以 HTTP 202 返回且文案不在
|
|
861
|
+
# head 区(首个 challenge 字样在 2600+ 字符处,urllib 回退路径会把这种
|
|
862
|
+
# body 当正常页送进解析),head 扫描必然漏。这张表只收带站点专名/语境
|
|
863
|
+
# 的短语——不带专名的词(如 anomaly,会在讨论异常检测的结果页正文里
|
|
864
|
+
# 合法出现)禁止进表,否则误杀正常页。
|
|
865
|
+
lowered = html.lower()
|
|
866
|
+
for marker in (
|
|
867
|
+
"bots use duckduckgo", # DDG challenge 页实测文案
|
|
868
|
+
"select all squares containing a duck", # 同上,点选验证题干
|
|
869
|
+
):
|
|
870
|
+
if marker in lowered:
|
|
871
|
+
return True
|
|
872
|
+
# 如果页面有大量链接且内容充实,判定为正常结果页
|
|
873
|
+
if len(html) > 50000:
|
|
874
|
+
try:
|
|
875
|
+
from bs4 import BeautifulSoup
|
|
876
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
877
|
+
if len(soup.find_all("a")) > 20:
|
|
878
|
+
return False
|
|
879
|
+
except Exception:
|
|
880
|
+
pass
|
|
881
|
+
return False
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
# SERP 垃圾结果守卫的引擎范围(冻结):仅覆盖无 API 的网页 SERP 引擎——它们
|
|
885
|
+
# 解析上游 HTML 页,最易被缓存 SERP / 无关结果静默命中;API 引擎结果自带结构化
|
|
886
|
+
# 契约,不走这张表。判定规则与逃生门(ARGO_SERP_GUARD=0)在 serp_guard 单点;
|
|
887
|
+
# 守卫接线与误杀账本 2026-10-10 拆到 serp_kill_log(本文件顶在尺寸上限,拆模块)。
|
|
888
|
+
SERP_GUARD_ENGINES = serp_kill_log.SERP_GUARD_ENGINES
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _serp_guard_apply(engine_name: str, query: str,
|
|
892
|
+
results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
893
|
+
"""守卫接线(薄委托,契约与账本见 serp_kill_log 模块 docstring)。"""
|
|
894
|
+
return serp_kill_log.serp_guard_apply(engine_name, query, results)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _build_html_engine(spec: dict[str, Any]) -> Any:
|
|
898
|
+
"""HTML 网页解析引擎:HTTP 抓取 + BeautifulSoup CSS 选择器解析。"""
|
|
899
|
+
url_template = spec.get("url", "")
|
|
900
|
+
# 注意:不设置 Accept-Encoding,让 urllib 自动处理 gzip/deflate
|
|
901
|
+
# 设 Accept-Encoding: br 会导致收到 Brotli 压缩但 urllib 无法解压
|
|
902
|
+
headers = {
|
|
903
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
904
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
905
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
906
|
+
"Cache-Control": "no-cache",
|
|
907
|
+
"Pragma": "no-cache",
|
|
908
|
+
"Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120"',
|
|
909
|
+
"Sec-Ch-Ua-Mobile": "?0",
|
|
910
|
+
"Sec-Ch-Ua-Platform": '"macOS"',
|
|
911
|
+
"Sec-Fetch-Dest": "document",
|
|
912
|
+
"Sec-Fetch-Mode": "navigate",
|
|
913
|
+
"Sec-Fetch-Site": "none",
|
|
914
|
+
"Upgrade-Insecure-Requests": "1",
|
|
915
|
+
}
|
|
916
|
+
# 覆盖自定义 headers
|
|
917
|
+
headers.update(spec.get("headers", {}))
|
|
918
|
+
query_param = spec.get("query_param", "q")
|
|
919
|
+
timeout = spec.get("timeout", 8)
|
|
920
|
+
extra_params = spec.get("extra_params", {})
|
|
921
|
+
engine_name = spec.get("_name", "html")
|
|
922
|
+
# spec 显式节流覆盖(max_concurrency / min_interval_ms),声明即契约
|
|
923
|
+
if spec.get("max_concurrency") is not None or \
|
|
924
|
+
spec.get("min_interval_ms") is not None:
|
|
925
|
+
try:
|
|
926
|
+
from http_client import register_spec_limit
|
|
927
|
+
register_spec_limit(engine_name,
|
|
928
|
+
spec.get("max_concurrency"),
|
|
929
|
+
spec.get("min_interval_ms"))
|
|
930
|
+
except ImportError:
|
|
931
|
+
pass
|
|
932
|
+
_parse_maps_cache: dict = {}
|
|
933
|
+
_tls_raw = spec.get("tls_impersonate")
|
|
934
|
+
_tls_profiles = ([_tls_raw] if isinstance(_tls_raw, str) else list(_tls_raw)) if _tls_raw else None
|
|
935
|
+
_resolve_redir = bool(spec.get("resolve_redirects"))
|
|
936
|
+
# 不透明跳转壳(baidu/sogou/so /link,token 型)不解析会被聚合层 P0 整批
|
|
937
|
+
# 滤掉 → 引擎永远 0 条;resolve_redirects 声明即契约。实现见 jump_resolver
|
|
938
|
+
# (单跳 Location 换真链,失败丢弃,与 _unwrap_ddg_link 同哲学)。
|
|
939
|
+
|
|
940
|
+
def _get_parse_maps() -> dict:
|
|
941
|
+
if not _parse_maps_cache:
|
|
942
|
+
_parse_maps_cache.update(_load_parse_maps())
|
|
943
|
+
return _parse_maps_cache
|
|
944
|
+
|
|
945
|
+
@safe_search
|
|
946
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
947
|
+
import urllib.parse as up
|
|
948
|
+
to = _timeout or timeout
|
|
949
|
+
resolved_url = _resolve(url_template, query, n)
|
|
950
|
+
separator = "&" if "?" in resolved_url else "?"
|
|
951
|
+
full_url = f"{resolved_url}{separator}{query_param}={up.quote(query)}"
|
|
952
|
+
for k, v in extra_params.items():
|
|
953
|
+
# 语言参数动态化:按查询主语言覆盖 setlang/hl/lang/mkt(此前漏 mkt)
|
|
954
|
+
if k in ("setlang", "hl", "lang", "uselang", "mkt"):
|
|
955
|
+
v = _lang_param(k, query) or v
|
|
956
|
+
full_url += f"&{k}={up.quote(_resolve(str(v), query, n, **kwargs))}" # **kwargs 必传(声明式路径 :441 传了):否则 {MODE}/{DEPTH} 带字面量花括号进 URL
|
|
957
|
+
resolved_headers = {
|
|
958
|
+
k: v for k, v in (
|
|
959
|
+
(k, _resolve(v, query, n))
|
|
960
|
+
for k, v in headers.items()
|
|
961
|
+
) if _header_meaningful(v) # 过滤空/认证前缀残留头(未配置的 {ENV} 不发送)
|
|
962
|
+
}
|
|
963
|
+
# HTML 引擎同样走 HttpClient 渐进增强(UA 轮换/重定向跟随/重试/节流);
|
|
964
|
+
# tls_impersonate 声明即契约:反爬按 TLS ClientHello 拦截的源(如 Baidu)
|
|
965
|
+
# 按序尝试仿冒档,全部失败按原归因路径上报
|
|
966
|
+
html = _http_get_raw(full_url, resolved_headers, to, engine=engine_name,
|
|
967
|
+
tls_profiles=_tls_profiles)
|
|
968
|
+
if html is None:
|
|
969
|
+
return []
|
|
970
|
+
if _detect_anti_bot(html):
|
|
971
|
+
# 命中拦截页:归因为封锁而非空结果。不记这条,封锁会在聚合层
|
|
972
|
+
# 伪装成「无结果」,被封引擎会被当故障累计进熔断。
|
|
973
|
+
note_failure(engine_name, "blocked", "anti-bot-page", full_url)
|
|
974
|
+
return []
|
|
975
|
+
maps = _get_parse_maps()
|
|
976
|
+
html_maps = maps.get("html", {})
|
|
977
|
+
mapping = html_maps.get(engine_name, html_maps.get("default", {}))
|
|
978
|
+
container_sel = mapping.get("container")
|
|
979
|
+
title_sel = mapping.get("title", "h2 a, h3 a")
|
|
980
|
+
url_sel = mapping.get("url", "a")
|
|
981
|
+
snippet_sel = mapping.get("snippet")
|
|
982
|
+
url_attr = mapping.get("url_attr", "href")
|
|
983
|
+
default_score = mapping.get("score", 0.7)
|
|
984
|
+
if not container_sel:
|
|
985
|
+
return []
|
|
986
|
+
try:
|
|
987
|
+
from bs4 import BeautifulSoup
|
|
988
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
989
|
+
containers = soup.select(container_sel)
|
|
990
|
+
except Exception:
|
|
991
|
+
return []
|
|
992
|
+
from bs4 import NavigableString
|
|
993
|
+
|
|
994
|
+
def _el_text(el) -> str:
|
|
995
|
+
"""提取元素文本。兼容 bs4 4.13+ 将页面文本标记为 TemplateString、
|
|
996
|
+
get_text()/strings() 失效(如萌娘百科搜索结果页)的情况。"""
|
|
997
|
+
if el is None:
|
|
998
|
+
return ""
|
|
999
|
+
if isinstance(el, str):
|
|
1000
|
+
return el
|
|
1001
|
+
parts = []
|
|
1002
|
+
stack = list(el.contents)
|
|
1003
|
+
while stack:
|
|
1004
|
+
node = stack.pop(0)
|
|
1005
|
+
if isinstance(node, NavigableString):
|
|
1006
|
+
parts.append(str(node))
|
|
1007
|
+
elif hasattr(node, "contents"):
|
|
1008
|
+
stack = node.contents + stack
|
|
1009
|
+
return "".join(parts)
|
|
1010
|
+
|
|
1011
|
+
# 条目页直接命中:MediaWiki/百科类搜索常直接跳转到条目页(如萌娘百科
|
|
1012
|
+
# 「初音未来」→ /初音未来(消歧义)),列表选择器必然不命中。
|
|
1013
|
+
# 提取主标题 + 正文首段作为单条结果,避免空结果。
|
|
1014
|
+
if not containers:
|
|
1015
|
+
try:
|
|
1016
|
+
heading = (soup.select_one("h1#firstHeading") or soup.select_one("h1"))
|
|
1017
|
+
title = ""
|
|
1018
|
+
if heading:
|
|
1019
|
+
title = _el_text(heading).strip()[:200]
|
|
1020
|
+
if not title and soup.title:
|
|
1021
|
+
# 萌娘百科等页面无 h1(bs4 4.13 下模板文本壳),标题在 <title>
|
|
1022
|
+
title = soup.title.get_text().split(" - ")[0].strip()[:200]
|
|
1023
|
+
if title:
|
|
1024
|
+
snippet = ""
|
|
1025
|
+
# 正文首段:mw-parser-output 下第一个有文本的 p
|
|
1026
|
+
for p in soup.select("div.mw-parser-output p")[:5]:
|
|
1027
|
+
t = _el_text(p).strip()
|
|
1028
|
+
if t:
|
|
1029
|
+
snippet = t[:300]
|
|
1030
|
+
break
|
|
1031
|
+
canonical = soup.find("link", rel="canonical")
|
|
1032
|
+
page_url = canonical["href"] if canonical and canonical.get("href") else resolved_url
|
|
1033
|
+
results = [{
|
|
1034
|
+
"title": title,
|
|
1035
|
+
"url": page_url,
|
|
1036
|
+
"snippet": snippet,
|
|
1037
|
+
"score": default_score,
|
|
1038
|
+
"source": engine_name,
|
|
1039
|
+
}]
|
|
1040
|
+
# 条目页直连分支同样过守卫:SERP 引擎落到这个分支说明页面
|
|
1041
|
+
# 不是结果列表(重定向/拦截页),单条「页面标题」恰是垃圾
|
|
1042
|
+
return _serp_guard_apply(engine_name, query, results)
|
|
1043
|
+
except Exception:
|
|
1044
|
+
pass
|
|
1045
|
+
|
|
1046
|
+
results = []
|
|
1047
|
+
for idx, item in enumerate(containers[:n * 2]):
|
|
1048
|
+
try:
|
|
1049
|
+
title_el = item.select_one(title_sel) if title_sel else None
|
|
1050
|
+
url_el = item.select_one(url_sel) if url_sel else None
|
|
1051
|
+
snippet_el = item.select_one(snippet_sel) if snippet_sel else None
|
|
1052
|
+
title = _el_text(title_el).strip()[:200] if title_el else ""
|
|
1053
|
+
url = ""
|
|
1054
|
+
if url_el and url_el.has_attr(url_attr):
|
|
1055
|
+
url = url_el[url_attr]
|
|
1056
|
+
elif item.has_attr(url_attr):
|
|
1057
|
+
# 容器自身带链接属性(如 <a class="item" href="..."> 自引用结构)
|
|
1058
|
+
url = item[url_attr]
|
|
1059
|
+
snippet = _el_text(snippet_el).strip()[:300] if snippet_el else ""
|
|
1060
|
+
if not title and not url:
|
|
1061
|
+
continue
|
|
1062
|
+
if url and url.startswith("/"):
|
|
1063
|
+
from urllib.parse import urljoin
|
|
1064
|
+
url = urljoin(resolved_url, url)
|
|
1065
|
+
score = max(default_score - idx * 0.05, 0.1)
|
|
1066
|
+
results.append({"title": title, "url": url, "snippet": snippet, "score": round(score, 3), "source": engine_name})
|
|
1067
|
+
except Exception:
|
|
1068
|
+
continue
|
|
1069
|
+
if _resolve_redir and results:
|
|
1070
|
+
from jump_resolver import is_jump_url, resolve_jump_url
|
|
1071
|
+
kept = []
|
|
1072
|
+
for r in results[:n]:
|
|
1073
|
+
u = str(r.get("url") or "")
|
|
1074
|
+
if is_jump_url(u):
|
|
1075
|
+
real = resolve_jump_url(u, _tls_profiles)
|
|
1076
|
+
if not real:
|
|
1077
|
+
continue # 解不开的跳转壳 = 不可核验信源,P0 也会滤它
|
|
1078
|
+
r["url"] = real
|
|
1079
|
+
kept.append(r)
|
|
1080
|
+
results = kept
|
|
1081
|
+
else:
|
|
1082
|
+
# 离线可还原的壳(Bing ck/a 的 u=a1<base64url>)不依赖
|
|
1083
|
+
# resolve_redirects 声明:解析阶段顺手换真链,零网络成本。声明
|
|
1084
|
+
# 开关管的是「要发请求才能解」的 token 型壳(baidu/sogou)。
|
|
1085
|
+
try:
|
|
1086
|
+
from jump_resolver import unwrap_offline_url
|
|
1087
|
+
for r in results:
|
|
1088
|
+
u = str(r.get("url") or "")
|
|
1089
|
+
real = unwrap_offline_url(u)
|
|
1090
|
+
if real:
|
|
1091
|
+
r["url"] = real
|
|
1092
|
+
except ImportError:
|
|
1093
|
+
pass
|
|
1094
|
+
return _serp_guard_apply(engine_name, query, results[:n])
|
|
1095
|
+
return _engine
|
|
1096
|
+
|
|
1097
|
+
# ── 通用解析器 ─────────────────────────────────────────────────────────────────
|
|
1098
|
+
|
|
1099
|
+
def _parse_text_output(text: str, engine_name: str, output_format: str = "", n: int = 10) -> list[dict[str, Any]]:
|
|
1100
|
+
"""通用 CLI 文本解析:YAML(声明式)/ JSON / 结构化文本。n 限制返回条数。"""
|
|
1101
|
+
if not text or not text.strip():
|
|
1102
|
+
return []
|
|
1103
|
+
text = text.strip()
|
|
1104
|
+
if output_format == "yaml":
|
|
1105
|
+
return _parse_yaml_output(text, engine_name, n=n)
|
|
1106
|
+
try:
|
|
1107
|
+
data = json.loads(text)
|
|
1108
|
+
limit = max(1, n)
|
|
1109
|
+
if isinstance(data, list):
|
|
1110
|
+
return finalize_image_fields(
|
|
1111
|
+
[_cli_result_row(i, engine_name) for i in data[:limit]
|
|
1112
|
+
if isinstance(i, dict)])
|
|
1113
|
+
if isinstance(data, dict):
|
|
1114
|
+
items = data.get("results", data.get("items", data.get("data", [])))
|
|
1115
|
+
if isinstance(items, list):
|
|
1116
|
+
return finalize_image_fields(
|
|
1117
|
+
[_cli_result_row(i, engine_name) for i in items[:limit]
|
|
1118
|
+
if isinstance(i, dict)])
|
|
1119
|
+
except (json.JSONDecodeError, ValueError):
|
|
1120
|
+
pass
|
|
1121
|
+
|
|
1122
|
+
results, cur = [], {}
|
|
1123
|
+
seen_url = False
|
|
1124
|
+
for line in text.split("\n"):
|
|
1125
|
+
s = line.strip()
|
|
1126
|
+
if s.startswith("### "):
|
|
1127
|
+
if cur:
|
|
1128
|
+
results.append(cur)
|
|
1129
|
+
cur = {"title": re.sub(r'^\d+\.\s*', '', s[4:].strip()), "source": engine_name,
|
|
1130
|
+
"score": max(1.0 - len(results) * 0.1, 0.1)}
|
|
1131
|
+
seen_url = False
|
|
1132
|
+
elif s.startswith("- **URL**: ") and cur:
|
|
1133
|
+
cur["url"] = s[11:].strip()
|
|
1134
|
+
seen_url = True
|
|
1135
|
+
elif s.startswith("- ") and not s.startswith("- **") and seen_url and cur:
|
|
1136
|
+
cur["snippet"] = " ".join(s[2:].strip().split())[:300]
|
|
1137
|
+
seen_url = False
|
|
1138
|
+
if cur:
|
|
1139
|
+
results.append(cur)
|
|
1140
|
+
return results[:max(1, n)]
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _parse_yaml_output(text: str, engine_name: str, n: int = 10) -> list[dict[str, Any]]:
|
|
1144
|
+
"""解析 YAML 输出(结构化 CLI 数据源的默认格式)。
|
|
1145
|
+
|
|
1146
|
+
支持顶层 list,或 dict 携带 results/items/data 列表;
|
|
1147
|
+
字段别名:snippet|description;保留 published_at 时间维度。
|
|
1148
|
+
"""
|
|
1149
|
+
try:
|
|
1150
|
+
from yaml_load import loads as _yaml_loads
|
|
1151
|
+
data = _yaml_loads(text)
|
|
1152
|
+
except Exception:
|
|
1153
|
+
return []
|
|
1154
|
+
if isinstance(data, dict):
|
|
1155
|
+
items = data.get("results", data.get("items", data.get("data", [])))
|
|
1156
|
+
elif isinstance(data, list):
|
|
1157
|
+
items = data
|
|
1158
|
+
else:
|
|
1159
|
+
return []
|
|
1160
|
+
if not isinstance(items, list):
|
|
1161
|
+
return []
|
|
1162
|
+
results = []
|
|
1163
|
+
for i in items:
|
|
1164
|
+
if not isinstance(i, dict):
|
|
1165
|
+
continue
|
|
1166
|
+
title = i.get("title") or i.get("name") or ""
|
|
1167
|
+
url = i.get("url") or i.get("link") or ""
|
|
1168
|
+
snippet = i.get("snippet") or i.get("description") or i.get("content") or ""
|
|
1169
|
+
if not title and not url:
|
|
1170
|
+
continue
|
|
1171
|
+
r = {
|
|
1172
|
+
"title": str(title)[:200],
|
|
1173
|
+
"url": str(url),
|
|
1174
|
+
"snippet": str(snippet)[:300],
|
|
1175
|
+
"source": engine_name,
|
|
1176
|
+
}
|
|
1177
|
+
published = i.get("published_at")
|
|
1178
|
+
if published:
|
|
1179
|
+
r["published_at"] = str(published)[:64]
|
|
1180
|
+
results.append(r)
|
|
1181
|
+
return results[:max(1, n)]
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _parse_xml(text: str, engine_name: str) -> list[dict[str, Any]]:
|
|
1185
|
+
"""解析 Atom XML(arXiv 等)。"""
|
|
1186
|
+
import xml.etree.ElementTree as ET
|
|
1187
|
+
results = []
|
|
1188
|
+
try:
|
|
1189
|
+
root = ET.fromstring(text)
|
|
1190
|
+
ns = {"atom": "http://www.w3.org/2005/Atom"}
|
|
1191
|
+
entries = root.findall(".//atom:entry", ns) or root.findall(".//{http://www.w3.org/2005/Atom}entry")
|
|
1192
|
+
for entry in entries:
|
|
1193
|
+
title = entry.findtext("atom:title", "", ns).strip().replace("\n", " ")[:200]
|
|
1194
|
+
summary = entry.findtext("atom:summary", "", ns).strip().replace("\n", " ")[:300]
|
|
1195
|
+
entry_id = entry.findtext("atom:id", "", ns)
|
|
1196
|
+
url = entry_id
|
|
1197
|
+
for link in entry.findall("atom:link", ns):
|
|
1198
|
+
if link.get("title") == "pdf":
|
|
1199
|
+
url = link.get("href", url)
|
|
1200
|
+
break
|
|
1201
|
+
if title:
|
|
1202
|
+
results.append({"title": title, "url": url, "snippet": summary, "source": engine_name})
|
|
1203
|
+
except ET.ParseError:
|
|
1204
|
+
pass
|
|
1205
|
+
return results
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _parse_generic(data: dict[str, Any], engine_name: str = "?",
|
|
1209
|
+
limit: int = 10) -> list[dict[str, Any]]:
|
|
1210
|
+
"""通用 JSON 解析:自动探测常见列表路径与字段别名。
|
|
1211
|
+
|
|
1212
|
+
列表探测(最多 3 层,只走已知键):
|
|
1213
|
+
- 顶层 results/items/data/works/search 等
|
|
1214
|
+
- 一层嵌套(data.results / data.value)
|
|
1215
|
+
- 二层嵌套(data.webPages.value —— 博查等 AI 搜索 API)
|
|
1216
|
+
|
|
1217
|
+
字段别名:title|name|heading;url|URL|html_url|link|href;
|
|
1218
|
+
snippet|summary|content|description|abstract。
|
|
1219
|
+
"""
|
|
1220
|
+
list_keys = (
|
|
1221
|
+
"results", "items", "value", "works", "search", "data",
|
|
1222
|
+
"organic", "webPages", "hits", "documents", "entries",
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
def _find_items(obj: Any, depth: int = 0) -> list | None:
|
|
1226
|
+
if depth > 3 or obj is None:
|
|
1227
|
+
return None
|
|
1228
|
+
if isinstance(obj, list):
|
|
1229
|
+
# 空列表或 dict 元素列表视为结果集;纯标量列表跳过
|
|
1230
|
+
if not obj or isinstance(obj[0], dict):
|
|
1231
|
+
return obj
|
|
1232
|
+
return None
|
|
1233
|
+
if not isinstance(obj, dict):
|
|
1234
|
+
return None
|
|
1235
|
+
for key in list_keys:
|
|
1236
|
+
if key not in obj:
|
|
1237
|
+
continue
|
|
1238
|
+
found = _find_items(obj[key], depth + 1)
|
|
1239
|
+
if found is not None:
|
|
1240
|
+
return found
|
|
1241
|
+
# 浅层扫描嵌套 dict(避免深扫整棵树误抓无关数组)
|
|
1242
|
+
if depth < 2:
|
|
1243
|
+
for v in obj.values():
|
|
1244
|
+
if isinstance(v, dict):
|
|
1245
|
+
found = _find_items(v, depth + 1)
|
|
1246
|
+
if found is not None:
|
|
1247
|
+
return found
|
|
1248
|
+
return None
|
|
1249
|
+
|
|
1250
|
+
items = _find_items(data) if isinstance(data, (dict, list)) else None
|
|
1251
|
+
if not items or not isinstance(items, list):
|
|
1252
|
+
return []
|
|
1253
|
+
|
|
1254
|
+
results: list[dict[str, Any]] = []
|
|
1255
|
+
for i in items:
|
|
1256
|
+
if not isinstance(i, dict):
|
|
1257
|
+
continue
|
|
1258
|
+
title = i.get("title") or i.get("name") or i.get("heading") or i.get("Title") or ""
|
|
1259
|
+
if isinstance(title, list):
|
|
1260
|
+
title = title[0] if title else ""
|
|
1261
|
+
url = (
|
|
1262
|
+
i.get("url") or i.get("URL") or i.get("html_url")
|
|
1263
|
+
or i.get("link") or i.get("href") or ""
|
|
1264
|
+
)
|
|
1265
|
+
snippet = (
|
|
1266
|
+
i.get("snippet") or i.get("summary") or i.get("content")
|
|
1267
|
+
or i.get("description") or i.get("abstract") or ""
|
|
1268
|
+
)
|
|
1269
|
+
if isinstance(snippet, list):
|
|
1270
|
+
snippet = snippet[0] if snippet else ""
|
|
1271
|
+
score = rank_score(next((v for v in (i.get("score"), i.get("relevance_score")) if v not in (None, "")), 0.5), len(results)) # 哨兵判空:显式 0 分不得膨胀成 0.5
|
|
1272
|
+
results.append({
|
|
1273
|
+
"title": str(title)[:200],
|
|
1274
|
+
"url": str(url),
|
|
1275
|
+
"snippet": str(snippet)[:300],
|
|
1276
|
+
"score": score,
|
|
1277
|
+
"source": engine_name,
|
|
1278
|
+
})
|
|
1279
|
+
return results[:limit]
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _ensure_engine_source(
|
|
1283
|
+
results: list[dict[str, Any]] | Any, engine_name: str, preserve: bool = False
|
|
1284
|
+
) -> list[dict[str, Any]]:
|
|
1285
|
+
"""纠正结果 source,避免 HTTP 解析器错标(如 uapi→stackoverflow)。
|
|
1286
|
+
|
|
1287
|
+
规则:
|
|
1288
|
+
- 空 / generic → 设为引擎名
|
|
1289
|
+
- source 既不等于引擎名、也不以「引擎名/」开头 → 纠正为引擎名
|
|
1290
|
+
- wigolo_npx 允许保留 wigolo/... 子源标注
|
|
1291
|
+
- preserve=True(声明式 spec 的 preserve_source)时保留 API 返回的
|
|
1292
|
+
真实来源标注(如聚合资讯引擎的上游发布方),只对空/generic 保底
|
|
1293
|
+
"""
|
|
1294
|
+
if not isinstance(results, list) or not engine_name:
|
|
1295
|
+
return results if isinstance(results, list) else []
|
|
1296
|
+
for r in results:
|
|
1297
|
+
if not isinstance(r, dict) or "error" in r:
|
|
1298
|
+
continue
|
|
1299
|
+
src = str(r.get("source") or "")
|
|
1300
|
+
if engine_name == "wigolo_npx" and src.startswith("wigolo"):
|
|
1301
|
+
continue
|
|
1302
|
+
if not src or src == "generic":
|
|
1303
|
+
r["source"] = engine_name
|
|
1304
|
+
elif not preserve and src != engine_name and not src.startswith(engine_name + "/"):
|
|
1305
|
+
r["source"] = engine_name
|
|
1306
|
+
return results
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _parse_uapi(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1310
|
+
"""解析 UAPI 搜索响应。"""
|
|
1311
|
+
if not isinstance(data, dict):
|
|
1312
|
+
return []
|
|
1313
|
+
results = data.get("results", [])
|
|
1314
|
+
if not isinstance(results, list):
|
|
1315
|
+
return []
|
|
1316
|
+
return [
|
|
1317
|
+
{
|
|
1318
|
+
"title": r.get("title", ""),
|
|
1319
|
+
"url": r.get("url", ""),
|
|
1320
|
+
"snippet": r.get("snippet", "")[:300],
|
|
1321
|
+
"source": "uapi",
|
|
1322
|
+
}
|
|
1323
|
+
for r in results if isinstance(r, dict)
|
|
1324
|
+
][:10]
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _parse_semantic_scholar(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1328
|
+
"""解析 Semantic Scholar API 响应。"""
|
|
1329
|
+
if not isinstance(data, dict):
|
|
1330
|
+
return []
|
|
1331
|
+
papers = data.get("data") or []
|
|
1332
|
+
if not isinstance(papers, list):
|
|
1333
|
+
return []
|
|
1334
|
+
results: list[dict[str, Any]] = []
|
|
1335
|
+
|
|
1336
|
+
for paper in papers:
|
|
1337
|
+
if not isinstance(paper, dict):
|
|
1338
|
+
continue
|
|
1339
|
+
|
|
1340
|
+
title = paper.get("title", "")
|
|
1341
|
+
abstract = paper.get("abstract", "") or ""
|
|
1342
|
+
citation_count = paper.get("citationCount", 0) or 0
|
|
1343
|
+
|
|
1344
|
+
pdf_info = paper.get("openAccessPdf", {})
|
|
1345
|
+
url = pdf_info.get("url", "") if isinstance(pdf_info, dict) else ""
|
|
1346
|
+
|
|
1347
|
+
authors = paper.get("authors", [])
|
|
1348
|
+
author_names = [a.get("name", "") for a in authors if isinstance(a, dict)][:3]
|
|
1349
|
+
author_str = ", ".join(author_names)
|
|
1350
|
+
|
|
1351
|
+
snippet = f"{abstract[:200]}"
|
|
1352
|
+
if citation_count:
|
|
1353
|
+
snippet += f" [引用: {citation_count}]"
|
|
1354
|
+
if author_str:
|
|
1355
|
+
snippet += f" [作者: {author_str}]"
|
|
1356
|
+
|
|
1357
|
+
results.append({
|
|
1358
|
+
"title": title,
|
|
1359
|
+
"url": url,
|
|
1360
|
+
"snippet": snippet[:300],
|
|
1361
|
+
"score": min(1.0, citation_count / 1000) if citation_count else 0.5,
|
|
1362
|
+
"source": "semantic_scholar",
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
return results[:10]
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _parse_unpaywall(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1369
|
+
"""解析 Unpaywall 响应(单对象根,嵌套 best_oa_location)。
|
|
1370
|
+
|
|
1371
|
+
为什么需要专用解析器:与 doi 同理,响应是**一个对象**而非数组,而声明式
|
|
1372
|
+
output_map 只支持数组根(`_extract_items` 对 dict 根返回空)。且「有没有
|
|
1373
|
+
合法免费全文」这个答案藏在嵌套字段里(is_oa / best_oa_location.url),
|
|
1374
|
+
需要展开成人可读的一条结果。
|
|
1375
|
+
|
|
1376
|
+
本引擎的价值不是「再给一篇论文」,而是回答「这篇论文有没有合法免费全文、
|
|
1377
|
+
在哪」——所以把 OA 状态写进标题,让人一眼可判。
|
|
1378
|
+
"""
|
|
1379
|
+
if not isinstance(data, dict):
|
|
1380
|
+
return []
|
|
1381
|
+
title = str(data.get("title") or "").strip()
|
|
1382
|
+
doi = str(data.get("doi") or "").strip()
|
|
1383
|
+
if not title:
|
|
1384
|
+
return []
|
|
1385
|
+
is_oa = data.get("is_oa") is True
|
|
1386
|
+
best = data.get("best_oa_location") or {}
|
|
1387
|
+
oa_url = ""
|
|
1388
|
+
if isinstance(best, dict):
|
|
1389
|
+
oa_url = str(best.get("url") or best.get("url_for_pdf") or "").strip()
|
|
1390
|
+
# 无 OA 全文时回落到 DOI 落地页:保证 url 非空(下游会过滤掉无 url 的条目,
|
|
1391
|
+
# 而那会表现为「引擎明明有记录却是 0 条」的静默空结果)。
|
|
1392
|
+
url = oa_url or (f"https://doi.org/{doi}" if doi else "")
|
|
1393
|
+
if not url:
|
|
1394
|
+
return []
|
|
1395
|
+
journal = str(data.get("journal_name") or "").strip()
|
|
1396
|
+
year = str(data.get("year") or "").strip()
|
|
1397
|
+
host = str(best.get("host_type") or "").strip() if isinstance(best, dict) else ""
|
|
1398
|
+
status = ("有合法免费全文" if (is_oa and oa_url)
|
|
1399
|
+
else "有 OA 记录但未给出链接" if is_oa
|
|
1400
|
+
else "无开放获取版本(需订阅或文献互助)")
|
|
1401
|
+
parts = [p for p in (journal, year, host, status) if p]
|
|
1402
|
+
return [{
|
|
1403
|
+
"title": f"{title} — {status}"[:500],
|
|
1404
|
+
"url": url,
|
|
1405
|
+
"snippet": " · ".join(parts)[:300],
|
|
1406
|
+
"source": "unpaywall",
|
|
1407
|
+
"published_at": year,
|
|
1408
|
+
}]
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _parse_doi(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1412
|
+
"""解析 doi.org 内容协商响应(CSL JSON,单对象根)。
|
|
1413
|
+
|
|
1414
|
+
doi.org 配 Accept: application/vnd.citationstyles.csl+json 返回的是
|
|
1415
|
+
**一个** CSL JSON 对象而非数组——声明式 output_map 只支持数组根,
|
|
1416
|
+
由本解析器包裹成单元素结果。CSL 字段多为嵌套(container-title 是
|
|
1417
|
+
list、issued.date-parts 是二维数组),在此展平。
|
|
1418
|
+
"""
|
|
1419
|
+
if not isinstance(data, dict):
|
|
1420
|
+
return []
|
|
1421
|
+
title = data.get("title") or ""
|
|
1422
|
+
if isinstance(title, list):
|
|
1423
|
+
title = " ".join(str(t) for t in title)
|
|
1424
|
+
title = str(title).strip()
|
|
1425
|
+
container = data.get("container-title") or ""
|
|
1426
|
+
if isinstance(container, list):
|
|
1427
|
+
container = " ".join(str(c) for c in container)
|
|
1428
|
+
doi = str(data.get("DOI") or "").strip()
|
|
1429
|
+
url = str(data.get("URL") or "").strip() or (f"https://doi.org/{doi}" if doi else "")
|
|
1430
|
+
if not title or not url:
|
|
1431
|
+
return []
|
|
1432
|
+
snippet_parts = [str(p) for p in (container, data.get("publisher") or "") if p]
|
|
1433
|
+
out: dict[str, Any] = {
|
|
1434
|
+
"title": title[:500],
|
|
1435
|
+
"url": url,
|
|
1436
|
+
"source": "doi",
|
|
1437
|
+
}
|
|
1438
|
+
if snippet_parts:
|
|
1439
|
+
out["snippet"] = " · ".join(snippet_parts)[:300]
|
|
1440
|
+
# CSL issued.date-parts: [[2021, 3, 1]] → "2021-3-1"
|
|
1441
|
+
issued = data.get("issued") or {}
|
|
1442
|
+
dp = issued.get("date-parts") or [] if isinstance(issued, dict) else []
|
|
1443
|
+
if dp and dp[0]:
|
|
1444
|
+
out["published_at"] = "-".join(str(p) for p in dp[0] if p)
|
|
1445
|
+
authors = data.get("author") or []
|
|
1446
|
+
if isinstance(authors, list):
|
|
1447
|
+
names = []
|
|
1448
|
+
for a in authors[:3]:
|
|
1449
|
+
if isinstance(a, dict):
|
|
1450
|
+
name = f"{a.get('given', '')} {a.get('family', '')}".strip()
|
|
1451
|
+
if name:
|
|
1452
|
+
names.append(name)
|
|
1453
|
+
if names:
|
|
1454
|
+
out["authors"] = "; ".join(names)
|
|
1455
|
+
return [out]
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
# 引擎名 → 专用 JSON 解析器(无 output_map 时的精确格式)
|
|
1459
|
+
_CUSTOM_JSON_PARSERS: dict[str, Callable[[dict[str, Any]], list[dict[str, Any]]]] = {
|
|
1460
|
+
"uapi": _parse_uapi,
|
|
1461
|
+
"semantic_scholar": _parse_semantic_scholar,
|
|
1462
|
+
"doi": _parse_doi,
|
|
1463
|
+
"unpaywall": _parse_unpaywall,
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def mcp_error_of(data: Any, source: str = "mcp") -> str | None:
|
|
1470
|
+
"""检查 MCP JSON-RPC 响应是否含错误,返回错误描述;无错误返回 None。
|
|
1471
|
+
|
|
1472
|
+
## 为什么需要这个函数(2026-09-10 实测)
|
|
1473
|
+
|
|
1474
|
+
argo 自己**输出** MCP 的 `isError` 约定(见 mcp_handlers.py),但**消费**
|
|
1475
|
+
上游 MCP 服务时完全忽略它。实测 anysearch 上游返回:
|
|
1476
|
+
|
|
1477
|
+
HTTP 200
|
|
1478
|
+
{"result": {"content": [{"text": "Service temporarily unavailable."}],
|
|
1479
|
+
"isError": true}}
|
|
1480
|
+
|
|
1481
|
+
旧实现只做两件事:① 在文本里找配额关键词(quota/429/rate limit…)
|
|
1482
|
+
② 按 "### N." 解析结果块。而 "Service temporarily unavailable." 不含配额
|
|
1483
|
+
词、也没有结果块 → **静默返回空列表**。用户看到「没有结果」,而不是
|
|
1484
|
+
「上游不可用」;熔断器也拿不到失败信号,无法降权。
|
|
1485
|
+
|
|
1486
|
+
这类「失败伪装成成功」在本仓已出现多次(V2EX 旧实现产出幻觉、
|
|
1487
|
+
缓存软命中跨引擎串味、juejin/qiita 返回热榜、you/parallel 状态说谎)。
|
|
1488
|
+
本函数把 MCP 错误判定收紧成唯一来源,供所有 MCP 消费者复用。
|
|
1489
|
+
|
|
1490
|
+
## 覆盖三类错误
|
|
1491
|
+
|
|
1492
|
+
1. 工具级:`{"result": {"isError": true, ...}}` —— MCP 规范的工具错误
|
|
1493
|
+
2. 协议级:`{"error": {"code": ..., "message": ...}}` —— JSON-RPC 错误
|
|
1494
|
+
3. 空响应:`result` 缺失或 content 为空且无结构化字段
|
|
1495
|
+
|
|
1496
|
+
业务级错误(如配额耗尽)语义因服务而异,不在此判定——由调用方
|
|
1497
|
+
结合 `content` 文本自行处理(见 anysearch 的配额关键词检查)。
|
|
1498
|
+
"""
|
|
1499
|
+
if not isinstance(data, dict):
|
|
1500
|
+
return f"{source}: 响应非 JSON 对象"
|
|
1501
|
+
# 协议级错误(JSON-RPC 标准)
|
|
1502
|
+
err = data.get("error")
|
|
1503
|
+
if isinstance(err, dict):
|
|
1504
|
+
msg = err.get("message") or err.get("code") or "unknown"
|
|
1505
|
+
return f"{source}: JSON-RPC 错误 {msg}"
|
|
1506
|
+
if isinstance(err, str) and err:
|
|
1507
|
+
return f"{source}: {err}"
|
|
1508
|
+
result = data.get("result")
|
|
1509
|
+
if not isinstance(result, dict):
|
|
1510
|
+
return None # 交回调用方按业务语义判断(可能是另一种响应形态)
|
|
1511
|
+
# 工具级错误(MCP 规范)
|
|
1512
|
+
if result.get("isError") is True:
|
|
1513
|
+
texts = []
|
|
1514
|
+
for item in (result.get("content") or []):
|
|
1515
|
+
if isinstance(item, dict) and item.get("text"):
|
|
1516
|
+
texts.append(str(item["text"]))
|
|
1517
|
+
elif isinstance(item, str):
|
|
1518
|
+
texts.append(item)
|
|
1519
|
+
detail = " ".join(texts).strip()[:200] or "isError=true(无详情)"
|
|
1520
|
+
return f"{source}: {detail}"
|
|
1521
|
+
return None
|