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,1468 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""批次九构建器:免密钥垂直数据源扩展(2026-09-13)。
|
|
3
|
+
|
|
4
|
+
收录原则与批次七/八一致:免密钥开箱可用、填真空白、过收录检查。
|
|
5
|
+
本批聚焦 argo 此前的七个真实空白域:
|
|
6
|
+
|
|
7
|
+
who_don 公共卫生事件(WHO 官方 OData)
|
|
8
|
+
who_gho 全球卫生指标(WHO GHO OData)
|
|
9
|
+
gdacs 多灾种预警(洪水/台风/野火,补 usgs 只有地震)
|
|
10
|
+
nebula —
|
|
11
|
+
osv —
|
|
12
|
+
obis 海洋物种观测(2.29 亿条,补 gbif 的陆生视角)
|
|
13
|
+
worms 海洋分类学权威命名
|
|
14
|
+
energy_charts 欧洲发电结构/可再生占比
|
|
15
|
+
openf1 F1 赛车
|
|
16
|
+
jolpica F1 积分榜(Ergast 继任)
|
|
17
|
+
openligadb 德甲足球赛程比分
|
|
18
|
+
artic 芝加哥艺术博物馆(补 art_museum 域)
|
|
19
|
+
cleveland 克利夫兰艺术博物馆(CC0 图像)
|
|
20
|
+
tvmaze 电视剧元数据(补 film_search 的剧集维度)
|
|
21
|
+
jikan 动漫库(MyAnimeList 数据)
|
|
22
|
+
deezer 音乐艺人/专辑(补 media_search 的西语圈)
|
|
23
|
+
listenbrainz 收听趋势(社区收听数据)
|
|
24
|
+
egov_law 日本法令(e-Gov 官方全文,补 kor_law/flk 之外的日韩)
|
|
25
|
+
k10plus 德国最大联合目录(SRU 标准,补 dnb 之外的馆藏)
|
|
26
|
+
ror 研究机构标识(补 org_entity 的学术机构维度)
|
|
27
|
+
nhtsa_vpic 车辆厂商本体
|
|
28
|
+
soilgrids 全球土壤属性(逐点栅格)
|
|
29
|
+
noaa_swpc 空间天气(Kp 指数/太阳活动区)
|
|
30
|
+
satnogs 卫星目录(配合 TLE 镜像)
|
|
31
|
+
tle_mirror TLE 两行根数(Celestrak 不可达时的替代)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
import re
|
|
39
|
+
import time
|
|
40
|
+
import urllib.error
|
|
41
|
+
import urllib.parse
|
|
42
|
+
import urllib.request
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from engines_base import (
|
|
46
|
+
rank_score,
|
|
47
|
+
safe_search,
|
|
48
|
+
http_open,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger("unified_search.engines")
|
|
52
|
+
|
|
53
|
+
_UA = "argo-search/2.8 (+batch9)"
|
|
54
|
+
|
|
55
|
+
# e-Gov 法令列表进程内缓存(体积大、变动低频)
|
|
56
|
+
_EGOV_ENTRIES: list[str] | None = None
|
|
57
|
+
_EGOV_FETCHED_AT: float = 0.0
|
|
58
|
+
def _latin_query(q: str) -> str:
|
|
59
|
+
"""从中英混排查询里抽出拉丁学名部分。
|
|
60
|
+
|
|
61
|
+
「Gadus morhua 海洋物种」->「Gadus morhua」;纯中文查询返回空串
|
|
62
|
+
(交回百科类引擎,OBIS/WoRMS 无中文名索引)。
|
|
63
|
+
"""
|
|
64
|
+
if not q:
|
|
65
|
+
return ""
|
|
66
|
+
stripped = re.sub(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]+", " ", q)
|
|
67
|
+
stripped = re.sub(r"[^A-Za-z0-9\-\s]", " ", stripped)
|
|
68
|
+
return " ".join(stripped.split())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _terms(q: str) -> list[str]:
|
|
72
|
+
"""切检索词:兼顾半角/全角空格(U+3000 不被 \\s 匹配,中文输入常见)。"""
|
|
73
|
+
return [t for t in re.split(r"[\s\u3000]+", (q or "").strip()) if t]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read(resp, engine: str = "") -> bytes:
|
|
78
|
+
"""读取响应体,容忍 IncompleteRead。
|
|
79
|
+
|
|
80
|
+
大体积响应(e-Gov 法令列表实测 2.7MB)在 http.client 上偶发
|
|
81
|
+
`IncompleteRead` —— 此时已读到的部分数据仍然可用(我们先拿头部/整体结构),
|
|
82
|
+
所以接受 partial 而不是直接失败。
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
return resp.read()
|
|
86
|
+
except Exception as e: # http.client.IncompleteRead 等
|
|
87
|
+
partial = getattr(e, "partial", None)
|
|
88
|
+
if partial:
|
|
89
|
+
logger.warning(f"{engine} 响应不完整,使用已读部分 {len(partial)}B")
|
|
90
|
+
return partial
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _json(url: str, to: float, engine: str = "") -> Any:
|
|
95
|
+
req = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
|
96
|
+
with http_open(req, timeout=to, engine=engine) as resp:
|
|
97
|
+
return json.loads(_read(resp, engine).decode("utf-8", "replace"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _text(url: str, to: float, engine: str = "", accept: str = "*/*") -> str:
|
|
101
|
+
req = urllib.request.Request(url, headers={"User-Agent": _UA, "Accept": accept})
|
|
102
|
+
with http_open(req, timeout=to, engine=engine) as resp:
|
|
103
|
+
return _read(resp, engine).decode("utf-8", "replace")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── WHO DON 疫情暴发通报 ──────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def _build_who_don_engine(spec: dict[str, Any]) -> Any:
|
|
109
|
+
"""WHO Disease Outbreak News(官方 OData,$filter contains 语法)。"""
|
|
110
|
+
timeout = spec.get("timeout", 15)
|
|
111
|
+
base = "https://www.who.int/api/news/diseaseoutbreaknews"
|
|
112
|
+
|
|
113
|
+
@safe_search
|
|
114
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
115
|
+
to = _timeout or timeout
|
|
116
|
+
q = (query or "").strip()
|
|
117
|
+
# OData:contains(Title,'x') 对中文无效(WHO 通报为英文),
|
|
118
|
+
# 取最近 n 条并按查询词做本地过滤,兼顾「查询无关时仍给官方事件流」。
|
|
119
|
+
# 不传 $top/$orderby:未编码的 $ 参数在此网关返回 400,改为取回后本地按日期排序
|
|
120
|
+
url = base
|
|
121
|
+
try:
|
|
122
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning(f"WHO DON 失败: {e}")
|
|
125
|
+
return []
|
|
126
|
+
# 该网关把 OData 的 value 展平为顶层 list;兼容两种形态
|
|
127
|
+
if isinstance(data, list):
|
|
128
|
+
items = data
|
|
129
|
+
else:
|
|
130
|
+
items = data.get("value") or []
|
|
131
|
+
if not isinstance(items, list):
|
|
132
|
+
return []
|
|
133
|
+
# 本地按发布日期倒序(新事件优先)
|
|
134
|
+
items = sorted(
|
|
135
|
+
[i for i in items if isinstance(i, dict)],
|
|
136
|
+
key=lambda i: str(i.get("PublicationDate") or ""), reverse=True)
|
|
137
|
+
# 只保留真正的暴发通报条目(该端点混有其它新闻类型)
|
|
138
|
+
items = [i for i in items if (i.get("Title") or i.get("OverrideTitle"))]
|
|
139
|
+
terms = [t for t in _terms(q.lower()) if len(t) > 1]
|
|
140
|
+
hits, rest = [], []
|
|
141
|
+
for it in items:
|
|
142
|
+
if not isinstance(it, dict):
|
|
143
|
+
continue
|
|
144
|
+
blob = " ".join(str(it.get(k) or "") for k in
|
|
145
|
+
("Title", "OverrideTitle", "Overview")).lower()
|
|
146
|
+
(hits if (terms and any(t in blob for t in terms)) else rest).append(it)
|
|
147
|
+
out = (hits + rest)[:n]
|
|
148
|
+
results = []
|
|
149
|
+
for _rk, it in enumerate(out):
|
|
150
|
+
title = str(it.get("Title") or it.get("OverrideTitle") or "").strip()
|
|
151
|
+
if not title:
|
|
152
|
+
continue
|
|
153
|
+
results.append({
|
|
154
|
+
"title": title[:200],
|
|
155
|
+
"url": f"https://www.who.int/emergencies/disease-outbreak-news/item/{it.get('Id','')}",
|
|
156
|
+
"snippet": str(it.get("Overview") or it.get("Summary") or "").strip()[:300],
|
|
157
|
+
"source": "who_don",
|
|
158
|
+
"score": rank_score(0.9, _rk),
|
|
159
|
+
"published_at": it.get("PublicationDate") or "",
|
|
160
|
+
})
|
|
161
|
+
return results
|
|
162
|
+
return _engine
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── WHO GHO 全球卫生指标 ──────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
def _build_who_gho_engine(spec: dict[str, Any]) -> Any:
|
|
168
|
+
"""WHO Global Health Observatory(官方 OData,指标目录检索)。"""
|
|
169
|
+
timeout = spec.get("timeout", 15)
|
|
170
|
+
base = "https://ghoapi.azureedge.net/api/Indicator"
|
|
171
|
+
|
|
172
|
+
@safe_search
|
|
173
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
174
|
+
to = _timeout or timeout
|
|
175
|
+
q = (query or "").strip().lower()
|
|
176
|
+
if not q:
|
|
177
|
+
return []
|
|
178
|
+
# 指标目录不大(数千条),一次取回本地过滤,避免 OData contains 的引号转义坑
|
|
179
|
+
try:
|
|
180
|
+
# 不传 $top:部分网关对未编码的 $ 参数返回 400;目录本身只有数千条
|
|
181
|
+
data = _json(base, to, spec.get("_name", ""))
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.warning(f"WHO GHO 失败: {e}")
|
|
184
|
+
return []
|
|
185
|
+
vals = data.get("value") or []
|
|
186
|
+
terms = [t for t in _terms(q) if len(t) > 2]
|
|
187
|
+
scored = []
|
|
188
|
+
for it in vals:
|
|
189
|
+
if not isinstance(it, dict):
|
|
190
|
+
continue
|
|
191
|
+
name = str(it.get("IndicatorName") or "")
|
|
192
|
+
code = str(it.get("IndicatorCode") or "")
|
|
193
|
+
low = f"{name} {code}".lower()
|
|
194
|
+
hits = sum(1 for t in terms if t in low)
|
|
195
|
+
if hits:
|
|
196
|
+
scored.append((hits, name, code))
|
|
197
|
+
scored.sort(key=lambda x: -x[0])
|
|
198
|
+
results = []
|
|
199
|
+
for _rk, (_h, name, code) in enumerate(scored[:n]):
|
|
200
|
+
results.append({
|
|
201
|
+
"title": name[:200],
|
|
202
|
+
"url": f"https://www.who.int/data/gho/data/indicators/indicator-details/GHO/{code}",
|
|
203
|
+
"snippet": f"WHO GHO 指标 · {code}"[:300],
|
|
204
|
+
"source": "who_gho",
|
|
205
|
+
"score": rank_score(0.85, _rk),
|
|
206
|
+
})
|
|
207
|
+
return results
|
|
208
|
+
return _engine
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── GDACS 多灾种预警 ──────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def _build_gdacs_engine(spec: dict[str, Any]) -> Any:
|
|
214
|
+
"""GDACS 全球灾害预警(EU/JRC 官方 GeoJSON,多灾种)。"""
|
|
215
|
+
timeout = spec.get("timeout", 20)
|
|
216
|
+
base = ("https://www.gdacs.org/gdacsapi/api/events/geteventlist/SEARCH"
|
|
217
|
+
"?eventlist=EQ,TC,FL,WF,DR,VO")
|
|
218
|
+
|
|
219
|
+
@safe_search
|
|
220
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
221
|
+
to = _timeout or timeout
|
|
222
|
+
q = (query or "").strip().lower()
|
|
223
|
+
try:
|
|
224
|
+
data = _json(base, to, spec.get("_name", ""))
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.warning(f"GDACS 失败: {e}")
|
|
227
|
+
return []
|
|
228
|
+
feats = data.get("features") or []
|
|
229
|
+
terms = [t for t in _terms(q) if len(t) > 2]
|
|
230
|
+
hits, rest = [], []
|
|
231
|
+
for f in feats:
|
|
232
|
+
if not isinstance(f, dict):
|
|
233
|
+
continue
|
|
234
|
+
p = f.get("properties") or {}
|
|
235
|
+
blob = f"{p.get('name','')} {p.get('eventtype','')} {p.get('htmldescription','')}".lower()
|
|
236
|
+
(hits if (terms and any(t in blob for t in terms)) else rest).append(p)
|
|
237
|
+
out = (hits + rest)[:n]
|
|
238
|
+
results = []
|
|
239
|
+
for _rk, p in enumerate(out):
|
|
240
|
+
name = (p.get("name") or p.get("htmldescription") or "").strip()
|
|
241
|
+
if not name:
|
|
242
|
+
continue
|
|
243
|
+
alert = (p.get("alertlevel") or "").lower()
|
|
244
|
+
base_score = 0.9 if alert in ("red",) else (0.85 if alert == "orange" else 0.8)
|
|
245
|
+
# GDACS 的 properties.url 是 dict({report, geometry, ...}),
|
|
246
|
+
# 不是字符串——直接透传会在下游 url.lower() 处抛 AttributeError。
|
|
247
|
+
u = p.get("url")
|
|
248
|
+
if isinstance(u, dict):
|
|
249
|
+
link = u.get("report") or u.get("geometry") or ""
|
|
250
|
+
else:
|
|
251
|
+
link = u or ""
|
|
252
|
+
if not link:
|
|
253
|
+
link = "https://www.gdacs.org/"
|
|
254
|
+
results.append({
|
|
255
|
+
"title": f"[{(p.get('eventtype') or '').upper()}] {name}"[:200],
|
|
256
|
+
"url": link,
|
|
257
|
+
"snippet": (p.get("htmldescription") or "")[:300],
|
|
258
|
+
"source": "gdacs",
|
|
259
|
+
"score": rank_score(base_score, _rk),
|
|
260
|
+
"published_at": (p.get("fromdate") or "")[:10],
|
|
261
|
+
})
|
|
262
|
+
return results
|
|
263
|
+
return _engine
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── OBIS 海洋生物观测 ─────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def _build_obis_engine(spec: dict[str, Any]) -> Any:
|
|
269
|
+
"""OBIS 海洋物种观测(IOC/UNESCO,2.29 亿条记录)。"""
|
|
270
|
+
timeout = spec.get("timeout", 15)
|
|
271
|
+
|
|
272
|
+
@safe_search
|
|
273
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
274
|
+
to = _timeout or timeout
|
|
275
|
+
q = (query or "").strip()
|
|
276
|
+
if not q:
|
|
277
|
+
return []
|
|
278
|
+
# OBIS 的 scientificname 过滤对拉丁学名准确;混排查询抽出拉丁部分再查
|
|
279
|
+
q = _latin_query(q)
|
|
280
|
+
if not q:
|
|
281
|
+
return []
|
|
282
|
+
url = ("https://api.obis.org/v3/occurrence?scientificname="
|
|
283
|
+
f"{urllib.parse.quote(q)}&size={n}")
|
|
284
|
+
try:
|
|
285
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.warning(f"OBIS 失败: {e}")
|
|
288
|
+
return []
|
|
289
|
+
total = data.get("total")
|
|
290
|
+
results = []
|
|
291
|
+
seen = set()
|
|
292
|
+
for _rk, r in enumerate((data.get("results") or [])[:n]):
|
|
293
|
+
if not isinstance(r, dict):
|
|
294
|
+
continue
|
|
295
|
+
sci = r.get("scientificName") or r.get("acceptedNameUsage") or ""
|
|
296
|
+
if not sci or sci in seen:
|
|
297
|
+
continue
|
|
298
|
+
seen.add(sci)
|
|
299
|
+
results.append({
|
|
300
|
+
"title": f"{sci}(海洋观测记录)"[:200],
|
|
301
|
+
"url": "https://obis.org/",
|
|
302
|
+
"snippet": (f"OBIS 海洋生物多样性 · 记录总数 {total} · "
|
|
303
|
+
f"深度 {r.get('depth','-')} m")[:300],
|
|
304
|
+
"source": "obis",
|
|
305
|
+
"score": rank_score(0.85, _rk),
|
|
306
|
+
})
|
|
307
|
+
return results
|
|
308
|
+
return _engine
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _build_worms_engine(spec: dict[str, Any]) -> Any:
|
|
312
|
+
"""WoRMS 海洋物种权威命名(分类学标准)。"""
|
|
313
|
+
timeout = spec.get("timeout", 15)
|
|
314
|
+
|
|
315
|
+
@safe_search
|
|
316
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
317
|
+
to = _timeout or timeout
|
|
318
|
+
q = (query or "").strip()
|
|
319
|
+
q = _latin_query(q)
|
|
320
|
+
if not q:
|
|
321
|
+
return []
|
|
322
|
+
url = f"https://www.marinespecies.org/rest/AphiaRecordsByName/{urllib.parse.quote(q)}?like=true"
|
|
323
|
+
try:
|
|
324
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
325
|
+
except Exception as e:
|
|
326
|
+
logger.warning(f"WoRMS 失败: {e}")
|
|
327
|
+
return []
|
|
328
|
+
if not isinstance(data, list):
|
|
329
|
+
return []
|
|
330
|
+
results = []
|
|
331
|
+
for _rk, r in enumerate(data[:n]):
|
|
332
|
+
if not isinstance(r, dict):
|
|
333
|
+
continue
|
|
334
|
+
name = r.get("scientificname") or ""
|
|
335
|
+
if not name:
|
|
336
|
+
continue
|
|
337
|
+
results.append({
|
|
338
|
+
"title": f"{name}({'已接受名' if r.get('status') == 'accepted' else r.get('status') or ''})"[:200],
|
|
339
|
+
"url": f"https://www.marinespecies.org/aphia.php?p=taxdetails&id={r.get('AphiaID','')}",
|
|
340
|
+
"snippet": (f"WoRMS AphiaID {r.get('AphiaID','')} · {r.get('rank','')} · "
|
|
341
|
+
f"{r.get('kingdom','')}")[:300],
|
|
342
|
+
"source": "worms",
|
|
343
|
+
"score": rank_score(0.85, _rk),
|
|
344
|
+
})
|
|
345
|
+
return results
|
|
346
|
+
return _engine
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ── Energy-Charts 欧洲发电结构 ────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
_EC_COUNTRY = {
|
|
352
|
+
"germany": "de", "德国": "de", "france": "fr", "法国": "fr",
|
|
353
|
+
"spain": "es", "西班牙": "es", "italy": "it", "意大利": "it",
|
|
354
|
+
"poland": "pl", "波兰": "pl", "austria": "at", "奥地利": "at",
|
|
355
|
+
"netherlands": "nl", "荷兰": "nl", "belgium": "be", "比利时": "be",
|
|
356
|
+
"switzerland": "ch", "瑞士": "ch", "denmark": "dk", "丹麦": "dk",
|
|
357
|
+
"sweden": "se", "瑞典": "se", "norway": "no", "挪威": "no",
|
|
358
|
+
"portugal": "pt", "葡萄牙": "pt", "czech": "cz", "捷克": "cz",
|
|
359
|
+
}
|
|
360
|
+
_EC_LABEL = {
|
|
361
|
+
"de": "德国", "fr": "法国", "es": "西班牙", "it": "意大利", "pl": "波兰",
|
|
362
|
+
"at": "奥地利", "nl": "荷兰", "be": "比利时", "ch": "瑞士", "dk": "丹麦",
|
|
363
|
+
"se": "瑞典", "no": "挪威", "pt": "葡萄牙", "cz": "捷克",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _build_energy_charts_engine(spec: dict[str, Any]) -> Any:
|
|
368
|
+
"""Energy-Charts 发电结构(Fraunhofer ISE,免认证,需署名)。"""
|
|
369
|
+
timeout = spec.get("timeout", 20)
|
|
370
|
+
|
|
371
|
+
@safe_search
|
|
372
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
373
|
+
to = _timeout or timeout
|
|
374
|
+
q = (query or "").strip()
|
|
375
|
+
low = q.lower()
|
|
376
|
+
cc = None
|
|
377
|
+
for k, v in _EC_COUNTRY.items():
|
|
378
|
+
if k in low:
|
|
379
|
+
cc = v
|
|
380
|
+
break
|
|
381
|
+
if cc is None:
|
|
382
|
+
cc = "de" # 默认德国(数据集覆盖最全)
|
|
383
|
+
# 取最近 2 天的逐时发电结构
|
|
384
|
+
from datetime import date, timedelta
|
|
385
|
+
end = date.today()
|
|
386
|
+
start = end - timedelta(days=1)
|
|
387
|
+
url = ("https://api.energy-charts.info/public_power?country="
|
|
388
|
+
f"{cc}&start={start.isoformat()}&end={end.isoformat()}")
|
|
389
|
+
try:
|
|
390
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
391
|
+
except Exception as e:
|
|
392
|
+
logger.warning(f"Energy-Charts 失败: {e}")
|
|
393
|
+
return []
|
|
394
|
+
types = data.get("production_types") or []
|
|
395
|
+
if not isinstance(types, list):
|
|
396
|
+
return []
|
|
397
|
+
label = _EC_LABEL.get(cc, cc.upper())
|
|
398
|
+
results = []
|
|
399
|
+
# 按累计发电量排序,给该国的发电结构快照
|
|
400
|
+
ranked = []
|
|
401
|
+
for t in types:
|
|
402
|
+
if not isinstance(t, dict):
|
|
403
|
+
continue
|
|
404
|
+
name = t.get("name") or ""
|
|
405
|
+
series = t.get("data") or []
|
|
406
|
+
total = sum(v for v in series if isinstance(v, (int, float)))
|
|
407
|
+
ranked.append((total, name))
|
|
408
|
+
ranked.sort(key=lambda x: -x[0])
|
|
409
|
+
for _rk, (total, name) in enumerate(ranked[:n]):
|
|
410
|
+
if not name:
|
|
411
|
+
continue
|
|
412
|
+
results.append({
|
|
413
|
+
"title": f"{label} 发电结构 · {name}"[:200],
|
|
414
|
+
"url": "https://www.energy-charts.info/",
|
|
415
|
+
"snippet": (f"{label} 近 24h {name} 累计发电 {round(total/1000, 1)} GWh "
|
|
416
|
+
f"(数据来源 Fraunhofer ISE Energy-Charts)")[:300],
|
|
417
|
+
"source": "energy_charts",
|
|
418
|
+
"score": rank_score(0.85, _rk),
|
|
419
|
+
})
|
|
420
|
+
return results
|
|
421
|
+
return _engine
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ── F1:OpenF1 + Jolpica ─────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
def _build_jolpica_engine(spec: dict[str, Any]) -> Any:
|
|
427
|
+
"""Jolpica F1(Ergast 继任 API,积分榜/赛程)。"""
|
|
428
|
+
timeout = spec.get("timeout", 20)
|
|
429
|
+
|
|
430
|
+
@safe_search
|
|
431
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
432
|
+
to = _timeout or timeout
|
|
433
|
+
q = (query or "").strip().lower()
|
|
434
|
+
year = None
|
|
435
|
+
m = re.search(r"\b(19|20)\d{2}\b", q)
|
|
436
|
+
if m:
|
|
437
|
+
year = m.group(0)
|
|
438
|
+
else:
|
|
439
|
+
from datetime import date
|
|
440
|
+
year = str(date.today().year)
|
|
441
|
+
url = f"https://api.jolpi.ca/ergast/f1/{year}/driverstandings/"
|
|
442
|
+
try:
|
|
443
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.warning(f"Jolpica 失败: {e}")
|
|
446
|
+
return []
|
|
447
|
+
try:
|
|
448
|
+
lists = data["MRData"]["StandingsTable"]["StandingsLists"][0]["DriverStandings"]
|
|
449
|
+
except (KeyError, IndexError, TypeError):
|
|
450
|
+
return []
|
|
451
|
+
results = []
|
|
452
|
+
terms = [t for t in _terms(q) if len(t) > 2 and not t.isdigit()]
|
|
453
|
+
filtered = []
|
|
454
|
+
for d in lists:
|
|
455
|
+
if not isinstance(d, dict):
|
|
456
|
+
continue
|
|
457
|
+
drv = d.get("Driver") or {}
|
|
458
|
+
nm = f"{drv.get('givenName','')} {drv.get('familyName','')}".strip()
|
|
459
|
+
cons = ", ".join((c.get("Constructor") or {}).get("name", "")
|
|
460
|
+
for c in (d.get("Constructors") or []) if isinstance(c, dict))
|
|
461
|
+
blob = f"{nm} {cons}".lower()
|
|
462
|
+
filtered.append((1 if (terms and any(t in blob for t in terms)) else 0, d, nm, cons))
|
|
463
|
+
filtered.sort(key=lambda x: -x[0])
|
|
464
|
+
for _rk, (_h, d, nm, cons) in enumerate(filtered[:n]):
|
|
465
|
+
if not nm:
|
|
466
|
+
continue
|
|
467
|
+
results.append({
|
|
468
|
+
"title": f"F1 {year} 车手积分榜 · P{d.get('position','')} {nm}"[:200],
|
|
469
|
+
"url": (d.get("Driver") or {}).get("url") or "https://www.formula1.com/",
|
|
470
|
+
"snippet": (f"{nm} · {cons} · 积分 {d.get('points','')} · 胜场 {d.get('wins','')}"
|
|
471
|
+
f"({year} 赛季)")[:300],
|
|
472
|
+
"source": "jolpica",
|
|
473
|
+
"score": rank_score(0.85, _rk),
|
|
474
|
+
})
|
|
475
|
+
return results
|
|
476
|
+
return _engine
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _build_openf1_engine(spec: dict[str, Any]) -> Any:
|
|
480
|
+
"""OpenF1 车手/车队(两级:sessions → drivers)。"""
|
|
481
|
+
timeout = spec.get("timeout", 20)
|
|
482
|
+
|
|
483
|
+
@safe_search
|
|
484
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
485
|
+
to = _timeout or timeout
|
|
486
|
+
q = (query or "").strip().lower()
|
|
487
|
+
try:
|
|
488
|
+
sessions = _json("https://api.openf1.org/v1/sessions?year=2026", to, spec.get("_name", ""))
|
|
489
|
+
except Exception as e:
|
|
490
|
+
logger.warning(f"OpenF1 sessions 失败: {e}")
|
|
491
|
+
return []
|
|
492
|
+
if not isinstance(sessions, list) or not sessions:
|
|
493
|
+
return []
|
|
494
|
+
# 取最近一场有 session_key 的场次
|
|
495
|
+
sk = None
|
|
496
|
+
for s in reversed(sessions):
|
|
497
|
+
if isinstance(s, dict) and s.get("session_key"):
|
|
498
|
+
sk = s["session_key"]
|
|
499
|
+
break
|
|
500
|
+
if sk is None:
|
|
501
|
+
return []
|
|
502
|
+
try:
|
|
503
|
+
drivers = _json(f"https://api.openf1.org/v1/drivers?session_key={sk}", to, spec.get("_name", ""))
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.warning(f"OpenF1 drivers 失败: {e}")
|
|
506
|
+
return []
|
|
507
|
+
if not isinstance(drivers, list):
|
|
508
|
+
return []
|
|
509
|
+
terms = [t for t in _terms(q) if len(t) > 2]
|
|
510
|
+
rows = []
|
|
511
|
+
for d in drivers:
|
|
512
|
+
if not isinstance(d, dict):
|
|
513
|
+
continue
|
|
514
|
+
nm = d.get("full_name") or d.get("broadcast_name") or ""
|
|
515
|
+
team = d.get("team_name") or ""
|
|
516
|
+
blob = f"{nm} {team}".lower()
|
|
517
|
+
rows.append((1 if (terms and any(t in blob for t in terms)) else 0, d, nm, team))
|
|
518
|
+
rows.sort(key=lambda x: -x[0])
|
|
519
|
+
results = []
|
|
520
|
+
for _rk, (_h, d, nm, team) in enumerate(rows[:n]):
|
|
521
|
+
if not nm:
|
|
522
|
+
continue
|
|
523
|
+
results.append({
|
|
524
|
+
"title": f"F1 {nm} · {team}"[:200],
|
|
525
|
+
"url": "https://openf1.org/",
|
|
526
|
+
"snippet": (f"车号 {d.get('driver_number','')} · {team} · "
|
|
527
|
+
f"{d.get('country_code','')}(OpenF1 遥测数据)")[:300],
|
|
528
|
+
"source": "openf1",
|
|
529
|
+
"score": rank_score(0.8, _rk),
|
|
530
|
+
})
|
|
531
|
+
return results
|
|
532
|
+
return _engine
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _build_openligadb_engine(spec: dict[str, Any]) -> Any:
|
|
536
|
+
"""OpenLigaDB 德甲/欧洲联赛赛程比分(免认证)。"""
|
|
537
|
+
timeout = spec.get("timeout", 15)
|
|
538
|
+
LEAGUES = {"bundesliga": "bl1", "德甲": "bl1", "bl1": "bl1",
|
|
539
|
+
"bl2": "bl2", "德乙": "bl2", "premier": "bl1"}
|
|
540
|
+
|
|
541
|
+
@safe_search
|
|
542
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
543
|
+
to = _timeout or timeout
|
|
544
|
+
q = (query or "").strip()
|
|
545
|
+
low = q.lower()
|
|
546
|
+
league = "bl1"
|
|
547
|
+
for k, v in LEAGUES.items():
|
|
548
|
+
if k in low:
|
|
549
|
+
league = v
|
|
550
|
+
break
|
|
551
|
+
from datetime import date
|
|
552
|
+
season = date.today().year
|
|
553
|
+
url = f"https://api.openligadb.de/getmatchdata/{league}/{season}"
|
|
554
|
+
try:
|
|
555
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
556
|
+
except Exception as e:
|
|
557
|
+
logger.warning(f"OpenLigaDB 失败: {e}")
|
|
558
|
+
return []
|
|
559
|
+
if not isinstance(data, list):
|
|
560
|
+
return []
|
|
561
|
+
terms = [t for t in _terms(low) if len(t) > 2 and not t.isdigit()]
|
|
562
|
+
rows = []
|
|
563
|
+
for m in data:
|
|
564
|
+
if not isinstance(m, dict):
|
|
565
|
+
continue
|
|
566
|
+
t1 = ((m.get("team1") or {}).get("teamName") or "")
|
|
567
|
+
t2 = ((m.get("team2") or {}).get("teamName") or "")
|
|
568
|
+
blob = f"{t1} {t2}".lower()
|
|
569
|
+
rows.append((1 if (terms and any(t in blob for t in terms)) else 0, m, t1, t2))
|
|
570
|
+
# 无命中时给最近场次(按时间倒序取尾部)
|
|
571
|
+
rows.sort(key=lambda x: x[0])
|
|
572
|
+
hits = [r for r in rows if r[0] == 1]
|
|
573
|
+
pool = (hits if hits else rows)[:n]
|
|
574
|
+
results = []
|
|
575
|
+
for _rk, (_h, m, t1, t2) in enumerate(pool):
|
|
576
|
+
if not t1 and not t2:
|
|
577
|
+
continue
|
|
578
|
+
res = m.get("matchResults") or []
|
|
579
|
+
score_txt = ""
|
|
580
|
+
if res and isinstance(res[-1], dict):
|
|
581
|
+
score_txt = f" 比分 {res[-1].get('pointsTeam1','')}-{res[-1].get('pointsTeam2','')}"
|
|
582
|
+
results.append({
|
|
583
|
+
"title": f"{t1} vs {t2}{score_txt}"[:200],
|
|
584
|
+
"url": "https://www.openligadb.de/",
|
|
585
|
+
"snippet": (f"{m.get('leagueName','')} · 第 {m.get('groupName') or ''} 轮 · "
|
|
586
|
+
f"{str(m.get('matchDateTime',''))[:16]}")[:300],
|
|
587
|
+
"source": "openligadb",
|
|
588
|
+
"score": rank_score(0.8, _rk),
|
|
589
|
+
"published_at": str(m.get("matchDateTime") or "")[:10],
|
|
590
|
+
})
|
|
591
|
+
return results
|
|
592
|
+
return _engine
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# ── 博物馆:AIC + Cleveland(补 art_museum 域)────────────────────────────────
|
|
596
|
+
|
|
597
|
+
def _build_artic_engine(spec: dict[str, Any]) -> Any:
|
|
598
|
+
"""Art Institute of Chicago 馆藏(免认证,含 IIIF 图像)。"""
|
|
599
|
+
timeout = spec.get("timeout", 20)
|
|
600
|
+
|
|
601
|
+
@safe_search
|
|
602
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
603
|
+
to = _timeout or timeout
|
|
604
|
+
q = (query or "").strip()
|
|
605
|
+
if not q:
|
|
606
|
+
return []
|
|
607
|
+
url = ("https://api.artic.edu/api/v1/artworks/search?"
|
|
608
|
+
f"q={urllib.parse.quote(q)}&limit={n}"
|
|
609
|
+
"&fields=id,title,artist_display,date_display,image_id")
|
|
610
|
+
try:
|
|
611
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
612
|
+
except Exception as e:
|
|
613
|
+
logger.warning(f"AIC 失败: {e}")
|
|
614
|
+
return []
|
|
615
|
+
# IIIF 基址取响应自带的 config.iiif_url,不硬编码——上游换域时硬编码会
|
|
616
|
+
# 静默失效。该图床要求带 AIC-User-Agent 头才放行直连(否则 403 HEAD)。
|
|
617
|
+
iiif = str(((data.get("config") or {}).get("iiif_url")) or "").rstrip("/")
|
|
618
|
+
results = []
|
|
619
|
+
for _rk, it in enumerate((data.get("data") or [])[:n]):
|
|
620
|
+
if not isinstance(it, dict):
|
|
621
|
+
continue
|
|
622
|
+
title = (it.get("title") or "").strip()
|
|
623
|
+
if not title:
|
|
624
|
+
continue
|
|
625
|
+
_row = {
|
|
626
|
+
"title": title[:200],
|
|
627
|
+
"url": f"https://www.artic.edu/artworks/{it.get('id','')}",
|
|
628
|
+
"snippet": (f"{it.get('artist_display','')} · {it.get('date_display','')}"
|
|
629
|
+
"(芝加哥艺术博物馆)")[:300],
|
|
630
|
+
"source": "artic",
|
|
631
|
+
"score": rank_score(0.85, _rk),
|
|
632
|
+
}
|
|
633
|
+
# 图片字段:请求里本就取了 image_id(此前取到即丢),这里按 AIC
|
|
634
|
+
# 官方 IIIF 模板拼出可直接打开的图;无 image_id 的藏品不加字段。
|
|
635
|
+
_iid = str(it.get("image_id") or "").strip()
|
|
636
|
+
if _iid and iiif:
|
|
637
|
+
_row["image_url"] = f"{iiif}/{_iid}/full/843,/0/default.jpg"
|
|
638
|
+
_row["image_license"] = "AIC 公开接口(权利状态见作品页)"
|
|
639
|
+
results.append(_row)
|
|
640
|
+
return results
|
|
641
|
+
return _engine
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _build_cleveland_engine(spec: dict[str, Any]) -> Any:
|
|
645
|
+
"""克利夫兰艺术博物馆(CC0 开放图像)。"""
|
|
646
|
+
timeout = spec.get("timeout", 20)
|
|
647
|
+
|
|
648
|
+
@safe_search
|
|
649
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
650
|
+
to = _timeout or timeout
|
|
651
|
+
q = (query or "").strip()
|
|
652
|
+
if not q:
|
|
653
|
+
return []
|
|
654
|
+
url = ("https://openaccess-api.clevelandart.org/api/artworks/?"
|
|
655
|
+
f"q={urllib.parse.quote(q)}&limit={n}&has_image=1")
|
|
656
|
+
try:
|
|
657
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
658
|
+
except Exception as e:
|
|
659
|
+
logger.warning(f"Cleveland 失败: {e}")
|
|
660
|
+
return []
|
|
661
|
+
results = []
|
|
662
|
+
for _rk, it in enumerate((data.get("data") or [])[:n]):
|
|
663
|
+
if not isinstance(it, dict):
|
|
664
|
+
continue
|
|
665
|
+
title = (it.get("title") or "").strip()
|
|
666
|
+
if not title:
|
|
667
|
+
continue
|
|
668
|
+
_lic = str(it.get("share_license_status") or "").strip()
|
|
669
|
+
_row = {
|
|
670
|
+
"title": title[:200],
|
|
671
|
+
"url": it.get("url") or "https://www.clevelandart.org/",
|
|
672
|
+
"snippet": (f"{it.get('tombstone','')} · 授权 {_lic}"
|
|
673
|
+
"(克利夫兰艺术博物馆)")[:300],
|
|
674
|
+
"source": "cleveland",
|
|
675
|
+
"score": rank_score(0.85, _rk),
|
|
676
|
+
}
|
|
677
|
+
# 图片字段:请求已带 has_image=1,这里把 images.web.url 一并带上
|
|
678
|
+
# (web 尺寸适合直接展示,full/print 留给需要原图的场景)。
|
|
679
|
+
_img = str((((it.get("images") or {}).get("web") or {}).get("url")) or "").strip()
|
|
680
|
+
if _img:
|
|
681
|
+
_row["image_url"] = _img
|
|
682
|
+
if _lic:
|
|
683
|
+
_row["image_license"] = _lic
|
|
684
|
+
results.append(_row)
|
|
685
|
+
return results
|
|
686
|
+
return _engine
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# ── 影视/动漫/音乐 ────────────────────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
def _build_tvmaze_engine(spec: dict[str, Any]) -> Any:
|
|
692
|
+
"""TVMaze 电视剧元数据(免认证,补 film_search 的剧集维度)。"""
|
|
693
|
+
timeout = spec.get("timeout", 15)
|
|
694
|
+
|
|
695
|
+
@safe_search
|
|
696
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
697
|
+
to = _timeout or timeout
|
|
698
|
+
q = (query or "").strip()
|
|
699
|
+
if not q:
|
|
700
|
+
return []
|
|
701
|
+
url = f"https://api.tvmaze.com/search/shows?q={urllib.parse.quote(q)}"
|
|
702
|
+
try:
|
|
703
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
704
|
+
except Exception as e:
|
|
705
|
+
logger.warning(f"TVMaze 失败: {e}")
|
|
706
|
+
return []
|
|
707
|
+
results = []
|
|
708
|
+
for _rk, row in enumerate((data or [])[:n]):
|
|
709
|
+
if not isinstance(row, dict):
|
|
710
|
+
continue
|
|
711
|
+
s = row.get("show") or {}
|
|
712
|
+
name = (s.get("name") or "").strip()
|
|
713
|
+
if not name:
|
|
714
|
+
continue
|
|
715
|
+
genres = ", ".join(s.get("genres") or [])
|
|
716
|
+
results.append({
|
|
717
|
+
"title": f"{name}({s.get('premiered','')[:4]})"[:200],
|
|
718
|
+
"url": s.get("url") or "",
|
|
719
|
+
"snippet": (f"{s.get('language','')} · {genres} · "
|
|
720
|
+
f"评分 {((s.get('rating') or {}).get('average')) or '-'}"
|
|
721
|
+
"(TVMaze)")[:300],
|
|
722
|
+
"source": "tvmaze",
|
|
723
|
+
"score": rank_score(0.8, _rk),
|
|
724
|
+
"published_at": s.get("premiered") or "",
|
|
725
|
+
})
|
|
726
|
+
return results
|
|
727
|
+
return _engine
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _build_jikan_engine(spec: dict[str, Any]) -> Any:
|
|
731
|
+
"""Jikan(MyAnimeList 非官方 API)动漫元数据。"""
|
|
732
|
+
timeout = spec.get("timeout", 20)
|
|
733
|
+
|
|
734
|
+
@safe_search
|
|
735
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
736
|
+
to = _timeout or timeout
|
|
737
|
+
q = (query or "").strip()
|
|
738
|
+
if not q:
|
|
739
|
+
return []
|
|
740
|
+
url = f"https://api.jikan.moe/v4/anime?q={urllib.parse.quote(q)}&limit={n}"
|
|
741
|
+
try:
|
|
742
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
743
|
+
except Exception as e:
|
|
744
|
+
logger.warning(f"Jikan 失败: {e}")
|
|
745
|
+
return []
|
|
746
|
+
results = []
|
|
747
|
+
for _rk, a in enumerate((data.get("data") or [])[:n]):
|
|
748
|
+
if not isinstance(a, dict):
|
|
749
|
+
continue
|
|
750
|
+
title = a.get("title") or a.get("title_japanese") or ""
|
|
751
|
+
if not title:
|
|
752
|
+
continue
|
|
753
|
+
results.append({
|
|
754
|
+
"title": title[:200],
|
|
755
|
+
"url": a.get("url") or "",
|
|
756
|
+
"snippet": (f"{a.get('type','')} · {a.get('episodes','') or '-'} 集 · "
|
|
757
|
+
f"评分 {a.get('score') or '-'} · {a.get('status','')}"
|
|
758
|
+
"(MyAnimeList)")[:300],
|
|
759
|
+
"source": "jikan",
|
|
760
|
+
"score": rank_score(0.8, _rk),
|
|
761
|
+
})
|
|
762
|
+
return results
|
|
763
|
+
return _engine
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _build_deezer_engine(spec: dict[str, Any]) -> Any:
|
|
767
|
+
"""Deezer 音乐艺人/专辑(免认证)。"""
|
|
768
|
+
timeout = spec.get("timeout", 15)
|
|
769
|
+
|
|
770
|
+
@safe_search
|
|
771
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
772
|
+
to = _timeout or timeout
|
|
773
|
+
q = (query or "").strip()
|
|
774
|
+
if not q:
|
|
775
|
+
return []
|
|
776
|
+
url = f"https://api.deezer.com/search/artist?q={urllib.parse.quote(q)}&limit={n}"
|
|
777
|
+
try:
|
|
778
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
779
|
+
except Exception as e:
|
|
780
|
+
logger.warning(f"Deezer 失败: {e}")
|
|
781
|
+
return []
|
|
782
|
+
results = []
|
|
783
|
+
for _rk, a in enumerate((data.get("data") or [])[:n]):
|
|
784
|
+
if not isinstance(a, dict):
|
|
785
|
+
continue
|
|
786
|
+
name = (a.get("name") or "").strip()
|
|
787
|
+
if not name:
|
|
788
|
+
continue
|
|
789
|
+
results.append({
|
|
790
|
+
"title": f"{name}(音乐人)"[:200],
|
|
791
|
+
"url": a.get("link") or "",
|
|
792
|
+
"snippet": (f"Deezer 音乐人 · 专辑 {a.get('nb_album','')} · "
|
|
793
|
+
f"粉丝 {a.get('nb_fan','')}")[:300],
|
|
794
|
+
"source": "deezer",
|
|
795
|
+
"score": rank_score(0.8, _rk),
|
|
796
|
+
})
|
|
797
|
+
return results
|
|
798
|
+
return _engine
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _build_listenbrainz_engine(spec: dict[str, Any]) -> Any:
|
|
802
|
+
"""ListenBrainz 收听趋势(社区收听统计,补 MusicBrainz 元数据的动态维度)。"""
|
|
803
|
+
timeout = spec.get("timeout", 15)
|
|
804
|
+
|
|
805
|
+
@safe_search
|
|
806
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
807
|
+
to = _timeout or timeout
|
|
808
|
+
q = (query or "").strip().lower()
|
|
809
|
+
url = "https://api.listenbrainz.org/1/stats/sitewide/artists?range=week"
|
|
810
|
+
try:
|
|
811
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
812
|
+
except Exception as e:
|
|
813
|
+
logger.warning(f"ListenBrainz 失败: {e}")
|
|
814
|
+
return []
|
|
815
|
+
arts = ((data.get("payload") or {}).get("artists")) or []
|
|
816
|
+
terms = [t for t in _terms(q) if len(t) > 2]
|
|
817
|
+
rows = []
|
|
818
|
+
for a in arts:
|
|
819
|
+
if not isinstance(a, dict):
|
|
820
|
+
continue
|
|
821
|
+
nm = a.get("artist_name") or ""
|
|
822
|
+
rows.append((1 if (terms and any(t in nm.lower() for t in terms)) else 0, a, nm))
|
|
823
|
+
rows.sort(key=lambda x: -x[0])
|
|
824
|
+
results = []
|
|
825
|
+
for _rk, (_h, a, nm) in enumerate(rows[:n]):
|
|
826
|
+
if not nm:
|
|
827
|
+
continue
|
|
828
|
+
results.append({
|
|
829
|
+
"title": f"{nm}(本周收听榜)"[:200],
|
|
830
|
+
"url": f"https://listenbrainz.org/artist/{a.get('artist_mbid','')}",
|
|
831
|
+
"snippet": f"ListenBrainz 全站本周收听 {a.get('listen_count','')} 次"[:300],
|
|
832
|
+
"source": "listenbrainz",
|
|
833
|
+
"score": rank_score(0.75, _rk),
|
|
834
|
+
})
|
|
835
|
+
return results
|
|
836
|
+
return _engine
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
# ── 法律/图书馆/机构 ──────────────────────────────────────────────────────────
|
|
840
|
+
|
|
841
|
+
def _build_egov_law_engine(spec: dict[str, Any]) -> Any:
|
|
842
|
+
"""日本 e-Gov 法令(官方全文,免认证 XML)。"""
|
|
843
|
+
timeout = spec.get("timeout", 20)
|
|
844
|
+
|
|
845
|
+
@safe_search
|
|
846
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
847
|
+
to = _timeout or timeout
|
|
848
|
+
q = (query or "").strip()
|
|
849
|
+
if not q:
|
|
850
|
+
return []
|
|
851
|
+
# lawlists 全量返回法令名列表 XML(~2.7MB),本地按关键词过滤。
|
|
852
|
+
# 该端点无服务端检索参数且体积大,加进程内缓存(TTL 1h)避免每次查询
|
|
853
|
+
# 都重新拉取——实测单次 3.5s,缓存后 <10ms。
|
|
854
|
+
global _EGOV_ENTRIES, _EGOV_FETCHED_AT
|
|
855
|
+
now = time.time()
|
|
856
|
+
if _EGOV_ENTRIES is None or (now - _EGOV_FETCHED_AT) > 3600:
|
|
857
|
+
try:
|
|
858
|
+
xml = _text("https://laws.e-gov.go.jp/api/1/lawlists/1", to,
|
|
859
|
+
spec.get("_name", ""), "application/xml")
|
|
860
|
+
except Exception as e:
|
|
861
|
+
logger.warning(f"e-Gov 失败: {e}")
|
|
862
|
+
return []
|
|
863
|
+
_EGOV_ENTRIES = re.findall(
|
|
864
|
+
r"<LawNameListInfo>(.*?)</LawNameListInfo>", xml, re.S)
|
|
865
|
+
_EGOV_FETCHED_AT = now
|
|
866
|
+
entries = _EGOV_ENTRIES
|
|
867
|
+
# 检索词做 OR + 打分:多词查询(如「日本民法 条文」)里「条文」
|
|
868
|
+
# 不会出现在法令名中,若要求每个词都命中会全数落空(实测踩过)。
|
|
869
|
+
# 同时把「日本/日语」等修饰词剔除——它们指语言而非法令名内容。
|
|
870
|
+
_STOP = {"日本", "日语", "日文", "条文", "法律", "法令", "法条", "japan", "japanese"}
|
|
871
|
+
terms = [t for t in _terms(q) if t and t.lower() not in _STOP]
|
|
872
|
+
# 中日文无空格分词:整串「日本民法」在法令名里查不到(法令名形如
|
|
873
|
+
# 「民法」「民法施行法」)。故对每个词补出 **2 字以上子串** 作为候选,
|
|
874
|
+
# 并对前缀「日本」做剥离,让「日本民法」也能命中「民法」。
|
|
875
|
+
expanded = []
|
|
876
|
+
for t in terms:
|
|
877
|
+
if not t:
|
|
878
|
+
continue
|
|
879
|
+
if t.startswith(("日本", "日本国")):
|
|
880
|
+
t = t[2:] if t.startswith("日本国") else t[2:]
|
|
881
|
+
if t:
|
|
882
|
+
expanded.append(t)
|
|
883
|
+
# 长词切成 2-gram 片段(中文/日文检索的常用退化策略)
|
|
884
|
+
if len(t) >= 4 and not t.isascii():
|
|
885
|
+
for i in range(len(t) - 1):
|
|
886
|
+
expanded.append(t[i:i + 2])
|
|
887
|
+
terms = list(dict.fromkeys(expanded)) or [q]
|
|
888
|
+
hits = []
|
|
889
|
+
for e in entries:
|
|
890
|
+
mid = re.search(r"<LawId>(.*?)</LawId>", e, re.S)
|
|
891
|
+
mnm = re.search(r"<LawName>(.*?)</LawName>", e, re.S)
|
|
892
|
+
mno = re.search(r"<LawNo>(.*?)</LawNo>", e, re.S)
|
|
893
|
+
if not mnm:
|
|
894
|
+
continue
|
|
895
|
+
name = mnm.group(1).strip()
|
|
896
|
+
blob = f"{name} {mno.group(1) if mno else ''}"
|
|
897
|
+
score = sum(1 for t in terms if t in blob)
|
|
898
|
+
# 命中任一检索词即入选,完全没命中则只保留含「法」的条目做保底
|
|
899
|
+
if score or any(t in blob for t in terms):
|
|
900
|
+
hits.append((score, name, mid.group(1).strip() if mid else "",
|
|
901
|
+
mno.group(1).strip() if mno else ""))
|
|
902
|
+
hits.sort(key=lambda x: -x[0])
|
|
903
|
+
results = []
|
|
904
|
+
for _rk, (_s, name, lid, lno) in enumerate(hits[:n]):
|
|
905
|
+
_row = {
|
|
906
|
+
"title": name[:200],
|
|
907
|
+
"url": f"https://laws.e-gov.go.jp/law/{lid}" if lid else "https://laws.e-gov.go.jp/",
|
|
908
|
+
"snippet": f"日本法令 · {lno}(e-Gov 法令検索)"[:300],
|
|
909
|
+
"source": "egov_law",
|
|
910
|
+
"score": rank_score(0.85, _rk),
|
|
911
|
+
}
|
|
912
|
+
# 源内全文直出:`/law/{id}` 是 JS 空壳页(实测去标签后只剩
|
|
913
|
+
# 「e-Gov 法令検索」几个字,拿不到条文),而 lawdata 端点返回
|
|
914
|
+
# 该法令的官方全文 XML。所以把**可确定性取正文**的 URL 单独带上,
|
|
915
|
+
# 交给下游取数建议优先使用,避免为了正文去走浏览器渲染那几级。
|
|
916
|
+
if lid:
|
|
917
|
+
_row["full_text_url"] = f"https://laws.e-gov.go.jp/api/1/lawdata/{lid}"
|
|
918
|
+
results.append(_row)
|
|
919
|
+
return results
|
|
920
|
+
return _engine
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _build_k10plus_engine(spec: dict[str, Any]) -> Any:
|
|
924
|
+
"""K10plus SRU 联合目录(德国最大,SRU 标准协议)。
|
|
925
|
+
|
|
926
|
+
SRU 检索在 k10plus 上较慢(实测常 5-15s),超时给足。
|
|
927
|
+
"""
|
|
928
|
+
timeout = spec.get("timeout", 30)
|
|
929
|
+
|
|
930
|
+
@safe_search
|
|
931
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
932
|
+
to = _timeout or timeout
|
|
933
|
+
q = (query or "").strip()
|
|
934
|
+
if not q:
|
|
935
|
+
return []
|
|
936
|
+
url = ("https://sru.k10plus.de/gvk?version=1.1&operation=searchRetrieve"
|
|
937
|
+
f"&query={urllib.parse.quote('pica.all=' + q)}"
|
|
938
|
+
f"&maximumRecords={max(n, 1)}&recordSchema=dc")
|
|
939
|
+
try:
|
|
940
|
+
xml = _text(url, to, spec.get("_name", ""), "application/xml")
|
|
941
|
+
except Exception as e:
|
|
942
|
+
logger.warning(f"K10plus 失败: {e}")
|
|
943
|
+
return []
|
|
944
|
+
# SRU 响应的 record 可能带命名空间前缀(zs:record),两种都收
|
|
945
|
+
recs = re.findall(r"<(?:\w+:)?record[^>]*>(.*?)</(?:\w+:)?record>", xml, re.S)
|
|
946
|
+
if not recs:
|
|
947
|
+
# 退一步:按 numberOfRecords 判空,避免把「确实无结果」误报为失败
|
|
948
|
+
recs = []
|
|
949
|
+
results = []
|
|
950
|
+
for _rk, r in enumerate(recs[:n]):
|
|
951
|
+
# OAI-DC 的元素带 inline xmlns 属性(<dc:title xmlns:dc="...">),
|
|
952
|
+
# 正则必须容忍标签属性,否则全部匹配失败(实测踩过)。
|
|
953
|
+
t = re.search(r"<dc:title\b[^>]*>(.*?)</dc:title>", r, re.S)
|
|
954
|
+
c = re.search(r"<dc:(?:creator|contributor)\b[^>]*>(.*?)</dc:(?:creator|contributor)>", r, re.S)
|
|
955
|
+
d = re.search(r"<dc:date\b[^>]*>(.*?)</dc:date>", r, re.S)
|
|
956
|
+
i = re.search(r"<dc:identifier\b[^>]*>(.*?)</dc:identifier>", r, re.S)
|
|
957
|
+
if not t:
|
|
958
|
+
continue
|
|
959
|
+
title = re.sub(r"<[^>]+>", "", t.group(1)).strip()
|
|
960
|
+
if not title:
|
|
961
|
+
continue
|
|
962
|
+
results.append({
|
|
963
|
+
"title": title[:200],
|
|
964
|
+
"url": (re.sub(r"<[^>]+>", "", i.group(1)).strip() if i else "https://k10plus.de/"),
|
|
965
|
+
"snippet": (f"{re.sub(r'<[^>]+>','',c.group(1)).strip() if c else ''} · "
|
|
966
|
+
f"{re.sub(r'<[^>]+>','',d.group(1)).strip() if d else ''}"
|
|
967
|
+
"(K10plus 联合目录)")[:300],
|
|
968
|
+
"source": "k10plus",
|
|
969
|
+
"score": rank_score(0.8, _rk),
|
|
970
|
+
})
|
|
971
|
+
return results
|
|
972
|
+
return _engine
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _build_ror_engine(spec: dict[str, Any]) -> Any:
|
|
976
|
+
"""ROR 研究机构标识(免认证,机构 ID 与域名映射)。"""
|
|
977
|
+
timeout = spec.get("timeout", 15)
|
|
978
|
+
|
|
979
|
+
@safe_search
|
|
980
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
981
|
+
to = _timeout or timeout
|
|
982
|
+
q = (query or "").strip()
|
|
983
|
+
if not q:
|
|
984
|
+
return []
|
|
985
|
+
url = f"https://api.ror.org/organizations?query={urllib.parse.quote(q)}"
|
|
986
|
+
try:
|
|
987
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
988
|
+
except Exception as e:
|
|
989
|
+
logger.warning(f"ROR 失败: {e}")
|
|
990
|
+
return []
|
|
991
|
+
results = []
|
|
992
|
+
items = data.get("items")
|
|
993
|
+
if not isinstance(items, list):
|
|
994
|
+
items = []
|
|
995
|
+
for _rk, o in enumerate(items[:n]):
|
|
996
|
+
if not isinstance(o, dict):
|
|
997
|
+
continue
|
|
998
|
+
# ROR v2 的机构名在 names[] 里,主名标 types 含 ror_display;
|
|
999
|
+
# 顶层 name 字段已不再返回(实测为 None)。
|
|
1000
|
+
name = ""
|
|
1001
|
+
names = o.get("names") or []
|
|
1002
|
+
for nm in names:
|
|
1003
|
+
if not isinstance(nm, dict):
|
|
1004
|
+
continue
|
|
1005
|
+
types = nm.get("types") or []
|
|
1006
|
+
if "ror_display" in types:
|
|
1007
|
+
name = str(nm.get("value") or "").strip()
|
|
1008
|
+
break
|
|
1009
|
+
if not name:
|
|
1010
|
+
for nm in names:
|
|
1011
|
+
if isinstance(nm, dict) and nm.get("value"):
|
|
1012
|
+
name = str(nm["value"]).strip()
|
|
1013
|
+
break
|
|
1014
|
+
if not name:
|
|
1015
|
+
continue
|
|
1016
|
+
# 国家在 locations[].geonames_details.country_name
|
|
1017
|
+
country = ""
|
|
1018
|
+
for loc in (o.get("locations") or []):
|
|
1019
|
+
if isinstance(loc, dict):
|
|
1020
|
+
gd = loc.get("geonames_details") or {}
|
|
1021
|
+
if gd.get("country_name"):
|
|
1022
|
+
country = str(gd["country_name"])
|
|
1023
|
+
break
|
|
1024
|
+
domains = ", ".join((o.get("domains") or [])[:2])
|
|
1025
|
+
results.append({
|
|
1026
|
+
"title": name[:200],
|
|
1027
|
+
"url": o.get("id") or "",
|
|
1028
|
+
"snippet": (f"ROR {str(o.get('id','')).rsplit('/',1)[-1]} · "
|
|
1029
|
+
f"{country} · {domains}(研究机构唯一标识)")[:300],
|
|
1030
|
+
"source": "ror",
|
|
1031
|
+
"score": rank_score(0.8, _rk),
|
|
1032
|
+
})
|
|
1033
|
+
return results
|
|
1034
|
+
return _engine
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
# ── 科学:土壤 / 空间天气 / 卫星 ──────────────────────────────────────────────
|
|
1038
|
+
|
|
1039
|
+
def _build_soilgrids_engine(spec: dict[str, Any]) -> Any:
|
|
1040
|
+
"""ISRIC SoilGrids 全球土壤属性(逐点栅格,免认证)。"""
|
|
1041
|
+
timeout = spec.get("timeout", 25)
|
|
1042
|
+
PROPS = ["clay", "sand", "silt", "soc", "phh2o", "nitrogen", "cec", "bdod"]
|
|
1043
|
+
|
|
1044
|
+
@safe_search
|
|
1045
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1046
|
+
to = _timeout or timeout
|
|
1047
|
+
q = (query or "").strip()
|
|
1048
|
+
if not q:
|
|
1049
|
+
return []
|
|
1050
|
+
# 先地理编码拿坐标(复用 Open-Meteo geocoding,免认证)
|
|
1051
|
+
try:
|
|
1052
|
+
geo = _json("https://geocoding-api.open-meteo.com/v1/search?name="
|
|
1053
|
+
f"{urllib.parse.quote(q)}&count=1&language=zh", to, spec.get("_name", ""))
|
|
1054
|
+
except Exception as e:
|
|
1055
|
+
logger.warning(f"SoilGrids 地理编码失败: {e}")
|
|
1056
|
+
return []
|
|
1057
|
+
places = geo.get("results") or []
|
|
1058
|
+
if not places:
|
|
1059
|
+
return []
|
|
1060
|
+
lat, lon = places[0].get("latitude"), places[0].get("longitude")
|
|
1061
|
+
place = places[0].get("name", "")
|
|
1062
|
+
if lat is None or lon is None:
|
|
1063
|
+
return []
|
|
1064
|
+
results = []
|
|
1065
|
+
for _rk, prop in enumerate(PROPS[:n]):
|
|
1066
|
+
try:
|
|
1067
|
+
d = _json("https://rest.isric.org/soilgrids/v2.0/properties/query?"
|
|
1068
|
+
f"lon={lon}&lat={lat}&property={prop}&depth=0-5cm&value=mean",
|
|
1069
|
+
to, spec.get("_name", ""))
|
|
1070
|
+
except Exception:
|
|
1071
|
+
continue
|
|
1072
|
+
try:
|
|
1073
|
+
val = d["properties"]["layers"][0]["depths"][0]["values"]["mean"]
|
|
1074
|
+
except (KeyError, IndexError, TypeError):
|
|
1075
|
+
continue
|
|
1076
|
+
if val is None:
|
|
1077
|
+
continue
|
|
1078
|
+
results.append({
|
|
1079
|
+
"title": f"{place} 土壤属性 · {prop}"[:200],
|
|
1080
|
+
"url": f"https://soilgrids.org/?lat={lat}&lon={lon}",
|
|
1081
|
+
"snippet": (f"{place}({round(lat,3)},{round(lon,3)})0-5cm {prop} 均值 "
|
|
1082
|
+
f"{val}(ISRIC SoilGrids)")[:300],
|
|
1083
|
+
"source": "soilgrids",
|
|
1084
|
+
"score": rank_score(0.8, _rk),
|
|
1085
|
+
})
|
|
1086
|
+
return results
|
|
1087
|
+
return _engine
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _build_noaa_swpc_engine(spec: dict[str, Any]) -> Any:
|
|
1091
|
+
"""NOAA SWPC 空间天气(Kp 指数 + 太阳活动区,官方免认证)。"""
|
|
1092
|
+
timeout = spec.get("timeout", 15)
|
|
1093
|
+
|
|
1094
|
+
@safe_search
|
|
1095
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1096
|
+
to = _timeout or timeout
|
|
1097
|
+
q = (query or "").strip().lower()
|
|
1098
|
+
results = []
|
|
1099
|
+
# 1) 行星 Kp 指数(最近若干条)
|
|
1100
|
+
try:
|
|
1101
|
+
kp = _json("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json",
|
|
1102
|
+
to, spec.get("_name", ""))
|
|
1103
|
+
if isinstance(kp, list) and len(kp) > 1:
|
|
1104
|
+
for _rk, row in enumerate(list(reversed(kp[1:]))[:max(1, n // 2)]):
|
|
1105
|
+
if not isinstance(row, list) or len(row) < 3:
|
|
1106
|
+
continue
|
|
1107
|
+
results.append({
|
|
1108
|
+
"title": f"行星 Kp 指数 {row[1]}({str(row[0])[:16]})"[:200],
|
|
1109
|
+
"url": "https://www.swpc.noaa.gov/products/planetary-k-index",
|
|
1110
|
+
"snippet": f"NOAA SWPC 地磁活动指数 Kp={row[1]} · a_running={row[2]}"[:300],
|
|
1111
|
+
"source": "noaa_swpc",
|
|
1112
|
+
"score": rank_score(0.85, len(results)),
|
|
1113
|
+
})
|
|
1114
|
+
except Exception as e:
|
|
1115
|
+
logger.warning(f"SWPC Kp 失败: {e}")
|
|
1116
|
+
# 2) 太阳活动区(黑子/耀斑)
|
|
1117
|
+
try:
|
|
1118
|
+
regs = _json("https://services.swpc.noaa.gov/json/solar_regions.json",
|
|
1119
|
+
to, spec.get("_name", ""))
|
|
1120
|
+
if isinstance(regs, list):
|
|
1121
|
+
for reg in list(reversed(regs))[:max(1, n - len(results))]:
|
|
1122
|
+
if not isinstance(reg, dict):
|
|
1123
|
+
continue
|
|
1124
|
+
results.append({
|
|
1125
|
+
"title": f"太阳活动区 {reg.get('region','')}({reg.get('location','')})"[:200],
|
|
1126
|
+
"url": "https://www.swpc.noaa.gov/products/solar-regions",
|
|
1127
|
+
"snippet": (f"NOAA SWPC 太阳活动区 · X 级耀斑 {reg.get('x_xray_events',0)} · "
|
|
1128
|
+
f"M 级 {reg.get('m_flares',0)}")[:300],
|
|
1129
|
+
"source": "noaa_swpc",
|
|
1130
|
+
"score": rank_score(0.8, len(results)),
|
|
1131
|
+
})
|
|
1132
|
+
except Exception as e:
|
|
1133
|
+
logger.warning(f"SWPC regions 失败: {e}")
|
|
1134
|
+
return results[:n]
|
|
1135
|
+
return _engine
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _build_satnogs_engine(spec: dict[str, Any]) -> Any:
|
|
1139
|
+
"""SatNOGS 卫星目录(免认证,NORAD ID 与发射信息)。"""
|
|
1140
|
+
timeout = spec.get("timeout", 20)
|
|
1141
|
+
|
|
1142
|
+
@safe_search
|
|
1143
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1144
|
+
to = _timeout or timeout
|
|
1145
|
+
q = (query or "").strip()
|
|
1146
|
+
if not q:
|
|
1147
|
+
return []
|
|
1148
|
+
url = f"https://db.satnogs.org/api/satellites/?format=json&search={urllib.parse.quote(q)}&limit={n}"
|
|
1149
|
+
try:
|
|
1150
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
1151
|
+
except Exception as e:
|
|
1152
|
+
logger.warning(f"SatNOGS 失败: {e}")
|
|
1153
|
+
return []
|
|
1154
|
+
if not isinstance(data, list):
|
|
1155
|
+
return []
|
|
1156
|
+
results = []
|
|
1157
|
+
for _rk, s in enumerate(data[:n]):
|
|
1158
|
+
if not isinstance(s, dict):
|
|
1159
|
+
continue
|
|
1160
|
+
name = (s.get("name") or "").strip()
|
|
1161
|
+
if not name:
|
|
1162
|
+
continue
|
|
1163
|
+
results.append({
|
|
1164
|
+
"title": f"{name}(NORAD {s.get('norad_cat_id','')})"[:200],
|
|
1165
|
+
"url": f"https://db.satnogs.org/satellite/{s.get('norad_cat_id','')}",
|
|
1166
|
+
"snippet": (f"卫星状态 {s.get('status','')} · 发射 {s.get('launched','')}"
|
|
1167
|
+
"(SatNOGS DB)")[:300],
|
|
1168
|
+
"source": "satnogs",
|
|
1169
|
+
"score": rank_score(0.8, _rk),
|
|
1170
|
+
"published_at": s.get("launched") or "",
|
|
1171
|
+
})
|
|
1172
|
+
return results
|
|
1173
|
+
return _engine
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _build_tle_mirror_engine(spec: dict[str, Any]) -> Any:
|
|
1177
|
+
"""TLE 两行根数镜像(Celestrak 不可达时的替代,第三方非官方)。"""
|
|
1178
|
+
timeout = spec.get("timeout", 15)
|
|
1179
|
+
|
|
1180
|
+
@safe_search
|
|
1181
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1182
|
+
to = _timeout or timeout
|
|
1183
|
+
q = (query or "").strip()
|
|
1184
|
+
if not q:
|
|
1185
|
+
return []
|
|
1186
|
+
url = f"https://tle.ivanstanojevic.me/api/tle?search={urllib.parse.quote(q)}"
|
|
1187
|
+
try:
|
|
1188
|
+
data = _json(url, to, spec.get("_name", ""))
|
|
1189
|
+
except Exception as e:
|
|
1190
|
+
logger.warning(f"TLE 镜像失败: {e}")
|
|
1191
|
+
return []
|
|
1192
|
+
members = data.get("member") or []
|
|
1193
|
+
results = []
|
|
1194
|
+
for _rk, m in enumerate(members[:n]):
|
|
1195
|
+
if not isinstance(m, dict):
|
|
1196
|
+
continue
|
|
1197
|
+
name = (m.get("name") or "").strip()
|
|
1198
|
+
if not name:
|
|
1199
|
+
continue
|
|
1200
|
+
line1 = m.get("line1") or ""
|
|
1201
|
+
results.append({
|
|
1202
|
+
"title": f"{name}(TLE 轨道根数)"[:200],
|
|
1203
|
+
"url": f"https://tle.ivanstanojevic.me/api/tle/{m.get('satelliteId','')}",
|
|
1204
|
+
"snippet": (f"NORAD {m.get('satelliteId','')} · {str(line1)[:60]}"
|
|
1205
|
+
"(第三方 TLE 镜像,非官方 SLA)")[:300],
|
|
1206
|
+
"source": "tle_mirror",
|
|
1207
|
+
"score": rank_score(0.75, _rk),
|
|
1208
|
+
})
|
|
1209
|
+
return results
|
|
1210
|
+
return _engine
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
# 常见车企中文名 → vPIC 英文厂商名(vPIC 无中文索引,中文问法需先归一)
|
|
1214
|
+
_VPIC_ZH = {
|
|
1215
|
+
"特斯拉": "TESLA", "比亚迪": "BYD", "蔚来": "NIO", "小鹏": "XPENG",
|
|
1216
|
+
"理想": "LI AUTO", "吉利": "GEELY", "长城": "GREAT WALL", "奇瑞": "CHERY",
|
|
1217
|
+
"长安": "CHANGAN", "上汽": "SAIC", "一汽": "FAW", "广汽": "GAC",
|
|
1218
|
+
"东风": "DONGFENG", "北汽": "BAIC", "五菱": "WULING", "红旗": "HONGQI",
|
|
1219
|
+
"丰田": "TOYOTA", "本田": "HONDA", "日产": "NISSAN", "马自达": "MAZDA",
|
|
1220
|
+
"大众": "VOLKSWAGEN", "奥迪": "AUDI", "宝马": "BMW", "奔驰": "MERCEDES-BENZ",
|
|
1221
|
+
"保时捷": "PORSCHE", "福特": "FORD", "通用": "GENERAL MOTORS",
|
|
1222
|
+
"别克": "BUICK", "雪佛兰": "CHEVROLET", "凯迪拉克": "CADILLAC",
|
|
1223
|
+
"现代": "HYUNDAI", "起亚": "KIA", "沃尔沃": "VOLVO", "路虎": "LAND ROVER",
|
|
1224
|
+
"捷豹": "JAGUAR", "标致": "PEUGEOT", "雪铁龙": "CITROEN", "雷诺": "RENAULT",
|
|
1225
|
+
"菲亚特": "FIAT", "法拉利": "FERRARI", "兰博基尼": "LAMBORGHINI",
|
|
1226
|
+
"玛莎拉蒂": "MASERATI", "宾利": "BENTLEY", "劳斯莱斯": "ROLLS-ROYCE",
|
|
1227
|
+
"阿斯顿马丁": "ASTON MARTIN", "三菱": "MITSUBISHI", "铃木": "SUZUKI",
|
|
1228
|
+
"斯巴鲁": "SUBARU", "雷克萨斯": "LEXUS", "英菲尼迪": "INFINITI",
|
|
1229
|
+
"讴歌": "ACURA", "吉普": "JEEP", "道奇": "DODGE", "克莱斯勒": "CHRYSLER",
|
|
1230
|
+
"斯柯达": "SKODA", "西雅特": "SEAT", "欧宝": "OPEL", "依维柯": "IVECO",
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _build_nhtsa_vpic_engine(spec: dict[str, Any]) -> Any:
|
|
1235
|
+
"""NHTSA vPIC 车辆厂商/车型本体(免认证)。
|
|
1236
|
+
|
|
1237
|
+
vPIC 只有英文厂商名,中文问法先过 _VPIC_ZH 归一(否则「特斯拉 车型」恒空)。
|
|
1238
|
+
"""
|
|
1239
|
+
timeout = spec.get("timeout", 20)
|
|
1240
|
+
|
|
1241
|
+
@safe_search
|
|
1242
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1243
|
+
to = _timeout or timeout
|
|
1244
|
+
q = (query or "").strip()
|
|
1245
|
+
if not q:
|
|
1246
|
+
return []
|
|
1247
|
+
try:
|
|
1248
|
+
data = _json("https://vpic.nhtsa.dot.gov/api/vehicles/getallmakes?format=json",
|
|
1249
|
+
to, spec.get("_name", ""))
|
|
1250
|
+
except Exception as e:
|
|
1251
|
+
logger.warning(f"NHTSA vPIC 失败: {e}")
|
|
1252
|
+
return []
|
|
1253
|
+
makes = (data.get("Results") or [])
|
|
1254
|
+
# 中文厂商名归一为英文;再去掉「车型/厂商/汽车」这类检索噪声词
|
|
1255
|
+
_NOISE = {"车型", "厂商", "汽车", "牌子", "品牌", "哪个", "什么", "make", "model"}
|
|
1256
|
+
norm = q
|
|
1257
|
+
for zh, en in _VPIC_ZH.items():
|
|
1258
|
+
if zh in norm:
|
|
1259
|
+
norm = norm.replace(zh, en)
|
|
1260
|
+
break
|
|
1261
|
+
terms = [t for t in _terms(norm.lower()) if len(t) > 1 and t not in _NOISE]
|
|
1262
|
+
hits = []
|
|
1263
|
+
for m in makes:
|
|
1264
|
+
if not isinstance(m, dict):
|
|
1265
|
+
continue
|
|
1266
|
+
nm = m.get("Make_Name") or ""
|
|
1267
|
+
if terms and any(t in nm.lower() for t in terms):
|
|
1268
|
+
hits.append((m.get("Make_ID"), nm))
|
|
1269
|
+
results = []
|
|
1270
|
+
for _rk, (mid, nm) in enumerate(hits[:n]):
|
|
1271
|
+
results.append({
|
|
1272
|
+
"title": f"{nm}(汽车厂商)"[:200],
|
|
1273
|
+
"url": f"https://vpic.nhtsa.dot.gov/api/vehicles/getmodelsformake/{urllib.parse.quote(nm)}?format=json",
|
|
1274
|
+
"snippet": f"NHTSA vPIC 厂商 ID {mid} · 含该厂商全部车型"[:300],
|
|
1275
|
+
"source": "nhtsa_vpic",
|
|
1276
|
+
"score": rank_score(0.8, _rk),
|
|
1277
|
+
})
|
|
1278
|
+
return results
|
|
1279
|
+
return _engine
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
# ── PubMed E-utilities(两段式:esearch 取 ID → esummary 取详情)──────────────
|
|
1283
|
+
|
|
1284
|
+
def _build_pubmed_engine(spec: dict[str, Any]) -> Any:
|
|
1285
|
+
"""PubMed 生物医学文献(NCBI E-utilities,免 key)。
|
|
1286
|
+
|
|
1287
|
+
## 为什么需要自定义 builder 而不是声明式 spec
|
|
1288
|
+
|
|
1289
|
+
旧实现是纯声明式:一次 esearch 拿 `idlist`,再靠 output_map
|
|
1290
|
+
`item_title/item_url/item_summary: pmid` 取字段。它有两处结构性错误,
|
|
1291
|
+
合起来让这个标着 ready 的引擎**静默返回 0 条**:
|
|
1292
|
+
|
|
1293
|
+
① **请求被注入 `format=json` 而 400**。E-utilities 的 `format` 是输出
|
|
1294
|
+
格式参数且不接受 json;同一条 URL 加 `&format=json` 实测 400、去掉
|
|
1295
|
+
200。根因在 engines_base 把解析提示当查询参数发(已加
|
|
1296
|
+
`format_is_query_param` 开关,本引擎声明 false)。
|
|
1297
|
+
② **idlist 是字符串数组**。`_make_field_parser` 对非 dict 条目
|
|
1298
|
+
`continue`,所以哪怕请求通了也一条都解析不出来;退一步说,
|
|
1299
|
+
esearch 只给 PMID,本就拿不到标题与摘要。
|
|
1300
|
+
|
|
1301
|
+
故本 builder 做两段:esearch 取 PMID → esummary 取 title/pubdate/
|
|
1302
|
+
source/DOI。第二段失败时**降级为带 PMID 的最小条目**而非整体返回空
|
|
1303
|
+
——至少让用户能拿到可点的 PubMed 链接。
|
|
1304
|
+
"""
|
|
1305
|
+
timeout = spec.get("timeout", 12)
|
|
1306
|
+
base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
|
|
1307
|
+
# NCBI 免 key 档限 3 req/s;声明在 spec 上由 http_client 统一节流
|
|
1308
|
+
tool = "argo-search"
|
|
1309
|
+
|
|
1310
|
+
@safe_search
|
|
1311
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None,
|
|
1312
|
+
**kwargs) -> list[dict[str, Any]]:
|
|
1313
|
+
to = _timeout or timeout
|
|
1314
|
+
q = (query or "").strip()
|
|
1315
|
+
if not q:
|
|
1316
|
+
return []
|
|
1317
|
+
eng = spec.get("_name", "local_pubmed")
|
|
1318
|
+
# 两段式要打两次网(实测 esearch≈1.4s、esummary≈1.1s,NCBI 本身不慢)。
|
|
1319
|
+
# 关键:**第二段必须分到独立预算**。若两段共用同一个 to,esearch 用掉
|
|
1320
|
+
# 大半后 esummary 只剩零头,并发场景下必然 timeout,整体降级成「无结果」
|
|
1321
|
+
# ——用户看到的是「这个源坏了」,而实际只是预算没切分。按 6:4 切,
|
|
1322
|
+
# 每段不低于 3s(低于 3s 时 NCBI 首包都可能收不完)。
|
|
1323
|
+
if to > 6.0:
|
|
1324
|
+
t1 = max(3.0, min(to * 0.6, to - 3.0))
|
|
1325
|
+
t2 = max(3.0, to - t1)
|
|
1326
|
+
else:
|
|
1327
|
+
t1 = t2 = to
|
|
1328
|
+
# 医学语料多为英文;中文查询直接进 esearch 会命中极少,交由
|
|
1329
|
+
# recovery/通用源补位(此处不做静默翻译,避免伪造查询语义)。
|
|
1330
|
+
want = max(1, min(int(n or 5), 20))
|
|
1331
|
+
url1 = (f"{base}/esearch.fcgi?db=pubmed&retmode=json&sort=relevance"
|
|
1332
|
+
f"&retmax={want}&tool={tool}&term={urllib.parse.quote(q)}")
|
|
1333
|
+
try:
|
|
1334
|
+
d1 = _json(url1, t1, eng)
|
|
1335
|
+
except Exception as e:
|
|
1336
|
+
logger.warning(f"PubMed esearch 失败: {e}")
|
|
1337
|
+
return []
|
|
1338
|
+
ids = (((d1 or {}).get("esearchresult") or {}).get("idlist")) or []
|
|
1339
|
+
ids = [str(i) for i in ids if str(i).strip()][:want]
|
|
1340
|
+
if not ids:
|
|
1341
|
+
return []
|
|
1342
|
+
|
|
1343
|
+
# 第二段:取详情。失败则降级为 PMID 最小条目(可点链接仍可用)。
|
|
1344
|
+
details: dict[str, dict] = {}
|
|
1345
|
+
try:
|
|
1346
|
+
url2 = (f"{base}/esummary.fcgi?db=pubmed&retmode=json"
|
|
1347
|
+
f"&tool={tool}&id={','.join(ids)}")
|
|
1348
|
+
d2 = _json(url2, t2, eng)
|
|
1349
|
+
res = (d2 or {}).get("result") or {}
|
|
1350
|
+
for pid in ids:
|
|
1351
|
+
it = res.get(pid)
|
|
1352
|
+
if isinstance(it, dict):
|
|
1353
|
+
details[pid] = it
|
|
1354
|
+
except Exception as e:
|
|
1355
|
+
logger.warning(f"PubMed esummary 失败(降级为 PMID 条目): {e}")
|
|
1356
|
+
|
|
1357
|
+
results: list[dict[str, Any]] = []
|
|
1358
|
+
for _rk, pid in enumerate(ids):
|
|
1359
|
+
it = details.get(pid) or {}
|
|
1360
|
+
title = str(it.get("title") or "").strip()
|
|
1361
|
+
src = str(it.get("source") or "").strip()
|
|
1362
|
+
pubdate = str(it.get("pubdate") or it.get("epubdate") or "").strip()
|
|
1363
|
+
# elocationid 形如 "doi: 10.xxxx/yyy";抽出后可给可验证的出处
|
|
1364
|
+
eloc = str(it.get("elocationid") or "")
|
|
1365
|
+
doi = ""
|
|
1366
|
+
m = re.search(r"10\.\d{4,9}/\S+", eloc)
|
|
1367
|
+
if m:
|
|
1368
|
+
doi = m.group(0).rstrip(".")
|
|
1369
|
+
# 摘要不在 esummary 里(需 efetch),如实给期刊/日期/DOI 而不是编造
|
|
1370
|
+
bits = [b for b in (src, pubdate, f"DOI {doi}" if doi else "") if b]
|
|
1371
|
+
snippet = " · ".join(bits) or "PubMed 记录(详情见原文)"
|
|
1372
|
+
results.append({
|
|
1373
|
+
"title": (title or f"PubMed {pid}")[:200],
|
|
1374
|
+
"url": f"https://pubmed.ncbi.nlm.nih.gov/{pid}/",
|
|
1375
|
+
"snippet": snippet[:300],
|
|
1376
|
+
"source": eng,
|
|
1377
|
+
"score": rank_score(0.9, _rk),
|
|
1378
|
+
"published_at": pubdate,
|
|
1379
|
+
})
|
|
1380
|
+
return results
|
|
1381
|
+
return _engine
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
# ── CORE:开放获取论文全文聚合(core.ac.uk v3,免 key)─────────────────────
|
|
1385
|
+
|
|
1386
|
+
_CORE_ERROR_KEYS = ("message", "error", "detail")
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _build_core_engine(spec: dict[str, Any]) -> Any:
|
|
1390
|
+
"""CORE 开放获取全文(api.core.ac.uk v3,免 key)。
|
|
1391
|
+
|
|
1392
|
+
## 两个必须写在这里的理由
|
|
1393
|
+
|
|
1394
|
+
① **限流是 HTTP 200 + 错误封套**。CORE 后端是 Azure Search,被限流时
|
|
1395
|
+
返回 `{"message": "Azure search failed with status code: 503 ..."}`
|
|
1396
|
+
且 HTTP 状态是 **200**。通用 `_envelope_error` 要求 `Code` 与
|
|
1397
|
+
`message` 同时在场(火山/知乎那种封套),这里只有 `message`,
|
|
1398
|
+
识别不出来 → 会被 pipeline 当成「这个词没结果」静默吞掉。
|
|
1399
|
+
本 builder 显式检查:**响应同时缺 `results` 又带 message/error 时报错**,
|
|
1400
|
+
让熔断器拿到失败信号而不是假空结果。
|
|
1401
|
+
② **端点必须带尾斜杠**。`/v3/search/works?q=` 会 301 到
|
|
1402
|
+
`/v3/search/works/?q=`,多一次往返且部分客户端不跟随。
|
|
1403
|
+
|
|
1404
|
+
无 `downloadUrl` 的条目退回 CORE 详情页;仍无则用 DOI 兜底。
|
|
1405
|
+
"""
|
|
1406
|
+
timeout = spec.get("timeout", 20)
|
|
1407
|
+
endpoint = "https://api.core.ac.uk/v3/search/works/"
|
|
1408
|
+
|
|
1409
|
+
@safe_search
|
|
1410
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None,
|
|
1411
|
+
**kwargs) -> list[dict[str, Any]]:
|
|
1412
|
+
to = _timeout or timeout
|
|
1413
|
+
q = (query or "").strip()
|
|
1414
|
+
if not q:
|
|
1415
|
+
return []
|
|
1416
|
+
eng = spec.get("_name", "core")
|
|
1417
|
+
want = max(1, min(int(n or 5), 20))
|
|
1418
|
+
url = f"{endpoint}?q={urllib.parse.quote(q)}&limit={want}"
|
|
1419
|
+
try:
|
|
1420
|
+
data = _json(url, to, eng)
|
|
1421
|
+
except Exception as e:
|
|
1422
|
+
logger.warning(f"CORE 请求失败: {e}")
|
|
1423
|
+
raise
|
|
1424
|
+
# 显式限流/错误封套识别(见文档串①)
|
|
1425
|
+
if isinstance(data, dict) and not data.get("results"):
|
|
1426
|
+
for k in _CORE_ERROR_KEYS:
|
|
1427
|
+
if data.get(k):
|
|
1428
|
+
msg = str(data[k])[:200]
|
|
1429
|
+
logger.warning(f"CORE 上游错误封套: {msg}")
|
|
1430
|
+
raise RuntimeError(f"core upstream error: {msg}")
|
|
1431
|
+
items = (data or {}).get("results") if isinstance(data, dict) else None
|
|
1432
|
+
if not isinstance(items, list):
|
|
1433
|
+
return []
|
|
1434
|
+
|
|
1435
|
+
results: list[dict[str, Any]] = []
|
|
1436
|
+
for _rk, it in enumerate(items[:want]):
|
|
1437
|
+
if not isinstance(it, dict):
|
|
1438
|
+
continue
|
|
1439
|
+
title = str(it.get("title") or "").strip()
|
|
1440
|
+
if not title:
|
|
1441
|
+
continue
|
|
1442
|
+
abstract = str(it.get("abstract") or "").strip()
|
|
1443
|
+
doi = str(it.get("doi") or "").strip()
|
|
1444
|
+
dl = str(it.get("downloadUrl") or "").strip()
|
|
1445
|
+
year = str(it.get("yearPublished") or "").strip()
|
|
1446
|
+
pub = str(it.get("publisher") or "").strip()
|
|
1447
|
+
# 出处优先级:可下载全文 > DOI > CORE 详情页(全部可验证)
|
|
1448
|
+
if dl:
|
|
1449
|
+
url_out = dl
|
|
1450
|
+
elif doi and doi.lower() != "none":
|
|
1451
|
+
url_out = f"https://doi.org/{doi}"
|
|
1452
|
+
else:
|
|
1453
|
+
url_out = f"https://core.ac.uk/works/{it.get('id','')}"
|
|
1454
|
+
bits = [b for b in (pub, year, f"DOI {doi}" if doi and doi.lower() != 'none' else "") if b]
|
|
1455
|
+
if abstract:
|
|
1456
|
+
snippet = abstract
|
|
1457
|
+
else:
|
|
1458
|
+
snippet = " · ".join(bits) or "CORE 开放获取记录"
|
|
1459
|
+
results.append({
|
|
1460
|
+
"title": title[:200],
|
|
1461
|
+
"url": url_out,
|
|
1462
|
+
"snippet": snippet[:300],
|
|
1463
|
+
"source": eng,
|
|
1464
|
+
"score": rank_score(0.85, _rk),
|
|
1465
|
+
"published_at": year,
|
|
1466
|
+
})
|
|
1467
|
+
return results
|
|
1468
|
+
return _engine
|