argo-search 1.0.1 → 2.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -21
- package/README.en.md +735 -0
- package/README.es.md +777 -0
- package/README.ja.md +777 -0
- package/README.ko.md +777 -0
- package/README.md +778 -320
- package/SKILL.md +135 -0
- package/assets/readme/hero.svg +121 -0
- package/assets/readme/made-with-beautify.svg +10 -0
- package/assets/readme/proof-routes.svg +73 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2356 -96
- package/backends/engine_registry.yaml +3428 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2488 -160
- package/backends/source_types_cn.json +109 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +772 -0
- package/bin/argo.js +102 -39
- package/config.yaml +5139 -367
- package/cordis.patch.yml +21 -0
- package/data/countries.json +40 -0
- package/data/lang_matrix/lang_capability.json +842 -0
- package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
- package/data/regions_cn.json +1 -0
- package/docs/ADDING_NEW_ENGINE.md +184 -0
- package/docs/ARGO_INTRO.md +124 -0
- package/docs/DATA_SOURCE_GAPS.md +81 -0
- package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
- package/docs/ENGINE_CATALOG.md +575 -0
- package/docs/MCP_SETUP.md +108 -0
- package/docs/PLATFORM_PATHS.md +111 -0
- package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
- package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
- package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
- package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
- package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
- package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
- package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
- package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
- package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
- package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
- package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
- package/docs/SEARCH_ARCHIVE.md +115 -0
- package/docs/assets/architecture.svg +104 -0
- package/docs/assets/hero.svg +98 -0
- package/docs/engines/bangumi.md +27 -0
- package/docs/engines/biorxiv.md +27 -0
- package/docs/engines/clawhub.md +27 -0
- package/docs/engines/crt_sh.md +27 -0
- package/docs/engines/datacite.md +27 -0
- package/docs/engines/deps_dev.md +27 -0
- package/docs/engines/douban_movie.md +27 -0
- package/docs/engines/endoflife.md +27 -0
- package/docs/engines/flk_law.md +27 -0
- package/docs/engines/fxtwitter.md +27 -0
- package/docs/engines/google_news.md +27 -0
- package/docs/engines/gov_regulations.md +27 -0
- package/docs/engines/guqian.md +27 -0
- package/docs/engines/hackernews.md +27 -0
- package/docs/engines/iplant.md +27 -0
- package/docs/engines/keenable.md +27 -0
- package/docs/engines/met_museum.md +27 -0
- package/docs/engines/nasa_images.md +27 -0
- package/docs/engines/netease_music.md +27 -0
- package/docs/engines/npm.md +27 -0
- package/docs/engines/nvd.md +27 -0
- package/docs/engines/openreview.md +27 -0
- package/docs/engines/openstd.md +27 -0
- package/docs/engines/parallel_free.md +27 -0
- package/docs/engines/people_daily.md +27 -0
- package/docs/engines/realtime_index.md +27 -0
- package/docs/engines/sec_edgar.md +27 -0
- package/docs/engines/seltz.md +27 -0
- package/docs/engines/skillsmp.md +27 -0
- package/docs/engines/sspai.md +27 -0
- package/docs/engines/std_samr.md +27 -0
- package/docs/engines/train.md +27 -0
- package/docs/engines/un_comtrade.md +27 -0
- package/docs/engines/wikisource.md +27 -0
- package/docs/engines/zdic.md +27 -0
- package/docs/engines/zenodo.md +27 -0
- package/docs/engines/zhihu_hot_app.md +27 -0
- package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
- package/engines/README.md +10 -0
- package/engines/_template_cli.yaml +39 -0
- package/engines/_template_http.yaml +37 -0
- package/engines/plugins/_template_plugin.py +50 -0
- package/engines/specs/_cisa_kev.yaml +54 -0
- package/engines/specs/_osv.yaml +63 -0
- package/engines/specs/aljazeera.yaml +43 -0
- package/engines/specs/antara.yaml +43 -0
- package/engines/specs/artic.yaml +20 -0
- package/engines/specs/aviation_weather.yaml +39 -0
- package/engines/specs/bangumi.yaml +21 -0
- package/engines/specs/biorxiv.yaml +24 -0
- package/engines/specs/carbon_intensity.yaml +32 -0
- package/engines/specs/cfets.yaml +41 -0
- package/engines/specs/cinii.yaml +44 -0
- package/engines/specs/clawhub.yaml +49 -0
- package/engines/specs/cleveland.yaml +19 -0
- package/engines/specs/cn_ai_news.yaml +43 -0
- package/engines/specs/core.yaml +35 -0
- package/engines/specs/crt_sh.yaml +38 -0
- package/engines/specs/ct24.yaml +43 -0
- package/engines/specs/datacite.yaml +27 -0
- package/engines/specs/datagov_uk.yaml +41 -0
- package/engines/specs/ddgs_images.yaml +45 -0
- package/engines/specs/deepseek_search.yaml +74 -0
- package/engines/specs/deezer.yaml +20 -0
- package/engines/specs/defillama.yaml +38 -0
- package/engines/specs/deps_dev.yaml +21 -0
- package/engines/specs/digup.yaml +36 -0
- package/engines/specs/doi.yaml +39 -0
- package/engines/specs/douban_movie.yaml +22 -0
- package/engines/specs/dw_news.yaml +47 -0
- package/engines/specs/egov_law.yaml +21 -0
- package/engines/specs/elpais.yaml +43 -0
- package/engines/specs/endoflife.yaml +21 -0
- package/engines/specs/energy_charts.yaml +21 -0
- package/engines/specs/factcheck_org.yaml +52 -0
- package/engines/specs/faz.yaml +43 -0
- package/engines/specs/federal_register.yaml +57 -0
- package/engines/specs/firecrawl.yaml +53 -0
- package/engines/specs/flk_law.yaml +24 -0
- package/engines/specs/folha.yaml +43 -0
- package/engines/specs/france24.yaml +44 -0
- package/engines/specs/full_fact.yaml +47 -0
- package/engines/specs/fxtwitter.yaml +36 -0
- package/engines/specs/gbfs_nyc.yaml +32 -0
- package/engines/specs/gdacs.yaml +21 -0
- package/engines/specs/gdelt.yaml +64 -0
- package/engines/specs/gleif.yaml +55 -0
- package/engines/specs/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -0
- package/engines/specs/hkex.yaml +40 -0
- package/engines/specs/hurriyet.yaml +43 -0
- package/engines/specs/iplant.yaml +25 -0
- package/engines/specs/jikan.yaml +24 -0
- package/engines/specs/jolpica.yaml +20 -0
- package/engines/specs/k10plus.yaml +20 -0
- package/engines/specs/keenable.yaml +44 -0
- package/engines/specs/keenable_free.yaml +36 -0
- package/engines/specs/lefigaro.yaml +43 -0
- package/engines/specs/listenbrainz.yaml +19 -0
- package/engines/specs/matichon.yaml +43 -0
- package/engines/specs/met_museum.yaml +21 -0
- package/engines/specs/nasa_images.yaml +45 -0
- package/engines/specs/netease_music.yaml +38 -0
- package/engines/specs/nhk.yaml +43 -0
- package/engines/specs/nhtsa_vpic.yaml +20 -0
- package/engines/specs/noaa_swpc.yaml +20 -0
- package/engines/specs/nvd.yaml +38 -0
- package/engines/specs/obis.yaml +20 -0
- package/engines/specs/opencitations.yaml +52 -0
- package/engines/specs/openf1.yaml +21 -0
- package/engines/specs/openfoodfacts.yaml +45 -0
- package/engines/specs/openligadb.yaml +20 -0
- package/engines/specs/openreview.yaml +37 -0
- package/engines/specs/openstd.yaml +21 -0
- package/engines/specs/people_daily.yaml +22 -0
- package/engines/specs/realtime_index.yaml +34 -0
- package/engines/specs/ror.yaml +20 -0
- package/engines/specs/satnogs.yaml +20 -0
- package/engines/specs/sec_edgar.yaml +46 -0
- package/engines/specs/shfe.yaml +39 -0
- package/engines/specs/skillsmp.yaml +50 -0
- package/engines/specs/sogou_weixin_browser.yaml +30 -0
- package/engines/specs/soilgrids.yaml +25 -0
- package/engines/specs/sspai.yaml +36 -0
- package/engines/specs/stackexchange.yaml +46 -0
- package/engines/specs/std_samr.yaml +21 -0
- package/engines/specs/tass.yaml +43 -0
- package/engines/specs/tavily_free.yaml +56 -0
- package/engines/specs/tle_mirror.yaml +21 -0
- package/engines/specs/train.yaml +37 -0
- package/engines/specs/tvmaze.yaml +20 -0
- package/engines/specs/twitter_syndication.yaml +31 -0
- package/engines/specs/un_comtrade.yaml +23 -0
- package/engines/specs/unpaywall.yaml +54 -0
- package/engines/specs/vnpress.yaml +43 -0
- package/engines/specs/weather.yaml +32 -0
- package/engines/specs/who_don.yaml +21 -0
- package/engines/specs/who_gho.yaml +20 -0
- package/engines/specs/wikimedia_commons.yaml +34 -0
- package/engines/specs/wikisource.yaml +22 -0
- package/engines/specs/worms.yaml +19 -0
- package/engines/specs/x_trending.yaml +34 -0
- package/engines/specs/yna.yaml +43 -0
- package/engines/specs/ynet.yaml +43 -0
- package/engines/specs/yuanbao.yaml +93 -0
- package/engines/specs/zbmath.yaml +40 -0
- package/engines/specs/zdic.yaml +22 -0
- package/engines/specs/zenodo.yaml +27 -0
- package/engines/specs/zhihu_hot_app.yaml +35 -0
- package/mcp/clients.yaml +106 -0
- package/package.json +55 -12
- package/packages/dsh-plugin/README.md +152 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1533 -0
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +527 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +749 -0
- package/packages/dsh-plugin/dsh/runner.js +666 -0
- package/packages/dsh-plugin/dsh/wide-research.js +878 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +406 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +1382 -0
- package/references/query-templates.md +96 -0
- package/references/query-templates.yaml +118 -0
- package/scripts/ab_eval.py +219 -0
- package/scripts/ab_eval_p0p1.py +266 -0
- package/scripts/adaptive.py +294 -70
- package/scripts/answer.py +154 -0
- package/scripts/archive_run.py +591 -0
- package/scripts/argo_engine_registry.py +172 -0
- package/scripts/argo_paths.py +728 -0
- package/scripts/article.py +136 -0
- package/scripts/article_meta.py +131 -0
- package/scripts/arxiv_source.py +201 -0
- package/scripts/badcase_ledger.py +252 -0
- package/scripts/batch_probe.py +241 -0
- package/scripts/benchmark.py +19 -0
- package/scripts/bounded_run.py +144 -0
- package/scripts/browser_auth.py +323 -0
- package/scripts/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1066 -67
- package/scripts/cache_guard.py +271 -0
- package/scripts/cache_key_vdom.py +56 -0
- package/scripts/cache_similarity.py +170 -0
- package/scripts/caliber_isolation.py +195 -0
- package/scripts/calibrate_lowquality.py +210 -0
- package/scripts/candidate_envelope.py +366 -0
- package/scripts/chrome_cdp.py +871 -0
- package/scripts/ci_drift_gate.py +90 -0
- package/scripts/circuit_breaker.py +389 -0
- package/scripts/citations.py +334 -0
- package/scripts/cite_cli.py +59 -0
- package/scripts/cjk_tokens.py +95 -0
- package/scripts/clarify.py +47 -24
- package/scripts/cli_io.py +240 -0
- package/scripts/config.py +683 -45
- package/scripts/config_cache.py +402 -0
- package/scripts/conn_pool.py +220 -0
- package/scripts/content_boundary.py +46 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +751 -0
- package/scripts/crawl.py +74 -35
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +982 -0
- package/scripts/engine_env.py +539 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +738 -0
- package/scripts/engine_policy.py +218 -0
- package/scripts/engine_requires.py +190 -0
- package/scripts/engine_status.py +322 -0
- package/scripts/engine_validate.py +638 -0
- package/scripts/engines.py +799 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +310 -0
- package/scripts/engines_builders_batch10.py +204 -0
- package/scripts/engines_builders_batch11.py +174 -0
- package/scripts/engines_builders_batch12.py +103 -0
- package/scripts/engines_builders_batch13.py +340 -0
- package/scripts/engines_builders_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -0
- package/scripts/engines_builders_batch9.py +1468 -0
- package/scripts/engines_builders_cn.py +2283 -0
- package/scripts/engines_builders_data.py +2923 -0
- package/scripts/engines_builders_data_macro.py +775 -0
- package/scripts/engines_builders_feeds.py +255 -0
- package/scripts/engines_builders_intl.py +1239 -0
- package/scripts/engines_builders_keyless.py +111 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1359 -0
- package/scripts/evidence.py +620 -166
- package/scripts/evidence_loop.py +624 -0
- package/scripts/evidence_tier.py +298 -0
- package/scripts/except_sets.py +25 -0
- package/scripts/extract.py +55 -16
- package/scripts/fact_align.py +207 -0
- package/scripts/fact_crosscheck.py +230 -0
- package/scripts/fetch.py +25 -12
- package/scripts/fetch_browser.py +87 -0
- package/scripts/fetch_jsrun.py +122 -0
- package/scripts/fetch_quality.py +225 -0
- package/scripts/fetch_render_tinyfish.py +117 -0
- package/scripts/fetch_v3.py +1804 -0
- package/scripts/focus_extract.py +350 -0
- package/scripts/fulltext_store.py +169 -0
- package/scripts/fxtwitter_api.py +325 -0
- package/scripts/gen_engine_catalog.py +364 -0
- package/scripts/gen_native_tools.py +91 -0
- package/scripts/gen_routing_snapshot.py +195 -0
- package/scripts/health_check.py +140 -402
- package/scripts/health_probe.py +23 -8
- package/scripts/hot_state.py +134 -0
- package/scripts/http_client.py +1053 -0
- package/scripts/image_ops.py +430 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +166 -0
- package/scripts/install.sh +221 -0
- package/scripts/job.py +1186 -0
- package/scripts/jump_resolver.py +235 -0
- package/scripts/lang_capability.py +150 -0
- package/scripts/lang_detect.py +289 -0
- package/scripts/lang_matrix_probe.py +329 -0
- package/scripts/lang_pref.py +311 -0
- package/scripts/link_source.py +340 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +402 -0
- package/scripts/macro_countries.py +97 -0
- package/scripts/matrix_search_eval.py +1187 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +492 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +124 -0
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +125 -419
- package/scripts/mcp_setup.py +431 -0
- package/scripts/mcp_tools.py +331 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/mcp_warm.py +127 -0
- package/scripts/min_version_scan.py +335 -0
- package/scripts/net_proxy.py +258 -0
- package/scripts/network_aware.py +152 -0
- package/scripts/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/paper.py +274 -0
- package/scripts/pdf_extract.py +518 -0
- package/scripts/phrase_filter.py +66 -0
- package/scripts/plan.py +422 -0
- package/scripts/postprocess_signals.py +150 -0
- package/scripts/pre-commit +48 -0
- package/scripts/query_enhance.py +157 -0
- package/scripts/query_operators.py +196 -0
- package/scripts/query_rewriter.py +116 -7
- package/scripts/query_signals.py +300 -0
- package/scripts/query_templates.py +167 -0
- package/scripts/query_understanding.py +411 -0
- package/scripts/query_variants.py +298 -0
- package/scripts/quota.py +633 -76
- package/scripts/rank_signals.py +775 -0
- package/scripts/ranking_eval.py +319 -0
- package/scripts/readability_extract.py +433 -0
- package/scripts/recompute.py +415 -0
- package/scripts/recovery.py +616 -0
- package/scripts/recovery_l0.py +160 -0
- package/scripts/recovery_runner.py +150 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +658 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +607 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +169 -0
- package/scripts/research_gates.py +272 -0
- package/scripts/research_report.py +209 -0
- package/scripts/research_strategy.py +37 -0
- package/scripts/research_work_packages.py +195 -0
- package/scripts/result_lang.py +311 -0
- package/scripts/retry_policy.py +76 -0
- package/scripts/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +243 -0
- package/scripts/route_combo.py +917 -0
- package/scripts/route_domains.py +341 -0
- package/scripts/route_lang.py +605 -0
- package/scripts/route_log.py +63 -0
- package/scripts/route_policy.py +250 -0
- package/scripts/rrf_classic.py +66 -0
- package/scripts/screenshot.py +73 -0
- package/scripts/search.py +886 -406
- package/scripts/search_attribution.py +185 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +640 -0
- package/scripts/search_entry.py +395 -0
- package/scripts/search_output.py +690 -0
- package/scripts/search_pipeline.py +926 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +129 -16
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +391 -0
- package/scripts/serp_guard.py +336 -0
- package/scripts/serp_kill_log.py +74 -0
- package/scripts/single_flight.py +120 -0
- package/scripts/social_engines/__init__.py +10 -0
- package/scripts/social_engines/bilibili_engine.py +131 -0
- package/scripts/social_engines/hackernews_engine.py +113 -0
- package/scripts/social_engines/reddit_engine.py +172 -0
- package/scripts/social_engines/twitter_engine.py +397 -0
- package/scripts/social_engines/v2ex_engine.py +101 -0
- package/scripts/social_engines/weibo_engine.py +128 -0
- package/scripts/social_engines/xiaohongshu_engine.py +137 -0
- package/scripts/social_engines/zhihu_engine.py +139 -0
- package/scripts/social_research.py +201 -0
- package/scripts/sogou_weixin_browser.py +318 -0
- package/scripts/source_ledger.py +249 -0
- package/scripts/stage_timing.py +58 -0
- package/scripts/stats_cli.py +107 -0
- package/scripts/straggler_policy.py +60 -0
- package/scripts/stylometry_detector.py +231 -0
- package/scripts/sync_backends.py +353 -0
- package/scripts/sync_installs.py +29 -0
- package/scripts/template_cli.py +127 -0
- package/scripts/text_lexicon.py +72 -0
- package/scripts/tfidf_router.py +109 -37
- package/scripts/time_utils.py +233 -0
- package/scripts/topic_research_profiles.py +643 -0
- package/scripts/train.py +737 -0
- package/scripts/tweet.py +596 -0
- package/scripts/url_canon.py +217 -0
- package/scripts/url_optimize.py +56 -0
- package/scripts/url_safety.py +236 -0
- package/scripts/usage_log.py +116 -0
- package/scripts/usage_log_clean.py +113 -0
- package/scripts/v2ex_nodes.py +311 -0
- package/scripts/variant_recall.py +188 -0
- package/scripts/watch.py +205 -0
- package/scripts/wechat_article.py +258 -0
- package/scripts/wide_research.py +447 -0
- package/scripts/wx.py +349 -0
- package/scripts/x_article_fetch.py +141 -0
- package/scripts/x_search.py +234 -0
- package/scripts/x_trending.py +189 -0
- package/scripts/yaml_load.py +67 -0
- package/scripts/zhihu_quota.py +139 -0
- package/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/scripts/ego_search.py +800 -0
- package/sub-skills/ego-search/scripts/merge.py +226 -0
- package/sub-skills/ego-search/scripts/quality.py +64 -0
- package/sub-skills/ego-search/scripts/runtime.py +172 -0
- package/sub-skills/ego-search/scripts/safety.py +109 -0
- package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
- package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
- package/sub-skills/js-run/SKILL.md +67 -0
- package/sub-skills/js-run/scripts/jsrun.py +112 -0
- package/sub-skills/js-run/scripts/shim.js +188 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +186 -0
- package/sub-skills/local-search/cli_runner.py +331 -0
- package/sub-skills/local-search/config.yaml +152 -55
- package/sub-skills/local-search/engine_registry.py +91 -26
- package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
- package/sub-skills/local-search/local_search_adapter.py +13 -2
- package/sub-skills/local-search/parse_maps.yaml +40 -42
- package/sub-skills/local-search/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +716 -242
- package/sub-skills/local-search/smart_router.py +47 -7
- package/sub-skills/local-seek/SKILL.md +110 -0
- package/sub-skills/local-seek/config/domains.yaml +20 -0
- package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
- package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
- package/sub-skills/local-seek/scripts/seek.py +1465 -0
- package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
- package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
- package/sub-skills/local-seek/scripts/seek_rank.py +197 -0
|
@@ -0,0 +1,2283 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""专用构建器:中文财经 / 热榜 / 百科"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from engine_env import get_env
|
|
17
|
+
|
|
18
|
+
from engines_base import safe_search, _run, _resolve, _get_path, _coerce_field, _http_get_raw, http_open, rank_score
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("unified_search.engines")
|
|
21
|
+
|
|
22
|
+
# ── 同花顺热点引擎 ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def _build_ths_hot_engine(spec: dict[str, Any]) -> Any:
|
|
25
|
+
"""同花顺当日强势股 + 题材归因(独家能力)
|
|
26
|
+
|
|
27
|
+
不只告诉你"哪些走强",还告诉你"为什么走强"——同花顺编辑部人工运营的题材标签。
|
|
28
|
+
"""
|
|
29
|
+
timeout = spec.get("timeout", 10)
|
|
30
|
+
|
|
31
|
+
@safe_search
|
|
32
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
33
|
+
to = _timeout or timeout
|
|
34
|
+
from datetime import date as _date
|
|
35
|
+
trade_date = _date.today().strftime("%Y-%m-%d")
|
|
36
|
+
|
|
37
|
+
url = (
|
|
38
|
+
f"http://zx.10jqka.com.cn/event/api/getharden/"
|
|
39
|
+
f"date/{trade_date}/orderby/date/orderway/desc/charset/GBK/"
|
|
40
|
+
)
|
|
41
|
+
headers = {
|
|
42
|
+
"User-Agent": (
|
|
43
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
44
|
+
"Chrome/117.0.0.0 Safari/537.36"
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
try:
|
|
48
|
+
req = urllib.request.Request(url, headers=headers)
|
|
49
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
50
|
+
data = json.loads(resp.read())
|
|
51
|
+
if data.get("errocode", 0) != 0:
|
|
52
|
+
return []
|
|
53
|
+
rows = data.get("data") or []
|
|
54
|
+
results = []
|
|
55
|
+
for r in rows[:n]:
|
|
56
|
+
results.append({
|
|
57
|
+
"title": f"{r.get('name', '')}({r.get('code', '')}) +{r.get('zhangfu', 0)}%",
|
|
58
|
+
"url": f"https://quote.eastmoney.com/{r.get('code', '')}.html",
|
|
59
|
+
"snippet": f"题材: {r.get('reason', '未知')} | 换手{r.get('huanshou', 0)}% | 成交额{r.get('chengjiaoe', 0)/1e8:.1f}亿",
|
|
60
|
+
"source": "ths_hot",
|
|
61
|
+
})
|
|
62
|
+
return results
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.warning(f"同花顺热点引擎失败: {e}")
|
|
65
|
+
return []
|
|
66
|
+
return _engine
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── 财联社电报引擎 ─────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
# 低信息量触发词:快讯/热榜类引擎的查询词仅作路由触发,不参与内容过滤。
|
|
72
|
+
# 「快讯」「美股」这类查询若当关键词过滤会把全量榜单滤空。
|
|
73
|
+
_TRIGGER_WORDS = frozenset({
|
|
74
|
+
"快讯", "电报", "资讯", "新闻", "热点", "实时", "美股", "行情",
|
|
75
|
+
"财经", "股市", "港股", "A股", "盘面", "速递", "播报", "latest",
|
|
76
|
+
"news", "flash", "market", "stock", "finance",
|
|
77
|
+
# 复合触发短语:各域 pattern 里就是这么写的(jin10_flash / cls_telegraph_search),
|
|
78
|
+
# 不补的话「财经快讯」会被当主题词去过滤,把全量榜单滤成 0 条
|
|
79
|
+
"财经快讯", "市场快讯", "实时快讯", "7x24快讯", "全球快讯",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _should_filter(query: str) -> bool:
|
|
84
|
+
"""查询是否值得做内容关键词过滤(含具体主题才过滤,纯触发词放行全量榜单)。"""
|
|
85
|
+
q = (query or "").strip()
|
|
86
|
+
if not q:
|
|
87
|
+
return False
|
|
88
|
+
tokens = [t for t in re.split(r"[\s,,。、]+|美股|港股|A股", q) if t]
|
|
89
|
+
if not tokens:
|
|
90
|
+
return False
|
|
91
|
+
return not all(t in _TRIGGER_WORDS for t in tokens)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _build_cls_telegraph_engine(spec: dict[str, Any]) -> Any:
|
|
95
|
+
"""财联社电报(全市场实时快讯,v1 API + 本地签名,零 key)"""
|
|
96
|
+
timeout = spec.get("timeout", 10)
|
|
97
|
+
|
|
98
|
+
@safe_search
|
|
99
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
100
|
+
import hashlib
|
|
101
|
+
from datetime import datetime
|
|
102
|
+
to = _timeout or timeout
|
|
103
|
+
# 关键词过滤时放大拉取量(3×),避免小样本过滤后 0 条
|
|
104
|
+
fetch_n = max(int(n) * 3, 10)
|
|
105
|
+
params = {"appName": "CailianpressWeb", "os": "web", "sv": "7.7.5",
|
|
106
|
+
"last_time": "", "refresh_type": "1", "rn": str(fetch_n)}
|
|
107
|
+
qs = "&".join(f"{k}={params[k]}" for k in sorted(params))
|
|
108
|
+
sign = hashlib.md5(hashlib.sha1(qs.encode()).hexdigest().encode()).hexdigest()
|
|
109
|
+
url = f"https://www.cls.cn/v1/roll/get_roll_list?{qs}&sign={sign}"
|
|
110
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.cls.cn/"}
|
|
111
|
+
try:
|
|
112
|
+
req = urllib.request.Request(url, headers=headers)
|
|
113
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
114
|
+
d = json.loads(resp.read())
|
|
115
|
+
results = []
|
|
116
|
+
for item in d.get("data", {}).get("roll_data", []) or []:
|
|
117
|
+
ts = item.get("ctime")
|
|
118
|
+
t = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else ""
|
|
119
|
+
title = item.get("title", "") or item.get("brief", "")
|
|
120
|
+
# 关键词过滤:纯触发词(快讯/美股等)放行全量榜单
|
|
121
|
+
if _should_filter(query):
|
|
122
|
+
keywords = query.strip().split()
|
|
123
|
+
if not any(kw.lower() in (title + item.get("content", "")).lower() for kw in keywords):
|
|
124
|
+
continue
|
|
125
|
+
results.append({
|
|
126
|
+
"title": title[:80],
|
|
127
|
+
"url": "https://www.cls.cn/",
|
|
128
|
+
"snippet": f"{t} | {(item.get('content', '') or item.get('brief', ''))[:150]}",
|
|
129
|
+
"source": "cls_telegraph",
|
|
130
|
+
})
|
|
131
|
+
return results[:n]
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.warning(f"财联社电报引擎失败: {e}")
|
|
134
|
+
return []
|
|
135
|
+
return _engine
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── 东财全球资讯引擎 ─────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _build_em_global_news_engine(spec: dict[str, Any]) -> Any:
|
|
141
|
+
"""东财全球财经资讯(7×24 滚动)"""
|
|
142
|
+
timeout = spec.get("timeout", 10)
|
|
143
|
+
|
|
144
|
+
@safe_search
|
|
145
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
146
|
+
import uuid
|
|
147
|
+
to = _timeout or timeout
|
|
148
|
+
url = "https://np-weblist.eastmoney.com/comm/web/getFastNewsList"
|
|
149
|
+
params = {
|
|
150
|
+
"client": "web", "biz": "web_724",
|
|
151
|
+
"fastColumn": "102", "sortEnd": "",
|
|
152
|
+
"pageSize": str(max(int(n) * 3, 10)), # 放大拉取供关键词过滤
|
|
153
|
+
"req_trace": str(uuid.uuid4()),
|
|
154
|
+
}
|
|
155
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://kuaixun.eastmoney.com/"}
|
|
156
|
+
try:
|
|
157
|
+
req = urllib.request.Request(url + "?" + "&".join(f"{k}={v}" for k, v in params.items()), headers=headers)
|
|
158
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
159
|
+
d = json.loads(resp.read())
|
|
160
|
+
results = []
|
|
161
|
+
for item in d.get("data", {}).get("fastNewsList", []):
|
|
162
|
+
title = item.get("title", "")
|
|
163
|
+
# 关键词过滤:纯触发词(快讯/美股等)放行全量榜单
|
|
164
|
+
if _should_filter(query):
|
|
165
|
+
keywords = query.strip().split()
|
|
166
|
+
if not any(kw.lower() in (title + item.get("summary", "")).lower() for kw in keywords):
|
|
167
|
+
continue
|
|
168
|
+
results.append({
|
|
169
|
+
"title": title[:80],
|
|
170
|
+
"url": "https://kuaixun.eastmoney.com/",
|
|
171
|
+
"snippet": f"{item.get('showTime', '')} | {(item.get('summary', '') or '')[:150]}",
|
|
172
|
+
"source": "em_global_news",
|
|
173
|
+
})
|
|
174
|
+
return results[:n]
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.warning(f"东财全球资讯引擎失败: {e}")
|
|
177
|
+
return []
|
|
178
|
+
return _engine
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── 东财妙想搜索(官方权威信源) ─────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def _build_em_miaoxiang_engine(spec: dict[str, Any]) -> Any:
|
|
185
|
+
"""东财妙想搜索(mkapi2.dfcfs.com/finskillshub,需 EASTMONEY_APIKEY)。
|
|
186
|
+
|
|
187
|
+
官方金融信源智能筛选:研报/公告/政策/解读,authorityLevel 权威分级,
|
|
188
|
+
比公开 search-api-web 接口更适合金融资讯场景。
|
|
189
|
+
"""
|
|
190
|
+
timeout = spec.get("timeout", 12)
|
|
191
|
+
url = "https://mkapi2.dfcfs.com/finskillshub/api/claw/news-search"
|
|
192
|
+
|
|
193
|
+
@safe_search
|
|
194
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
195
|
+
import json as _json
|
|
196
|
+
to = _timeout or timeout
|
|
197
|
+
key = get_env(["ARGO_EASTMONEY_APIKEY", "EASTMONEY_APIKEY"])
|
|
198
|
+
if not key:
|
|
199
|
+
logger.warning("妙想搜索缺 EASTMONEY_APIKEY")
|
|
200
|
+
return []
|
|
201
|
+
body = _json.dumps({"query": query, "size": min(n + 3, 15)}).encode("utf-8")
|
|
202
|
+
req = urllib.request.Request(
|
|
203
|
+
url, data=body, method="POST",
|
|
204
|
+
headers={
|
|
205
|
+
"apikey": key,
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
"User-Agent": "argo-search/2.4 (unified-search@local)",
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
212
|
+
data = _json.loads(resp.read().decode("utf-8", "replace"))
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.warning(f"妙想搜索失败: {e}")
|
|
215
|
+
return []
|
|
216
|
+
items = []
|
|
217
|
+
try:
|
|
218
|
+
items = data["data"]["data"]["llmSearchResponse"]["data"] or []
|
|
219
|
+
except (KeyError, TypeError):
|
|
220
|
+
logger.warning("妙想搜索返回结构异常")
|
|
221
|
+
return []
|
|
222
|
+
results = []
|
|
223
|
+
seen: set[str] = set()
|
|
224
|
+
for _rk, it in enumerate(items):
|
|
225
|
+
title = (it.get("title") or "").strip()
|
|
226
|
+
if not title or title in seen:
|
|
227
|
+
continue
|
|
228
|
+
seen.add(title)
|
|
229
|
+
content = (it.get("content") or "").strip().replace("\n", " ")
|
|
230
|
+
date = it.get("date") or ""
|
|
231
|
+
authority = it.get("authorityLevel") or ""
|
|
232
|
+
info_type = it.get("informationType") or ""
|
|
233
|
+
parts = [p for p in (date, authority, info_type, content[:200]) if p]
|
|
234
|
+
results.append({
|
|
235
|
+
"title": title[:120],
|
|
236
|
+
"url": it.get("jumpUrl") or "https://eastmoney.com",
|
|
237
|
+
"snippet": " | ".join(parts)[:280],
|
|
238
|
+
"source": "em_miaoxiang",
|
|
239
|
+
"score": rank_score(0.85, _rk),
|
|
240
|
+
})
|
|
241
|
+
if len(results) >= n:
|
|
242
|
+
break
|
|
243
|
+
return results
|
|
244
|
+
return _engine
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ── 巨潮资讯公告引擎(官方公告) ──────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def _build_cninfo_engine(spec: dict[str, Any]) -> Any:
|
|
250
|
+
"""巨潮资讯网官方公告检索(www.cninfo.com.cn/new/hisAnnouncement/query)。
|
|
251
|
+
|
|
252
|
+
A 股上市公司公告第一官方源,覆盖沪深京三所,免认证。
|
|
253
|
+
查询词命中公司名/公告标题关键词,返回标题/日期/PDF 原文链接。
|
|
254
|
+
"""
|
|
255
|
+
timeout = spec.get("timeout", 12)
|
|
256
|
+
url = "https://www.cninfo.com.cn/new/hisAnnouncement/query"
|
|
257
|
+
|
|
258
|
+
@safe_search
|
|
259
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
260
|
+
import urllib.parse as up
|
|
261
|
+
to = _timeout or timeout
|
|
262
|
+
|
|
263
|
+
def _search_once(keyword: str) -> list[dict[str, Any]]:
|
|
264
|
+
body = up.urlencode({
|
|
265
|
+
"pageNum": "1",
|
|
266
|
+
"pageSize": str(min(n + 2, 15)),
|
|
267
|
+
"column": "szse",
|
|
268
|
+
"tabName": "fulltext",
|
|
269
|
+
"plate": "", "stock": "", "searchkey": keyword,
|
|
270
|
+
"secid": "", "category": "", "trade": "", "seDate": "",
|
|
271
|
+
"sortName": "", "sortType": "", "isHLtitle": "true",
|
|
272
|
+
}).encode("utf-8")
|
|
273
|
+
req = urllib.request.Request(
|
|
274
|
+
url, data=body, method="POST",
|
|
275
|
+
headers={
|
|
276
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
277
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
278
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
279
|
+
"Referer": "https://www.cninfo.com.cn/new/commonUrl/pageOfSearch",
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
284
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.warning(f"巨潮公告搜索失败: {e}")
|
|
287
|
+
return []
|
|
288
|
+
items = data.get("announcements") or []
|
|
289
|
+
if not items:
|
|
290
|
+
return []
|
|
291
|
+
results = []
|
|
292
|
+
seen: set[str] = set()
|
|
293
|
+
for _rk5, it in enumerate(items):
|
|
294
|
+
raw_title = (it.get("announcementTitle") or "").strip()
|
|
295
|
+
if not raw_title or raw_title in seen:
|
|
296
|
+
continue
|
|
297
|
+
seen.add(raw_title)
|
|
298
|
+
title = re.sub(r"<[^>]+>", "", raw_title)[:100]
|
|
299
|
+
sec = re.sub(r"<[^>]+>", "", it.get("secName") or "").strip()
|
|
300
|
+
ts = it.get("announcementTime")
|
|
301
|
+
date = time.strftime("%Y-%m-%d", time.localtime(ts / 1000)) if ts else ""
|
|
302
|
+
adjunct = it.get("adjunctUrl") or ""
|
|
303
|
+
pdf_url = "https://static.cninfo.com.cn/" + adjunct if adjunct else "https://www.cninfo.com.cn/"
|
|
304
|
+
results.append({
|
|
305
|
+
"title": f"{sec} {title}" if sec and sec not in title else title,
|
|
306
|
+
"url": pdf_url,
|
|
307
|
+
"snippet": " | ".join(p for p in (date, "公告原文 PDF", "巨潮资讯网官方") if p)[:280],
|
|
308
|
+
"source": "cninfo",
|
|
309
|
+
"score": rank_score(0.9, _rk5),
|
|
310
|
+
})
|
|
311
|
+
if len(results) >= n:
|
|
312
|
+
break
|
|
313
|
+
return results
|
|
314
|
+
|
|
315
|
+
# 候选词干:super_search 的查询改写会把原句拼上「贵州茅台 600519 白酒 股票行情」
|
|
316
|
+
# 等扩展词,整句全文搜索命中率反而低。先试整句,0 结果时按分词去 STOP 词重试。
|
|
317
|
+
_STOP = ("公告", "披露", "查询", "什么", "怎么样", "多少", "怎么", "了", "吗",
|
|
318
|
+
"今日", "最新", "股票", "股价", "行情", "走势", "报价", "分红方案",
|
|
319
|
+
"利润分配", "每股", "多少钱")
|
|
320
|
+
cands = [query]
|
|
321
|
+
for token in re.split(r"[\s,,、/]+", query):
|
|
322
|
+
t = token.strip()
|
|
323
|
+
if not t:
|
|
324
|
+
continue
|
|
325
|
+
for stop in _STOP:
|
|
326
|
+
t = t.replace(stop, "")
|
|
327
|
+
t = t.strip()
|
|
328
|
+
if 2 <= len(t) <= 20 and t not in cands:
|
|
329
|
+
cands.append(t)
|
|
330
|
+
for c in cands:
|
|
331
|
+
res = _search_once(c)
|
|
332
|
+
if res:
|
|
333
|
+
return res
|
|
334
|
+
return []
|
|
335
|
+
return _engine
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ── 新浪行情引擎(实时行情快照) ─────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
def _build_sina_quote_engine(spec: dict[str, Any]) -> Any:
|
|
341
|
+
"""新浪实时行情快照(hq.sinajs.cn + suggest3 代码解析)。
|
|
342
|
+
|
|
343
|
+
免认证直连:suggest3.sinajs.cn 把中文名/拼音/代码解析为证券代码,
|
|
344
|
+
再拉 hq.sinajs.cn 实时快照(现价/涨跌/开高低/成交量),适合"茅台股价"类查询。
|
|
345
|
+
"""
|
|
346
|
+
timeout = spec.get("timeout", 8)
|
|
347
|
+
suggest_url = "https://suggest3.sinajs.cn/suggest/type=11,12,15,21,31,41&key="
|
|
348
|
+
quote_url = "https://hq.sinajs.cn/list="
|
|
349
|
+
|
|
350
|
+
_MARKET_PREFIX = {
|
|
351
|
+
"sh": "沪", "sz": "深", "bj": "北",
|
|
352
|
+
"hk": "港", "us": "美", "hf": "期货",
|
|
353
|
+
"gb_": "外盘", "rt_hk": "港", "znb_": "银行",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
@safe_search
|
|
357
|
+
def _engine(query: str, n: int = 3, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
358
|
+
import urllib.parse as up
|
|
359
|
+
to = _timeout or timeout
|
|
360
|
+
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
361
|
+
"Referer": "https://finance.sina.com.cn/"}
|
|
362
|
+
code = _resolve_code(query, to, headers)
|
|
363
|
+
if not code:
|
|
364
|
+
return []
|
|
365
|
+
symbol = code.split(",")[0] if "," in code else code
|
|
366
|
+
try:
|
|
367
|
+
req = urllib.request.Request(quote_url + symbol, headers=headers)
|
|
368
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
369
|
+
text = resp.read().decode("gbk", "replace").strip()
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.warning(f"新浪行情失败: {e}")
|
|
372
|
+
return []
|
|
373
|
+
if not text or "=" not in text:
|
|
374
|
+
return []
|
|
375
|
+
var_part = text.split("=", 1)[1].strip().strip('"')
|
|
376
|
+
fields = var_part.split(",")
|
|
377
|
+
if not fields or len(fields) < 4:
|
|
378
|
+
return []
|
|
379
|
+
name = fields[0]
|
|
380
|
+
# A 股字段:0名称 1今开 2昨收 3现价 4最高 5最低 6买一 7卖一 8成交量 9成交额
|
|
381
|
+
if len(fields) >= 10:
|
|
382
|
+
cur, prev = fields[3], fields[2]
|
|
383
|
+
try:
|
|
384
|
+
chg = float(cur) - float(prev) if cur and prev else 0.0
|
|
385
|
+
pct = chg / float(prev) * 100 if prev and float(prev) else 0.0
|
|
386
|
+
except ValueError:
|
|
387
|
+
chg, pct = 0.0, 0.0
|
|
388
|
+
arrow = "↑" if pct > 0 else ("↓" if pct < 0 else "→")
|
|
389
|
+
title = f"{name} {cur} {arrow}{pct:+.2f}%" if cur else f"{name} 行情"
|
|
390
|
+
snip_parts = [
|
|
391
|
+
f"现价 {cur}", f"涨跌 {chg:+.2f} ({pct:+.2f}%)" if chg else "",
|
|
392
|
+
f"今开 {fields[1]}", f"昨收 {prev}",
|
|
393
|
+
f"最高 {fields[4]}", f"最低 {fields[5]}",
|
|
394
|
+
f"成交量 {_fmt_vol(fields[8])}" if fields[8] else "",
|
|
395
|
+
]
|
|
396
|
+
else:
|
|
397
|
+
title = f"{name} 行情"
|
|
398
|
+
snip_parts = [f"数据 {var_part[:60]}"]
|
|
399
|
+
market = symbol[:2]
|
|
400
|
+
prefix = _MARKET_PREFIX.get(market, "")
|
|
401
|
+
quote_page = "https://finance.sina.com.cn/realstock/company/" + symbol + "/nc.shtml"
|
|
402
|
+
return [{
|
|
403
|
+
"title": title[:80],
|
|
404
|
+
"url": quote_page,
|
|
405
|
+
"snippet": " | ".join(p for p in snip_parts if p)[:200],
|
|
406
|
+
"source": "sina_quote",
|
|
407
|
+
"score": 0.9,
|
|
408
|
+
}]
|
|
409
|
+
|
|
410
|
+
def _resolve_code(q: str, to: float, headers: dict) -> str:
|
|
411
|
+
import urllib.parse as up
|
|
412
|
+
# 候选词干:先整句,再逐词试;去掉常见行情后缀词
|
|
413
|
+
_STOP = ("股价", "行情", "股票", "价格", "走势", "最新", "今日", "报价", "查询",
|
|
414
|
+
"怎么样", "多少", "怎么", "了", "吗", "的", "a股", "港股", "美股")
|
|
415
|
+
cands = [q]
|
|
416
|
+
for token in re.split(r"[\s,,、/]+", q):
|
|
417
|
+
t = token.strip()
|
|
418
|
+
if not t:
|
|
419
|
+
continue
|
|
420
|
+
for stop in _STOP:
|
|
421
|
+
t = t.replace(stop, "")
|
|
422
|
+
t = t.strip()
|
|
423
|
+
if 2 <= len(t) <= 8 and t not in cands:
|
|
424
|
+
cands.append(t)
|
|
425
|
+
for c in cands:
|
|
426
|
+
try:
|
|
427
|
+
req = urllib.request.Request(suggest_url + up.quote(c), headers=headers)
|
|
428
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
429
|
+
text = resp.read().decode("gbk", "replace")
|
|
430
|
+
except Exception as e:
|
|
431
|
+
logger.warning(f"新浪代码解析失败: {e}")
|
|
432
|
+
continue
|
|
433
|
+
if "suggestvalue=" not in text:
|
|
434
|
+
continue
|
|
435
|
+
val = text.split("suggestvalue=", 1)[1].strip().strip('"')
|
|
436
|
+
# 格式: 名称,类型,代码,符号,拼音,... 取第一个符号
|
|
437
|
+
parts = val.split(",")
|
|
438
|
+
if len(parts) >= 4 and parts[3]:
|
|
439
|
+
return parts[3]
|
|
440
|
+
return ""
|
|
441
|
+
|
|
442
|
+
def _fmt_vol(v: str) -> str:
|
|
443
|
+
try:
|
|
444
|
+
f = float(v)
|
|
445
|
+
except ValueError:
|
|
446
|
+
return v
|
|
447
|
+
if f >= 1e8:
|
|
448
|
+
return f"{f / 1e8:.2f}亿手"
|
|
449
|
+
if f >= 1e4:
|
|
450
|
+
return f"{f / 1e4:.2f}万手"
|
|
451
|
+
return v
|
|
452
|
+
return _engine
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ── 腾讯行情引擎(实时行情快照,含换手率/市盈率/五档) ───────────────────────
|
|
456
|
+
|
|
457
|
+
def _build_tencent_quote_engine(spec: dict[str, Any]) -> Any:
|
|
458
|
+
"""腾讯实时行情快照(qt.gtimg.cn + smartbox 代码解析)。
|
|
459
|
+
|
|
460
|
+
免费直连 GBK 接口,比新浪多换手率/市盈率/市净率/总市值等字段,
|
|
461
|
+
与 sina_quote 互为交叉验证,适合「茅台股价」「上证指数」类查询。
|
|
462
|
+
"""
|
|
463
|
+
timeout = spec.get("timeout", 8)
|
|
464
|
+
suggest_url = "https://smartbox.gtimg.cn/s3/?v=2&t=all&q="
|
|
465
|
+
quote_url = "https://qt.gtimg.cn/q="
|
|
466
|
+
|
|
467
|
+
@safe_search
|
|
468
|
+
def _engine(query: str, n: int = 3, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
469
|
+
import urllib.parse as up
|
|
470
|
+
to = _timeout or timeout
|
|
471
|
+
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
472
|
+
"Referer": "https://gu.qq.com/"}
|
|
473
|
+
symbol = _resolve_symbol(query, to, headers)
|
|
474
|
+
if not symbol:
|
|
475
|
+
return []
|
|
476
|
+
try:
|
|
477
|
+
req = urllib.request.Request(quote_url + symbol, headers=headers)
|
|
478
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
479
|
+
text = resp.read().decode("gbk", "replace").strip()
|
|
480
|
+
except Exception as e:
|
|
481
|
+
logger.warning(f"腾讯行情失败: {e}")
|
|
482
|
+
return []
|
|
483
|
+
if "=" not in text:
|
|
484
|
+
return []
|
|
485
|
+
var_part = text.split("=", 1)[1].strip().strip('"')
|
|
486
|
+
fields = var_part.split("~")
|
|
487
|
+
if len(fields) < 35:
|
|
488
|
+
return []
|
|
489
|
+
name, code = fields[1], fields[2]
|
|
490
|
+
cur, prev, opn = fields[3], fields[4], fields[5]
|
|
491
|
+
try:
|
|
492
|
+
chg = float(fields[31]) if fields[31] else 0.0
|
|
493
|
+
pct = float(fields[32]) if fields[32] else 0.0
|
|
494
|
+
except ValueError:
|
|
495
|
+
chg, pct = 0.0, 0.0
|
|
496
|
+
arrow = "↑" if pct > 0 else ("↓" if pct < 0 else "→")
|
|
497
|
+
title = f"{name} {cur} {arrow}{pct:+.2f}%" if cur else f"{name} 行情"
|
|
498
|
+
parts = [
|
|
499
|
+
f"现价 {cur}", f"涨跌 {chg:+.2f} ({pct:+.2f}%)",
|
|
500
|
+
f"今开 {opn}", f"昨收 {prev}",
|
|
501
|
+
f"最高 {fields[33]}", f"最低 {fields[34]}",
|
|
502
|
+
f"成交量 {fields[36]}手" if len(fields) > 36 and fields[36] else "",
|
|
503
|
+
f"成交额 {float(fields[37]) / 1e4:.2f}亿" if len(fields) > 37 and fields[37] else "",
|
|
504
|
+
f"换手率 {fields[38]}%" if len(fields) > 38 and fields[38] else "",
|
|
505
|
+
f"市盈率(动) {fields[39]}" if len(fields) > 39 and fields[39] else "",
|
|
506
|
+
]
|
|
507
|
+
return [{
|
|
508
|
+
"title": title[:80],
|
|
509
|
+
"url": f"https://gu.qq.com/{symbol}/gp",
|
|
510
|
+
"snippet": " | ".join(p for p in parts if p)[:220],
|
|
511
|
+
"source": "tencent_quote",
|
|
512
|
+
"score": 0.9,
|
|
513
|
+
}]
|
|
514
|
+
|
|
515
|
+
def _resolve_symbol(q: str, to: float, headers: dict) -> str:
|
|
516
|
+
import urllib.parse as up
|
|
517
|
+
_STOP = ("股价", "行情", "股票", "价格", "走势", "最新", "今日", "报价", "查询",
|
|
518
|
+
"怎么样", "多少", "怎么", "了", "吗", "的", "a股", "港股", "美股")
|
|
519
|
+
cands = [q]
|
|
520
|
+
for token in re.split(r"[\s,,、/]+", q):
|
|
521
|
+
t = token.strip()
|
|
522
|
+
if not t:
|
|
523
|
+
continue
|
|
524
|
+
for stop in _STOP:
|
|
525
|
+
t = t.replace(stop, "")
|
|
526
|
+
t = t.strip()
|
|
527
|
+
if 2 <= len(t) <= 8 and t not in cands:
|
|
528
|
+
cands.append(t)
|
|
529
|
+
for c in cands:
|
|
530
|
+
try:
|
|
531
|
+
req = urllib.request.Request(suggest_url + up.quote(c), headers=headers)
|
|
532
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
533
|
+
text = resp.read().decode("gbk", "replace")
|
|
534
|
+
except Exception as e:
|
|
535
|
+
logger.warning(f"腾讯代码解析失败: {e}")
|
|
536
|
+
continue
|
|
537
|
+
# v_hint="sh~600519~贵州茅台~600519~gp~A股~贵州茅台~GP-A"
|
|
538
|
+
for m in re.finditer(r'v_hint="([^"]+)"', text):
|
|
539
|
+
parts = m.group(1).split("~")
|
|
540
|
+
if len(parts) >= 3 and parts[2]:
|
|
541
|
+
return parts[0] + parts[1]
|
|
542
|
+
return ""
|
|
543
|
+
return _engine
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ── 东财资金流引擎(个股主力资金流/北向资金/板块资金流) ─────────────────────
|
|
547
|
+
|
|
548
|
+
def _build_em_flow_engine(spec: dict[str, Any]) -> Any:
|
|
549
|
+
"""东方财富资金流向(push2delay.eastmoney.com,免认证直连)。
|
|
550
|
+
|
|
551
|
+
三类数据:个股主力资金流(fflow/kline)、北向资金(kamt/get)、
|
|
552
|
+
板块资金流排行(clist/get)。「资金流/主力/北向」类查询的答案源。
|
|
553
|
+
"""
|
|
554
|
+
timeout = spec.get("timeout", 8)
|
|
555
|
+
smartbox_url = "https://smartbox.gtimg.cn/s3/?v=2&t=all&q="
|
|
556
|
+
fflow_url = "https://push2delay.eastmoney.com/api/qt/stock/fflow/kline/get"
|
|
557
|
+
kamt_url = "https://push2delay.eastmoney.com/api/qt/kamt/get"
|
|
558
|
+
clist_url = "https://push2delay.eastmoney.com/api/qt/clist/get"
|
|
559
|
+
|
|
560
|
+
@safe_search
|
|
561
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
562
|
+
import urllib.parse as up
|
|
563
|
+
to = _timeout or timeout
|
|
564
|
+
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
565
|
+
"Referer": "https://data.eastmoney.com/"}
|
|
566
|
+
q = query.lower()
|
|
567
|
+
if "北向" in q or "沪深港通" in q or ("外资" in q and "流入" in q):
|
|
568
|
+
res = _northbound(to, headers)
|
|
569
|
+
if res:
|
|
570
|
+
return res
|
|
571
|
+
if "板块" in q and ("资金" in q or "流入" in q or "净额" in q):
|
|
572
|
+
res = _sector_flow(to, headers, n)
|
|
573
|
+
if res:
|
|
574
|
+
return res
|
|
575
|
+
symbol = _resolve_symbol(query, to, headers)
|
|
576
|
+
if symbol:
|
|
577
|
+
res = _stock_flow(symbol, to, headers)
|
|
578
|
+
if res:
|
|
579
|
+
return res
|
|
580
|
+
return _sector_flow(to, headers, n)
|
|
581
|
+
|
|
582
|
+
def _northbound(to: float, headers: dict) -> list[dict[str, Any]]:
|
|
583
|
+
data = None
|
|
584
|
+
for _a in range(2):
|
|
585
|
+
try:
|
|
586
|
+
req = urllib.request.Request(
|
|
587
|
+
kamt_url + "?fields1=f1,f3&fields2=f51,f52,f53,f54,f55,f56",
|
|
588
|
+
headers=headers)
|
|
589
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
590
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
591
|
+
break
|
|
592
|
+
except Exception as e:
|
|
593
|
+
logger.warning(f"北向资金失败(重试): {e}")
|
|
594
|
+
time.sleep(0.3)
|
|
595
|
+
if data is None:
|
|
596
|
+
return []
|
|
597
|
+
try:
|
|
598
|
+
hk2sh = data["data"]["hk2sh"]
|
|
599
|
+
hk2sz = data["data"]["hk2sz"]
|
|
600
|
+
except (KeyError, TypeError):
|
|
601
|
+
return []
|
|
602
|
+
rows = []
|
|
603
|
+
for name, leg in (("沪股通", hk2sh), ("深股通", hk2sz)):
|
|
604
|
+
amt = leg.get("dayNetAmtIn")
|
|
605
|
+
date = leg.get("date2") or leg.get("date") or ""
|
|
606
|
+
rows.append((name, amt, date))
|
|
607
|
+
results = []
|
|
608
|
+
total = 0.0
|
|
609
|
+
for _rk1, name, amt, date in enumerate(rows):
|
|
610
|
+
try:
|
|
611
|
+
f = float(amt) / 1e8
|
|
612
|
+
except (TypeError, ValueError):
|
|
613
|
+
continue
|
|
614
|
+
total += f
|
|
615
|
+
results.append({
|
|
616
|
+
"title": f"北向资金-{name} 当日净流入 {f:+.2f} 亿元",
|
|
617
|
+
"url": "https://data.eastmoney.com/hsgt/index.html",
|
|
618
|
+
"snippet": f"日期 {date} | 沪深港通北向资金 | 东方财富数据中心".strip(),
|
|
619
|
+
"source": "em_flow",
|
|
620
|
+
"score": rank_score(0.9, _rk1),
|
|
621
|
+
})
|
|
622
|
+
if total:
|
|
623
|
+
results.append({
|
|
624
|
+
"title": f"北向资金合计 当日净流入 {total:+.2f} 亿元",
|
|
625
|
+
"url": "https://data.eastmoney.com/hsgt/index.html",
|
|
626
|
+
"snippet": "沪股通 + 深股通合计 | 东方财富数据中心",
|
|
627
|
+
"source": "em_flow",
|
|
628
|
+
"score": 0.85,
|
|
629
|
+
})
|
|
630
|
+
return results
|
|
631
|
+
|
|
632
|
+
def _sector_flow(to: float, headers: dict, n: int) -> list[dict[str, Any]]:
|
|
633
|
+
url = (clist_url + "?pn=1&pz=%d&po=1&np=1&fltt=2&invt=2&fid=f62"
|
|
634
|
+
"&fs=m:90+t:2&fields=f12,f14,f2,f3,f62,f184" % min(max(n, 3), 10))
|
|
635
|
+
data = None
|
|
636
|
+
for _a in range(2):
|
|
637
|
+
try:
|
|
638
|
+
req = urllib.request.Request(url, headers=headers)
|
|
639
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
640
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
641
|
+
break
|
|
642
|
+
except Exception as e:
|
|
643
|
+
logger.warning(f"板块资金流失败(重试): {e}")
|
|
644
|
+
time.sleep(0.3)
|
|
645
|
+
if data is None:
|
|
646
|
+
return []
|
|
647
|
+
try:
|
|
648
|
+
diff = data["data"]["diff"] or []
|
|
649
|
+
except (KeyError, TypeError):
|
|
650
|
+
return []
|
|
651
|
+
results = []
|
|
652
|
+
for _rk2, it in enumerate(diff):
|
|
653
|
+
name = it.get("f14", "")
|
|
654
|
+
chg = it.get("f3")
|
|
655
|
+
flow = it.get("f62")
|
|
656
|
+
pct = it.get("f184")
|
|
657
|
+
if not name:
|
|
658
|
+
continue
|
|
659
|
+
try:
|
|
660
|
+
flow_yi = float(flow) / 1e8
|
|
661
|
+
except (TypeError, ValueError):
|
|
662
|
+
flow_yi = 0.0
|
|
663
|
+
try:
|
|
664
|
+
chg_s = f"{float(chg):+.2f}%" if chg is not None else ""
|
|
665
|
+
except (TypeError, ValueError):
|
|
666
|
+
chg_s = ""
|
|
667
|
+
try:
|
|
668
|
+
pct_s = f"主力净占比 {float(pct):.2f}%"
|
|
669
|
+
except (TypeError, ValueError):
|
|
670
|
+
pct_s = ""
|
|
671
|
+
results.append({
|
|
672
|
+
"title": f"板块 {name} 主力净流入 {flow_yi:+.2f} 亿元",
|
|
673
|
+
"url": "https://data.eastmoney.com/bkzj/hy.html",
|
|
674
|
+
"snippet": " | ".join(x for x in (chg_s, pct_s, "东方财富板块资金流") if x)[:200],
|
|
675
|
+
"source": "em_flow",
|
|
676
|
+
"score": rank_score(0.9, _rk2),
|
|
677
|
+
})
|
|
678
|
+
return results
|
|
679
|
+
|
|
680
|
+
def _stock_flow(symbol: str, to: float, headers: dict) -> list[dict[str, Any]]:
|
|
681
|
+
# symbol 形如 sh600519 / sz000858,secid 沪市=1.xxx 深市=0.xxx
|
|
682
|
+
market = "1" if symbol.startswith("sh") else "0"
|
|
683
|
+
code = symbol[2:]
|
|
684
|
+
url = (fflow_url + "?lmt=0&klt=101&fields1=f1,f2,f3,f7"
|
|
685
|
+
"&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63"
|
|
686
|
+
"&secid=%s.%s" % (market, code))
|
|
687
|
+
data = None
|
|
688
|
+
for _a in range(2):
|
|
689
|
+
try:
|
|
690
|
+
req = urllib.request.Request(url, headers=headers)
|
|
691
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
692
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
693
|
+
break
|
|
694
|
+
except Exception as e:
|
|
695
|
+
logger.warning(f"个股资金流失败(重试): {e}")
|
|
696
|
+
time.sleep(0.3)
|
|
697
|
+
if data is None:
|
|
698
|
+
return []
|
|
699
|
+
try:
|
|
700
|
+
klines = data["data"]["klines"] or []
|
|
701
|
+
name = data["data"]["name"] or code
|
|
702
|
+
except (KeyError, TypeError):
|
|
703
|
+
return []
|
|
704
|
+
if not klines:
|
|
705
|
+
return []
|
|
706
|
+
last = klines[-1].split(",")
|
|
707
|
+
if len(last) < 6:
|
|
708
|
+
return []
|
|
709
|
+
# push2delay 返回 6 字段: 0日期 1主力 2小单 3中单 4大单 5超大单
|
|
710
|
+
# push2 完整 13 字段: 6-10 净占比 11收盘 12涨跌幅(延迟源仅 6 字段)
|
|
711
|
+
date, main, small, mid, big, xbig = last[0], last[1], last[2], last[3], last[4], last[5]
|
|
712
|
+
try:
|
|
713
|
+
main_yi = float(main) / 1e8
|
|
714
|
+
except (TypeError, ValueError):
|
|
715
|
+
main_yi = 0.0
|
|
716
|
+
main_ratio, chg = 0.0, 0.0
|
|
717
|
+
if len(last) >= 13:
|
|
718
|
+
try:
|
|
719
|
+
main_ratio = float(last[6]) if last[6] else 0.0
|
|
720
|
+
chg = float(last[12]) if last[12] else 0.0
|
|
721
|
+
except (TypeError, ValueError):
|
|
722
|
+
pass
|
|
723
|
+
try:
|
|
724
|
+
detail = (f"超大单 {float(xbig) / 1e8:+.2f}亿 大单 {float(big) / 1e8:+.2f}亿"
|
|
725
|
+
f" 中单 {float(mid) / 1e8:+.2f}亿 小单 {float(small) / 1e8:+.2f}亿")
|
|
726
|
+
except (TypeError, ValueError):
|
|
727
|
+
detail = ""
|
|
728
|
+
extra = " | ".join(x for x in (
|
|
729
|
+
f"主力净占比 {main_ratio:+.2f}%" if main_ratio else "",
|
|
730
|
+
f"涨跌幅 {chg:+.2f}%" if chg else "",
|
|
731
|
+
) if x)
|
|
732
|
+
snippet = " | ".join(x for x in (f"日期 {date}", extra, detail, "东方财富资金流向") if x)
|
|
733
|
+
return [{
|
|
734
|
+
"title": f"{name} 主力资金净流入 {main_yi:+.2f} 亿元",
|
|
735
|
+
"url": f"https://data.eastmoney.com/zjlx/{code}.html",
|
|
736
|
+
"snippet": snippet[:220],
|
|
737
|
+
"source": "em_flow",
|
|
738
|
+
"score": 0.9,
|
|
739
|
+
}]
|
|
740
|
+
|
|
741
|
+
def _resolve_symbol(q: str, to: float, headers: dict) -> str:
|
|
742
|
+
import urllib.parse as up
|
|
743
|
+
_STOP = ("股价", "行情", "股票", "价格", "走势", "最新", "今日", "资金流", "资金",
|
|
744
|
+
"主力", "净流入", "净流出", "查询", "怎么样", "多少", "怎么", "了", "吗",
|
|
745
|
+
"的", "a股", "港股", "美股")
|
|
746
|
+
cands = [q]
|
|
747
|
+
for token in re.split(r"[\s,,、/]+", q):
|
|
748
|
+
t = token.strip()
|
|
749
|
+
if not t:
|
|
750
|
+
continue
|
|
751
|
+
for stop in _STOP:
|
|
752
|
+
t = t.replace(stop, "")
|
|
753
|
+
t = t.strip()
|
|
754
|
+
if 2 <= len(t) <= 8 and t not in cands:
|
|
755
|
+
cands.append(t)
|
|
756
|
+
for c in cands:
|
|
757
|
+
try:
|
|
758
|
+
req = urllib.request.Request(smartbox_url + up.quote(c), headers=headers)
|
|
759
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
760
|
+
text = resp.read().decode("gbk", "replace")
|
|
761
|
+
except Exception as e:
|
|
762
|
+
logger.warning(f"东财代码解析失败: {e}")
|
|
763
|
+
continue
|
|
764
|
+
for m in re.finditer(r'v_hint="([^"]+)"', text):
|
|
765
|
+
parts = m.group(1).split("~")
|
|
766
|
+
if len(parts) >= 3 and parts[2]:
|
|
767
|
+
return parts[0] + parts[1]
|
|
768
|
+
return ""
|
|
769
|
+
return _engine
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
# ── 东财财经搜索引擎 ─────────────────────────────────────────────────────────
|
|
773
|
+
|
|
774
|
+
def _build_eastmoney_engine(spec: dict[str, Any]) -> Any:
|
|
775
|
+
"""东财经搜搜索(纯 HTTP API,零外部依赖)
|
|
776
|
+
|
|
777
|
+
160→ 支持:
|
|
778
|
+
- 个股新闻搜索(按股票代码或关键词)
|
|
779
|
+
- 东财全球资讯(7×24 财经快讯)
|
|
780
|
+
"""
|
|
781
|
+
timeout = spec.get("timeout", 12)
|
|
782
|
+
|
|
783
|
+
@safe_search
|
|
784
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
785
|
+
import urllib.parse as up
|
|
786
|
+
import re
|
|
787
|
+
to = _timeout or timeout
|
|
788
|
+
|
|
789
|
+
# 判断是股票代码(6位数字)还是关键词
|
|
790
|
+
is_stock_code = re.match(r'^\d{6}$', query.strip())
|
|
791
|
+
|
|
792
|
+
if is_stock_code:
|
|
793
|
+
# 按股票代码搜新闻
|
|
794
|
+
return _eastmoney_stock_news(query.strip(), n, to)
|
|
795
|
+
else:
|
|
796
|
+
# 按关键词搜全球资讯
|
|
797
|
+
return _eastmoney_keyword_news(query, n, to)
|
|
798
|
+
|
|
799
|
+
def _eastmoney_stock_news(code: str, n: int, to: float) -> list[dict[str, Any]]:
|
|
800
|
+
"""按股票代码搜新闻"""
|
|
801
|
+
import json as _json
|
|
802
|
+
import urllib.parse as up
|
|
803
|
+
cb = "jQuery_news"
|
|
804
|
+
url = "https://search-api-web.eastmoney.com/search/jsonp"
|
|
805
|
+
inner_params = _json.dumps({
|
|
806
|
+
"uid": "", "keyword": code, "type": ["cmsArticleWebOld"],
|
|
807
|
+
"client": "web", "clientType": "web", "clientVersion": "curr",
|
|
808
|
+
"param": {"cmsArticleWebOld": {"searchScope": "default", "sort": "default",
|
|
809
|
+
"pageIndex": 1, "pageSize": n, "preTag": "", "postTag": ""}},
|
|
810
|
+
}, separators=(',', ':'))
|
|
811
|
+
params = {"cb": cb, "param": inner_params}
|
|
812
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://so.eastmoney.com/"}
|
|
813
|
+
try:
|
|
814
|
+
full_url = url + "?" + "&".join(f"{k}={up.quote(str(v))}" for k, v in params.items())
|
|
815
|
+
req = urllib.request.Request(full_url, headers=headers)
|
|
816
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
817
|
+
text = resp.read().decode("utf-8")
|
|
818
|
+
json_str = text[text.index("(") + 1:text.rindex(")")]
|
|
819
|
+
d = _json.loads(json_str)
|
|
820
|
+
articles = d.get("result", {}).get("cmsArticleWebOld", []) or []
|
|
821
|
+
results = []
|
|
822
|
+
for a in articles[:n]:
|
|
823
|
+
results.append({
|
|
824
|
+
"title": re.sub(r'<[^>]+>', '', a.get("title", ""))[:80],
|
|
825
|
+
"url": a.get("url", ""),
|
|
826
|
+
"snippet": re.sub(r'<[^>]+>', '', a.get("content", ""))[:200],
|
|
827
|
+
"source": "eastmoney",
|
|
828
|
+
})
|
|
829
|
+
return results
|
|
830
|
+
except Exception as e:
|
|
831
|
+
logger.warning(f"东财个股新闻搜索失败: {e}")
|
|
832
|
+
return []
|
|
833
|
+
|
|
834
|
+
def _eastmoney_keyword_news(query: str, n: int, to: float) -> list[dict[str, Any]]:
|
|
835
|
+
"""按关键词搜东财资讯(search-api-web 检索接口,按词真正检索)"""
|
|
836
|
+
import urllib.parse as up
|
|
837
|
+
cb = "jQuery_news"
|
|
838
|
+
url = "https://search-api-web.eastmoney.com/search/jsonp"
|
|
839
|
+
inner_params = json.dumps({
|
|
840
|
+
"uid": "", "keyword": query, "type": ["cmsArticleWebOld"],
|
|
841
|
+
"client": "web", "clientType": "web", "clientVersion": "curr",
|
|
842
|
+
"param": {"cmsArticleWebOld": {"searchScope": "default", "sort": "default",
|
|
843
|
+
"pageIndex": 1, "pageSize": n, "preTag": "", "postTag": ""}},
|
|
844
|
+
}, separators=(',', ':'))
|
|
845
|
+
params = {"cb": cb, "param": inner_params}
|
|
846
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://so.eastmoney.com/"}
|
|
847
|
+
try:
|
|
848
|
+
full_url = url + "?" + "&".join(f"{k}={up.quote(str(v))}" for k, v in params.items())
|
|
849
|
+
req = urllib.request.Request(full_url, headers=headers)
|
|
850
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
851
|
+
text = resp.read().decode("utf-8")
|
|
852
|
+
json_str = text[text.index("(") + 1:text.rindex(")")]
|
|
853
|
+
d = json.loads(json_str)
|
|
854
|
+
articles = d.get("result", {}).get("cmsArticleWebOld", []) or []
|
|
855
|
+
results = []
|
|
856
|
+
for a in articles[:n]:
|
|
857
|
+
results.append({
|
|
858
|
+
"title": re.sub(r'<[^>]+>', '', a.get("title", ""))[:80],
|
|
859
|
+
"url": a.get("url", "") or "https://so.eastmoney.com/",
|
|
860
|
+
"snippet": re.sub(r'<[^>]+>', '', a.get("content", ""))[:200],
|
|
861
|
+
"source": "eastmoney",
|
|
862
|
+
})
|
|
863
|
+
return results
|
|
864
|
+
except Exception as e:
|
|
865
|
+
logger.warning(f"东财关键词搜索失败: {e}")
|
|
866
|
+
return []
|
|
867
|
+
|
|
868
|
+
return _engine
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
# ── itotii 梗百科引擎 ────────────────────────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
def _build_itotii_engine(spec: dict[str, Any]) -> Any:
|
|
874
|
+
"""itotii 梗百科(中文流行语/网络梗词条,WordPress REST API,免认证)"""
|
|
875
|
+
timeout = spec.get("timeout", 10)
|
|
876
|
+
|
|
877
|
+
@safe_search
|
|
878
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
879
|
+
import urllib.parse as up
|
|
880
|
+
to = _timeout or timeout
|
|
881
|
+
url = (
|
|
882
|
+
"https://geng.itotii.com/wp-json/wp/v2/posts?search="
|
|
883
|
+
+ up.quote(query) + f"&per_page={min(n, 10)}"
|
|
884
|
+
)
|
|
885
|
+
headers = {"User-Agent": "Mozilla/5.0"}
|
|
886
|
+
req = urllib.request.Request(url, headers=headers)
|
|
887
|
+
try:
|
|
888
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
889
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
890
|
+
results = []
|
|
891
|
+
for p in data:
|
|
892
|
+
title = p.get("title", {})
|
|
893
|
+
title = title.get("rendered", "") if isinstance(title, dict) else str(title)
|
|
894
|
+
content = p.get("content", {})
|
|
895
|
+
content = content.get("rendered", "") if isinstance(content, dict) else str(content)
|
|
896
|
+
title = re.sub(r"<[^>]+>", "", title).strip()
|
|
897
|
+
content = re.sub(r"<[^>]+>", "", content).strip()
|
|
898
|
+
if not title:
|
|
899
|
+
continue
|
|
900
|
+
results.append({
|
|
901
|
+
"title": title[:80],
|
|
902
|
+
"url": p.get("link", ""),
|
|
903
|
+
"snippet": f"{p.get('date', '')[:10]} | {content[:200]}",
|
|
904
|
+
"source": "itotii",
|
|
905
|
+
"score": max(1.0 - len(results) * 0.1, 0.1),
|
|
906
|
+
})
|
|
907
|
+
return results[:n]
|
|
908
|
+
except Exception as e:
|
|
909
|
+
logger.warning(f"itotii 梗百科失败: {e}")
|
|
910
|
+
return []
|
|
911
|
+
return _engine
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
# ── 百度热搜引擎 ─────────────────────────────────────────────────────────────
|
|
915
|
+
|
|
916
|
+
def _build_baidu_hot_engine(spec: dict[str, Any]) -> Any:
|
|
917
|
+
"""百度热搜(top.baidu.com 实时热搜榜,HTML 解析)"""
|
|
918
|
+
timeout = spec.get("timeout", 10)
|
|
919
|
+
|
|
920
|
+
@safe_search
|
|
921
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
922
|
+
import urllib.parse as up
|
|
923
|
+
to = _timeout or timeout
|
|
924
|
+
url = "https://top.baidu.com/board?tab=realtime"
|
|
925
|
+
headers = {"User-Agent": "Mozilla/5.0"}
|
|
926
|
+
req = urllib.request.Request(url, headers=headers)
|
|
927
|
+
try:
|
|
928
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
929
|
+
page = resp.read().decode("utf-8", "replace")
|
|
930
|
+
words = re.findall(r'word":"([^"]+)"', page)
|
|
931
|
+
results, seen = [], set()
|
|
932
|
+
for w in words:
|
|
933
|
+
if w in seen:
|
|
934
|
+
continue
|
|
935
|
+
seen.add(w)
|
|
936
|
+
results.append({
|
|
937
|
+
"title": w[:60],
|
|
938
|
+
"url": "https://www.baidu.com/s?wd=" + up.quote(w),
|
|
939
|
+
"snippet": "百度热搜",
|
|
940
|
+
"source": "baidu_hot",
|
|
941
|
+
"score": max(1.0 - len(results) * 0.05, 0.1),
|
|
942
|
+
})
|
|
943
|
+
if len(results) >= n:
|
|
944
|
+
break
|
|
945
|
+
return results
|
|
946
|
+
except Exception as e:
|
|
947
|
+
logger.warning(f"百度热搜失败: {e}")
|
|
948
|
+
return []
|
|
949
|
+
return _engine
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
# ── 今日头条热榜引擎 ─────────────────────────────────────────────────────────
|
|
953
|
+
|
|
954
|
+
def _build_toutiao_hot_engine(spec: dict[str, Any]) -> Any:
|
|
955
|
+
"""今日头条热榜(hot-board JSON,免认证)"""
|
|
956
|
+
timeout = spec.get("timeout", 10)
|
|
957
|
+
|
|
958
|
+
@safe_search
|
|
959
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
960
|
+
to = _timeout or timeout
|
|
961
|
+
url = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc"
|
|
962
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.toutiao.com/"}
|
|
963
|
+
req = urllib.request.Request(url, headers=headers)
|
|
964
|
+
try:
|
|
965
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
966
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
967
|
+
results = []
|
|
968
|
+
for i, item in enumerate(data.get("data", [])[:n]):
|
|
969
|
+
hot = item.get("HotValue", "")
|
|
970
|
+
results.append({
|
|
971
|
+
"title": item.get("Title", "")[:60],
|
|
972
|
+
"url": item.get("Url", ""),
|
|
973
|
+
"snippet": f"热度 {hot}" if hot else "今日头条热榜",
|
|
974
|
+
"source": "toutiao_hot",
|
|
975
|
+
"score": max(1.0 - i * 0.05, 0.1),
|
|
976
|
+
})
|
|
977
|
+
return results
|
|
978
|
+
except Exception as e:
|
|
979
|
+
logger.warning(f"今日头条热榜失败: {e}")
|
|
980
|
+
return []
|
|
981
|
+
return _engine
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
# ── B站热搜引擎 ──────────────────────────────────────────────────────────────
|
|
985
|
+
|
|
986
|
+
def _build_bilibili_hot_engine(spec: dict[str, Any]) -> Any:
|
|
987
|
+
"""B站热搜(search/square 热搜词,免认证)"""
|
|
988
|
+
timeout = spec.get("timeout", 10)
|
|
989
|
+
|
|
990
|
+
@safe_search
|
|
991
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
992
|
+
import urllib.parse as up
|
|
993
|
+
to = _timeout or timeout
|
|
994
|
+
url = "https://api.bilibili.com/x/web-interface/search/square?limit=20"
|
|
995
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.bilibili.com"}
|
|
996
|
+
req = urllib.request.Request(url, headers=headers)
|
|
997
|
+
try:
|
|
998
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
999
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
1000
|
+
items = data.get("data", {}).get("trending", {}).get("list", [])
|
|
1001
|
+
results = []
|
|
1002
|
+
for i, item in enumerate(items[:n]):
|
|
1003
|
+
kw = item.get("keyword", "")
|
|
1004
|
+
if not kw:
|
|
1005
|
+
continue
|
|
1006
|
+
results.append({
|
|
1007
|
+
"title": kw[:60],
|
|
1008
|
+
"url": "https://search.bilibili.com/all?keyword=" + up.quote(kw),
|
|
1009
|
+
"snippet": "B站热搜",
|
|
1010
|
+
"source": "bilibili_hot",
|
|
1011
|
+
"score": max(1.0 - i * 0.05, 0.1),
|
|
1012
|
+
})
|
|
1013
|
+
return results
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
logger.warning(f"B站热搜失败: {e}")
|
|
1016
|
+
return []
|
|
1017
|
+
return _engine
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
# ── 知乎全网搜索(global_search,需 ZHIHU_ACCESS_SECRET)─────────────────────
|
|
1021
|
+
|
|
1022
|
+
# site:/host: 站点限定语法 → Filter host=="..." 表达式。
|
|
1023
|
+
# 取值吸收到首个空白或中文字符(值只能是域名,纯中文值视为无效 host)。
|
|
1024
|
+
_SITE_FILTER_RE = re.compile(r"(?:site|host)\s*[::]\s*([^\s\u4e00-\u9fff]+)")
|
|
1025
|
+
# 残留的 site:/host: 词法片段(无有效 host 值也剥离,避免把语法词当搜索词)
|
|
1026
|
+
_SITE_TOKEN_RE = re.compile(r"(?:site|host)\s*[::]")
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _parse_site_filter(query: str) -> tuple[str, str]:
|
|
1030
|
+
"""解析 site:/host: 站点限定语法 → (Filter 表达式, 剔除后的查询词)。
|
|
1031
|
+
|
|
1032
|
+
只取第一处匹配;host 兼容裸域名 / 完整 URL / 带端口三种写法,
|
|
1033
|
+
统一剥离 scheme、路径、端口为裸域名(global_search 的 Filter host==
|
|
1034
|
+
要求裸域名,`site:https://blog.csdn.net/x` 若整串传入会取到 https 当 host)。
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
(filter_expr, cleaned_query);无有效 host 时 filter_expr 为空串,
|
|
1038
|
+
但残留的 site:/host: 词法片段仍会被剥离。
|
|
1039
|
+
"""
|
|
1040
|
+
m = _SITE_FILTER_RE.search(query)
|
|
1041
|
+
if not m:
|
|
1042
|
+
t = _SITE_TOKEN_RE.search(query)
|
|
1043
|
+
if not t:
|
|
1044
|
+
return "", query.strip()
|
|
1045
|
+
cleaned = (query[: t.start()] + query[t.end():]).strip()
|
|
1046
|
+
return "", re.sub(r"\s{2,}", " ", cleaned)
|
|
1047
|
+
raw = m.group(1).strip().rstrip(".,;:,。;")
|
|
1048
|
+
host = re.sub(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", "", raw) # 去 scheme
|
|
1049
|
+
host = host.split("/", 1)[0].split(":", 1)[0].strip().lower() # 去路径与端口
|
|
1050
|
+
cleaned = (query[: m.start()] + query[m.end():]).strip()
|
|
1051
|
+
cleaned = re.sub(r"\s{2,}", " ", cleaned)
|
|
1052
|
+
return (f'host=="{host}"' if host else ""), cleaned
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _build_zhihu_global_engine(spec: dict[str, Any]) -> Any:
|
|
1056
|
+
"""知乎开放平台全网搜索(developer.zhihu.com global_search)。
|
|
1057
|
+
|
|
1058
|
+
第一性:zhihu_global 是「真全网搜索」——默认 SearchDB=all 搜全网索引,
|
|
1059
|
+
且支持 Filter host== 精确限定站点(byted/bocha 做不到的精确能力)。
|
|
1060
|
+
响应含 AuthorityLevel(1-4 权威等级)、VoteUpCount、EditTime 等结构化信号,
|
|
1061
|
+
全部提取进统一 schema,供 evidence 消费。
|
|
1062
|
+
|
|
1063
|
+
查询语法:
|
|
1064
|
+
- `site:zhuanlan.zhihu.com 关键词` → Filter: host=="zhuanlan.zhihu.com"
|
|
1065
|
+
- `host:blog.csdn.net 关键词` → 同上(兼容写法)
|
|
1066
|
+
- 普通查询 → SearchDB=all 全网
|
|
1067
|
+
"""
|
|
1068
|
+
timeout = spec.get("timeout", 10)
|
|
1069
|
+
|
|
1070
|
+
@safe_search
|
|
1071
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1072
|
+
import urllib.parse as up
|
|
1073
|
+
to = _timeout or timeout
|
|
1074
|
+
secret = get_env(["ARGO_ZHIHU_ACCESS_SECRET", "ZHIHU_ACCESS_SECRET"])
|
|
1075
|
+
if not secret:
|
|
1076
|
+
return []
|
|
1077
|
+
|
|
1078
|
+
# 解析 site:/host: 站点限定语法 → Filter: host=="..."
|
|
1079
|
+
filter_expr, search_query = _parse_site_filter(query)
|
|
1080
|
+
|
|
1081
|
+
params: dict[str, Any] = {
|
|
1082
|
+
"Query": search_query or query,
|
|
1083
|
+
"Count": str(min(n, 20)),
|
|
1084
|
+
"SearchDB": "all",
|
|
1085
|
+
}
|
|
1086
|
+
if filter_expr:
|
|
1087
|
+
params["Filter"] = filter_expr
|
|
1088
|
+
url = "https://developer.zhihu.com/api/v1/content/global_search?" + up.urlencode(params)
|
|
1089
|
+
headers = {
|
|
1090
|
+
"Authorization": f"Bearer {secret}",
|
|
1091
|
+
"X-Request-Timestamp": str(int(time.time())),
|
|
1092
|
+
"Content-Type": "application/json",
|
|
1093
|
+
"User-Agent": "argo-search/2.6 (unified-search@local)",
|
|
1094
|
+
}
|
|
1095
|
+
try:
|
|
1096
|
+
with http_open(urllib.request.Request(url, headers=headers), timeout=to, engine=spec.get("_name", "")) as resp:
|
|
1097
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
1098
|
+
except urllib.error.HTTPError as e:
|
|
1099
|
+
# 401/403 等必须暴露为 error item 而非静默空——调用侧把
|
|
1100
|
+
# 「没配置」「鉴权失败」「没结果」区分开才可行动(保持一致
|
|
1101
|
+
# social_engines/zhihu_engine.py 同接口的计算方式)
|
|
1102
|
+
return [{"error": f"zhihu_global API HTTP {e.code}", "source": "zhihu_global"}]
|
|
1103
|
+
except Exception as e:
|
|
1104
|
+
logger.warning(f"zhihu_global 失败: {e}")
|
|
1105
|
+
return [{"error": f"zhihu_global {type(e).__name__}: {e}", "source": "zhihu_global"}]
|
|
1106
|
+
if data.get("Code") not in (0, None):
|
|
1107
|
+
# 30001=频率限制 30002=配额限制,显式暴露供配额状态机归类
|
|
1108
|
+
logger.warning(f"zhihu_global 返回码异常: {data.get('Code')} {data.get('Message')}")
|
|
1109
|
+
return [{"error": f"zhihu_global Code={data.get('Code')} "
|
|
1110
|
+
f"{str(data.get('Message') or '')[:100]}",
|
|
1111
|
+
"source": "zhihu_global"}]
|
|
1112
|
+
items = (data.get("Data") or {}).get("Items") or []
|
|
1113
|
+
results = []
|
|
1114
|
+
for _rk3, item in enumerate(items[:n]):
|
|
1115
|
+
title = (item.get("Title") or "").strip()
|
|
1116
|
+
# API 标题统一带「 - 知乎」尾巴:截断前剥掉(先剥再切,尾巴不占正文)
|
|
1117
|
+
if title.endswith(" - 知乎"):
|
|
1118
|
+
title = title[: -len(" - 知乎")].rstrip()
|
|
1119
|
+
url_ = item.get("Url") or ""
|
|
1120
|
+
snippet = item.get("ContentText") or ""
|
|
1121
|
+
# 去 <em> 高亮标签
|
|
1122
|
+
snippet = re.sub(r"<[^>]+>", "", snippet).strip()
|
|
1123
|
+
if not title and not url_:
|
|
1124
|
+
continue
|
|
1125
|
+
# 结构化信号:权威等级 / 互动 / 时效
|
|
1126
|
+
social_meta = {
|
|
1127
|
+
"author": item.get("AuthorName") or "",
|
|
1128
|
+
"content_type": item.get("ContentType") or "",
|
|
1129
|
+
"vote_up": item.get("VoteUpCount") or 0,
|
|
1130
|
+
"comment_count": item.get("CommentCount") or 0,
|
|
1131
|
+
"authority_level": item.get("AuthorityLevel") or "",
|
|
1132
|
+
"edit_time": item.get("EditTime") or 0,
|
|
1133
|
+
}
|
|
1134
|
+
results.append({
|
|
1135
|
+
"title": title[:200],
|
|
1136
|
+
"url": url_,
|
|
1137
|
+
"snippet": snippet[:300],
|
|
1138
|
+
"source": "zhihu_global",
|
|
1139
|
+
"score": rank_score(0.7, _rk3),
|
|
1140
|
+
"authority_level": social_meta["authority_level"],
|
|
1141
|
+
"social_meta": social_meta,
|
|
1142
|
+
})
|
|
1143
|
+
return results
|
|
1144
|
+
return _engine
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
# ── 博查 Web Search 引擎(专用解析 + 动态时效)──────────────────────────────────
|
|
1148
|
+
|
|
1149
|
+
_BOCHA_FRESH_RE = re.compile(
|
|
1150
|
+
r"(本周|本月|最近一周|近一周|近一个月|recent|past\s*(week|month)|last\s*(week|month))",
|
|
1151
|
+
re.I,
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _bocha_freshness(query: str) -> str:
|
|
1156
|
+
"""按查询时效敏感度动态选择 freshness 参数。
|
|
1157
|
+
|
|
1158
|
+
周/月级窗口词(本周/本月/近一周等)→ oneWeek;
|
|
1159
|
+
日/时级时效词(今日/实时/最新/盘中/快讯等,复用缓存层的敏感检测)→ oneDay;
|
|
1160
|
+
其余放宽为 noLimit(全量)。
|
|
1161
|
+
"""
|
|
1162
|
+
if re.search(r"(本周|本月|近一周|近一个月|recent|past\s*(week|month)|last\s*(week|month)|this\s*week)", query or "", re.I):
|
|
1163
|
+
return "oneWeek"
|
|
1164
|
+
try:
|
|
1165
|
+
from cache import is_freshness_sensitive_query
|
|
1166
|
+
if is_freshness_sensitive_query(query or ""):
|
|
1167
|
+
return "oneDay"
|
|
1168
|
+
except Exception:
|
|
1169
|
+
pass
|
|
1170
|
+
return "noLimit"
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
# ── zhihu_user:知乎个人数据(本人创作/收藏/关注)─────────────────────────
|
|
1174
|
+
# 站内搜索/全网搜覆盖「找内容」;本引擎覆盖「看自己」——个人创作运营
|
|
1175
|
+
# (哪些回答点赞高、最近发了什么)与素材回溯(收藏夹)。查本人数据用
|
|
1176
|
+
# Access Secret 直调(官方文档:不传 X-OAuth-Token 即本人),OAuth 仅在
|
|
1177
|
+
# 查其他用户时才需要。/user/* 端点共享 user_data 日额度(10000/天)。
|
|
1178
|
+
|
|
1179
|
+
_ZHIHU_USER_API = "https://developer.zhihu.com/api/v1/user"
|
|
1180
|
+
|
|
1181
|
+
# 子意图 → 端点与参数(按序匹配,命中即停)
|
|
1182
|
+
_ZHIHU_USER_INTENTS: list[tuple[str, str, dict[str, str]]] = [
|
|
1183
|
+
# (pattern, endpoint, extra query params)
|
|
1184
|
+
(r"我的收藏夹|收藏夹(列表|有哪些)", "favlists", {}),
|
|
1185
|
+
(r"我的收藏|收藏的内容", "collections", {}),
|
|
1186
|
+
(r"我关注(的)?(人|列表|谁)?(?!.*动态)", "followees", {}),
|
|
1187
|
+
(r"我的(回答|答主)", "contents", {"ContentType": "answer"}),
|
|
1188
|
+
(r"我的(文章|专栏)", "contents", {"ContentType": "article"}),
|
|
1189
|
+
(r"我的视频", "contents", {"ContentType": "zvideo"}),
|
|
1190
|
+
(r"我的(想法|动态|短内容)", "contents", {"ContentType": "pin"}),
|
|
1191
|
+
(r"我的(提问|问题)", "contents", {"ContentType": "question"}),
|
|
1192
|
+
]
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _parse_zhihu_user_intent(query: str) -> tuple[str, dict[str, str], str]:
|
|
1196
|
+
"""解析子意图,返回 (endpoint, extra_params, 剩余关键词)。"""
|
|
1197
|
+
q = (query or "").strip()
|
|
1198
|
+
for pat, endpoint, extra in _ZHIHU_USER_INTENTS:
|
|
1199
|
+
if re.search(pat, q):
|
|
1200
|
+
leftover = re.sub(pat, " ", q, count=1)
|
|
1201
|
+
return endpoint, extra, leftover
|
|
1202
|
+
# 默认:全部内容(时间倒序)
|
|
1203
|
+
leftover = re.sub(r"(我的知乎|知乎我的|我的(全部)?内容|个人(主页|数据)|我的创作)", " ", q)
|
|
1204
|
+
return "contents", {"ContentType": "all"}, leftover
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _build_zhihu_user_engine(spec: dict[str, Any]) -> Any:
|
|
1208
|
+
timeout = spec.get("timeout", 10)
|
|
1209
|
+
|
|
1210
|
+
@safe_search
|
|
1211
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None,
|
|
1212
|
+
**kwargs) -> list[dict[str, Any]]:
|
|
1213
|
+
import urllib.parse as up
|
|
1214
|
+
to = _timeout or timeout
|
|
1215
|
+
secret = get_env(["ARGO_ZHIHU_ACCESS_SECRET", "ZHIHU_ACCESS_SECRET"])
|
|
1216
|
+
if not secret:
|
|
1217
|
+
return []
|
|
1218
|
+
|
|
1219
|
+
endpoint, extra, leftover = _parse_zhihu_user_intent(query)
|
|
1220
|
+
params: dict[str, Any] = {"Limit": str(min(int(n), 50)), **extra}
|
|
1221
|
+
if endpoint == "contents":
|
|
1222
|
+
# 创作分析语义:点赞最多/最受欢迎 → 按赞排序(默认最新)
|
|
1223
|
+
if re.search(r"点赞(最)?(多|高)|最受欢迎|高赞|表现(最)?好", query):
|
|
1224
|
+
params["SortField"] = "like_count"
|
|
1225
|
+
url = f"{_ZHIHU_USER_API}/{endpoint}?" + up.urlencode(params)
|
|
1226
|
+
headers = {
|
|
1227
|
+
"Authorization": f"Bearer {secret}",
|
|
1228
|
+
"X-Request-Timestamp": str(int(time.time())),
|
|
1229
|
+
"Content-Type": "application/json",
|
|
1230
|
+
"User-Agent": "argo-search/2.6 (unified-search@local)",
|
|
1231
|
+
}
|
|
1232
|
+
try:
|
|
1233
|
+
with http_open(urllib.request.Request(url, headers=headers), timeout=to, engine=spec.get("_name", "")) as resp:
|
|
1234
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
1235
|
+
except urllib.error.HTTPError as e:
|
|
1236
|
+
return [{"error": f"zhihu_user API HTTP {e.code}", "source": "zhihu_user"}]
|
|
1237
|
+
except Exception as e:
|
|
1238
|
+
logger.warning(f"zhihu_user 失败: {e}")
|
|
1239
|
+
return [{"error": f"zhihu_user {type(e).__name__}: {e}", "source": "zhihu_user"}]
|
|
1240
|
+
if data.get("Code") not in (0, None):
|
|
1241
|
+
logger.warning(f"zhihu_user 返回码异常: {data.get('Code')} {data.get('Message')}")
|
|
1242
|
+
return [{"error": f"zhihu_user Code={data.get('Code')} "
|
|
1243
|
+
f"{str(data.get('Message') or '')[:100]}",
|
|
1244
|
+
"source": "zhihu_user"}]
|
|
1245
|
+
|
|
1246
|
+
items = (data.get("Data") or {}).get("Items") or []
|
|
1247
|
+
# 剩余关键词做标题/摘要本地过滤(「我的收藏 大模型」→ 只留含大模型的)
|
|
1248
|
+
leftover_kw = re.sub(r"[\s,,。??的]+", " ", leftover).strip()
|
|
1249
|
+
keywords = [w for w in leftover_kw.split() if len(w) >= 2]
|
|
1250
|
+
|
|
1251
|
+
results: list[dict[str, Any]] = []
|
|
1252
|
+
for item in items[:n]:
|
|
1253
|
+
if not isinstance(item, dict):
|
|
1254
|
+
continue
|
|
1255
|
+
title = str(item.get("Title") or "").strip()
|
|
1256
|
+
url_ = item.get("Url") or ""
|
|
1257
|
+
if endpoint == "followees":
|
|
1258
|
+
name = str(item.get("Fullname") or "").strip()
|
|
1259
|
+
if not name:
|
|
1260
|
+
continue
|
|
1261
|
+
results.append({
|
|
1262
|
+
"title": name[:100],
|
|
1263
|
+
"url": item.get("Url") or "",
|
|
1264
|
+
"snippet": str(item.get("Headline") or "")[:300],
|
|
1265
|
+
"source": "zhihu_user",
|
|
1266
|
+
"score": max(1.0 - len(results) * 0.1, 0.1),
|
|
1267
|
+
"social_meta": {"platform": "zhihu_user",
|
|
1268
|
+
"content_type": "followee",
|
|
1269
|
+
"followers": item.get("FollowerCount") or 0},
|
|
1270
|
+
})
|
|
1271
|
+
continue
|
|
1272
|
+
# 收藏夹列表:Title/Description 为收藏夹本身
|
|
1273
|
+
summary = str(item.get("Summary") or item.get("Description") or "")[:300]
|
|
1274
|
+
if keywords and not any(
|
|
1275
|
+
k in title or k in summary for k in keywords):
|
|
1276
|
+
continue
|
|
1277
|
+
created = item.get("CreatedAt") or 0
|
|
1278
|
+
results.append({
|
|
1279
|
+
"title": (title[:100] + ("..." if len(title) > 100 else "")) if title else "(无标题)",
|
|
1280
|
+
"url": url_,
|
|
1281
|
+
"snippet": summary,
|
|
1282
|
+
"source": "zhihu_user",
|
|
1283
|
+
"score": max(1.0 - len(results) * 0.1, 0.1),
|
|
1284
|
+
"published_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(created)) if created else None,
|
|
1285
|
+
"social_meta": {
|
|
1286
|
+
"platform": "zhihu_user",
|
|
1287
|
+
"content_type": str(item.get("ContentType") or endpoint),
|
|
1288
|
+
"likes": item.get("LikeCount") or 0,
|
|
1289
|
+
"comments": item.get("CommentCount") or 0,
|
|
1290
|
+
"favorites": item.get("FavoriteCount") or 0,
|
|
1291
|
+
"fav_time": item.get("FavTime") or None,
|
|
1292
|
+
},
|
|
1293
|
+
})
|
|
1294
|
+
return results
|
|
1295
|
+
|
|
1296
|
+
return _engine
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _bocha_http_error(exc: Exception, source: str) -> list[dict[str, Any]]:
|
|
1300
|
+
"""把博查的 HTTP 失败转成 error 记录(错误体一并带上)。
|
|
1301
|
+
|
|
1302
|
+
为什么不能直接抛给 safe_search:safe_search 吞掉异常返回空列表,
|
|
1303
|
+
「接口 403」与「这个词真没结果」在下游看起来一模一样。博查 AI Search
|
|
1304
|
+
端点要单独套餐,本机 key 只有 web-search 权限,实测
|
|
1305
|
+
`HTTP 403 {"message":"You do not have enough money or package quota"}`——
|
|
1306
|
+
此前这条被吃成空结果,引擎一直显示 ready,结构化卡静默降级成普通网页结果。
|
|
1307
|
+
这里把状态码与错误体交出去,search.py 的 `_QUOTA_ERROR_KEYWORDS`(含
|
|
1308
|
+
"quota")就能判成 quota-exhausted,配额状态机据此停用该源。
|
|
1309
|
+
"""
|
|
1310
|
+
detail = ""
|
|
1311
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
1312
|
+
try:
|
|
1313
|
+
detail = exc.read().decode("utf-8", errors="replace")[:200]
|
|
1314
|
+
except Exception:
|
|
1315
|
+
detail = ""
|
|
1316
|
+
return [{"error": f"HTTP {exc.code}: {detail}" if detail else f"HTTP {exc.code}",
|
|
1317
|
+
"source": source}]
|
|
1318
|
+
return [{"error": f"{type(exc).__name__}: {str(exc)[:160]}", "source": source}]
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _bocha_key() -> str:
|
|
1322
|
+
return get_env(["ARGO_BOCHA_API_KEY", "BOCHA_API_KEY"])
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _bocha_web_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
1326
|
+
"""webPages.value 单条 → 统一结果项(name/summary/datePublished/siteName 语义映射)。"""
|
|
1327
|
+
return {
|
|
1328
|
+
"title": str(item.get("name") or item.get("title") or "")[:200],
|
|
1329
|
+
"url": str(item.get("url") or ""),
|
|
1330
|
+
"snippet": str(item.get("summary") or item.get("snippet") or item.get("description") or "")[:300],
|
|
1331
|
+
"source": "bocha",
|
|
1332
|
+
"score": 0.7,
|
|
1333
|
+
"date": item.get("datePublished") or "",
|
|
1334
|
+
"site_name": item.get("siteName") or "",
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _build_bocha_engine(spec: dict[str, Any]) -> Any:
|
|
1339
|
+
"""博查 Web Search(中文全网搜索,AI 友好摘要)。
|
|
1340
|
+
|
|
1341
|
+
专用解析修复通用 parser 对 `data.webPages.value` 嵌套路径的漏检;
|
|
1342
|
+
freshness 按查询时效敏感度动态化(替代静态 oneYear)。
|
|
1343
|
+
"""
|
|
1344
|
+
timeout = spec.get("timeout", 8)
|
|
1345
|
+
|
|
1346
|
+
@safe_search
|
|
1347
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1348
|
+
to = _timeout or timeout
|
|
1349
|
+
key = _bocha_key()
|
|
1350
|
+
if not key:
|
|
1351
|
+
return [{"error": "BOCHA_API_KEY 未设置", "source": "bocha"}]
|
|
1352
|
+
body = {
|
|
1353
|
+
"query": query or "",
|
|
1354
|
+
"summary": True,
|
|
1355
|
+
"freshness": _bocha_freshness(query),
|
|
1356
|
+
"count": max(1, min(int(n or 5), 50)),
|
|
1357
|
+
}
|
|
1358
|
+
req = urllib.request.Request(
|
|
1359
|
+
"https://api.bochaai.com/v1/web-search",
|
|
1360
|
+
data=json.dumps(body).encode("utf-8"),
|
|
1361
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
1362
|
+
)
|
|
1363
|
+
try:
|
|
1364
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
1365
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1366
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e:
|
|
1367
|
+
return _bocha_http_error(e, "bocha")
|
|
1368
|
+
pages = (data.get("data") or {}).get("webPages") or {}
|
|
1369
|
+
return [_bocha_web_item(i) for i in (pages.get("value") or [])]
|
|
1370
|
+
return _engine
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
# ── 博查 AI Search 引擎 ─────────────────────────────────────────────────────
|
|
1374
|
+
|
|
1375
|
+
def _build_bocha_ai_engine(spec: dict[str, Any]) -> Any:
|
|
1376
|
+
"""博查 AI Search:网页结果直连(ai-search 端点的 webpage 通道)。
|
|
1377
|
+
|
|
1378
|
+
历史版本还解析该端点的垂直结构化模态卡(天气/股票/汇率等,url 为空 +
|
|
1379
|
+
card_type 标注)——2026-09-28 移除:博查配额 403 期间该功能本就不可达,
|
|
1380
|
+
且无 url 的卡行会被 SERP 过滤闸整体吞掉(is_serp_or_jump_url 对空 url
|
|
1381
|
+
返回 True),形成「产出来就被扔」的静默浪费。要接回时参考 git 历史
|
|
1382
|
+
(本函数旧版 + search_rank 仍保留的 card_type 感知键),并先解决
|
|
1383
|
+
「无 url 卡行如何过 SERP 过滤」的豁免判据。
|
|
1384
|
+
"""
|
|
1385
|
+
timeout = spec.get("timeout", 12)
|
|
1386
|
+
|
|
1387
|
+
@safe_search
|
|
1388
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1389
|
+
to = _timeout or timeout
|
|
1390
|
+
key = _bocha_key()
|
|
1391
|
+
if not key:
|
|
1392
|
+
return [{"error": "BOCHA_API_KEY 未设置", "source": "bocha_ai"}]
|
|
1393
|
+
body = {
|
|
1394
|
+
"query": query or "",
|
|
1395
|
+
"freshness": _bocha_freshness(query),
|
|
1396
|
+
"count": max(1, min(int(n or 5), 50)),
|
|
1397
|
+
"answer": False,
|
|
1398
|
+
"stream": False,
|
|
1399
|
+
}
|
|
1400
|
+
req = urllib.request.Request(
|
|
1401
|
+
"https://api.bochaai.com/v1/ai-search",
|
|
1402
|
+
data=json.dumps(body).encode("utf-8"),
|
|
1403
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
1404
|
+
)
|
|
1405
|
+
try:
|
|
1406
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
1407
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1408
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e:
|
|
1409
|
+
return _bocha_http_error(e, "bocha_ai")
|
|
1410
|
+
|
|
1411
|
+
results: list[dict[str, Any]] = []
|
|
1412
|
+
for _rk4, message in enumerate(data.get("messages") or []):
|
|
1413
|
+
ct = message.get("content_type") or ""
|
|
1414
|
+
raw = message.get("content") or "{}"
|
|
1415
|
+
try:
|
|
1416
|
+
parsed = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
1417
|
+
except (json.JSONDecodeError, ValueError):
|
|
1418
|
+
parsed = {}
|
|
1419
|
+
if ct == "webpage":
|
|
1420
|
+
for item in parsed.get("value") or []:
|
|
1421
|
+
if not isinstance(item, dict):
|
|
1422
|
+
continue
|
|
1423
|
+
it = _bocha_web_item(item)
|
|
1424
|
+
it["source"] = "bocha_ai"
|
|
1425
|
+
results.append(it)
|
|
1426
|
+
else:
|
|
1427
|
+
# 非 webpage 消息一律跳过(模态卡特化已移除,见 docstring)
|
|
1428
|
+
continue
|
|
1429
|
+
return results
|
|
1430
|
+
return _engine
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
# ── 批次八:数据源扩展(2026-09-12)──────────────────────────────────────────
|
|
1437
|
+
|
|
1438
|
+
# std.samr 的 C_C_NAME 与政府类接口正文常带高亮标记,进结果前一律剥掉
|
|
1439
|
+
_SACINFO_TAG_RE = re.compile(r"</?sacinfo\s*>")
|
|
1440
|
+
_HL_TAG_RE = re.compile(r"</?em[^>]*>")
|
|
1441
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _strip_hl(text: str) -> str:
|
|
1445
|
+
return re.sub(r"\s+", " ", _HL_TAG_RE.sub("", str(text or ""))).strip()
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _build_std_samr_engine(spec: dict[str, Any]) -> Any:
|
|
1449
|
+
"""全国标准信息公共服务平台(国标检索,免认证)。
|
|
1450
|
+
|
|
1451
|
+
C_C_NAME 带 <sacinfo> 高亮标签;id 即 openstd 的 hcno,直通全文预览页。
|
|
1452
|
+
"""
|
|
1453
|
+
timeout = spec.get("timeout", 12)
|
|
1454
|
+
|
|
1455
|
+
@safe_search
|
|
1456
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1457
|
+
q = query.strip()
|
|
1458
|
+
if not q:
|
|
1459
|
+
return []
|
|
1460
|
+
to = _timeout or timeout
|
|
1461
|
+
url = ("https://std.samr.gov.cn/gb/search/gbQueryPage"
|
|
1462
|
+
f"?searchText={urllib.parse.quote(q)}&pageNumber=1&pageSize={min(max(int(n), 1), 50)}")
|
|
1463
|
+
raw = _http_get_raw(url, {"User-Agent": "argo-search/1.0 (+std_samr)", "Accept": "application/json"},
|
|
1464
|
+
to, engine=spec.get("_name", "std_samr"))
|
|
1465
|
+
if raw is None:
|
|
1466
|
+
return []
|
|
1467
|
+
try:
|
|
1468
|
+
rows = (json.loads(raw) or {}).get("rows") or []
|
|
1469
|
+
except (json.JSONDecodeError, ValueError):
|
|
1470
|
+
return []
|
|
1471
|
+
out = []
|
|
1472
|
+
for r in rows:
|
|
1473
|
+
# C_STD_CODE 同样带 <sacinfo> 高亮:旧实现只剥了 C_C_NAME,
|
|
1474
|
+
# 把 XML 标签直接印进了结果标题(实测
|
|
1475
|
+
# '<sacinfo>GB</sacinfo>/<sacinfo>T</sacinfo> <sacinfo>45577</sacinfo>-2025 …')。
|
|
1476
|
+
code = _SACINFO_TAG_RE.sub("", str(r.get("C_STD_CODE") or "")).strip()
|
|
1477
|
+
name = _SACINFO_TAG_RE.sub("", str(r.get("C_C_NAME") or "")).strip()
|
|
1478
|
+
if not code and not name:
|
|
1479
|
+
continue
|
|
1480
|
+
bits = [str(r.get(k) or "").strip() for k in ("STD_NATURE", "STATE")]
|
|
1481
|
+
if r.get("ISSUE_DATE"):
|
|
1482
|
+
bits.append(f"发布 {r['ISSUE_DATE']}")
|
|
1483
|
+
if r.get("ACT_DATE"):
|
|
1484
|
+
bits.append(f"实施 {r['ACT_DATE']}")
|
|
1485
|
+
hcno = str(r.get("id") or "").strip()
|
|
1486
|
+
out.append({
|
|
1487
|
+
"title": f"{code} {name}".strip(),
|
|
1488
|
+
"url": f"https://openstd.samr.gov.cn/bzgk/gb/newGbInfo?hcno={hcno}" if hcno else "",
|
|
1489
|
+
"snippet": " · ".join(b for b in bits if b),
|
|
1490
|
+
"source": "std_samr",
|
|
1491
|
+
})
|
|
1492
|
+
return out[:max(int(n), 1)]
|
|
1493
|
+
return _engine
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
_STD_TAIL_WORDS = ("全文", "标准", "最新", "下载")
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _build_openstd_engine(spec: dict[str, Any]) -> Any:
|
|
1500
|
+
"""国家标准全文公开系统(GB 全文预览入口,HTML 行解析,免认证)。
|
|
1501
|
+
|
|
1502
|
+
列表为服务端渲染表格,详情键在 onclick="showInfo('hcno')";列位置随
|
|
1503
|
+
行型浮动,按内容特征定位(标准号模式 / 日期模式 / 状态词表),不按序号。
|
|
1504
|
+
"""
|
|
1505
|
+
timeout = spec.get("timeout", 15)
|
|
1506
|
+
_std_code_re = re.compile(r"^[A-Z]{2,4}(?:/[A-Z]{1,2})?\s?\d+[-—]\d{4}")
|
|
1507
|
+
_date_re = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
|
1508
|
+
_known_words = {"推标", "强标", "推荐性", "强制性", "现行", "即将实施", "废止", "作废", "被代替", "查看详细"}
|
|
1509
|
+
|
|
1510
|
+
@safe_search
|
|
1511
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1512
|
+
q = query.strip()
|
|
1513
|
+
for w in _STD_TAIL_WORDS:
|
|
1514
|
+
q = re.sub(rf"{w}$", "", q).strip()
|
|
1515
|
+
if not q:
|
|
1516
|
+
return []
|
|
1517
|
+
to = _timeout or timeout
|
|
1518
|
+
url = ("https://openstd.samr.gov.cn/bzgk/gb/std_list"
|
|
1519
|
+
f"?p.p1=0&p.p2={urllib.parse.quote(q)}&p.p90=circulation_date&p.p91=desc")
|
|
1520
|
+
raw = _http_get_raw(url, {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
1521
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"},
|
|
1522
|
+
to, engine=spec.get("_name", "openstd"))
|
|
1523
|
+
if raw is None:
|
|
1524
|
+
return []
|
|
1525
|
+
out = []
|
|
1526
|
+
for tr in re.findall(r"<tr[^>]*>(.*?)</tr>", raw, re.S):
|
|
1527
|
+
m = re.search(r"showInfo\('([0-9A-Fa-f]+)'\)", tr)
|
|
1528
|
+
if not m:
|
|
1529
|
+
continue
|
|
1530
|
+
tds = [re.sub(r"\s+", " ", _TAG_RE.sub("", t)).strip()
|
|
1531
|
+
for t in re.findall(r"<td[^>]*>(.*?)</td>", tr, re.S)]
|
|
1532
|
+
code_i = next((i for i, t in enumerate(tds) if _std_code_re.match(t)), None)
|
|
1533
|
+
if code_i is None:
|
|
1534
|
+
continue
|
|
1535
|
+
code = tds[code_i]
|
|
1536
|
+
name = next((t for t in tds[code_i + 1:]
|
|
1537
|
+
if len(t) > 3 and not _date_re.match(t) and t not in _known_words), "")
|
|
1538
|
+
dates = [t for t in tds if _date_re.match(t)]
|
|
1539
|
+
words = [t for t in tds[code_i + 1:] if t in _known_words and t != "查看详细"]
|
|
1540
|
+
bits = words + [f"发布 {dates[0]}" for _ in [0] if dates]
|
|
1541
|
+
if len(dates) > 1:
|
|
1542
|
+
bits.append(f"实施 {dates[1]}")
|
|
1543
|
+
out.append({
|
|
1544
|
+
"title": f"{code} {name}".strip(),
|
|
1545
|
+
"url": f"https://openstd.samr.gov.cn/bzgk/gb/newGbInfo?hcno={m.group(1)}",
|
|
1546
|
+
"snippet": " · ".join(bits),
|
|
1547
|
+
"source": "openstd",
|
|
1548
|
+
})
|
|
1549
|
+
if len(out) >= max(int(n), 1):
|
|
1550
|
+
break
|
|
1551
|
+
return out
|
|
1552
|
+
return _engine
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def _build_bangumi_engine(spec: dict[str, Any]) -> Any:
|
|
1556
|
+
"""Bangumi 番剧仓库(动画/漫画/游戏条目元数据,官方开放 API,POST JSON)。
|
|
1557
|
+
|
|
1558
|
+
name_cn 常缺省 → 回落 name;rating.score 可能为 0/null。
|
|
1559
|
+
"""
|
|
1560
|
+
timeout = spec.get("timeout", 12)
|
|
1561
|
+
|
|
1562
|
+
@safe_search
|
|
1563
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1564
|
+
q = query.strip()
|
|
1565
|
+
if not q:
|
|
1566
|
+
return []
|
|
1567
|
+
to = _timeout or timeout
|
|
1568
|
+
req = urllib.request.Request(
|
|
1569
|
+
"https://api.bgm.tv/v0/search/subjects",
|
|
1570
|
+
data=json.dumps({"keyword": q}).encode("utf-8"),
|
|
1571
|
+
headers={
|
|
1572
|
+
"Content-Type": "application/json",
|
|
1573
|
+
"User-Agent": "taxue/argo-search (+bangumi engine)",
|
|
1574
|
+
"Accept": "application/json",
|
|
1575
|
+
},
|
|
1576
|
+
method="POST",
|
|
1577
|
+
)
|
|
1578
|
+
try:
|
|
1579
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "bangumi")) as resp:
|
|
1580
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1581
|
+
except Exception as e:
|
|
1582
|
+
logger.warning(f"Bangumi 引擎失败: {e}")
|
|
1583
|
+
return []
|
|
1584
|
+
out = []
|
|
1585
|
+
for it in (data.get("data") or [])[:max(int(n), 1)]:
|
|
1586
|
+
title = str(it.get("name_cn") or it.get("name") or "").strip()
|
|
1587
|
+
if not title:
|
|
1588
|
+
continue
|
|
1589
|
+
bits = [str(it.get(k) or "").strip() for k in ("platform", "date")]
|
|
1590
|
+
rating = it.get("rating") if isinstance(it.get("rating"), dict) else {}
|
|
1591
|
+
if rating and rating.get("score"):
|
|
1592
|
+
bits.append(f"评分 {rating.get('score')}")
|
|
1593
|
+
summary = re.sub(r"\s+", " ", str(it.get("summary") or "")).strip()
|
|
1594
|
+
head = " · ".join(b for b in bits if b)
|
|
1595
|
+
out.append({
|
|
1596
|
+
"title": title,
|
|
1597
|
+
"url": f"https://bgm.tv/subject/{it.get('id')}",
|
|
1598
|
+
"snippet": (f"{head} {summary}"[:300] if head else summary[:300]),
|
|
1599
|
+
"source": "bangumi",
|
|
1600
|
+
})
|
|
1601
|
+
return out
|
|
1602
|
+
return _engine
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
_DOUBAN_TYPE_CN = {"movie": "电影", "tv": "剧集"}
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _build_douban_movie_engine(spec: dict[str, Any]) -> Any:
|
|
1609
|
+
"""豆瓣电影 suggest 接口(中文片名/年份/类型,免认证)。
|
|
1610
|
+
|
|
1611
|
+
suggest 无评分字段;type 为 movie/tv。豆瓣对无 cookie 请求逐步收紧,
|
|
1612
|
+
失败诚实空。
|
|
1613
|
+
"""
|
|
1614
|
+
timeout = spec.get("timeout", 10)
|
|
1615
|
+
|
|
1616
|
+
@safe_search
|
|
1617
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1618
|
+
q = query.strip()
|
|
1619
|
+
if not q:
|
|
1620
|
+
return []
|
|
1621
|
+
to = _timeout or timeout
|
|
1622
|
+
url = f"https://movie.douban.com/j/subject_suggest?q={urllib.parse.quote(q)}"
|
|
1623
|
+
raw = _http_get_raw(url, {
|
|
1624
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
1625
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
1626
|
+
"Referer": "https://movie.douban.com/",
|
|
1627
|
+
"Accept": "application/json",
|
|
1628
|
+
}, to, engine=spec.get("_name", "douban_movie"))
|
|
1629
|
+
if raw is None:
|
|
1630
|
+
return []
|
|
1631
|
+
try:
|
|
1632
|
+
items = json.loads(raw)
|
|
1633
|
+
except (json.JSONDecodeError, ValueError):
|
|
1634
|
+
return []
|
|
1635
|
+
if not isinstance(items, list):
|
|
1636
|
+
return []
|
|
1637
|
+
out = []
|
|
1638
|
+
for it in items[:max(int(n), 1)]:
|
|
1639
|
+
title = str(it.get("title") or "").strip()
|
|
1640
|
+
if not title:
|
|
1641
|
+
continue
|
|
1642
|
+
year = str(it.get("year") or "").strip()
|
|
1643
|
+
t = _DOUBAN_TYPE_CN.get(str(it.get("type") or ""), str(it.get("type") or ""))
|
|
1644
|
+
out.append({
|
|
1645
|
+
"title": title,
|
|
1646
|
+
"url": str(it.get("url") or "").strip(),
|
|
1647
|
+
"snippet": " · ".join(b for b in (t, f"{year}年" if year else "") if b),
|
|
1648
|
+
"source": "douban_movie",
|
|
1649
|
+
})
|
|
1650
|
+
return out
|
|
1651
|
+
return _engine
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def _build_zdic_engine(spec: dict[str, Any]) -> Any:
|
|
1655
|
+
"""汉典(中文字词典,词条页 /hans/{词} 直达)。
|
|
1656
|
+
|
|
1657
|
+
取「详细解释」前几条释义拼摘要;查无此字 404 → _http_get_raw None → 诚实空。
|
|
1658
|
+
"""
|
|
1659
|
+
timeout = spec.get("timeout", 12)
|
|
1660
|
+
|
|
1661
|
+
@safe_search
|
|
1662
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1663
|
+
q = query.strip()
|
|
1664
|
+
if not q or len(q) > 12:
|
|
1665
|
+
return []
|
|
1666
|
+
to = _timeout or timeout
|
|
1667
|
+
url = f"https://www.zdic.net/hans/{urllib.parse.quote(q)}"
|
|
1668
|
+
raw = _http_get_raw(url, {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
1669
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"},
|
|
1670
|
+
to, engine=spec.get("_name", "zdic"))
|
|
1671
|
+
if raw is None:
|
|
1672
|
+
return []
|
|
1673
|
+
defs = [_TAG_RE.sub("", d) for d in
|
|
1674
|
+
re.findall(r'<div class="xxjs-item__def">(.*?)</div>', raw, re.S)]
|
|
1675
|
+
defs = [re.sub(r"\s+", " ", d).strip() for d in defs]
|
|
1676
|
+
defs = [d for d in defs if d]
|
|
1677
|
+
if not defs:
|
|
1678
|
+
return []
|
|
1679
|
+
title_m = re.search(r"<title>([^<]+)</title>", raw)
|
|
1680
|
+
title = title_m.group(1).split(" - ")[0].strip() if title_m else q
|
|
1681
|
+
return [{
|
|
1682
|
+
"title": title,
|
|
1683
|
+
"url": url,
|
|
1684
|
+
"snippet": "释义:" + ";".join(defs[:3])[:280],
|
|
1685
|
+
"source": "zdic",
|
|
1686
|
+
}]
|
|
1687
|
+
return _engine
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
# ── iPlant 植物智(中文植物名 → 学名 + 分类)──────────────────────────────────
|
|
1691
|
+
|
|
1692
|
+
_IPLANT_HOST = "https://www.iplant.cn"
|
|
1693
|
+
_IPLANT_HEADERS = {
|
|
1694
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
1695
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
1696
|
+
"Referer": "https://www.iplant.cn/",
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
# 服务端渲染的页面变量。可靠——与「物种保护」「分类信息」那些 AJAX 填充块不同。
|
|
1700
|
+
_IPLANT_VAR_RE = re.compile(r"var\s+(spno|spcname|latin2|systype)\s*=\s*\"([^\"]*)\"")
|
|
1701
|
+
# 「您是否要找」消歧块:别名/泛称页在此给出接受名(中文名 + 学名 + 带内部 id 的链接)
|
|
1702
|
+
_IPLANT_SUGGEST_RE = re.compile(r"您是否要找[::](.*?)</div>", re.S)
|
|
1703
|
+
_IPLANT_ANCHOR_RE = re.compile(r"<a\s+href='([^']+)'[^>]*>([^<]+)</a>")
|
|
1704
|
+
# 俗名块:<div>俗名:<a href='/info/苞米'>苞米</a>、…</div>
|
|
1705
|
+
# 不取「异名」:那是拉丁→拉丁的映射,gbif 本来就管得好,不属于本源承诺的
|
|
1706
|
+
# 「中文名/俗名 → 学名 + 分类」(它的块结构也不同,异名:后紧跟 </div>)
|
|
1707
|
+
_IPLANT_VERN_RE = re.compile(r"俗名:(.{0,400}?)</div>", re.S)
|
|
1708
|
+
# 用户常把「玉米 学名」这类整体丢进来,去掉修饰词后才是可查的名字
|
|
1709
|
+
_IPLANT_NOISE_RE = re.compile(
|
|
1710
|
+
r"(学名|拉丁名|拉丁学名|俗名|别名|别称|植物|物种|分类|是什么|有哪些|查询|搜索)"
|
|
1711
|
+
)
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def _build_iplant_engine(spec: dict[str, Any]) -> Any:
|
|
1715
|
+
"""iPlant 植物智——中文植物名(含俗名)→ 学名 + 分类(HTML 解析,免认证)。
|
|
1716
|
+
|
|
1717
|
+
补的是 argo 一条硬盲区:此前没有任何从中文名进入生物数据库的路。gbif 对纯
|
|
1718
|
+
中文查询返回的是无关属种(实测「玉米」首条是 Frithia 属;补 qField=VERNACULAR
|
|
1719
|
+
仍是垃圾),本仓 gbif builder 也因此在中文查询上直接短路。iPlant 的 /info/{名}
|
|
1720
|
+
直接吃中文名、学名、属名、科名,实测时延 0.29–0.41s。
|
|
1721
|
+
|
|
1722
|
+
**三级判据,少一级就错**(每级的实测依据见下):
|
|
1723
|
+
1. `spno` 为空 → 未收录 → 诚实空。**不能用 HTTP 状态码**:不存在的名字同样
|
|
1724
|
+
返回 200(只是页面小 1.8KB);**也不能用 latin2**:未收录时它把查询词本身
|
|
1725
|
+
填回去(查 zzzz → latin2=zzzznotexist),看着像命中。
|
|
1726
|
+
2. `spno` 非空且 `latin2` 非空 → 直接命中(玉米 / 牡丹 / 银杏)。
|
|
1727
|
+
3. `spno` 非空但 `latin2` 为空 → 别名或泛称,接受名在「您是否要找」块里。
|
|
1728
|
+
**这是俗名查询的主路径而非边缘情况**:玉米页列出的 5 个俗名(包谷 / 苞米 /
|
|
1729
|
+
玉蜀黍 / 珍珠米 / 麻蜀棒子)逐个反查,全部走这一级——只做前两级等于对
|
|
1730
|
+
中文俗名基本失效。
|
|
1731
|
+
|
|
1732
|
+
`systype` 过滤非植物:该站带「名称校对」功能,查「霸王龙」返回恐龙条目、
|
|
1733
|
+
「蘑菇」返回菌物条目,对植物源是噪声。实测 1=植物 2=动物 3=菌物,只放行 1;
|
|
1734
|
+
字段缺失时不拦(判不出类群就不拦截,宁放过不误杀)。
|
|
1735
|
+
|
|
1736
|
+
分类链来自 `/ashx/getspinfos.ashx?spid=&type=classsys`(免认证,实测可直调),
|
|
1737
|
+
返回 8 套分类系统的 HTML 片段,取第一套——站点的默认展示(种>属>科>目>纲)。
|
|
1738
|
+
植物志正文(frps/foc)、分布、标本都是 AJAX 填充且内部接口不可直调(参数空间
|
|
1739
|
+
已试开,除本接口外一律空返回),不做承诺。
|
|
1740
|
+
"""
|
|
1741
|
+
timeout = spec.get("timeout", 12)
|
|
1742
|
+
engine_name = spec.get("_name", "iplant")
|
|
1743
|
+
|
|
1744
|
+
def _fetch(path: str, to: float) -> str | None:
|
|
1745
|
+
return _http_get_raw(f"{_IPLANT_HOST}{path}", _IPLANT_HEADERS, to, engine=engine_name)
|
|
1746
|
+
|
|
1747
|
+
def _entry(path: str, to: float) -> dict[str, Any] | None:
|
|
1748
|
+
"""取一个物种条目。未收录、非植物、取不到都返回 None。"""
|
|
1749
|
+
html = _fetch(path, to)
|
|
1750
|
+
if not html:
|
|
1751
|
+
return None
|
|
1752
|
+
v = dict(_IPLANT_VAR_RE.findall(html))
|
|
1753
|
+
if not v.get("spno"):
|
|
1754
|
+
return None
|
|
1755
|
+
if v.get("systype") and v["systype"] != "1":
|
|
1756
|
+
return None
|
|
1757
|
+
return {"spno": v["spno"], "cname": v.get("spcname", ""),
|
|
1758
|
+
"latin": v.get("latin2", ""), "html": html}
|
|
1759
|
+
|
|
1760
|
+
def _classsys(spno: str, to: float) -> list[str]:
|
|
1761
|
+
"""分类链(第一套系统),从种到门;取不到返回空列表(分类是增益不是前提)。"""
|
|
1762
|
+
raw = _fetch(f"/ashx/getspinfos.ashx?spid={spno}&type=classsys", to)
|
|
1763
|
+
if not raw:
|
|
1764
|
+
return []
|
|
1765
|
+
try:
|
|
1766
|
+
chains = (json.loads(raw) or {}).get("classsys") or []
|
|
1767
|
+
except (json.JSONDecodeError, ValueError):
|
|
1768
|
+
return []
|
|
1769
|
+
if not isinstance(chains, list) or not chains:
|
|
1770
|
+
return []
|
|
1771
|
+
return re.findall(r"<a[^>]*>([^<]*)</a>", str(chains[0]))
|
|
1772
|
+
|
|
1773
|
+
def _suggest(html: str) -> list[dict[str, str]]:
|
|
1774
|
+
"""「您是否要找」里的接受名。href 带内部 id,需再取一次才有 spno 与分类。"""
|
|
1775
|
+
blk = _IPLANT_SUGGEST_RE.search(html)
|
|
1776
|
+
if not blk:
|
|
1777
|
+
return []
|
|
1778
|
+
out = []
|
|
1779
|
+
for href, text in _IPLANT_ANCHOR_RE.findall(blk.group(1)):
|
|
1780
|
+
label = re.sub(r"\s+", " ", text).strip()
|
|
1781
|
+
m = re.match(r"^(\S+)\s+([A-Z][A-Za-z.\s×x]*)$", label)
|
|
1782
|
+
out.append({"href": href,
|
|
1783
|
+
"cname": m.group(1) if m else label,
|
|
1784
|
+
"latin": m.group(2).strip() if m else ""})
|
|
1785
|
+
return out
|
|
1786
|
+
|
|
1787
|
+
def _rel_path(href: str) -> str:
|
|
1788
|
+
"""页面里的相对 href → 可直接 GET 的路径。名字部分要转义,?id= 原样保留。"""
|
|
1789
|
+
path, _, qs = href.partition("?")
|
|
1790
|
+
return f"{urllib.parse.quote(path)}?{qs}" if qs else urllib.parse.quote(path)
|
|
1791
|
+
|
|
1792
|
+
def _names(html: str, pat: re.Pattern) -> list[str]:
|
|
1793
|
+
"""从「俗名:」「异名:」块取锚文本。"""
|
|
1794
|
+
blk = pat.search(html or "")
|
|
1795
|
+
if not blk:
|
|
1796
|
+
return []
|
|
1797
|
+
return [re.sub(r"\s+", " ", t).strip()
|
|
1798
|
+
for t in re.findall(r"<a[^>]*>([^<]+)</a>", blk.group(1)) if t.strip()]
|
|
1799
|
+
|
|
1800
|
+
def _result(e: dict[str, Any], ranks: list[str], q: str, idx: int) -> dict[str, Any]:
|
|
1801
|
+
cname = e.get("cname") or q
|
|
1802
|
+
latin = e.get("latin") or ""
|
|
1803
|
+
html = e.get("html") or ""
|
|
1804
|
+
bits = [f"学名 {latin}"] if latin else []
|
|
1805
|
+
# 分类链首段是种本身,取其后三段当属/科/目
|
|
1806
|
+
bits += [f"{lab} {val}" for lab, val in zip(("属", "科", "目"), ranks[1:4])]
|
|
1807
|
+
vern = _names(html, _IPLANT_VERN_RE)
|
|
1808
|
+
if vern:
|
|
1809
|
+
bits.append("俗名 " + "、".join(vern[:6]))
|
|
1810
|
+
return {
|
|
1811
|
+
"title": f"{cname} {latin}".strip(),
|
|
1812
|
+
"url": f"{_IPLANT_HOST}/info/{urllib.parse.quote(cname)}",
|
|
1813
|
+
"snippet": " | ".join(bits)[:300],
|
|
1814
|
+
"source": engine_name,
|
|
1815
|
+
"score": rank_score(0.95, idx),
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
@safe_search
|
|
1819
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1820
|
+
q = _IPLANT_NOISE_RE.sub("", query).strip()
|
|
1821
|
+
if not q or len(q) > 40:
|
|
1822
|
+
return []
|
|
1823
|
+
to = _timeout or timeout
|
|
1824
|
+
head = _entry(f"/info/{urllib.parse.quote(q)}", to)
|
|
1825
|
+
if head is None:
|
|
1826
|
+
return []
|
|
1827
|
+
if head["latin"]:
|
|
1828
|
+
return [_result(head, _classsys(head["spno"], to), q, 0)]
|
|
1829
|
+
# 第三级:别名/泛称 → 取「您是否要找」给的接受名
|
|
1830
|
+
out: list[dict[str, Any]] = []
|
|
1831
|
+
for s in _suggest(head["html"])[: max(int(n), 1)]:
|
|
1832
|
+
acc = _entry(_rel_path(s["href"]), to)
|
|
1833
|
+
if acc is None:
|
|
1834
|
+
# 接受名页取不到(改版/超时)时退一步用提示里的名字对,仍比空手强
|
|
1835
|
+
if not s["latin"]:
|
|
1836
|
+
continue
|
|
1837
|
+
acc = {"spno": "", "cname": s["cname"], "latin": s["latin"], "html": ""}
|
|
1838
|
+
ranks = _classsys(acc["spno"], to) if acc["spno"] else []
|
|
1839
|
+
out.append(_result(acc, ranks, q, len(out)))
|
|
1840
|
+
return out
|
|
1841
|
+
|
|
1842
|
+
return _engine
|
|
1843
|
+
|
|
1844
|
+
|
|
1845
|
+
def _build_people_daily_engine(spec: dict[str, Any]) -> Any:
|
|
1846
|
+
"""人民网搜索(权威综合中文新闻,官方接口,免认证)。
|
|
1847
|
+
|
|
1848
|
+
走 http 而非 https(https 301 丢 body);标题/正文带 <em> 高亮标签;
|
|
1849
|
+
displayTime 为毫秒时间戳。
|
|
1850
|
+
"""
|
|
1851
|
+
timeout = spec.get("timeout", 12)
|
|
1852
|
+
|
|
1853
|
+
@safe_search
|
|
1854
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1855
|
+
q = query.strip()
|
|
1856
|
+
if not q:
|
|
1857
|
+
return []
|
|
1858
|
+
to = _timeout or timeout
|
|
1859
|
+
req = urllib.request.Request(
|
|
1860
|
+
"http://search.people.cn/search-platform/front/search",
|
|
1861
|
+
data=json.dumps({
|
|
1862
|
+
"key": q, "page": 1, "limit": min(max(int(n), 1), 20),
|
|
1863
|
+
"hasTitle": True, "hasContent": True, "isFuzzy": True,
|
|
1864
|
+
"type": 0, "sortType": 2, "startTime": 0, "endTime": 0,
|
|
1865
|
+
}).encode("utf-8"),
|
|
1866
|
+
headers={"Content-Type": "application/json", "User-Agent": "argo-search/1.0 (+people_daily)"},
|
|
1867
|
+
method="POST",
|
|
1868
|
+
)
|
|
1869
|
+
try:
|
|
1870
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "people_daily")) as resp:
|
|
1871
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1872
|
+
except Exception as e:
|
|
1873
|
+
logger.warning(f"人民网引擎失败: {e}")
|
|
1874
|
+
return []
|
|
1875
|
+
records = ((data.get("data") or {}).get("records")) or []
|
|
1876
|
+
out = []
|
|
1877
|
+
for r in records[:max(int(n), 1)]:
|
|
1878
|
+
title = _strip_hl(r.get("title"))
|
|
1879
|
+
if not title:
|
|
1880
|
+
continue
|
|
1881
|
+
ts = str(r.get("displayTime") or "").strip()
|
|
1882
|
+
published = ""
|
|
1883
|
+
if ts.isdigit() and ts.isascii(): # isascii:'²'.isdigit()=True 但 int 崩
|
|
1884
|
+
published = time.strftime("%Y-%m-%d", time.localtime(int(ts) / 1000))
|
|
1885
|
+
content = _strip_hl(r.get("content"))[:280]
|
|
1886
|
+
out.append({
|
|
1887
|
+
"title": title,
|
|
1888
|
+
"url": str(r.get("url") or "").strip(),
|
|
1889
|
+
"snippet": content,
|
|
1890
|
+
"source": "people_daily",
|
|
1891
|
+
"published_at": published,
|
|
1892
|
+
})
|
|
1893
|
+
return out
|
|
1894
|
+
return _engine
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
# flk 时效性枚举(官网 enumData/前端常量):1 已废止 / 2 已修改 / 3 有效 / 4 尚未生效
|
|
1898
|
+
_FLK_SXX = {1: "已废止", 2: "已修改", 3: "有效", 4: "尚未生效"}
|
|
1899
|
+
|
|
1900
|
+
# flk 的模糊检索对「第 N 条」内部带空格的写法返回 0 条,压掉空格后返回
|
|
1901
|
+
# 8-10 条(实测 2026-09-12:「民法典 第 1062 条」→「民法典 第1062条」)。
|
|
1902
|
+
# 只压「第…条」内部的空白,不动查询里其余空格——法名与并列检索词之间的
|
|
1903
|
+
# 空格是有语义的分隔(「个人信息保护 数据安全」必须原样透传)。
|
|
1904
|
+
_FLK_ARTICLE_SPACE_RE = re.compile(r"第\s*(\d{1,7})\s*条")
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def _build_flk_law_engine(spec: dict[str, Any]) -> Any:
|
|
1908
|
+
"""国家法律法规数据库(法律/行政法规/司法解释/地方性法规,权威法条源)。
|
|
1909
|
+
|
|
1910
|
+
接口为 flk SPA 前端逆向:searchRange 1=标题 2=正文;searchType 1=精确
|
|
1911
|
+
2=模糊;orderByParam 必须是 {order, sort} 对象(扁平字符串后端 500)。
|
|
1912
|
+
无验证码无签名;失败诚实空,不做绕过。
|
|
1913
|
+
"""
|
|
1914
|
+
timeout = spec.get("timeout", 25)
|
|
1915
|
+
try:
|
|
1916
|
+
from http_client import register_spec_limit
|
|
1917
|
+
register_spec_limit(spec.get("_name", "flk_law"), None, 2500)
|
|
1918
|
+
except ImportError:
|
|
1919
|
+
pass
|
|
1920
|
+
|
|
1921
|
+
@safe_search
|
|
1922
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1923
|
+
q = _FLK_ARTICLE_SPACE_RE.sub(r"第\1条", query.strip())
|
|
1924
|
+
if not q:
|
|
1925
|
+
return []
|
|
1926
|
+
to = _timeout or timeout
|
|
1927
|
+
req = urllib.request.Request(
|
|
1928
|
+
"https://flk.npc.gov.cn/law-search/search/list",
|
|
1929
|
+
data=json.dumps({
|
|
1930
|
+
"searchRange": 1, "sxrq": [], "gbrq": [], "searchType": 2,
|
|
1931
|
+
"sxx": [], "gbrqYear": [], "flfgCodeId": [], "zdjgCodeId": [],
|
|
1932
|
+
"searchContent": q,
|
|
1933
|
+
"orderByParam": {"order": "-1", "sort": ""},
|
|
1934
|
+
"pageNum": 1, "pageSize": min(max(int(n), 1), 20),
|
|
1935
|
+
}).encode("utf-8"),
|
|
1936
|
+
headers={
|
|
1937
|
+
"Content-Type": "application/json;charset=utf-8",
|
|
1938
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
1939
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
1940
|
+
"Referer": "https://flk.npc.gov.cn/",
|
|
1941
|
+
"Accept": "application/json",
|
|
1942
|
+
},
|
|
1943
|
+
method="POST",
|
|
1944
|
+
)
|
|
1945
|
+
# 服务端间歇性掐断 TLS 握手(实测约半数首连失败),单次退避重试消化波动
|
|
1946
|
+
data = None
|
|
1947
|
+
for attempt in range(2):
|
|
1948
|
+
try:
|
|
1949
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "flk_law")) as resp:
|
|
1950
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1951
|
+
break
|
|
1952
|
+
except Exception as e:
|
|
1953
|
+
if attempt == 0:
|
|
1954
|
+
time.sleep(4)
|
|
1955
|
+
continue
|
|
1956
|
+
logger.warning(f"flk 引擎失败: {e}")
|
|
1957
|
+
return []
|
|
1958
|
+
if data is None:
|
|
1959
|
+
return []
|
|
1960
|
+
if not isinstance(data, dict) or data.get("code") != 200:
|
|
1961
|
+
return []
|
|
1962
|
+
out = []
|
|
1963
|
+
for r in (data.get("rows") or [])[:max(int(n), 1)]:
|
|
1964
|
+
title = _strip_hl(r.get("title"))
|
|
1965
|
+
if not title:
|
|
1966
|
+
continue
|
|
1967
|
+
sxx = _FLK_SXX.get(r.get("sxx"), "")
|
|
1968
|
+
bits = [str(r.get("flxz") or "").strip(), sxx,
|
|
1969
|
+
str(r.get("zdjgName") or "").strip()]
|
|
1970
|
+
if r.get("gbrq"):
|
|
1971
|
+
bits.append(f"公布 {r['gbrq']}")
|
|
1972
|
+
if r.get("sxrq"):
|
|
1973
|
+
bits.append(f"施行 {r['sxrq']}")
|
|
1974
|
+
bbbs = str(r.get("bbbs") or "").strip()
|
|
1975
|
+
out.append({
|
|
1976
|
+
"title": title,
|
|
1977
|
+
"url": f"https://flk.npc.gov.cn/detail?id={bbbs}" if bbbs else "",
|
|
1978
|
+
"snippet": " · ".join(b for b in bits if b),
|
|
1979
|
+
"source": "flk_law",
|
|
1980
|
+
"published_at": str(r.get("gbrq") or ""),
|
|
1981
|
+
})
|
|
1982
|
+
return out
|
|
1983
|
+
return _engine
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _build_wikisource_engine(spec: dict[str, Any]) -> Any:
|
|
1987
|
+
"""维基文库(古文/公版文献全文,MediaWiki API,免认证)。
|
|
1988
|
+
|
|
1989
|
+
标题含空格需编码后拼 wiki 路径——声明式 url_template 不做编码,
|
|
1990
|
+
故走 builder。snippet 带 <span class="searchmatch"> 高亮,剥离保文本。
|
|
1991
|
+
"""
|
|
1992
|
+
timeout = spec.get("timeout", 10)
|
|
1993
|
+
|
|
1994
|
+
@safe_search
|
|
1995
|
+
def _engine(query: str, n: int = 5, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
1996
|
+
q = query.strip()
|
|
1997
|
+
if not q:
|
|
1998
|
+
return []
|
|
1999
|
+
to = _timeout or timeout
|
|
2000
|
+
url = ("https://zh.wikisource.org/w/api.php?action=query&format=json&list=search"
|
|
2001
|
+
f"&srsearch={urllib.parse.quote(q)}&srlimit={min(max(int(n), 1), 20)}")
|
|
2002
|
+
raw = _http_get_raw(url, {"User-Agent": "argo-search/1.0 (+wikisource)", "Accept": "application/json"},
|
|
2003
|
+
to, engine=spec.get("_name", "wikisource"))
|
|
2004
|
+
if raw is None:
|
|
2005
|
+
return []
|
|
2006
|
+
try:
|
|
2007
|
+
items = ((json.loads(raw) or {}).get("query") or {}).get("search") or []
|
|
2008
|
+
except (json.JSONDecodeError, ValueError):
|
|
2009
|
+
return []
|
|
2010
|
+
out = []
|
|
2011
|
+
for it in items[:max(int(n), 1)]:
|
|
2012
|
+
title = str(it.get("title") or "").strip()
|
|
2013
|
+
if not title:
|
|
2014
|
+
continue
|
|
2015
|
+
snippet = re.sub(r"\s+", " ", _TAG_RE.sub("", str(it.get("snippet") or ""))).strip()
|
|
2016
|
+
out.append({
|
|
2017
|
+
"title": title,
|
|
2018
|
+
"url": "https://zh.wikisource.org/wiki/" + urllib.parse.quote(title.replace(" ", "_")),
|
|
2019
|
+
"snippet": snippet[:300],
|
|
2020
|
+
"source": "wikisource",
|
|
2021
|
+
"published_at": str(it.get("timestamp") or ""),
|
|
2022
|
+
})
|
|
2023
|
+
return out
|
|
2024
|
+
return _engine
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
# ── 微博热搜榜引擎 ─────────────────────────────────────────────────────────────
|
|
2028
|
+
|
|
2029
|
+
def _build_weibo_hot_engine(spec: dict[str, Any]) -> Any:
|
|
2030
|
+
"""微博热搜榜(weibo.com/ajax/side/hotSearch,免登录)
|
|
2031
|
+
|
|
2032
|
+
榜单型源:查询词只作路由触发,不参与内容过滤(见 config 的 relevance_check: false)。
|
|
2033
|
+
"""
|
|
2034
|
+
timeout = spec.get("timeout", 10)
|
|
2035
|
+
|
|
2036
|
+
@safe_search
|
|
2037
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
2038
|
+
to = _timeout or timeout
|
|
2039
|
+
url = "https://weibo.com/ajax/side/hotSearch"
|
|
2040
|
+
headers = {
|
|
2041
|
+
"User-Agent": "Mozilla/5.0",
|
|
2042
|
+
# 缺 Referer 时上游直接返回 {"error":"Forbidden"}(实测)
|
|
2043
|
+
"Referer": "https://weibo.com/",
|
|
2044
|
+
}
|
|
2045
|
+
req = urllib.request.Request(url, headers=headers)
|
|
2046
|
+
try:
|
|
2047
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
2048
|
+
d = json.loads(resp.read().decode("utf-8", "replace"))
|
|
2049
|
+
rows = (d.get("data") or {}).get("realtime") or []
|
|
2050
|
+
results = []
|
|
2051
|
+
for i, it in enumerate(rows):
|
|
2052
|
+
if len(results) >= n:
|
|
2053
|
+
break
|
|
2054
|
+
word = str(it.get("word") or "").strip()
|
|
2055
|
+
if not word:
|
|
2056
|
+
continue
|
|
2057
|
+
rank = it.get("realpos") or (i + 1)
|
|
2058
|
+
label = str(it.get("label_name") or "").strip()
|
|
2059
|
+
num = it.get("num") or 0
|
|
2060
|
+
results.append({
|
|
2061
|
+
"title": word[:80],
|
|
2062
|
+
"url": "https://s.weibo.com/weibo?q=" + urllib.parse.quote("#" + word + "#"),
|
|
2063
|
+
"snippet": f"微博热搜 第{rank}位 · 热度 {num}" + (f" · {label}" if label else ""),
|
|
2064
|
+
"source": "weibo_hot",
|
|
2065
|
+
"score": max(1.0 - len(results) * 0.02, 0.2),
|
|
2066
|
+
})
|
|
2067
|
+
return results
|
|
2068
|
+
except Exception as e:
|
|
2069
|
+
logger.warning(f"微博热搜榜失败: {e}")
|
|
2070
|
+
return []
|
|
2071
|
+
return _engine
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
# ── 抖音热榜引擎 ─────────────────────────────────────────────────────────────
|
|
2075
|
+
|
|
2076
|
+
def _build_douyin_hot_engine(spec: dict[str, Any]) -> Any:
|
|
2077
|
+
"""抖音热榜(iesdouyin web api,免认证,榜单型)"""
|
|
2078
|
+
timeout = spec.get("timeout", 10)
|
|
2079
|
+
|
|
2080
|
+
@safe_search
|
|
2081
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
2082
|
+
to = _timeout or timeout
|
|
2083
|
+
url = "https://www.iesdouyin.com/web/api/v2/hotsearch/billboard/word/"
|
|
2084
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.douyin.com/"}
|
|
2085
|
+
req = urllib.request.Request(url, headers=headers)
|
|
2086
|
+
try:
|
|
2087
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
2088
|
+
d = json.loads(resp.read().decode("utf-8", "replace"))
|
|
2089
|
+
rows = d.get("word_list") or []
|
|
2090
|
+
results = []
|
|
2091
|
+
for i, it in enumerate(rows):
|
|
2092
|
+
if len(results) >= n:
|
|
2093
|
+
break
|
|
2094
|
+
word = str(it.get("word") or "").strip()
|
|
2095
|
+
if not word:
|
|
2096
|
+
continue
|
|
2097
|
+
hot = it.get("hot_value") or 0
|
|
2098
|
+
results.append({
|
|
2099
|
+
"title": word[:80],
|
|
2100
|
+
"url": "https://www.douyin.com/search/" + urllib.parse.quote(word),
|
|
2101
|
+
"snippet": f"抖音热榜 第{i + 1}位 · 热度 {hot}",
|
|
2102
|
+
"source": "douyin_hot",
|
|
2103
|
+
"score": max(1.0 - len(results) * 0.02, 0.2),
|
|
2104
|
+
})
|
|
2105
|
+
return results
|
|
2106
|
+
except Exception as e:
|
|
2107
|
+
logger.warning(f"抖音热榜失败: {e}")
|
|
2108
|
+
return []
|
|
2109
|
+
return _engine
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
# ── CSDN 搜索 ────────────────────────────────────────────────────────────────
|
|
2113
|
+
|
|
2114
|
+
_EM_TAG_RE = re.compile(r"</?em>")
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
def _build_csdn_engine(spec: dict[str, Any]) -> Any:
|
|
2118
|
+
"""CSDN 搜索(so.csdn.net v3 API,免认证)
|
|
2119
|
+
|
|
2120
|
+
标题与摘要是带 <em> 高亮的片段,URL 带 utm/request_id 追踪参数——两者都在这里清掉,
|
|
2121
|
+
否则召回分会被标签噪声拉低、URL 也无法直接复用。
|
|
2122
|
+
"""
|
|
2123
|
+
timeout = spec.get("timeout", 12)
|
|
2124
|
+
|
|
2125
|
+
@safe_search
|
|
2126
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
2127
|
+
to = _timeout or timeout
|
|
2128
|
+
params = urllib.parse.urlencode({"q": query, "t": "blog", "p": 1})
|
|
2129
|
+
url = f"https://so.csdn.net/api/v3/search?{params}"
|
|
2130
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://so.csdn.net/"}
|
|
2131
|
+
req = urllib.request.Request(url, headers=headers)
|
|
2132
|
+
try:
|
|
2133
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
2134
|
+
d = json.loads(resp.read().decode("utf-8", "replace"))
|
|
2135
|
+
rows = d.get("result_vos") or []
|
|
2136
|
+
results = []
|
|
2137
|
+
for it in rows:
|
|
2138
|
+
if len(results) >= n:
|
|
2139
|
+
break
|
|
2140
|
+
title = _EM_TAG_RE.sub("", str(it.get("title") or "")).strip()
|
|
2141
|
+
link = str(it.get("url") or "").split("?")[0]
|
|
2142
|
+
if not title or not link:
|
|
2143
|
+
continue
|
|
2144
|
+
desc = _EM_TAG_RE.sub(
|
|
2145
|
+
"", str(it.get("description") or it.get("digest") or "")
|
|
2146
|
+
).strip()
|
|
2147
|
+
results.append({
|
|
2148
|
+
"title": title[:120],
|
|
2149
|
+
"url": link,
|
|
2150
|
+
"snippet": re.sub(r"\s+", " ", desc)[:220],
|
|
2151
|
+
"source": "csdn",
|
|
2152
|
+
"author": str(it.get("author") or ""),
|
|
2153
|
+
"published_at": str(it.get("created_at") or it.get("create_time_str") or ""),
|
|
2154
|
+
})
|
|
2155
|
+
return results
|
|
2156
|
+
except Exception as e:
|
|
2157
|
+
logger.warning(f"CSDN 搜索失败: {e}")
|
|
2158
|
+
return []
|
|
2159
|
+
return _engine
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
# ── 华尔街见闻快讯引擎 ─────────────────────────────────────────────────────────
|
|
2163
|
+
|
|
2164
|
+
def _build_wallstreetcn_engine(spec: dict[str, Any]) -> Any:
|
|
2165
|
+
"""华尔街见闻快讯(lives 直播流,免认证)
|
|
2166
|
+
|
|
2167
|
+
与财联社电报同为「全量快讯流 + 本地关键词过滤」形态:带具体主题的查询做过滤,
|
|
2168
|
+
纯触发词(快讯/财经等)放行全量榜单,避免过滤后空结果。
|
|
2169
|
+
"""
|
|
2170
|
+
timeout = spec.get("timeout", 10)
|
|
2171
|
+
|
|
2172
|
+
@safe_search
|
|
2173
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
2174
|
+
from datetime import datetime
|
|
2175
|
+
to = _timeout or timeout
|
|
2176
|
+
# 关键词过滤时放大拉取量:上游 limit 上限就是 100,过滤窗口开满才不至于空手
|
|
2177
|
+
fetch_n = min(max(int(n) * 5, 60), 100)
|
|
2178
|
+
url = (
|
|
2179
|
+
"https://api-one.wallstcn.com/apiv1/content/lives"
|
|
2180
|
+
f"?channel=global-channel&limit={fetch_n}"
|
|
2181
|
+
)
|
|
2182
|
+
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://wallstreetcn.com/"}
|
|
2183
|
+
req = urllib.request.Request(url, headers=headers)
|
|
2184
|
+
try:
|
|
2185
|
+
with http_open(req, timeout=to, engine=spec.get("_name", "")) as resp:
|
|
2186
|
+
d = json.loads(resp.read().decode("utf-8", "replace"))
|
|
2187
|
+
rows = (d.get("data") or {}).get("items") or []
|
|
2188
|
+
keywords = query.strip().split() if _should_filter(query) else []
|
|
2189
|
+
results = []
|
|
2190
|
+
for it in rows:
|
|
2191
|
+
if len(results) >= n:
|
|
2192
|
+
break
|
|
2193
|
+
title = str(it.get("title") or "").strip()
|
|
2194
|
+
content = str(it.get("content_text") or "")
|
|
2195
|
+
if keywords and not any(
|
|
2196
|
+
k.lower() in (title + content).lower() for k in keywords
|
|
2197
|
+
):
|
|
2198
|
+
continue
|
|
2199
|
+
ts = it.get("display_time")
|
|
2200
|
+
t = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
|
|
2201
|
+
results.append({
|
|
2202
|
+
"title": (title or content[:60]).strip()[:100],
|
|
2203
|
+
"url": str(it.get("uri") or "https://wallstreetcn.com/live/global"),
|
|
2204
|
+
"snippet": (f"{t} | {content}" if t else content)[:220],
|
|
2205
|
+
"source": "wallstreetcn",
|
|
2206
|
+
"published_at": t,
|
|
2207
|
+
})
|
|
2208
|
+
return results
|
|
2209
|
+
except Exception as e:
|
|
2210
|
+
logger.warning(f"华尔街见闻快讯失败: {e}")
|
|
2211
|
+
return []
|
|
2212
|
+
return _engine
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
# ── 中国天气网引擎 ─────────────────────────────────────────────────────────────
|
|
2216
|
+
|
|
2217
|
+
# 查询里的自然语言噪声:城市名之外的部分全部剥掉("上海今天天气怎么样" → "上海")
|
|
2218
|
+
# 「今日/明日/昨日」是口语高频形态(与「今天」等价),漏掉会让联想接口拿
|
|
2219
|
+
# 「今日北京」查不到 cityid → 引擎静默返回 0 条。
|
|
2220
|
+
_WEATHER_CN_NOISE_RE = re.compile(
|
|
2221
|
+
r"(今[天日晚]|明[天日晚]|后[天日]|昨[天日]|现在|实时|当前|查询|怎么样|如何|多少度|"
|
|
2222
|
+
r"气温|温度|天气|预报|阴晴|下雨|的|了|呢|吗|\?|?|,|,|。|\s)+"
|
|
2223
|
+
)
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
def _build_weather_cn_engine(spec: dict[str, Any]) -> Any:
|
|
2227
|
+
"""中国天气网(城市联想 → 实况,免认证,两步)
|
|
2228
|
+
|
|
2229
|
+
第一步 toy1.weather.com.cn/search 把城市名换成 cityid,第二步 data/sk/<id>.html 取实况。
|
|
2230
|
+
联想接口返回 JSONP 外壳(`([...])` 或 `callback([...])`),这里把外层剥掉再解析。
|
|
2231
|
+
"""
|
|
2232
|
+
timeout = spec.get("timeout", 10)
|
|
2233
|
+
|
|
2234
|
+
@safe_search
|
|
2235
|
+
def _engine(query: str, n: int = 10, _timeout: float | None = None, **kwargs) -> list[dict[str, Any]]:
|
|
2236
|
+
to = _timeout or timeout
|
|
2237
|
+
city = _WEATHER_CN_NOISE_RE.sub("", query or "").strip() or (query or "").strip()
|
|
2238
|
+
if not city:
|
|
2239
|
+
return []
|
|
2240
|
+
headers = {
|
|
2241
|
+
"User-Agent": "Mozilla/5.0",
|
|
2242
|
+
"Referer": "https://www.weather.com.cn/",
|
|
2243
|
+
}
|
|
2244
|
+
try:
|
|
2245
|
+
u1 = "https://toy1.weather.com.cn/search?cityname=" + urllib.parse.quote(city)
|
|
2246
|
+
with http_open(urllib.request.Request(u1, headers=headers),
|
|
2247
|
+
timeout=to, engine=spec.get("_name", "")) as resp:
|
|
2248
|
+
raw = resp.read().decode("utf-8", "replace").strip()
|
|
2249
|
+
if not raw.startswith("["):
|
|
2250
|
+
lo, hi = raw.find("("), raw.rfind(")")
|
|
2251
|
+
raw = raw[lo + 1:hi] if lo >= 0 and hi > lo else raw
|
|
2252
|
+
cand = json.loads(raw) or []
|
|
2253
|
+
if not cand:
|
|
2254
|
+
return []
|
|
2255
|
+
parts = str(cand[0].get("ref") or "").split("~")
|
|
2256
|
+
if not parts or not parts[0]:
|
|
2257
|
+
return []
|
|
2258
|
+
cid = parts[0]
|
|
2259
|
+
name = parts[2] if len(parts) > 2 else city
|
|
2260
|
+
|
|
2261
|
+
# 注意:老的 /data/sk/<id>.html 已 301 到 HTML 页(不再吐 JSON),
|
|
2262
|
+
# 现役实况端点是 d1 的 sk_2d,响应为 JS 赋值 `var dataSK={...}`。
|
|
2263
|
+
u2 = f"https://d1.weather.com.cn/sk_2d/{cid}.html"
|
|
2264
|
+
with http_open(urllib.request.Request(u2, headers=headers),
|
|
2265
|
+
timeout=to, engine=spec.get("_name", "")) as resp:
|
|
2266
|
+
body = resp.read().decode("utf-8", "replace").strip()
|
|
2267
|
+
brace = body.find("{")
|
|
2268
|
+
sk = json.loads(body[brace:]) if brace >= 0 else {}
|
|
2269
|
+
if not sk:
|
|
2270
|
+
return []
|
|
2271
|
+
return [{
|
|
2272
|
+
"title": (f"{sk.get('cityname') or name} 实况:{sk.get('temp', '')}℃ "
|
|
2273
|
+
f"{sk.get('weather', '')} {sk.get('WD', '')}{sk.get('WS', '')}"),
|
|
2274
|
+
"url": f"https://www.weather.com.cn/weather/{cid}.shtml",
|
|
2275
|
+
"snippet": (f"湿度 {sk.get('SD', '')} · 气压 {sk.get('qy', '')}hPa · "
|
|
2276
|
+
f"AQI {sk.get('aqi', '')} · 能见度 {sk.get('njd', '')} · "
|
|
2277
|
+
f"观测 {sk.get('time', '')} · {sk.get('date', '')}"),
|
|
2278
|
+
"source": "weather_cn",
|
|
2279
|
+
}]
|
|
2280
|
+
except Exception as e:
|
|
2281
|
+
logger.warning(f"中国天气网失败: {e}")
|
|
2282
|
+
return []
|
|
2283
|
+
return _engine
|