argo-search 1.0.1 → 2.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -21
- package/README.en.md +822 -0
- package/README.es.md +864 -0
- package/README.ja.md +864 -0
- package/README.ko.md +864 -0
- package/README.md +867 -320
- package/SKILL.md +135 -0
- package/assets/readme/hero.svg +121 -0
- package/assets/readme/made-with-beautify.svg +10 -0
- package/assets/readme/proof-routes.svg +73 -0
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2308 -96
- package/backends/engine_registry.yaml +3314 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2409 -161
- package/backends/source_types_cn.json +83 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +703 -0
- package/bin/argo.js +71 -39
- package/config.yaml +4976 -363
- package/cordis.patch.yml +21 -0
- package/data/countries.json +40 -0
- package/data/lang_matrix/lang_capability.json +842 -0
- package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
- package/data/regions_cn.json +1 -0
- package/docs/ADDING_NEW_ENGINE.md +184 -0
- package/docs/ARGO_INTRO.md +124 -0
- package/docs/DATA_SOURCE_GAPS.md +81 -0
- package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
- package/docs/ENGINE_CATALOG.md +564 -0
- package/docs/MCP_SETUP.md +106 -0
- package/docs/PLATFORM_PATHS.md +111 -0
- package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
- package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
- package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
- package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
- package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
- package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
- package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
- package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
- package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
- package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
- package/docs/SEARCH_ARCHIVE.md +115 -0
- package/docs/assets/architecture.svg +104 -0
- package/docs/assets/hero.svg +98 -0
- package/docs/engines/bangumi.md +27 -0
- package/docs/engines/biorxiv.md +27 -0
- package/docs/engines/clawhub.md +27 -0
- package/docs/engines/crt_sh.md +27 -0
- package/docs/engines/datacite.md +27 -0
- package/docs/engines/deps_dev.md +27 -0
- package/docs/engines/douban_movie.md +27 -0
- package/docs/engines/endoflife.md +27 -0
- package/docs/engines/flk_law.md +27 -0
- package/docs/engines/fxtwitter.md +27 -0
- package/docs/engines/google_news.md +27 -0
- package/docs/engines/gov_regulations.md +27 -0
- package/docs/engines/guqian.md +27 -0
- package/docs/engines/hackernews.md +27 -0
- package/docs/engines/iplant.md +27 -0
- package/docs/engines/keenable.md +27 -0
- package/docs/engines/met_museum.md +27 -0
- package/docs/engines/nasa_images.md +27 -0
- package/docs/engines/netease_music.md +27 -0
- package/docs/engines/npm.md +27 -0
- package/docs/engines/nvd.md +27 -0
- package/docs/engines/openreview.md +27 -0
- package/docs/engines/openstd.md +27 -0
- package/docs/engines/parallel_free.md +27 -0
- package/docs/engines/people_daily.md +27 -0
- package/docs/engines/realtime_index.md +27 -0
- package/docs/engines/sec_edgar.md +27 -0
- package/docs/engines/seltz.md +27 -0
- package/docs/engines/skillsmp.md +27 -0
- package/docs/engines/sspai.md +27 -0
- package/docs/engines/std_samr.md +27 -0
- package/docs/engines/train.md +27 -0
- package/docs/engines/un_comtrade.md +27 -0
- package/docs/engines/wikisource.md +27 -0
- package/docs/engines/zdic.md +27 -0
- package/docs/engines/zenodo.md +27 -0
- package/docs/engines/zhihu_hot_app.md +27 -0
- package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
- package/engines/README.md +10 -0
- package/engines/_template_cli.yaml +39 -0
- package/engines/_template_http.yaml +37 -0
- package/engines/plugins/_template_plugin.py +50 -0
- package/engines/specs/_cisa_kev.yaml +54 -0
- package/engines/specs/_osv.yaml +63 -0
- package/engines/specs/aljazeera.yaml +43 -0
- package/engines/specs/antara.yaml +43 -0
- package/engines/specs/artic.yaml +20 -0
- package/engines/specs/aviation_weather.yaml +39 -0
- package/engines/specs/bangumi.yaml +21 -0
- package/engines/specs/biorxiv.yaml +24 -0
- package/engines/specs/carbon_intensity.yaml +32 -0
- package/engines/specs/cinii.yaml +44 -0
- package/engines/specs/clawhub.yaml +49 -0
- package/engines/specs/cleveland.yaml +19 -0
- package/engines/specs/cn_ai_news.yaml +43 -0
- package/engines/specs/core.yaml +35 -0
- package/engines/specs/crt_sh.yaml +38 -0
- package/engines/specs/ct24.yaml +43 -0
- package/engines/specs/datacite.yaml +27 -0
- package/engines/specs/datagov_uk.yaml +41 -0
- package/engines/specs/ddgs_images.yaml +45 -0
- package/engines/specs/deepseek_search.yaml +74 -0
- package/engines/specs/deezer.yaml +20 -0
- package/engines/specs/deps_dev.yaml +21 -0
- package/engines/specs/digup.yaml +36 -0
- package/engines/specs/doi.yaml +39 -0
- package/engines/specs/douban_movie.yaml +22 -0
- package/engines/specs/dw_news.yaml +47 -0
- package/engines/specs/egov_law.yaml +21 -0
- package/engines/specs/elpais.yaml +43 -0
- package/engines/specs/endoflife.yaml +21 -0
- package/engines/specs/energy_charts.yaml +21 -0
- package/engines/specs/factcheck_org.yaml +52 -0
- package/engines/specs/faz.yaml +43 -0
- package/engines/specs/federal_register.yaml +57 -0
- package/engines/specs/firecrawl.yaml +53 -0
- package/engines/specs/flk_law.yaml +24 -0
- package/engines/specs/folha.yaml +43 -0
- package/engines/specs/france24.yaml +44 -0
- package/engines/specs/full_fact.yaml +47 -0
- package/engines/specs/fxtwitter.yaml +31 -0
- package/engines/specs/gbfs_nyc.yaml +32 -0
- package/engines/specs/gdacs.yaml +21 -0
- package/engines/specs/gdelt.yaml +64 -0
- package/engines/specs/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -0
- package/engines/specs/hurriyet.yaml +43 -0
- package/engines/specs/iplant.yaml +25 -0
- package/engines/specs/jikan.yaml +24 -0
- package/engines/specs/jolpica.yaml +20 -0
- package/engines/specs/k10plus.yaml +20 -0
- package/engines/specs/keenable.yaml +44 -0
- package/engines/specs/lefigaro.yaml +43 -0
- package/engines/specs/listenbrainz.yaml +19 -0
- package/engines/specs/matichon.yaml +43 -0
- package/engines/specs/met_museum.yaml +21 -0
- package/engines/specs/nasa_images.yaml +45 -0
- package/engines/specs/netease_music.yaml +38 -0
- package/engines/specs/nhk.yaml +43 -0
- package/engines/specs/nhtsa_vpic.yaml +20 -0
- package/engines/specs/noaa_swpc.yaml +20 -0
- package/engines/specs/nvd.yaml +38 -0
- package/engines/specs/obis.yaml +20 -0
- package/engines/specs/opencitations.yaml +52 -0
- package/engines/specs/openf1.yaml +21 -0
- package/engines/specs/openfoodfacts.yaml +45 -0
- package/engines/specs/openligadb.yaml +20 -0
- package/engines/specs/openreview.yaml +37 -0
- package/engines/specs/openstd.yaml +21 -0
- package/engines/specs/people_daily.yaml +22 -0
- package/engines/specs/realtime_index.yaml +34 -0
- package/engines/specs/ror.yaml +20 -0
- package/engines/specs/satnogs.yaml +20 -0
- package/engines/specs/sec_edgar.yaml +46 -0
- package/engines/specs/skillsmp.yaml +50 -0
- package/engines/specs/sogou_weixin_browser.yaml +30 -0
- package/engines/specs/soilgrids.yaml +25 -0
- package/engines/specs/sspai.yaml +36 -0
- package/engines/specs/stackexchange.yaml +46 -0
- package/engines/specs/std_samr.yaml +21 -0
- package/engines/specs/tass.yaml +43 -0
- package/engines/specs/tle_mirror.yaml +21 -0
- package/engines/specs/train.yaml +37 -0
- package/engines/specs/tvmaze.yaml +20 -0
- package/engines/specs/twitter_syndication.yaml +31 -0
- package/engines/specs/un_comtrade.yaml +23 -0
- package/engines/specs/unpaywall.yaml +54 -0
- package/engines/specs/vnpress.yaml +43 -0
- package/engines/specs/weather.yaml +32 -0
- package/engines/specs/who_don.yaml +21 -0
- package/engines/specs/who_gho.yaml +20 -0
- package/engines/specs/wikimedia_commons.yaml +34 -0
- package/engines/specs/wikisource.yaml +22 -0
- package/engines/specs/worms.yaml +19 -0
- package/engines/specs/x_trending.yaml +34 -0
- package/engines/specs/yna.yaml +43 -0
- package/engines/specs/ynet.yaml +43 -0
- package/engines/specs/zbmath.yaml +40 -0
- package/engines/specs/zdic.yaml +22 -0
- package/engines/specs/zenodo.yaml +27 -0
- package/engines/specs/zhihu_hot_app.yaml +35 -0
- package/mcp/clients.yaml +74 -0
- package/package.json +51 -12
- package/packages/dsh-plugin/README.md +134 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1244 -0
- package/packages/dsh-plugin/dsh/index.js +2290 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/test/client.test.js +232 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/index.test.js +1338 -0
- package/references/query-templates.md +96 -0
- package/references/query-templates.yaml +118 -0
- package/scripts/ab_eval.py +219 -0
- package/scripts/ab_eval_p0p1.py +266 -0
- package/scripts/adaptive.py +271 -70
- package/scripts/answer.py +154 -0
- package/scripts/archive_run.py +591 -0
- package/scripts/argo_engine_registry.py +172 -0
- package/scripts/argo_paths.py +728 -0
- package/scripts/article.py +136 -0
- package/scripts/arxiv_source.py +201 -0
- package/scripts/badcase_ledger.py +252 -0
- package/scripts/batch_probe.py +241 -0
- package/scripts/benchmark.py +19 -0
- package/scripts/bounded_run.py +144 -0
- package/scripts/browser_auth.py +323 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1078 -67
- package/scripts/cache_guard.py +271 -0
- package/scripts/cache_key_vdom.py +56 -0
- package/scripts/cache_similarity.py +170 -0
- package/scripts/caliber_isolation.py +195 -0
- package/scripts/calibrate_lowquality.py +210 -0
- package/scripts/candidate_envelope.py +366 -0
- package/scripts/chrome_cdp.py +871 -0
- package/scripts/circuit_breaker.py +389 -0
- package/scripts/citations.py +334 -0
- package/scripts/cite_cli.py +59 -0
- package/scripts/cjk_tokens.py +95 -0
- package/scripts/clarify.py +47 -24
- package/scripts/cli_io.py +240 -0
- package/scripts/config.py +683 -45
- package/scripts/config_cache.py +402 -0
- package/scripts/conn_pool.py +220 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +730 -0
- package/scripts/crawl.py +74 -35
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +1019 -0
- package/scripts/engine_env.py +534 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +732 -0
- package/scripts/engine_policy.py +218 -0
- package/scripts/engine_requires.py +190 -0
- package/scripts/engine_status.py +322 -0
- package/scripts/engine_validate.py +638 -0
- package/scripts/engines.py +778 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +298 -0
- package/scripts/engines_builders_batch10.py +159 -0
- package/scripts/engines_builders_batch11.py +174 -0
- package/scripts/engines_builders_batch12.py +103 -0
- package/scripts/engines_builders_batch13.py +340 -0
- package/scripts/engines_builders_batch9.py +1468 -0
- package/scripts/engines_builders_cn.py +2283 -0
- package/scripts/engines_builders_data.py +2923 -0
- package/scripts/engines_builders_data_macro.py +775 -0
- package/scripts/engines_builders_feeds.py +255 -0
- package/scripts/engines_builders_intl.py +1239 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1360 -0
- package/scripts/evidence.py +588 -162
- package/scripts/evidence_loop.py +592 -0
- package/scripts/evidence_tier.py +298 -0
- package/scripts/except_sets.py +25 -0
- package/scripts/extract.py +55 -16
- package/scripts/fact_align.py +207 -0
- package/scripts/fact_crosscheck.py +230 -0
- package/scripts/fetch.py +25 -12
- package/scripts/fetch_browser.py +87 -0
- package/scripts/fetch_jsrun.py +122 -0
- package/scripts/fetch_quality.py +225 -0
- package/scripts/fetch_render_tinyfish.py +117 -0
- package/scripts/fetch_v3.py +1779 -0
- package/scripts/focus_extract.py +350 -0
- package/scripts/fulltext_store.py +169 -0
- package/scripts/fxtwitter_api.py +325 -0
- package/scripts/gen_engine_catalog.py +364 -0
- package/scripts/gen_native_tools.py +91 -0
- package/scripts/gen_routing_snapshot.py +184 -0
- package/scripts/health_check.py +140 -402
- package/scripts/health_probe.py +23 -8
- package/scripts/hot_state.py +134 -0
- package/scripts/http_client.py +1053 -0
- package/scripts/image_ops.py +430 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +206 -0
- package/scripts/job.py +1186 -0
- package/scripts/jump_resolver.py +235 -0
- package/scripts/lang_capability.py +150 -0
- package/scripts/lang_detect.py +289 -0
- package/scripts/lang_matrix_probe.py +329 -0
- package/scripts/lang_pref.py +311 -0
- package/scripts/link_source.py +340 -0
- package/scripts/local_engines.py +321 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +366 -0
- package/scripts/macro_countries.py +82 -0
- package/scripts/matrix_search_eval.py +1105 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +1074 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +122 -0
- package/scripts/mcp_server.py +118 -417
- package/scripts/mcp_setup.py +407 -0
- package/scripts/mcp_tools.py +330 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/min_version_scan.py +335 -0
- package/scripts/net_proxy.py +258 -0
- package/scripts/network_aware.py +152 -0
- package/scripts/paper.py +274 -0
- package/scripts/pdf_extract.py +518 -0
- package/scripts/phrase_filter.py +66 -0
- package/scripts/plan.py +422 -0
- package/scripts/postprocess_signals.py +150 -0
- package/scripts/pre-commit +48 -0
- package/scripts/query_enhance.py +157 -0
- package/scripts/query_operators.py +196 -0
- package/scripts/query_rewriter.py +116 -7
- package/scripts/query_signals.py +300 -0
- package/scripts/query_templates.py +167 -0
- package/scripts/query_understanding.py +411 -0
- package/scripts/query_variants.py +298 -0
- package/scripts/quota.py +488 -76
- package/scripts/rank_signals.py +775 -0
- package/scripts/ranking_eval.py +319 -0
- package/scripts/readability_extract.py +433 -0
- package/scripts/recompute.py +415 -0
- package/scripts/recovery.py +616 -0
- package/scripts/recovery_l0.py +160 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +640 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +591 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +146 -0
- package/scripts/research_gates.py +272 -0
- package/scripts/research_report.py +209 -0
- package/scripts/research_strategy.py +37 -0
- package/scripts/research_work_packages.py +195 -0
- package/scripts/result_lang.py +311 -0
- package/scripts/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +225 -0
- package/scripts/route_combo.py +910 -0
- package/scripts/route_domains.py +328 -0
- package/scripts/route_lang.py +605 -0
- package/scripts/route_log.py +63 -0
- package/scripts/route_policy.py +250 -0
- package/scripts/rrf_classic.py +66 -0
- package/scripts/screenshot.py +73 -0
- package/scripts/search.py +897 -405
- package/scripts/search_attribution.py +88 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +627 -0
- package/scripts/search_entry.py +389 -0
- package/scripts/search_output.py +606 -0
- package/scripts/search_pipeline.py +1077 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +27 -12
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +215 -0
- package/scripts/serp_guard.py +228 -0
- package/scripts/serp_kill_log.py +51 -0
- package/scripts/single_flight.py +120 -0
- package/scripts/social_engines/__init__.py +10 -0
- package/scripts/social_engines/bilibili_engine.py +131 -0
- package/scripts/social_engines/hackernews_engine.py +113 -0
- package/scripts/social_engines/reddit_engine.py +172 -0
- package/scripts/social_engines/twitter_engine.py +397 -0
- package/scripts/social_engines/v2ex_engine.py +101 -0
- package/scripts/social_engines/weibo_engine.py +128 -0
- package/scripts/social_engines/xiaohongshu_engine.py +137 -0
- package/scripts/social_engines/zhihu_engine.py +139 -0
- package/scripts/social_research.py +201 -0
- package/scripts/sogou_weixin_browser.py +318 -0
- package/scripts/source_ledger.py +249 -0
- package/scripts/stage_timing.py +58 -0
- package/scripts/stats_cli.py +95 -0
- package/scripts/straggler_policy.py +60 -0
- package/scripts/stylometry_detector.py +231 -0
- package/scripts/sync_backends.py +353 -0
- package/scripts/sync_installs.py +29 -0
- package/scripts/template_cli.py +127 -0
- package/scripts/text_lexicon.py +72 -0
- package/scripts/tfidf_router.py +109 -37
- package/scripts/time_utils.py +233 -0
- package/scripts/topic_research_profiles.py +643 -0
- package/scripts/train.py +737 -0
- package/scripts/tweet.py +596 -0
- package/scripts/url_canon.py +217 -0
- package/scripts/url_optimize.py +56 -0
- package/scripts/url_safety.py +236 -0
- package/scripts/usage_log.py +116 -0
- package/scripts/usage_log_clean.py +113 -0
- package/scripts/v2ex_nodes.py +311 -0
- package/scripts/variant_recall.py +188 -0
- package/scripts/watch.py +205 -0
- package/scripts/wechat_article.py +258 -0
- package/scripts/wide_research.py +402 -0
- package/scripts/wx.py +349 -0
- package/scripts/x_article_fetch.py +141 -0
- package/scripts/x_search.py +234 -0
- package/scripts/x_trending.py +189 -0
- package/scripts/yaml_load.py +67 -0
- package/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/references/browser-runtime.md +43 -0
- package/sub-skills/ego-search/references/install.md +67 -0
- package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
- package/sub-skills/ego-search/scripts/ego_search.py +800 -0
- package/sub-skills/ego-search/scripts/merge.py +226 -0
- package/sub-skills/ego-search/scripts/quality.py +64 -0
- package/sub-skills/ego-search/scripts/runtime.py +172 -0
- package/sub-skills/ego-search/scripts/safety.py +109 -0
- package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
- package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
- package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
- package/sub-skills/js-run/SKILL.md +67 -0
- package/sub-skills/js-run/scripts/jsrun.py +112 -0
- package/sub-skills/js-run/scripts/shim.js +188 -0
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
- package/sub-skills/js-run/tests/test_jsrun.py +165 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +162 -0
- package/sub-skills/local-search/config.yaml +152 -55
- package/sub-skills/local-search/engine_registry.py +91 -26
- package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
- package/sub-skills/local-search/local_search_adapter.py +13 -2
- package/sub-skills/local-search/parse_maps.yaml +40 -42
- package/sub-skills/local-search/search_v3.py +1182 -65
- package/sub-skills/local-search/smart_router.py +47 -7
- package/sub-skills/local-seek/SKILL.md +110 -0
- package/sub-skills/local-seek/config/domains.yaml +20 -0
- package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
- package/sub-skills/local-seek/references/git-integration.md +54 -0
- package/sub-skills/local-seek/references/remote-handoff.md +46 -0
- package/sub-skills/local-seek/references/rg-recipes.md +96 -0
- package/sub-skills/local-seek/references/strategies.md +52 -0
- package/sub-skills/local-seek/references/structural-search.md +59 -0
- package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
- package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
- package/sub-skills/local-seek/scripts/seek.py +1465 -0
- package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
- package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
- package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
- package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
- package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
- package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
- package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
- package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
- package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
package/scripts/train.py
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""火车余票/中转/经停查询脚本(免 Key,纯标准库)。
|
|
3
|
+
|
|
4
|
+
能力(原生吸纳自外部列车查询技能,不保留其名称):
|
|
5
|
+
- 余票:官方两步接口 GET leftTicket/init 取会话 Cookie
|
|
6
|
+
→ GET leftTicket/queryG 查余票(返回 URL 编码的管道分隔行)
|
|
7
|
+
- 中转:GET lcQuery/init 运行时解析 lc_search_url → lcquery/queryG
|
|
8
|
+
按 result_index 翻页取换乘方案(前段+换乘站+后段)
|
|
9
|
+
- 经停:search.12306.cn 车次号搜 train_no → otn/queryTrainInfo/query
|
|
10
|
+
查全程经停(整条路线压成一行,防输出端截断)
|
|
11
|
+
- 站点表 station_name.js 解析 + 7 天本地缓存
|
|
12
|
+
- 输入:自然语言查询词("上海到北京" / "北京→上海 后天 G" /
|
|
13
|
+
"北京到拉萨 中转(经西宁)" / "G2 经停")
|
|
14
|
+
- 输出:YAML 结果列表,行 URL 带查询身份参数(同查询多行不共享裸 URL,
|
|
15
|
+
不会被 argo 的 URL 去重折叠——fred/worldbank 时序引擎同款约定)
|
|
16
|
+
|
|
17
|
+
用法:
|
|
18
|
+
python3 scripts/train.py "上海到北京"
|
|
19
|
+
python3 scripts/train.py "北京→上海 后天 G" --n 10
|
|
20
|
+
python3 scripts/train.py "北京到拉萨 中转"
|
|
21
|
+
python3 scripts/train.py "G2 经停"
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import urllib.parse as up
|
|
32
|
+
import urllib.request
|
|
33
|
+
from datetime import date, datetime, timedelta, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from net_proxy import open_url # 出口调度唯一入口(issue #13 同类修复)
|
|
36
|
+
|
|
37
|
+
_INIT_URL = "https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc"
|
|
38
|
+
_QUERY_URL = "https://kyfw.12306.cn/otn/leftTicket/queryG"
|
|
39
|
+
_LC_INIT_URL = "https://kyfw.12306.cn/otn/lcQuery/init"
|
|
40
|
+
_TRAIN_INFO_URL = "https://kyfw.12306.cn/otn/queryTrainInfo/query"
|
|
41
|
+
_SEARCH_API_URL = "https://search.12306.cn/search/v1/train/search"
|
|
42
|
+
_STATION_JS_URL = "https://kyfw.12306.cn/otn/resources/js/framework/station_name.js"
|
|
43
|
+
_CACHE_TTL_SECONDS = 7 * 24 * 3600
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cache_dir() -> Path:
|
|
47
|
+
"""站点表缓存目录:argo 状态目录下的 train/,**不写进源码树**。
|
|
48
|
+
|
|
49
|
+
此前是 `scripts/data/`(源码树内的目录),两个后果都实测过:
|
|
50
|
+
|
|
51
|
+
1. 测试污染生产缓存:tests/conftest.py 只隔离了 ARGO_STATE_DIR,而这里
|
|
52
|
+
绕过了它。2026-09-19 一次全量 pytest 把夹具里的 4 个站写进
|
|
53
|
+
scripts/data/stations.json,此后 7 天(TTL)真实查询只认那 4 个站,
|
|
54
|
+
「张家口 到 北京」这类站名全部解析失败——静默降级,没有任何报错。
|
|
55
|
+
(conftest 的注释记着同一类事故:v2ex 节点表曾把假节点写进
|
|
56
|
+
~/.cache/unified-search/。)
|
|
57
|
+
2. npx / 只读安装下源码树不可写,缓存必须落到可写的位置。
|
|
58
|
+
|
|
59
|
+
与 circuit_breaker / admission / health 同惯例:走 argo_paths.state_path(),
|
|
60
|
+
测试会话自动隔离,平台惯例目录也自动生效。
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
import argo_paths
|
|
64
|
+
return argo_paths.state_path("train")
|
|
65
|
+
except Exception:
|
|
66
|
+
# 兜底也不能落回源码树:scripts/data/ 正是「测试污染生产缓存」的事故
|
|
67
|
+
# 现场,兜底回那里等于把刚修掉的坑重新挖开。退到系统临时目录仍然
|
|
68
|
+
# fail-open(火车查询不会整个挂掉),且不再可能被测试写进仓库。
|
|
69
|
+
return Path(tempfile.gettempdir()) / "argo-train-cache"
|
|
70
|
+
|
|
71
|
+
_HEADERS = {
|
|
72
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
73
|
+
"Accept": "application/json,text/javascript,*/*",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# 12306 余票查询结果每行为管道分隔的 58 字段,字段索引映射(社区整理的稳定结构)。
|
|
77
|
+
_F = {
|
|
78
|
+
"trainNo": 2, "trainCode": 3, "fromCode": 6, "toCode": 7,
|
|
79
|
+
"departTime": 8, "arriveTime": 9, "duration": 10, "canBuy": 11, "date": 13,
|
|
80
|
+
"gr": 21, "rw": 23, "rz": 24, "tz": 25, "wz": 26, "yw": 28, "yz": 29,
|
|
81
|
+
"ze": 30, "zy": 31, "swz": 32, "dw": 33,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# 席别展示顺序与标签(软卧优先动卧、商务优先特等)
|
|
85
|
+
_SEAT_LABELS = [
|
|
86
|
+
("swz", "商务/特等"), ("zy", "一等"), ("ze", "二等"),
|
|
87
|
+
("rw", "软卧/动卧"), ("yw", "硬卧"), ("yz", "硬座"), ("wz", "无座"),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
_DATE_WORD = {"今天": 0, "明天": 1, "后天": 2, "大后天": 3}
|
|
91
|
+
_DATE_RE = re.compile(
|
|
92
|
+
r"(今天|明天|后天|大后天|\d{4}[-/]\d{1,2}[-/]\d{1,2}|\d{1,2}月\d{1,2}[日号])"
|
|
93
|
+
)
|
|
94
|
+
_TYPE_WORD = {
|
|
95
|
+
"高铁": "G", "G字头": "G", "动车": "D", "D字头": "D",
|
|
96
|
+
"直达": "Z", "Z字头": "Z", "特快": "T", "T字头": "T",
|
|
97
|
+
"快速": "K", "K字头": "K",
|
|
98
|
+
}
|
|
99
|
+
def _today_cn(days: int = 0) -> date:
|
|
100
|
+
return (datetime.now(timezone(timedelta(hours=8))) + timedelta(days=days)).date()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# 查询模式路由:车次号(G/D/C/Z/T/K/L/Y + 1-5 位数字)。前后禁字母数字:
|
|
104
|
+
# Python 的 \b 对 CJK 无效(\w 含汉字,「G2经停」在 2 和 经 之间没有词边界)。
|
|
105
|
+
_TRAIN_CODE_RE = re.compile(r"(?<![A-Za-z0-9])([GDCZTKLY]\d{1,5})(?![0-9A-Za-z])")
|
|
106
|
+
_ROUTE_WORDS = ("经停", "途经", "路过", "停靠", "停站", "时刻")
|
|
107
|
+
_TRANSFER_WORDS = ("中转", "换乘")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_travel_date(text: str) -> tuple[date | None, str]:
|
|
111
|
+
"""提取日期词 → (date, 去掉该词后的文本)。三种查询模式共用。"""
|
|
112
|
+
m = _DATE_RE.search(text)
|
|
113
|
+
if not m:
|
|
114
|
+
return None, text
|
|
115
|
+
tok = m.group(1)
|
|
116
|
+
text = text.replace(tok, " ", 1)
|
|
117
|
+
if tok in _DATE_WORD:
|
|
118
|
+
return _today_cn(_DATE_WORD[tok]), text
|
|
119
|
+
if re.match(r"^\d{4}", tok):
|
|
120
|
+
try:
|
|
121
|
+
return datetime.strptime(tok.replace("/", "-"), "%Y-%m-%d").date(), text
|
|
122
|
+
except ValueError:
|
|
123
|
+
return None, text
|
|
124
|
+
ymd = re.match(r"(\d{1,2})月(\d{1,2})[日号]", tok)
|
|
125
|
+
if ymd:
|
|
126
|
+
mm, dd = int(ymd.group(1)), int(ymd.group(2))
|
|
127
|
+
try:
|
|
128
|
+
return date(_today_cn().year, mm, dd), text
|
|
129
|
+
except ValueError:
|
|
130
|
+
return None, text
|
|
131
|
+
return None, text
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_query(q: str) -> dict | None:
|
|
135
|
+
"""从自然语言查询词解析 出发站/到达站/日期/车次类型。
|
|
136
|
+
|
|
137
|
+
支持:"上海到北京"、"北京→上海 后天"、"2026-08-10 北京 上海 高铁"、"从北京到上海 G"。
|
|
138
|
+
返回 dict 或 None(无法解析出起止站)。
|
|
139
|
+
"""
|
|
140
|
+
text = (q or "").strip()
|
|
141
|
+
if not text:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# 1) 日期词 → date(经停/中转模式共用 _parse_travel_date)
|
|
145
|
+
travel_date, text = _parse_travel_date(text)
|
|
146
|
+
|
|
147
|
+
# 2) 车次类型词 → type
|
|
148
|
+
train_type = ""
|
|
149
|
+
m = _TYPE_WORD_RE().search(text)
|
|
150
|
+
if m:
|
|
151
|
+
train_type = _TYPE_WORD[m.group(1)]
|
|
152
|
+
text = text.replace(m.group(1), " ", 1)
|
|
153
|
+
|
|
154
|
+
# 3) 去掉「从」前缀,按分隔符拆分起止站
|
|
155
|
+
text = re.sub(r"^从", " ", text.strip())
|
|
156
|
+
# 「N张」是购票数量,不是站名:不摘掉的话「1张 北京 到 上海」的起站会变成
|
|
157
|
+
# 「1张」(实测 2026-09-21)。判据是「独立成词」而不是「后面不接某几个字」:
|
|
158
|
+
# 负向列举永远列不全(实测「买1 张家口 到 北京」会被吃掉张字变成「家口」),
|
|
159
|
+
# 正向断言词尾才是这条规则的真实含义。
|
|
160
|
+
text = re.sub(r"\d{1,2}\s*张(?:票)?(?=\s|$|[,。!?、]|到|→|->|=>|至)", " ", text)
|
|
161
|
+
parts = re.split(r"到|→|->|=>|至|—|--|\s+", text)
|
|
162
|
+
# 只去空白与句读,**不要按字符集剥站名**:站名里含「张/高/次/包/头/站/车/票」
|
|
163
|
+
# 的很多,`strip("站车票张次列高动直特快速字头")` 会把首字当填充字符吃掉——
|
|
164
|
+
# 2026-09-21 实测「张家口 到 北京」→「家口」、「包头 到 北京」→「包」、
|
|
165
|
+
# 「高碑店」→「碑店」、「次渠」→「渠」。站名后缀(北京站/上海市)由
|
|
166
|
+
# _resolve_station 统一剥离(它已有 `[市站]$`),这里不必也不该再剥一遍。
|
|
167
|
+
parts = [p.strip(" \t,。!?、") for p in parts if p.strip()]
|
|
168
|
+
if len(parts) < 2:
|
|
169
|
+
return None
|
|
170
|
+
from_name, to_name = parts[0], parts[1]
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"from": from_name,
|
|
174
|
+
"to": to_name,
|
|
175
|
+
"date": (travel_date or _today_cn(1)).isoformat(),
|
|
176
|
+
"type": train_type,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_TYPE_WORD_RE_CACHE = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _TYPE_WORD_RE():
|
|
184
|
+
global _TYPE_WORD_RE_CACHE
|
|
185
|
+
if _TYPE_WORD_RE_CACHE is None:
|
|
186
|
+
_TYPE_WORD_RE_CACHE = re.compile(
|
|
187
|
+
"(" + "|".join(re.escape(k) for k in sorted(_TYPE_WORD, key=len, reverse=True)) + ")"
|
|
188
|
+
)
|
|
189
|
+
return _TYPE_WORD_RE_CACHE
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_mode(text: str) -> str:
|
|
193
|
+
"""查询模式路由:route(经停)/ transfer(中转)/ tickets(余票,默认)。
|
|
194
|
+
|
|
195
|
+
车次号 + 经停类词 → route;裸车次号(除车次号只剩「次/车/时刻表」这类
|
|
196
|
+
后缀词)→ route——「G2」「G2次」单独出现时问的只能是这趟车本身;
|
|
197
|
+
含中转/换乘 → transfer;其余走原有余票解析。
|
|
198
|
+
"""
|
|
199
|
+
m = _TRAIN_CODE_RE.search(text)
|
|
200
|
+
if m and any(w in text for w in _ROUTE_WORDS):
|
|
201
|
+
return "route"
|
|
202
|
+
if m:
|
|
203
|
+
rest = (text[: m.start()] + text[m.end():]).strip(" \t,。!?、")
|
|
204
|
+
if not rest or re.fullmatch(r"次|车|列车|时刻表|的时刻表", rest):
|
|
205
|
+
return "route"
|
|
206
|
+
if any(w in text for w in _TRANSFER_WORDS):
|
|
207
|
+
return "transfer"
|
|
208
|
+
return "tickets"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# 换乘站提取:「中转(经/停/于/站)X」「在X中转」「经X」三式。捕获用非贪婪 +
|
|
212
|
+
# 边界字符收尾(下一位必须是 到/至/空白/标点/结尾,不许跨字扫描):前瞻里若
|
|
213
|
+
# 写 `[\u4e00-\u9fff]*(?:到|至)`,前缀能跳过任意汉字,「中转南京南到上海」会
|
|
214
|
+
# 在「南京」处提前满足边界截成两半。提取结果必须过 _resolve_station 认证——
|
|
215
|
+
# 解析不出站码的捕获一律弃用,宁可丢中转站也不能把站名误当换乘站。
|
|
216
|
+
_MIDDLE_RES = (
|
|
217
|
+
re.compile(r"在([\u4e00-\u9fff]{2,8}?)(?:中转|换乘)"),
|
|
218
|
+
re.compile(r"(?:中转|换乘)(?:经|停|经停|于|站)?([\u4e00-\u9fff]{2,8}?)"
|
|
219
|
+
r"(?=到|至|$|\s|[,。!?、])"),
|
|
220
|
+
re.compile(r"经(?:停|于)?([\u4e00-\u9fff]{2,8}?)"
|
|
221
|
+
r"(?=到|至|$|\s|[,。!?、])"),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_middle(text: str, data: dict) -> tuple[dict | None, str]:
|
|
226
|
+
"""从 transfer 查询词提取换乘站 → (站点码 dict, 去掉该片段后的文本)。"""
|
|
227
|
+
for rex in _MIDDLE_RES:
|
|
228
|
+
m = rex.search(text)
|
|
229
|
+
if not m:
|
|
230
|
+
continue
|
|
231
|
+
st = _resolve_station(data, m.group(1))
|
|
232
|
+
if st:
|
|
233
|
+
return st, (text[: m.start()] + " " + text[m.end():])
|
|
234
|
+
return None, text
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _fetch(url: str, headers: dict | None = None, timeout: float = 15):
|
|
238
|
+
req = urllib.request.Request(url, headers={**_HEADERS, **(headers or {})})
|
|
239
|
+
return open_url(req, timeout=timeout)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _get_cookie(timeout: float = 15) -> str:
|
|
243
|
+
"""取 12306 会话 Cookie(多个 Set-Cookie 拼一行)。"""
|
|
244
|
+
with _fetch(_INIT_URL, timeout=timeout) as resp:
|
|
245
|
+
cookies = resp.headers.get_all("Set-Cookie") or []
|
|
246
|
+
return "; ".join(c.split(";")[0] for c in cookies if c)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _query_api(from_code: str, to_code: str, travel_date: str, cookie: str,
|
|
250
|
+
timeout: float = 15) -> list[list[str]]:
|
|
251
|
+
"""调 queryG 接口,返回每行 unquote 后的字段数组。"""
|
|
252
|
+
params = up.urlencode({
|
|
253
|
+
"leftTicketDTO.train_date": travel_date,
|
|
254
|
+
"leftTicketDTO.from_station": from_code,
|
|
255
|
+
"leftTicketDTO.to_station": to_code,
|
|
256
|
+
"purpose_codes": "ADULT",
|
|
257
|
+
})
|
|
258
|
+
url = f"{_QUERY_URL}?{params}"
|
|
259
|
+
with _fetch(url, headers={"Cookie": cookie, "Referer": _INIT_URL}, timeout=timeout) as resp:
|
|
260
|
+
payload = json.loads(resp.read().decode("utf-8", "replace"))
|
|
261
|
+
rows = ((payload.get("data") or {}).get("result")) or []
|
|
262
|
+
return [up.unquote(r).split("|") for r in rows]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _parse_station_data(js: str) -> dict:
|
|
266
|
+
"""解析 station_name.js(@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||)。"""
|
|
267
|
+
m = re.search(r"'([^']+)'", js)
|
|
268
|
+
raw = m.group(1) if m else ""
|
|
269
|
+
|
|
270
|
+
stations, city_stations = {}, {}
|
|
271
|
+
name_stations, city_codes = {}, {}
|
|
272
|
+
for entry in raw.split("@"):
|
|
273
|
+
parts = entry.split("|")
|
|
274
|
+
if len(parts) < 8:
|
|
275
|
+
continue
|
|
276
|
+
name, code = parts[1], parts[2]
|
|
277
|
+
if not name or not code:
|
|
278
|
+
continue
|
|
279
|
+
city = parts[7] or name
|
|
280
|
+
stations[code] = {"station_name": name, "station_code": code}
|
|
281
|
+
name_stations[name] = {"station_name": name, "station_code": code}
|
|
282
|
+
city_stations.setdefault(city, []).append({"station_name": name, "station_code": code})
|
|
283
|
+
if name == city:
|
|
284
|
+
city_codes[city] = {"station_name": name, "station_code": code}
|
|
285
|
+
return {
|
|
286
|
+
"STATIONS": stations, "NAME_STATIONS": name_stations,
|
|
287
|
+
"CITY_STATIONS": city_stations, "CITY_CODES": city_codes,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _load_stations(force: bool = False, cache_dir: Path | None = None) -> dict:
|
|
292
|
+
"""读取站点表(优先 7 天缓存)。"""
|
|
293
|
+
cache_dir = cache_dir or _cache_dir()
|
|
294
|
+
cache_file = cache_dir / "stations.json"
|
|
295
|
+
if not force and cache_file.is_file():
|
|
296
|
+
try:
|
|
297
|
+
cached = json.loads(cache_file.read_text("utf-8"))
|
|
298
|
+
if int(cached.get("ts", 0)) and \
|
|
299
|
+
int(cached["ts"]) > (datetime.now().timestamp() - _CACHE_TTL_SECONDS):
|
|
300
|
+
return cached["data"]
|
|
301
|
+
except (ValueError, KeyError, OSError):
|
|
302
|
+
pass
|
|
303
|
+
with _fetch(_STATION_JS_URL, timeout=20) as resp:
|
|
304
|
+
js = resp.read().decode("utf-8", "replace")
|
|
305
|
+
data = _parse_station_data(js)
|
|
306
|
+
try:
|
|
307
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
cache_file.write_text(
|
|
309
|
+
json.dumps({"ts": int(datetime.now().timestamp()), "data": data}, ensure_ascii=False),
|
|
310
|
+
"utf-8",
|
|
311
|
+
)
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
314
|
+
return data
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _resolve_station(data: dict, name: str) -> dict | None:
|
|
318
|
+
"""把城市/站名解析为 12306 站点码(精确站名 > 城市主站 > 城市首站)。"""
|
|
319
|
+
name = (name or "").strip()
|
|
320
|
+
if not name:
|
|
321
|
+
return None
|
|
322
|
+
if name in data["NAME_STATIONS"]:
|
|
323
|
+
return data["NAME_STATIONS"][name]
|
|
324
|
+
if name in data["CITY_CODES"]:
|
|
325
|
+
return data["CITY_CODES"][name]
|
|
326
|
+
if name in data["CITY_STATIONS"]:
|
|
327
|
+
return data["CITY_STATIONS"][name][0]
|
|
328
|
+
trimmed = re.sub(r"[市站]$", "", name)
|
|
329
|
+
if trimmed in data["NAME_STATIONS"]:
|
|
330
|
+
return data["NAME_STATIONS"][trimmed]
|
|
331
|
+
if trimmed in data["CITY_CODES"]:
|
|
332
|
+
return data["CITY_CODES"][trimmed]
|
|
333
|
+
if trimmed in data["CITY_STATIONS"]:
|
|
334
|
+
return data["CITY_STATIONS"][trimmed][0]
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _fmt_duration(raw: str) -> str:
|
|
339
|
+
h, _, m = raw.partition(":")
|
|
340
|
+
try:
|
|
341
|
+
hh, mm = int(h), int(m)
|
|
342
|
+
except ValueError:
|
|
343
|
+
return raw
|
|
344
|
+
return f"{hh}h{mm:02d}m" if hh else f"{mm}m"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _fmt_seat(v: str) -> str:
|
|
348
|
+
v = (v or "").strip()
|
|
349
|
+
if v in ("", "--"):
|
|
350
|
+
return ""
|
|
351
|
+
return v
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _parse_ticket(fields: list[str], data: dict) -> dict:
|
|
355
|
+
v = lambda key: fields[_F[key]] if len(fields) > _F[key] else ""
|
|
356
|
+
from_code, to_code = v("fromCode"), v("toCode")
|
|
357
|
+
stations = data["STATIONS"]
|
|
358
|
+
return {
|
|
359
|
+
"trainCode": v("trainCode"),
|
|
360
|
+
"fromStation": stations.get(from_code, {}).get("station_name", from_code),
|
|
361
|
+
"toStation": stations.get(to_code, {}).get("station_name", to_code),
|
|
362
|
+
"departTime": v("departTime"), "arriveTime": v("arriveTime"),
|
|
363
|
+
"duration": v("duration"), "canBuy": v("canBuy"), "date": v("date"),
|
|
364
|
+
"swz": v("swz"), "tz": v("tz"), "zy": v("zy"), "ze": v("ze"),
|
|
365
|
+
"gr": v("gr"), "rw": v("rw"), "dw": v("dw"),
|
|
366
|
+
"yw": v("yw"), "rz": v("rz"), "yz": v("yz"), "wz": v("wz"),
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _row_url(route: dict | None, train_code: str) -> str:
|
|
371
|
+
"""余票行的行身份 URL。
|
|
372
|
+
|
|
373
|
+
余票行没有独立落地页,载体都是同一个 init 查询页;此前每行发裸 URL,
|
|
374
|
+
argo 的 URL 去重(deduplicate_by_url 按归一 URL 折叠)把 10 条车次压成
|
|
375
|
+
1 条(2026-10-06 canary 实测 returned 10 → deduped 1)。修法走本仓
|
|
376
|
+
既有约定(fred/worldbank 等时序引擎):数据行的内容身份放进 URL 查询
|
|
377
|
+
参数——url_canon 只删追踪参数不删语义参数,search_rank 的
|
|
378
|
+
_distinct_data_rows 亦按「同文档、不同查询」豁免 minhash 近重复。
|
|
379
|
+
参数形态取自 queryG 真实请求,train_code 标行。
|
|
380
|
+
"""
|
|
381
|
+
if not route:
|
|
382
|
+
return "https://kyfw.12306.cn/otn/leftTicket/init"
|
|
383
|
+
qs = up.urlencode({
|
|
384
|
+
"leftTicketDTO.train_date": route.get("date", ""),
|
|
385
|
+
"leftTicketDTO.from_station": route.get("from_code", ""),
|
|
386
|
+
"leftTicketDTO.to_station": route.get("to_code", ""),
|
|
387
|
+
"purpose_codes": "ADULT",
|
|
388
|
+
"train_code": train_code,
|
|
389
|
+
})
|
|
390
|
+
return f"https://kyfw.12306.cn/otn/leftTicket/init?{qs}"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _build_rows(tickets: list[dict], limit: int,
|
|
394
|
+
route: dict | None = None) -> list[dict]:
|
|
395
|
+
rows = []
|
|
396
|
+
for t in tickets[: max(1, limit)]:
|
|
397
|
+
seats = []
|
|
398
|
+
for key, label in _SEAT_LABELS:
|
|
399
|
+
val = _fmt_seat(t.get(key))
|
|
400
|
+
if not val:
|
|
401
|
+
continue
|
|
402
|
+
if key == "swz" and not val and t.get("tz"):
|
|
403
|
+
val = _fmt_seat(t["tz"])
|
|
404
|
+
if key == "rw" and not val and t.get("dw"):
|
|
405
|
+
val = _fmt_seat(t["dw"])
|
|
406
|
+
seats.append(f"{label} {val}")
|
|
407
|
+
status = "可购" if t.get("canBuy") == "Y" else "停售"
|
|
408
|
+
d = (t.get("date") or "")[:8]
|
|
409
|
+
published_at = f"{d[:4]}-{d[4:6]}-{d[6:8]}" if len(d) == 8 else d
|
|
410
|
+
rows.append({
|
|
411
|
+
"title": f"{t['trainCode']} {t['fromStation']} {t['departTime']}→{t['toStation']} {t['arriveTime']}",
|
|
412
|
+
"url": _row_url(route, t["trainCode"]),
|
|
413
|
+
"snippet": " · ".join(["历时 " + _fmt_duration(t.get("duration", ""))] + seats + [status]),
|
|
414
|
+
"published_at": published_at,
|
|
415
|
+
})
|
|
416
|
+
return rows
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _fmt_minutes(raw: str) -> str:
|
|
420
|
+
try:
|
|
421
|
+
return f"{int(raw)}分"
|
|
422
|
+
except (TypeError, ValueError):
|
|
423
|
+
return ""
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _norm_date(raw) -> str:
|
|
427
|
+
d = (raw or "").strip()
|
|
428
|
+
if re.match(r"^\d{8}$", d):
|
|
429
|
+
return f"{d[:4]}-{d[4:6]}-{d[6:8]}"
|
|
430
|
+
return d
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _search_train_no(code: str, travel_date: str, cookie: str,
|
|
434
|
+
timeout: float = 15) -> str | None:
|
|
435
|
+
"""车次号(G2)→ 内部编号 train_no。search.12306.cn 关键字搜索接口,
|
|
436
|
+
优先取 station_train_code 精确命中,退而取首条。"""
|
|
437
|
+
params = up.urlencode({"keyword": code, "date": travel_date.replace("-", "")})
|
|
438
|
+
with _fetch(f"{_SEARCH_API_URL}?{params}",
|
|
439
|
+
headers={"Cookie": cookie, "Referer": _INIT_URL}, timeout=timeout) as resp:
|
|
440
|
+
payload = json.loads(resp.read().decode("utf-8", "replace"))
|
|
441
|
+
items = payload.get("data") if isinstance(payload, dict) else None
|
|
442
|
+
if not isinstance(items, list):
|
|
443
|
+
return None
|
|
444
|
+
for it in items:
|
|
445
|
+
if isinstance(it, dict) and it.get("station_train_code") == code and it.get("train_no"):
|
|
446
|
+
return it["train_no"]
|
|
447
|
+
for it in items:
|
|
448
|
+
if isinstance(it, dict) and it.get("train_no"):
|
|
449
|
+
return it["train_no"]
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _route_api(train_no: str, travel_date: str, cookie: str,
|
|
454
|
+
timeout: float = 15) -> list[dict]:
|
|
455
|
+
"""train_no + 日期 → 全程经停列表(queryTrainInfo/query,经停站在 data.data)。"""
|
|
456
|
+
params = up.urlencode({
|
|
457
|
+
"leftTicketDTO.train_no": train_no,
|
|
458
|
+
"leftTicketDTO.train_date": travel_date,
|
|
459
|
+
"rand_code": "",
|
|
460
|
+
})
|
|
461
|
+
with _fetch(f"{_TRAIN_INFO_URL}?{params}",
|
|
462
|
+
headers={"Cookie": cookie, "Referer": _INIT_URL}, timeout=timeout) as resp:
|
|
463
|
+
payload = json.loads(resp.read().decode("utf-8", "replace"))
|
|
464
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
465
|
+
stations = data.get("data") if isinstance(data, dict) else None
|
|
466
|
+
if not isinstance(stations, list):
|
|
467
|
+
return []
|
|
468
|
+
return [s for s in stations if isinstance(s, dict)]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _get_lcquery_path(timeout: float = 15) -> str:
|
|
472
|
+
"""中转查询路径运行时解析(init 页 JS 变量 var lc_search_url = '...')。
|
|
473
|
+
|
|
474
|
+
12306 会无预告改查询路径(余票侧 CLeftTicketUrl 同款机制,外部实现亦
|
|
475
|
+
靠运行时解析跟随)。解析失败必须报错而不是静默回落旧路径——回落后
|
|
476
|
+
「接口改版」会被伪装成「无中转方案」,比报错难查得多。
|
|
477
|
+
"""
|
|
478
|
+
with _fetch(_LC_INIT_URL, headers={"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.5"},
|
|
479
|
+
timeout=timeout) as resp:
|
|
480
|
+
html = resp.read().decode("utf-8", "replace")
|
|
481
|
+
m = re.search(r"var\s+lc_search_url\s*=\s*'(.+?)'", html)
|
|
482
|
+
if not m:
|
|
483
|
+
raise RuntimeError("lc_search_url 解析失败(12306 中转查询路径可能已改版)")
|
|
484
|
+
return m.group(1)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _transfer_api(from_code: str, to_code: str, middle_code: str, travel_date: str,
|
|
488
|
+
cookie: str, limit: int = 5, timeout: float = 15):
|
|
489
|
+
"""中转换乘查询(lcquery/queryG),按 result_index 翻页取满 limit 条。
|
|
490
|
+
|
|
491
|
+
返回 (方案列表, 实际查询路径, 错误消息)。can_query=='N'、单批为空或
|
|
492
|
+
翻到第 5 页(防 can_query 恒 Y 时死循环)即停。
|
|
493
|
+
"""
|
|
494
|
+
path = _get_lcquery_path(timeout=timeout)
|
|
495
|
+
plans: list[dict] = []
|
|
496
|
+
result_index = 0
|
|
497
|
+
for _page in range(5):
|
|
498
|
+
params = up.urlencode({
|
|
499
|
+
"train_date": travel_date,
|
|
500
|
+
"from_station_telecode": from_code,
|
|
501
|
+
"to_station_telecode": to_code,
|
|
502
|
+
"middle_station": middle_code,
|
|
503
|
+
"result_index": str(result_index),
|
|
504
|
+
"can_query": "Y",
|
|
505
|
+
"isShowWZ": "N",
|
|
506
|
+
"purpose_codes": "00",
|
|
507
|
+
"channel": "E",
|
|
508
|
+
})
|
|
509
|
+
with _fetch(f"https://kyfw.12306.cn{path}?{params}",
|
|
510
|
+
headers={"Cookie": cookie, "Referer": _LC_INIT_URL}, timeout=timeout) as resp:
|
|
511
|
+
payload = json.loads(resp.read().decode("utf-8", "replace"))
|
|
512
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
513
|
+
if not isinstance(data, dict):
|
|
514
|
+
msg = payload.get("errorMsg") if isinstance(payload, dict) else ""
|
|
515
|
+
return plans, path, (msg or "中转接口返回异常")
|
|
516
|
+
batch = [p for p in (data.get("middleList") or []) if isinstance(p, dict)]
|
|
517
|
+
plans.extend(batch)
|
|
518
|
+
if len(plans) >= limit or data.get("can_query") == "N" or not batch:
|
|
519
|
+
break
|
|
520
|
+
nxt = data.get("result_index")
|
|
521
|
+
result_index = nxt if isinstance(nxt, int) else result_index + len(batch)
|
|
522
|
+
return plans, path, None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _build_route_row(code: str, stations: list[dict], travel_date: str,
|
|
526
|
+
train_no: str) -> dict:
|
|
527
|
+
"""经停行:整条路线压成**一行**。
|
|
528
|
+
|
|
529
|
+
不按站拆行——经停查询的答案是有序完整列表,按站拆行后 argo 输出端
|
|
530
|
+
按 max_results 截断只露前几站,恰好截掉用户要的那一站;一行承载全表
|
|
531
|
+
则不受输出截断影响(snippet 无管线级截断,31 站约 600 字)。
|
|
532
|
+
"""
|
|
533
|
+
segs = []
|
|
534
|
+
for s in stations:
|
|
535
|
+
name = (s.get("station_name") or "?").strip() or "?"
|
|
536
|
+
# 12306 的空位可以是 "--" 也可以是 "----",按纯杠串统一视为缺省
|
|
537
|
+
arrive = (s.get("arrive_time") or "").strip()
|
|
538
|
+
start = (s.get("start_time") or "").strip()
|
|
539
|
+
stop = (s.get("stopover_time") or "").strip()
|
|
540
|
+
arrive = "" if arrive.strip("-") == "" else arrive
|
|
541
|
+
start = "" if start.strip("-") == "" else start
|
|
542
|
+
stop = "" if stop.strip("-") == "" else stop
|
|
543
|
+
if arrive in ("", "--") and start not in ("", "--"):
|
|
544
|
+
t = f"{start}开"
|
|
545
|
+
elif start in ("", "--"):
|
|
546
|
+
t = f"{arrive}到"
|
|
547
|
+
else:
|
|
548
|
+
t = f"{arrive}到/{start}开"
|
|
549
|
+
segs.append(f"{name} {t}" + (f"(停{stop}分)" if stop not in ("", "--") else ""))
|
|
550
|
+
qs = up.urlencode({
|
|
551
|
+
"leftTicketDTO.train_no": train_no,
|
|
552
|
+
"leftTicketDTO.train_date": travel_date,
|
|
553
|
+
})
|
|
554
|
+
return {
|
|
555
|
+
"title": f"{code} 经停站 {travel_date} · 共{len(stations)}站",
|
|
556
|
+
"url": f"{_TRAIN_INFO_URL}?{qs}",
|
|
557
|
+
"snippet": " → ".join(segs),
|
|
558
|
+
"published_at": travel_date,
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _build_transfer_rows(plans: list[dict], limit: int, ctx: dict) -> list[dict]:
|
|
563
|
+
"""中转方案行:一行 = 前段车次 + 换乘站 + 后段车次。
|
|
564
|
+
|
|
565
|
+
行 URL 带 first_train_no 标行身份——同一次查询的方案行共享查询参数,
|
|
566
|
+
没有 first_train_no 会在 URL 去重里折叠成一条(同 _row_url 的教训)。
|
|
567
|
+
"""
|
|
568
|
+
rows = []
|
|
569
|
+
for p in plans[: max(1, limit)]:
|
|
570
|
+
full = [s for s in (p.get("fullList") or []) if isinstance(s, dict)]
|
|
571
|
+
first = full[0] if full else {}
|
|
572
|
+
last = full[-1] if full else {}
|
|
573
|
+
fcode = (first.get("station_train_code") or "?").strip()
|
|
574
|
+
lcode = (last.get("station_train_code") or "?").strip()
|
|
575
|
+
mid = (p.get("middle_station_name") or "").strip() or "中转"
|
|
576
|
+
parts = []
|
|
577
|
+
lishi = _fmt_duration((p.get("all_lishi") or "").strip())
|
|
578
|
+
if lishi:
|
|
579
|
+
parts.append(f"总历时 {lishi}")
|
|
580
|
+
wait = _fmt_minutes((p.get("wait_time") or "").strip())
|
|
581
|
+
parts.append(f"换乘 {mid}" + (f" 等{wait}" if wait else ""))
|
|
582
|
+
if first:
|
|
583
|
+
parts.append(f"{fcode} {first.get('start_time', '')}→"
|
|
584
|
+
f"{first.get('to_station_name') or mid} {first.get('arrive_time', '')}")
|
|
585
|
+
if last:
|
|
586
|
+
parts.append(f"{lcode} {last.get('from_station_name') or mid} "
|
|
587
|
+
f"{last.get('start_time', '')}→{last.get('to_station_name', '')} "
|
|
588
|
+
f"{last.get('arrive_time', '')}")
|
|
589
|
+
seats = []
|
|
590
|
+
for key, label in _SEAT_LABELS:
|
|
591
|
+
v = (last.get(f"{key}_num") or "").strip()
|
|
592
|
+
if v and v != "--":
|
|
593
|
+
seats.append(f"{label} {v}")
|
|
594
|
+
if seats:
|
|
595
|
+
parts.append("末段 " + " · ".join(seats))
|
|
596
|
+
qs = up.urlencode({
|
|
597
|
+
"train_date": ctx.get("date", ""),
|
|
598
|
+
"from_station_telecode": ctx.get("from_code", ""),
|
|
599
|
+
"to_station_telecode": ctx.get("to_code", ""),
|
|
600
|
+
"middle_station": ctx.get("middle_code", ""),
|
|
601
|
+
"first_train_no": p.get("first_train_no") or fcode,
|
|
602
|
+
})
|
|
603
|
+
rows.append({
|
|
604
|
+
"title": (f"{fcode}+{lcode} {p.get('from_station_name', '')}→{mid}"
|
|
605
|
+
f"→{p.get('end_station_name', '')} "
|
|
606
|
+
f"{p.get('start_time', '')}→{p.get('arrive_time', '')}"),
|
|
607
|
+
"url": f"https://kyfw.12306.cn{ctx.get('path', '/lcquery/queryG')}?{qs}",
|
|
608
|
+
"snippet": " · ".join(parts),
|
|
609
|
+
"published_at": _norm_date(p.get("train_date")) or ctx.get("date", ""),
|
|
610
|
+
})
|
|
611
|
+
return rows
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _print_rows(rows: list[dict]) -> None:
|
|
615
|
+
import yaml
|
|
616
|
+
print(yaml.safe_dump(rows, allow_unicode=True, sort_keys=False))
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _run_tickets(text: str, n: int, debug: bool = False) -> int:
|
|
620
|
+
q = _parse_query(text)
|
|
621
|
+
if not q:
|
|
622
|
+
print("无法解析起止站:请用「出发站 到 到达站」的格式,如:上海到北京", file=sys.stderr)
|
|
623
|
+
return 2
|
|
624
|
+
|
|
625
|
+
data = _load_stations()
|
|
626
|
+
frm = _resolve_station(data, q["from"])
|
|
627
|
+
to = _resolve_station(data, q["to"])
|
|
628
|
+
if not frm or not to:
|
|
629
|
+
missing = q["from"] if not frm else q["to"]
|
|
630
|
+
print(f"未找到站点:{missing}", file=sys.stderr)
|
|
631
|
+
return 2
|
|
632
|
+
|
|
633
|
+
cookie = _get_cookie()
|
|
634
|
+
raw = _query_api(frm["station_code"], to["station_code"], q["date"], cookie)
|
|
635
|
+
if debug and raw:
|
|
636
|
+
# 调试模式:输出第一行的所有字段及其索引,便于分析票价位置
|
|
637
|
+
first_row = raw[0]
|
|
638
|
+
print(f"[DEBUG] queryG 返回 {len(raw)} 行,每行 {len(first_row)} 个字段", file=sys.stderr)
|
|
639
|
+
for i, val in enumerate(first_row):
|
|
640
|
+
if val and val.strip():
|
|
641
|
+
print(f" [{i:2d}] {val}", file=sys.stderr)
|
|
642
|
+
tickets = [_parse_ticket(f, data) for f in raw]
|
|
643
|
+
if q["type"]:
|
|
644
|
+
tickets = [t for t in tickets if t["trainCode"].startswith(q["type"])]
|
|
645
|
+
code_m = _TRAIN_CODE_RE.search(text)
|
|
646
|
+
if code_m:
|
|
647
|
+
code = code_m.group(1)
|
|
648
|
+
exact = [t for t in tickets if t["trainCode"] == code]
|
|
649
|
+
# 点名车次("北京到上海 G531")按精确车次过滤;当日查无此车时
|
|
650
|
+
# 回落全量——点名扑空不该连别的车也不给看
|
|
651
|
+
if exact:
|
|
652
|
+
tickets = exact
|
|
653
|
+
|
|
654
|
+
route = {"date": q["date"], "from_code": frm["station_code"],
|
|
655
|
+
"to_code": to["station_code"]}
|
|
656
|
+
_print_rows(_build_rows(tickets, n, route))
|
|
657
|
+
return 0
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _run_route(text: str) -> int:
|
|
661
|
+
m = _TRAIN_CODE_RE.search(text)
|
|
662
|
+
if not m:
|
|
663
|
+
print("经停查询需要车次号,如:G2 经停", file=sys.stderr)
|
|
664
|
+
return 2
|
|
665
|
+
code = m.group(1)
|
|
666
|
+
travel_date, _ = _parse_travel_date(text)
|
|
667
|
+
d = (travel_date or _today_cn(1)).isoformat()
|
|
668
|
+
|
|
669
|
+
cookie = _get_cookie()
|
|
670
|
+
train_no = _search_train_no(code, d, cookie)
|
|
671
|
+
if not train_no:
|
|
672
|
+
print(f"未找到 {code} 在 {d} 的车次编号(可能当日无此车)", file=sys.stderr)
|
|
673
|
+
return 2
|
|
674
|
+
stations = _route_api(train_no, d, cookie)
|
|
675
|
+
if not stations:
|
|
676
|
+
print(f"未查到 {code} 的经停信息({d})", file=sys.stderr)
|
|
677
|
+
return 2
|
|
678
|
+
_print_rows([_build_route_row(code, stations, d, train_no)])
|
|
679
|
+
return 0
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _run_transfer(text: str, n: int) -> int:
|
|
683
|
+
data = _load_stations()
|
|
684
|
+
middle, text = _parse_middle(text, data)
|
|
685
|
+
text = re.sub(r"中转|换乘", " ", text)
|
|
686
|
+
|
|
687
|
+
q = _parse_query(text)
|
|
688
|
+
if not q:
|
|
689
|
+
print("无法解析起止站:请用「出发站 到 到达站 中转(可「经X」指定换乘站)」"
|
|
690
|
+
"的格式,如:北京到拉萨 中转", file=sys.stderr)
|
|
691
|
+
return 2
|
|
692
|
+
frm = _resolve_station(data, q["from"])
|
|
693
|
+
to = _resolve_station(data, q["to"])
|
|
694
|
+
if not frm or not to:
|
|
695
|
+
missing = q["from"] if not frm else q["to"]
|
|
696
|
+
print(f"未找到站点:{missing}", file=sys.stderr)
|
|
697
|
+
return 2
|
|
698
|
+
|
|
699
|
+
cookie = _get_cookie()
|
|
700
|
+
plans, path, err = _transfer_api(
|
|
701
|
+
frm["station_code"], to["station_code"],
|
|
702
|
+
(middle or {}).get("station_code", ""), q["date"], cookie, limit=max(n, 5))
|
|
703
|
+
if err:
|
|
704
|
+
print(f"中转查询失败:{err}", file=sys.stderr)
|
|
705
|
+
return 2
|
|
706
|
+
if not plans:
|
|
707
|
+
print("未查到中转方案", file=sys.stderr)
|
|
708
|
+
return 2
|
|
709
|
+
ctx = {"date": q["date"], "path": path,
|
|
710
|
+
"from_code": frm["station_code"], "to_code": to["station_code"],
|
|
711
|
+
"middle_code": (middle or {}).get("station_code", "")}
|
|
712
|
+
_print_rows(_build_transfer_rows(plans, n, ctx))
|
|
713
|
+
return 0
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def main() -> int:
|
|
717
|
+
parser = argparse.ArgumentParser(
|
|
718
|
+
description="火车余票/中转/经停查询(免 Key,12306 官方接口)")
|
|
719
|
+
parser.add_argument("query", nargs="*",
|
|
720
|
+
help="余票:上海到北京 · 中转:北京到拉萨 中转(可「经西宁」指定换乘站)"
|
|
721
|
+
" · 经停:G2 经停")
|
|
722
|
+
parser.add_argument("--n", type=int, default=5, help="最多返回条数")
|
|
723
|
+
parser.add_argument("--debug", action="store_true",
|
|
724
|
+
help="调试模式:dump queryG 原始字段到 stderr")
|
|
725
|
+
args = parser.parse_args()
|
|
726
|
+
|
|
727
|
+
raw = " ".join(args.query).strip()
|
|
728
|
+
mode = _parse_mode(raw)
|
|
729
|
+
if mode == "route":
|
|
730
|
+
return _run_route(raw)
|
|
731
|
+
if mode == "transfer":
|
|
732
|
+
return _run_transfer(raw, args.n)
|
|
733
|
+
return _run_tickets(raw, args.n, debug=args.debug)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
if __name__ == "__main__":
|
|
737
|
+
sys.exit(main())
|