├── .gitignore ├── .gitpod.yml ├── Dockerfile ├── LICENSE ├── README.md ├── data ├── cdn.txt ├── images │ ├── order_success.png │ └── web.png └── stations.txt ├── depdencies.txt ├── docker-compose.yml.example ├── env.docker.py.example ├── env.py.example ├── env.slave.py.example ├── main.py ├── py12306 ├── __init__.py ├── app.py ├── cluster │ ├── __init__.py │ ├── cluster.py │ └── redis.py ├── config.py ├── exceptions │ └── __init__.py ├── helpers │ ├── OCR.py │ ├── __init__.py │ ├── api.py │ ├── auth_code.py │ ├── cdn.py │ ├── event.py │ ├── func.py │ ├── notification.py │ ├── qrcode.py │ ├── request.py │ ├── station.py │ └── type.py ├── log │ ├── __init__.py │ ├── base.py │ ├── cluster_log.py │ ├── common_log.py │ ├── order_log.py │ ├── query_log.py │ ├── redis_log.py │ └── user_log.py ├── order │ └── order.py ├── query │ ├── __init__.py │ ├── job.py │ └── query.py ├── user │ ├── __init__.py │ ├── job.py │ └── user.py ├── vender │ └── ruokuai │ │ └── main.py └── web │ ├── __init__.py │ ├── handler │ ├── __init__.py │ ├── app.py │ ├── log.py │ ├── query.py │ ├── stat.py │ └── user.py │ ├── static │ ├── css │ │ ├── app.35e2fbd94557d71d1e2bfa0d4bb44d13.css │ │ ├── app.7dba7f569524413218fde54c298188f4.css │ │ └── app.dfb5ffed622907edd7c5f81709f2b782.css │ ├── fonts │ │ ├── element-icons.6f0a763.ttf │ │ ├── fa-brands-400.292a564.woff │ │ ├── fa-brands-400.87b76b9.woff2 │ │ ├── fa-brands-400.f83bc05.ttf │ │ ├── fa-brands-400.f902692.eot │ │ ├── fa-regular-400.732726c.woff2 │ │ ├── fa-regular-400.abde9e5.ttf │ │ ├── fa-regular-400.b4cfd51.woff │ │ ├── fa-regular-400.d1ce381.eot │ │ ├── fa-solid-900.3b921c2.eot │ │ ├── fa-solid-900.bed3b0a.woff2 │ │ ├── fa-solid-900.d751e66.ttf │ │ └── fa-solid-900.e0c419c.woff │ ├── img │ │ ├── avatar_default.svg │ │ ├── fa-brands-400.27183da.svg │ │ ├── fa-regular-400.1e51c39.svg │ │ └── fa-solid-900.a868400.svg │ ├── index.html │ └── js │ │ ├── app.680b1bbd04444c6d9d3a.js │ │ ├── app.7d7d65cccfbfa339beba.js │ │ ├── app.96ef02c9e5601eb5ebcb.js │ │ ├── app.cdb00779aeb087dabd94.js │ │ ├── manifest.82f431004cf9bb6ad2cb.js │ │ ├── vendor.532ecf213e49d36e5e9e.js │ │ └── vendor.aebd1de04bf90e88d9c7.js │ └── web.py ├── requirements.txt └── runtime ├── .gitignore ├── query └── .gitignore └── user └── .gitignore /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | venv 5 | __pycache__ 6 | env.py 7 | env.slave.py 8 | env.docker.py 9 | docker-compose.yml -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | ports: 2 | - port: 8008 3 | onOpen: open-preview 4 | tasks: 5 | - init: pip install -r requirements.txt && cp env.py.example env.py 6 | command: python main.py -t 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6.6-slim 2 | 3 | MAINTAINER 4 | ENV TZ Asia/Shanghai 5 | 6 | WORKDIR /code 7 | 8 | COPY requirements.txt . 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | 12 | RUN mkdir -p /data/query /data/user 13 | VOLUME /data 14 | 15 | COPY . . 16 | 17 | COPY env.docker.py.example /config/env.py 18 | 19 | CMD [ "python", "main.py" , "-c", "/config/env.py"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚂 py12306 购票助手 2 | 分布式,多账号,多任务购票 3 | 4 | ## Features 5 | - [x] 多日期查询余票 6 | - [x] 自动打码下单 7 | - [x] 用户状态恢复 8 | - [x] 电话语音通知 9 | - [x] 多账号、多任务、多线程支持 10 | - [x] 单个任务多站点查询 11 | - [x] 分布式运行 12 | - [x] Docker 支持 13 | - [x] 动态修改配置文件 14 | - [x] 邮件通知 15 | - [x] Web 管理页面 16 | - [x] 微信消息通知 17 | - [ ] 代理池支持 ([pyproxy-async](https://github.com/pjialin/pyproxy-async)) 18 | 19 | ## 使用 20 | py12306 需要运行在 python 3.6 以上版本(其它版本暂未测试) 21 | 22 | **1. 安装依赖** 23 | ```bash 24 | git clone https://github.com/pjialin/py12306 25 | 26 | pip install -r requirements.txt 27 | ``` 28 | 29 | **2. 配置程序** 30 | ```bash 31 | cp env.py.example env.py 32 | ``` 33 | 自动打码 34 | 35 | (若快已停止服务,目前只能设置**free**打码模式) 36 | free 已对接到打码共享平台,[https://py12306-helper.pjialin.com](https://py12306-helper.pjialin.com/),欢迎参与分享 37 | 38 | 语音通知 39 | 40 | 语音验证码使用的是阿里云 API 市场上的一个服务商,需要到 [https://market.aliyun.com/products/56928004/cmapi026600.html](https://market.aliyun.com/products/56928004/cmapi026600.html) 购买后将 appcode 填写到配置中 41 | 42 | **3. 启动前测试** 43 | 44 | 目前提供了一些简单的测试,包括用户账号检测,乘客信息检测,车站检测等 45 | 46 | 开始测试 -t 47 | ```bash 48 | python main.py -t 49 | ``` 50 | 51 | 测试通知消息 (语音, 邮件) -t -n 52 | ```bash 53 | # 默认不会进行通知测试,要对通知进行测试需要加上 -n 参数 54 | python main.py -t -n 55 | ``` 56 | 57 | **4. 运行程序** 58 | ```bash 59 | python main.py 60 | ``` 61 | 62 | ### 参数列表 63 | 64 | - -t 测试配置信息 65 | - -t -n 测试配置信息以及通知消息 66 | - -c 指定自定义配置文件位置 67 | 68 | ### 分布式集群 69 | 70 | 集群依赖于 redis,目前支持情况 71 | - 单台主节点多个子节点同时运行 72 | - 主节点宕机后自动切换提升子节点为主节点 73 | - 主节点恢复后自动恢复为真实主节点 74 | - 配置通过主节点同步到所有子节点 75 | - 主节点配置修改后无需重启子节点,支持自动更新 76 | - 子节点消息实时同步到主节点 77 | 78 | **使用** 79 | 80 | 将配置文件的中 `CLUSTER_ENABLED` 打开即开启分布式 81 | 82 | 目前提供了一个单独的子节点配置文件 `env.slave.py.example` 将文件修改为 `env.slave.py`, 通过 `python main.py -c env.slave.py` 即可快速启动 83 | 84 | 85 | ## Docker 使用 86 | **1. 将配置文件下载到本地** 87 | ```bash 88 | docker run --rm pjialin/py12306 cat /config/env.py > env.py 89 | # 或 90 | curl https://raw.githubusercontent.com/pjialin/py12306/master/env.docker.py.example -o env.py 91 | ``` 92 | 93 | **2. 修改好配置后运行** 94 | ```bash 95 | docker run --rm --name py12306 -p 8008:8008 -d -v $(pwd):/config -v py12306:/data pjialin/py12306 96 | ``` 97 | 当前目录会多一个 12306.log 的日志文件, `tail -f 12306.log` 98 | 99 | ### Docker-compose 中使用 100 | **1. 复制配置文件** 101 | ``` 102 | cp docker-compose.yml.example docker-compose.yml 103 | ``` 104 | 105 | **2. 从 docker-compose 运行** 106 | 107 | 在`docker-compose.yml`所在的目录使用命令 108 | ``` 109 | docker-compose up -d 110 | ``` 111 | 112 | ## Web 管理页面 113 | 114 | 目前支持用户和任务以及实时日志查看,更多功能后续会不断加入 115 | 116 | **使用** 117 | 118 | 打开 Web 功能需要将配置中的 `WEB_ENABLE` 打开,启动程序后访问当前主机地址 + 端口号 (默认 8008) 即可,如 http://127.0.0.1:8008 119 | 120 | ## 更新 121 | - 19-01-10 122 | - 支持分布式集群 123 | - 19-01-11 124 | - 配置文件支持动态修改 125 | - 19-01-12 126 | - 新增免费打码 127 | - 19-01-14 128 | - 新增 Web 页面支持 129 | - 19-01-15 130 | - 新增 钉钉通知 131 | - 新增 Telegram 通知 132 | - 新增 ServerChan 和 PushBear 微信推送 133 | - 19-01-18 134 | - 新增 CDN 查询 135 | 136 | ## 截图 137 | ### Web 管理页面 138 | ![Web 管理页面图片](https://github.com/pjialin/py12306/blob/master/data/images/web.png) 139 | 140 | ### 下单成功 141 | ![下单成功图片](https://github.com/pjialin/py12306/blob/master/data/images/order_success.png) 142 | 143 | ### 关于防封 144 | 目前查询和登录操作是分开的,查询是不依赖用户是否登录,放在 A 云 T 云容易被限制 ip,建议在其它网络环境下运行 145 | 146 | QQ 交流群 [780289875](https://jq.qq.com/?_wv=1027&k=5PgzDwV),TG 群 [Py12306 交流](https://t.me/joinchat/F3sSegrF3x8KAmsd1mTu7w) 147 | 148 | ### Online IDE 149 | [![在 Gitpod 中打开](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io#https://github.com/pjialin/py12306) 150 | 151 | ## Thanks 152 | - 感谢大佬 [testerSunshine](https://github.com/testerSunshine/12306),借鉴了部分实现 153 | - 感谢所有提供 pr 的大佬 154 | - 感谢大佬 [zhaipro](https://github.com/zhaipro/easy12306) 的验证码本地识别模型与算法 155 | 156 | ## License 157 | 158 | [Apache License.](https://github.com/pjialin/py12306/blob/master/LICENSE) 159 | 160 | -------------------------------------------------------------------------------- /data/images/order_success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/data/images/order_success.png -------------------------------------------------------------------------------- /data/images/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/data/images/web.png -------------------------------------------------------------------------------- /depdencies.txt: -------------------------------------------------------------------------------- 1 | Python 3.8.10 2 | Package Version 3 | ----------------------- -------------------- 4 | absl-py 1.2.0 5 | appdirs 1.4.3 6 | astunparse 1.6.3 7 | attrs 19.3.0 8 | Automat 0.8.0 9 | autopep8 1.7.0 10 | beautifulsoup4 4.7.0 11 | blinker 1.4 12 | browser-cookie3 0.16.1 13 | bs4 0.0.1 14 | cachetools 4.2.4 15 | certifi 2021.5.30 16 | chardet 3.0.4 17 | charset-normalizer 2.1.1 18 | Click 7.0 19 | cloud-init 22.2 20 | colorama 0.4.3 21 | command-not-found 0.3 22 | configobj 5.0.6 23 | constantly 15.1.0 24 | cryptography 2.8 25 | cssselect 1.0.3 26 | dbus-python 1.2.16 27 | DingtalkChatbot 1.3.0 28 | distro 1.4.0 29 | distro-info 0.23ubuntu1 30 | entrypoints 0.3 31 | fake-useragent 0.1.11 32 | Flask 1.0.2 33 | Flask-JWT-Extended 3.15.0 34 | gast 0.3.3 35 | google-auth 1.35.0 36 | google-auth-oauthlib 0.4.6 37 | google-pasta 0.2.0 38 | grpcio 1.48.1 39 | h5py 2.10.0 40 | httplib2 0.14.0 41 | hyperlink 19.0.0 42 | idna 2.8 43 | importlib-metadata 4.12.0 44 | incremental 16.10.1 45 | itsdangerous 1.1.0 46 | Jinja2 2.10 47 | jsonpatch 1.22 48 | jsonpointer 2.0 49 | jsonschema 3.2.0 50 | Keras 2.4.0 51 | Keras-Preprocessing 1.1.2 52 | keyring 18.0.1 53 | language-selector 0.1 54 | launchpadlib 1.10.13 55 | lazr.restfulclient 0.14.2 56 | lazr.uri 1.0.3 57 | lightpush 0.1.3 58 | lxml 4.6.3 59 | lz4 4.0.2 60 | Markdown 3.4.1 61 | MarkupSafe 2.0.1 62 | more-itertools 4.2.0 63 | netifaces 0.10.4 64 | numpy 1.18.5 65 | oauthlib 3.1.0 66 | opencv-python 4.6.0.66 67 | opt-einsum 3.3.0 68 | p5py 1.0.0 69 | parse 1.9.0 70 | pbkdf2 1.3 71 | pbr 5.10.0 72 | pep517 0.13.0 73 | pexpect 4.6.0 74 | pip 22.2.2 75 | protobuf 3.9.2 76 | pyaes 1.6.1 77 | pyasn1 0.4.2 78 | pyasn1-modules 0.2.1 79 | pycodestyle 2.9.1 80 | pycryptodome 3.15.0 81 | pyee 6.0.0 82 | PyGObject 3.36.0 83 | PyHamcrest 1.9.0 84 | PyJWT 1.7.1 85 | pymacaroons 0.13.0 86 | PyNaCl 1.3.0 87 | pyOpenSSL 19.0.0 88 | pypng 0.20220715.0 89 | pyppeteer 0.0.25 90 | pyppeteer-box 0.0.27 91 | pyquery 1.4.0 92 | pyrsistent 0.15.5 93 | pyserial 3.4 94 | python-apt 2.0.0+ubuntu0.20.4.8 95 | PyYAML 5.3.1 96 | redis 3.0.1 97 | requests 2.28.1 98 | requests-html 0.9.0 99 | requests-oauthlib 1.3.1 100 | requests-unixsocket 0.2.0 101 | rsa 4.9 102 | scipy 1.4.1 103 | SecretStorage 2.3.1 104 | service-identity 18.1.0 105 | setuptools 65.3.0 106 | simplejson 3.16.0 107 | six 1.15.0 108 | sos 4.3 109 | soupsieve 1.6.2 110 | ssh-import-id 5.10 111 | systemd-python 234 112 | tensorboard 2.10.0 113 | tensorboard-data-server 0.6.1 114 | tensorboard-plugin-wit 1.8.1 115 | tensorflow 2.3.0 116 | tensorflow-estimator 2.3.0 117 | termcolor 2.0.1 118 | testresources 2.0.1 119 | toml 0.10.2 120 | tomli 2.0.1 121 | tqdm 4.64.1 122 | Twisted 18.9.0 123 | typing_extensions 4.3.0 124 | ubuntu-advantage-tools 27.10 125 | ufw 0.36 126 | unattended-upgrades 0.1 127 | urllib3 1.26.12 128 | w3lib 1.19.0 129 | wadllib 1.3.3 130 | websockets 7.0 131 | Werkzeug 0.15.5 132 | wheel 0.37.1 133 | wrapt 1.14.1 134 | zipp 1.0.0 135 | zope.interface 4.7.1 136 | -------------------------------------------------------------------------------- /docker-compose.yml.example: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | py12306: 4 | build: . 5 | volumes: 6 | # - ./runtime:/code/runtime # 未使用 env.docker.py.example 可以打开此项 7 | - ./env.py:/config/env.py 8 | - py12306:/data 9 | ports: 10 | - 8008:8008 11 | 12 | volumes: 13 | py12306: 14 | -------------------------------------------------------------------------------- /env.docker.py.example: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # 12306 账号 4 | USER_ACCOUNTS = [ 5 | # 目前已支持仅查询,不下单,屏蔽掉下面的账号即可 6 | { 7 | 'key': 0, # 如使用多个账号 key 不能重复 8 | 'user_name': 'your user name', 9 | 'password': '忽略', 10 | 'type': 'qr' # qr 为扫码登录,填写其他为密码登录 11 | }, 12 | # { 13 | # 'key': 'wangwu', 14 | # 'user_name': 'wangwu@qq.com', 15 | # 'password': 'wangwu', 16 | # 'type': '' 17 | # } 18 | ] 19 | 20 | # 查询间隔(指每一个任务中每一个日期的间隔 / 单位秒) 21 | # 默认取间隔/2 到 间隔之间的随机数 如设置为 1 间隔则为 0.5 ~ 1 之间的随机数 22 | # 接受字典形式 格式: {'min': 0.5, 'max': 1} 23 | QUERY_INTERVAL = 1 24 | 25 | # 用户心跳检测间隔 格式同上 26 | USER_HEARTBEAT_INTERVAL = 120 27 | 28 | # 多线程查询 29 | QUERY_JOB_THREAD_ENABLED = 0 # 是否开启多线程查询,开启后第个任务会单独分配线程处理 30 | 31 | # 打码平台账号 32 | # 目前只支持免费打码接口 和 若快打码,注册地址:http://www.ruokuai.com/login 33 | AUTO_CODE_PLATFORM = 'free' # 免费填写 free 若快 ruokuai # 免费打码无法保证持续可用,如失效请手动切换 #个人本地打码填写 user,并修改 API_USER_CODE_QCR_API 34 | API_USER_CODE_QCR_API = '' 35 | AUTO_CODE_ACCOUNT = { 36 | 'user': 'your user name', 37 | 'pwd': 'your password' 38 | } 39 | 40 | # 语音验证码 41 | # 没找到比较好用的,现在用的这个是阿里云 API 市场上的,基本满足要求,价格也便宜 42 | # 购买成功后到控制台找到 APPCODE 放在下面就可以了 43 | # 地址:易源 https://market.aliyun.com/products/57126001/cmapi019902.html 44 | # 2019-01-18 更新 45 | # 增加新的服务商 鼎信 https://market.aliyun.com/products/56928004/cmapi026600.html?spm=5176.2020520132.101.2.e27e7218KQttQS 46 | NOTIFICATION_BY_VOICE_CODE = 1 # 开启语音通知 47 | NOTIFICATION_VOICE_CODE_TYPE = 'dingxin' # 语音验证码服务商 可用项 dingxin yiyuan 48 | NOTIFICATION_API_APP_CODE = 'your app code' 49 | NOTIFICATION_VOICE_CODE_PHONE = 'your phone' # 接受通知的手机号 50 | 51 | # 钉钉通知 52 | DINGTALK_ENABLED = 0 53 | DINGTALK_WEBHOOK = 'https://oapi.dingtalk.com/robot/send?access_token=your token' 54 | 55 | # Telegram消息推送 56 | # 目前共有两个Bot: 57 | # 1:https://t.me/notificationme_bot 58 | # 2:https://t.me/RE_Link_Push_bot 59 | # 任选一个Bot,关注获取URL链接,如果没有回复则发送给Bot这条信息: /start 60 | # 将获取的URL填入下面对应位置 61 | # 注意:因为以上Bot都由他人公益提供,无法保证随时可用,如以上Bot都无法使用,请使用其他消息推送方式 62 | # Bot1来源:https://github.com/Fndroid/tg_push_bot 63 | # Bot2来源:https://szc.me/post/2.html 64 | TELEGRAM_ENABLED = 0 65 | TELEGRAM_BOT_API_URL = 'https://tgbot.lbyczf.com/sendMessage/:your_token' 66 | 67 | # ServerChan 和 PushBear 微信消息推送 68 | # 使用说明 69 | # ServerChan http://sc.ftqq.com 70 | # PushBear http://pushbear.ftqq.com 71 | SERVERCHAN_ENABLED = 0 72 | SERVERCHAN_KEY = '' 73 | PUSHBEAR_ENABLED = 0 74 | PUSHBEAR_KEY = '' 75 | 76 | # Bark 推送到ios设备 77 | # 参考 https://www.v2ex.com/t/467407 78 | BARK_ENABLED = 0 79 | BARK_PUSH_URL = 'https://api.day.app/:your_token' 80 | 81 | # 输出日志到文件 (Docker 中不建议修改此组配置项) 82 | OUT_PUT_LOG_TO_FILE_ENABLED = 1 83 | OUT_PUT_LOG_TO_FILE_PATH = '/config/12306.log' # 日志目录 84 | RUNTIME_DIR = '/data/' 85 | QUERY_DATA_DIR = '/data/query/' 86 | USER_DATA_DIR = '/data/user/' 87 | 88 | # 分布式集群配置 89 | CLUSTER_ENABLED = 0 # 集群状态 90 | NODE_IS_MASTER = 1 # 是否是主节点 同时只能启用 1 个主节点 91 | NODE_SLAVE_CAN_BE_MASTER = 1 # 主节点宕机后,子节点是否可以自动提升为主节点(建议打开) 92 | NODE_NAME = 'master' # 节点名称,不能重复 93 | REDIS_HOST = 'localhost' # Redis host 94 | REDIS_PORT = '6379' # Redis port 95 | REDIS_PASSWORD = '' # Redis 密码 没有可以留空 96 | 97 | # 邮箱配置 98 | EMAIL_ENABLED = 0 # 是否开启邮件通知 99 | EMAIL_SENDER = 'sender@example.com' # 邮件发送者 100 | EMAIL_RECEIVER = 'receiver@example.com' # 邮件接受者 # 可以多个 [email1@gmail.com, email2@gmail.com] 101 | EMAIL_SERVER_HOST = 'localhost' # 邮件服务 host 102 | EMAIL_SERVER_USER = '' # 邮件服务登录用户名 103 | EMAIL_SERVER_PASSWORD = '' # 邮件服务登录密码 104 | 105 | # Web 管理 106 | WEB_ENABLE = 1 # 是否打开 Web 管理 107 | WEB_USER = { # 登录信息 108 | 'username': 'admin', 109 | 'password': 'password' 110 | } 111 | WEB_PORT = 8008 # 监听端口 112 | 113 | # 是否开启 CDN 查询 114 | CDN_ENABLED = 0 115 | CDN_CHECK_TIME_OUT = 1 # 检测单个 cdn 是否可用超时时间 116 | 117 | # 查询任务 118 | QUERY_JOBS = [ 119 | { 120 | # 'job_name': 'bj -> sz', # 任务名称,不填默认会以车站名命名,不可重复 121 | 'account_key': 0, # 将会使用指定账号下单 122 | 'left_dates': [ # 出发日期 :Array 123 | "2019-01-25", 124 | "2019-01-26", 125 | ], 126 | 'stations': { # 车站 支持多个车站同时查询 :Dict or :List 127 | 'left': '北京', 128 | 'arrive': '深圳', 129 | }, 130 | # # 多个车站示例 (建议添加多个,有时多买几站成功率会高一点) 131 | # 'stations': [{ 132 | # 'left': '北京', 133 | # 'arrive': '深圳', 134 | # },{ # 多个车站示例 135 | # 'left': '北京', 136 | # 'arrive': '广州', 137 | # }], 138 | 'members': [ # 乘客姓名,会根据当前账号自动识别乘客类型 购买儿童票 设置两个相同的姓名即可,程序会自动识别 如 ['张三', '张三'] 139 | "张三", 140 | "王五", 141 | # 7, # 支持通过序号确定唯一乘客,序号查看可通过 python main.py -t 登录成功之后在 runtime/user/ 下找到对应的 用户名_passengers.json 文件,找到对应的 code 填入 142 | ], 143 | 'allow_less_member': 0, # 是否允许余票不足时提交部分乘客 144 | 'seats': [ # 筛选座位 有先后顺序 :Array 145 | # 可用值: 特等座, 商务座, 一等座, 二等座, 软卧, 硬卧, 动卧, 软座, 硬座, 无座 146 | '硬卧', 147 | '硬座' 148 | ], 149 | 'train_numbers': [ # 筛选车次 可以为空,为空则所有车次都可以提交 如 [] 注意大小写需要保持一致 150 | "K356", 151 | "K1172", 152 | "K4184" 153 | ], 154 | 'except_train_numbers': [ # 筛选车次,排除车次 train_numbers 和 except_train_numbers 不可同时存在 155 | ], 156 | 'period': { # 筛选时间 157 | 'from': '00:00', 158 | 'to': '24:00' 159 | } 160 | 161 | }, 162 | # { 163 | # 'job_name': 'cd -> gz', # 任务名称,不填默认会以车站名命名,不可重复 164 | # 'account_key': 0, # 将会使用指定账号下单 165 | # 'left_dates': [ 166 | # "2019-01-27", 167 | # "2019-01-28" 168 | # ], 169 | # 'stations': { 170 | # 'left': '成都', 171 | # 'arrive': '广州', 172 | # }, 173 | # 'members': [ 174 | # "小王", 175 | # ], 176 | # 'allow_less_member': 0, 177 | # 'seats': [ 178 | # '硬卧', 179 | # ], 180 | # 'train_numbers': [] 181 | # } 182 | ] 183 | -------------------------------------------------------------------------------- /env.py.example: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # 12306 账号 4 | USER_ACCOUNTS = [ 5 | # 目前已支持仅查询,不下单,屏蔽掉下面的账号即可 6 | { 7 | 'key': 0, # 如使用多个账号 key 不能重复 8 | 'user_name': 'your user name', 9 | 'password': '忽略', 10 | 'type': 'qr' # qr 为扫码登录,填写其他为密码登录 11 | }, 12 | # { 13 | # 'key': 'wangwu', 14 | # 'user_name': 'wangwu@qq.com', 15 | # 'password': 'wangwu', 16 | # 'type': '' 17 | # } 18 | ] 19 | 20 | # 查询间隔(指每一个任务中每一个日期的间隔 / 单位秒) 21 | # 默认取间隔/2 到 间隔之间的随机数 如设置为 1 间隔则为 0.5 ~ 1 之间的随机数 22 | # 接受字典形式 格式: {'min': 0.5, 'max': 1} 23 | QUERY_INTERVAL = 1 24 | 25 | # 网络请求重试次数 26 | REQUEST_MAX_RETRY = 5 27 | 28 | # 用户心跳检测间隔 格式同上 29 | USER_HEARTBEAT_INTERVAL = 120 30 | 31 | # 多线程查询 32 | QUERY_JOB_THREAD_ENABLED = 0 # 是否开启多线程查询,开启后第个任务会单独分配线程处理 33 | 34 | # 打码平台账号 35 | # 目前只支持免费打码接口 和 若快打码,注册地址:http://www.ruokuai.com/login 36 | AUTO_CODE_PLATFORM = 'free' # 免费填写 free 若快 ruokuai # 免费打码无法保证持续可用,如失效请手动切换; 个人打码填写 user 并修改API_USER_CODE_QCR_API 为自己地址 37 | API_USER_CODE_QCR_API = '' 38 | AUTO_CODE_ACCOUNT = { # 使用 free 可用省略 39 | 'user': 'your user name', 40 | 'pwd': 'your password' 41 | } 42 | 43 | # 语音验证码 44 | # 没找到比较好用的,现在用的这个是阿里云 API 市场上的,基本满足要求,价格也便宜 45 | # 购买成功后到控制台找到 APPCODE 放在下面就可以了 46 | # 地址:易源 https://market.aliyun.com/products/57126001/cmapi019902.html 47 | # 2019-01-18 更新 48 | # 增加新的服务商 鼎信 https://market.aliyun.com/products/56928004/cmapi026600.html?spm=5176.2020520132.101.2.e27e7218KQttQS 49 | NOTIFICATION_BY_VOICE_CODE = 1 # 开启语音通知 50 | NOTIFICATION_VOICE_CODE_TYPE = 'dingxin' # 语音验证码服务商 可用项 dingxin yiyuan 51 | NOTIFICATION_API_APP_CODE = 'your app code' 52 | NOTIFICATION_VOICE_CODE_PHONE = 'your phone' # 接受通知的手机号 53 | 54 | # 钉钉通知 55 | # 使用说明 https://open-doc.dingtalk.com/docs/doc.htm?treeId=257&articleId=105735&docType=1 56 | DINGTALK_ENABLED = 0 57 | DINGTALK_WEBHOOK = 'https://oapi.dingtalk.com/robot/send?access_token=your token' 58 | 59 | # Telegram消息推送 60 | # 目前共有两个Bot: 61 | # 1:https://t.me/notificationme_bot 62 | # 2:https://t.me/RE_Link_Push_bot 63 | # 任选一个Bot,关注获取URL链接,如果没有回复则发送给Bot这条信息: /start 64 | # 将获取的URL填入下面对应位置 65 | # 注意:因为以上Bot都由他人公益提供,无法保证随时可用,如以上Bot都无法使用,请使用其他消息推送方式 66 | # Bot1来源:https://github.com/Fndroid/tg_push_bot 67 | # Bot2来源:https://szc.me/post/2.html 68 | TELEGRAM_ENABLED = 0 69 | TELEGRAM_BOT_API_URL = 'https://tgbot.lbyczf.com/sendMessage/:your_token' 70 | 71 | # ServerChan 和 PushBear 微信消息推送 72 | # 使用说明 73 | # ServerChan http://sc.ftqq.com 74 | # PushBear http://pushbear.ftqq.com 75 | SERVERCHAN_ENABLED = 0 76 | SERVERCHAN_KEY = '' 77 | PUSHBEAR_ENABLED = 0 78 | PUSHBEAR_KEY = '' 79 | 80 | # Bark 推送到ios设备 81 | # 参考 https://www.v2ex.com/t/467407 82 | BARK_ENABLED = 0 83 | BARK_PUSH_URL = 'https://api.day.app/:your_token' 84 | 85 | # 输出日志到文件 86 | OUT_PUT_LOG_TO_FILE_ENABLED = 0 87 | OUT_PUT_LOG_TO_FILE_PATH = 'runtime/12306.log' # 日志目录 88 | 89 | # 分布式集群配置 90 | CLUSTER_ENABLED = 0 # 集群状态 91 | NODE_IS_MASTER = 1 # 是否是主节点 同时只能启用 1 个主节点 92 | NODE_SLAVE_CAN_BE_MASTER = 1 # 主节点宕机后,子节点是否可以自动提升为主节点(建议打开) 93 | NODE_NAME = 'master' # 节点名称,不能重复 94 | REDIS_HOST = 'localhost' # Redis host 95 | REDIS_PORT = '6379' # Redis port 96 | REDIS_PASSWORD = '' # Redis 密码 没有可以留空 97 | 98 | # 邮箱配置 99 | EMAIL_ENABLED = 0 # 是否开启邮件通知 100 | EMAIL_SENDER = 'sender@example.com' # 邮件发送者 101 | EMAIL_RECEIVER = 'receiver@example.com' # 邮件接受者 # 可以多个 [email1@gmail.com, email2@gmail.com] 102 | EMAIL_SERVER_HOST = 'localhost' # 邮件服务 host 103 | EMAIL_SERVER_USER = '' # 邮件服务登录用户名 104 | EMAIL_SERVER_PASSWORD = '' # 邮件服务登录密码 105 | 106 | # Web 管理 107 | WEB_ENABLE = 1 # 是否打开 Web 管理 108 | WEB_USER = { # 登录信息 109 | 'username': 'admin', 110 | 'password': 'password' 111 | } 112 | WEB_PORT = 8008 # 监听端口 113 | 114 | # 是否开启 CDN 查询 115 | CDN_ENABLED = 0 116 | CDN_CHECK_TIME_OUT = 1 # 检测单个 cdn 是否可用超时时间 117 | 118 | # 是否使用浏览器缓存中的RAIL_EXPIRATION 和 RAIL_DEVICEID 119 | CACHE_RAIL_ID_ENABLED = 0 120 | RAIL_EXPIRATION = '' #浏览12306 网站中的Cache的RAIL_EXPIRATION 值 121 | RAIL_DEVICEID = '' #浏览12306 网站中的Cache的RAIL_DEVICEID 值 122 | 123 | # 查询任务 124 | QUERY_JOBS = [ 125 | { 126 | # 'job_name': 'bj -> sz', # 任务名称,不填默认会以车站名命名,不可重复 127 | 'account_key': 0, # 将会使用指定账号下单 128 | 'left_dates': [ # 出发日期 :Array 129 | "2020-01-25", 130 | "2020-01-26", 131 | ], 132 | 'stations': { # 车站 支持多个车站同时查询 :Dict or :List 133 | 'left': '北京', 134 | 'arrive': '深圳', 135 | }, 136 | # # 多个车站示例 (建议添加多个,有时多买几站成功率会高一点) 137 | # 'stations': [{ 138 | # 'left': '北京', 139 | # 'arrive': '深圳', 140 | # },{ # 多个车站示例 141 | # 'left': '北京', 142 | # 'arrive': '广州', 143 | # }], 144 | 'members': [ # 乘客姓名,会根据当前账号自动识别乘客类型 购买儿童票 设置两个相同的姓名即可,程序会自动识别 如 ['张三', '张三'] 145 | "张三", 146 | #"*王五", #在姓名前加*表示学生购买成人票 147 | # 7, # 支持通过序号确定唯一乘客,序号查看可通过 python main.py -t 登录成功之后在 runtime/user/ 下找到对应的 用户名_passengers.json 文件,找到对应的 code 填入 148 | ], 149 | 'allow_less_member': 0, # 是否允许余票不足时提交部分乘客 150 | 'seats': [ # 筛选座位 有先后顺序 :Array 151 | # 可用值: 特等座, 商务座, 一等座, 二等座, 软卧, 硬卧, 动卧, 软座, 硬座, 无座 152 | '硬卧', 153 | '硬座' 154 | ], 155 | 'train_numbers': [ # 筛选车次 可以为空,为空则所有车次都可以提交 如 [] 注意大小写需要保持一致 156 | "K356", 157 | "K1172", 158 | "K4184" 159 | ], 160 | 'except_train_numbers': [ # 筛选车次,排除车次 train_numbers 和 except_train_numbers 不可同时存在 161 | ], 162 | 'period': { # 筛选时间 163 | 'from': '00:00', 164 | 'to': '24:00' 165 | } 166 | 167 | }, 168 | # { 169 | # 'job_name': 'cd -> gz', # 任务名称,不填默认会以车站名命名,不可重复 170 | # 'account_key': 0, # 将会使用指定账号下单 171 | # 'left_dates': [ 172 | # "2019-01-27", 173 | # "2019-01-28" 174 | # ], 175 | # 'stations': { 176 | # 'left': '成都', 177 | # 'arrive': '广州', 178 | # }, 179 | # 'members': [ 180 | # "小王", 181 | # ], 182 | # 'allow_less_member': 0, 183 | # 'seats': [ 184 | # '硬卧', 185 | # ], 186 | # 'train_numbers': [] 187 | # } 188 | ] 189 | -------------------------------------------------------------------------------- /env.slave.py.example: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 分布式子节点配置文件示例 3 | 4 | # 分布式集群配置 5 | CLUSTER_ENABLED = 1 # 集群状态 6 | NODE_IS_MASTER = 0 # 是否是主节点 7 | NODE_NAME = 'slave 1' # 节点名称,不能重复 8 | REDIS_HOST = 'localhost' # Redis host 9 | REDIS_PORT = '6379' # Redis port 10 | REDIS_PASSWORD = '' # Redis 密码 没有可以留空 11 | 12 | # 没了,其它配置会自动从主节点同步 13 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | 4 | from py12306.app import * 5 | from py12306.helpers.cdn import Cdn 6 | from py12306.log.common_log import CommonLog 7 | from py12306.query.query import Query 8 | from py12306.user.user import User 9 | from py12306.web.web import Web 10 | 11 | 12 | def main(): 13 | load_argvs() 14 | CommonLog.print_welcome() 15 | App.run() 16 | CommonLog.print_configs() 17 | App.did_start() 18 | 19 | App.run_check() 20 | Query.check_before_run() 21 | 22 | ####### 运行任务 23 | Web.run() 24 | Cdn.run() 25 | User.run() 26 | Query.run() 27 | if not Const.IS_TEST: 28 | while True: 29 | sleep(10000) 30 | else: 31 | if Config().is_cluster_enabled(): stay_second(5) # 等待接受完通知 32 | CommonLog.print_test_complete() 33 | 34 | 35 | def test(): 36 | """ 37 | 功能检查 38 | 包含: 39 | 账号密码验证 (打码) 40 | 座位验证 41 | 乘客验证 42 | 语音验证码验证 43 | 通知验证 44 | :return: 45 | """ 46 | Const.IS_TEST = True 47 | Config.OUT_PUT_LOG_TO_FILE_ENABLED = False 48 | if '--test-notification' in sys.argv or '-n' in sys.argv: 49 | Const.IS_TEST_NOTIFICATION = True 50 | pass 51 | 52 | 53 | def load_argvs(): 54 | if '--test' in sys.argv or '-t' in sys.argv: test() 55 | config_index = None 56 | 57 | if '--config' in sys.argv: config_index = sys.argv.index('--config') 58 | if '-c' in sys.argv: config_index = sys.argv.index('-c') 59 | if config_index: 60 | Config.CONFIG_FILE = sys.argv[config_index + 1:config_index + 2].pop() 61 | 62 | 63 | if __name__ == '__main__': 64 | main() 65 | -------------------------------------------------------------------------------- /py12306/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/__init__.py -------------------------------------------------------------------------------- /py12306/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import signal 3 | import sys 4 | 5 | from py12306.helpers.func import * 6 | from py12306.config import Config 7 | from py12306.helpers.notification import Notification 8 | from py12306.log.common_log import CommonLog 9 | from py12306.log.order_log import OrderLog 10 | 11 | 12 | def app_available_check(): 13 | if Config().IS_DEBUG: 14 | return True 15 | now = time_now() 16 | if now.weekday() == 1 and (now.hour > 23 and now.minute > 30 or now.hour < 5): 17 | CommonLog.add_quick_log(CommonLog.MESSAGE_12306_IS_CLOSED.format(time_now())).flush() 18 | open_time = datetime.datetime(now.year, now.month, now.day, 5) 19 | if open_time < now: 20 | open_time += datetime.timedelta(1) 21 | sleep((open_time - now).seconds) 22 | elif 1 < now.hour < 5: 23 | CommonLog.add_quick_log(CommonLog.MESSAGE_12306_IS_CLOSED.format(time_now())).flush() 24 | open_time = datetime.datetime(now.year, now.month, now.day, 5) 25 | sleep((open_time - now).seconds) 26 | return True 27 | 28 | 29 | @singleton 30 | class App: 31 | """ 32 | 程序主类 33 | TODO 代码需要优化 34 | """ 35 | 36 | @classmethod 37 | def run(cls): 38 | self = cls() 39 | self.register_sign() 40 | self.start() 41 | 42 | def start(self): 43 | Config().run() 44 | self.init_class() 45 | 46 | @classmethod 47 | def did_start(cls): 48 | self = cls() 49 | from py12306.helpers.station import Station 50 | Station() # 防止多线程时初始化出现问题 51 | # if Config.is_cluster_enabled(): 52 | # from py12306.cluster.cluster import Cluster 53 | # Cluster().run() 54 | 55 | def init_class(self): 56 | from py12306.cluster.cluster import Cluster 57 | if Config.is_cluster_enabled(): 58 | Cluster().run() 59 | 60 | def register_sign(self): 61 | is_windows = os.name == 'nt' 62 | # if is_windows: 63 | signs = [signal.SIGINT, signal.SIGTERM] 64 | # else: 65 | # signs = [signal.SIGINT, signal.SIGHUP, signal.SIGTERM] # SIGHUP 会导致终端退出,程序也退出,暂时去掉 66 | for sign in signs: 67 | signal.signal(sign, self.handler_exit) 68 | 69 | pass 70 | 71 | def handler_exit(self, *args, **kwargs): 72 | """ 73 | 程序退出 74 | :param args: 75 | :param kwargs: 76 | :return: 77 | """ 78 | if Config.is_cluster_enabled(): 79 | from py12306.cluster.cluster import Cluster 80 | Cluster().left_cluster() 81 | 82 | sys.exit() 83 | 84 | @classmethod 85 | def check_auto_code(cls): 86 | if Config().AUTO_CODE_PLATFORM == 'free' or Config().AUTO_CODE_PLATFORM == 'user': return True 87 | if not Config().AUTO_CODE_ACCOUNT.get('user') or not Config().AUTO_CODE_ACCOUNT.get('pwd'): 88 | return False 89 | return True 90 | 91 | @classmethod 92 | def check_user_account_is_empty(cls): 93 | if Config().USER_ACCOUNTS: 94 | for account in Config().USER_ACCOUNTS: 95 | if account: 96 | return False 97 | return True 98 | 99 | @staticmethod 100 | def check_data_dir_exists(): 101 | os.makedirs(Config().QUERY_DATA_DIR, exist_ok=True) 102 | os.makedirs(Config().USER_DATA_DIR, exist_ok=True) 103 | touch_file(Config().OUT_PUT_LOG_TO_FILE_PATH) 104 | 105 | @classmethod 106 | def test_send_notifications(cls): 107 | if Config().NOTIFICATION_BY_VOICE_CODE: # 语音通知 108 | CommonLog.add_quick_log(CommonLog.MESSAGE_TEST_SEND_VOICE_CODE).flush() 109 | if Config().NOTIFICATION_VOICE_CODE_TYPE == 'dingxin': 110 | voice_content = {'left_station': '广州', 'arrive_station': '深圳', 'set_type': '硬座', 'orderno': 'E123542'} 111 | else: 112 | voice_content = OrderLog.MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_VOICE_CODE_CONTENT.format('北京', 113 | '深圳') 114 | Notification.voice_code(Config().NOTIFICATION_VOICE_CODE_PHONE, '张三', voice_content) 115 | if Config().EMAIL_ENABLED: # 邮件通知 116 | CommonLog.add_quick_log(CommonLog.MESSAGE_TEST_SEND_EMAIL).flush() 117 | Notification.send_email(Config().EMAIL_RECEIVER, '测试发送邮件', 'By py12306') 118 | 119 | if Config().DINGTALK_ENABLED: # 钉钉通知 120 | CommonLog.add_quick_log(CommonLog.MESSAGE_TEST_SEND_DINGTALK).flush() 121 | Notification.dingtalk_webhook('测试发送信息') 122 | 123 | if Config().TELEGRAM_ENABLED: # Telegram通知 124 | CommonLog.add_quick_log(CommonLog.MESSAGE_TEST_SEND_TELEGRAM).flush() 125 | Notification.send_to_telegram('测试发送信息') 126 | 127 | if Config().SERVERCHAN_ENABLED: # ServerChan通知 128 | CommonLog.add_quick_log(CommonLog.MESSAGE_TEST_SEND_SERVER_CHAN).flush() 129 | Notification.server_chan(Config().SERVERCHAN_KEY, '测试发送消息', 'By py12306') 130 | 131 | if Config().PUSHBEAR_ENABLED: # PushBear通知 132 | CommonLog.add_quick_log(CommonLog.MESSAGE_TEST_SEND_PUSH_BEAR).flush() 133 | Notification.push_bear(Config().PUSHBEAR_KEY, '测试发送消息', 'By py12306') 134 | 135 | if Config().BARK_ENABLED: # Bark通知 136 | CommonLog.add_quick_log(CommonLog.MESSAGE_TEST_SEND_PUSH_BARK).flush() 137 | Notification.push_bark('测试发送信息') 138 | 139 | @classmethod 140 | def run_check(cls): 141 | """ 142 | 待优化 143 | :return: 144 | """ 145 | cls.check_data_dir_exists() 146 | if not cls.check_user_account_is_empty(): 147 | # CommonLog.add_quick_log(CommonLog.MESSAGE_CHECK_EMPTY_USER_ACCOUNT).flush(exit=True, publish=False) # 不填写用户则不自动下单 148 | if not cls.check_auto_code(): 149 | CommonLog.add_quick_log(CommonLog.MESSAGE_CHECK_AUTO_CODE_FAIL).flush(exit=True, publish=False) 150 | if Const.IS_TEST_NOTIFICATION: cls.test_send_notifications() 151 | 152 | 153 | # Expand 154 | class Dict(dict): 155 | def get(self, key, default=None, sep='.'): 156 | keys = key.split(sep) 157 | for i, key in enumerate(keys): 158 | try: 159 | value = self[key] 160 | if len(keys[i + 1:]) and isinstance(value, Dict): 161 | return value.get(sep.join(keys[i + 1:]), default=default, sep=sep) 162 | return value 163 | except: 164 | return self.dict_to_dict(default) 165 | 166 | def __getitem__(self, k): 167 | return self.dict_to_dict(super().__getitem__(k)) 168 | 169 | @staticmethod 170 | def dict_to_dict(value): 171 | return Dict(value) if isinstance(value, dict) else value 172 | -------------------------------------------------------------------------------- /py12306/cluster/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/cluster/__init__.py -------------------------------------------------------------------------------- /py12306/cluster/cluster.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pickle 4 | import sys 5 | import time 6 | 7 | import redis 8 | from redis.client import PubSub 9 | 10 | from py12306.cluster.redis import Redis 11 | from py12306.config import Config 12 | from py12306.helpers.func import * 13 | from py12306.log.cluster_log import ClusterLog 14 | 15 | 16 | @singleton 17 | class Cluster(): 18 | KEY_PREFIX = 'py12306_' # 目前只能手动 19 | KEY_QUERY_COUNT = KEY_PREFIX + 'query_count' 20 | KEY_QUERY_LAST_TIME = KEY_PREFIX + 'query_last_time' 21 | KEY_CONFIGS = KEY_PREFIX + 'configs' 22 | KEY_NODES = KEY_PREFIX + 'nodes' 23 | KEY_CHANNEL_LOG = KEY_PREFIX + 'channel_log' 24 | KEY_CHANNEL_EVENT = KEY_PREFIX + 'channel_even' 25 | KEY_USER_COOKIES = KEY_PREFIX + 'user_cookies' 26 | KEY_USER_INFOS = KEY_PREFIX + 'user_infos' 27 | KEY_USER_LAST_HEARTBEAT = KEY_PREFIX + 'user_last_heartbeat' 28 | KEY_NODES_ALIVE_PREFIX = KEY_PREFIX + 'nodes_alive_' 29 | 30 | KEY_CDN_AVAILABLE_ITEMS = KEY_PREFIX + 'cdn_available_items' 31 | KEY_CDN_LAST_CHECK_AT = KEY_PREFIX + 'cdn_last_check_at' 32 | 33 | # 锁 34 | KEY_LOCK_INIT_USER = KEY_PREFIX + 'lock_init_user' # 暂未使用 35 | KEY_LOCK_DO_ORDER = KEY_PREFIX + 'lock_do_order' # 订单锁 36 | lock_do_order_time = 60 * 1 # 订单锁超时时间 37 | 38 | lock_prefix = KEY_PREFIX + 'lock_' # 锁键前缀 39 | lock_info_prefix = KEY_PREFIX + 'info_' 40 | 41 | KEY_MASTER = 1 42 | KEY_SLAVE = 0 43 | 44 | session: Redis = None 45 | pubsub: PubSub = None 46 | refresh_channel_time = 0.5 47 | retry_time = 2 48 | keep_alive_time = 3 # 报告存活间隔 49 | lost_alive_time = keep_alive_time * 2 50 | 51 | nodes = {} 52 | node_name = None 53 | is_ready = False 54 | is_master = False 55 | 56 | def __init__(self, *args): 57 | if Config.is_cluster_enabled(): 58 | self.session = Redis() 59 | return self 60 | 61 | @classmethod 62 | def run(cls): 63 | self = cls() 64 | self.start() 65 | 66 | def start(self): 67 | self.pubsub = self.session.pubsub() 68 | self.pubsub.subscribe(self.KEY_CHANNEL_LOG, self.KEY_CHANNEL_EVENT) 69 | create_thread_and_run(self, 'subscribe', wait=False) 70 | self.is_ready = True 71 | self.get_nodes() # 提前获取节点列表 72 | self.check_nodes() # 防止 节点列表未清空 73 | self.join_cluster() 74 | create_thread_and_run(self, 'keep_alive', wait=False) 75 | create_thread_and_run(self, 'refresh_data', wait=False) 76 | 77 | def join_cluster(self): 78 | """ 79 | 加入到集群 80 | :return: 81 | """ 82 | self.node_name = node_name = Config().NODE_NAME 83 | 84 | if Config().NODE_IS_MASTER: 85 | if self.node_name in self.nodes: # 重复运行主节点 86 | ClusterLog.add_quick_log(ClusterLog.MESSAGE_MASTER_NODE_ALREADY_RUN.format(node_name)).flush( 87 | publish=False) 88 | os._exit(1) 89 | if self.have_master(): # 子节点提升为主节点情况,交回控制 90 | message = ClusterLog.MESSAGE_NODE_BECOME_MASTER_AGAIN.format(node_name) 91 | self.publish_log_message(message) 92 | self.make_nodes_as_slave() 93 | elif not self.have_master(): # 只能通过主节点启动 94 | ClusterLog.add_quick_log(ClusterLog.MESSAGE_MASTER_NODE_NOT_FOUND).flush(publish=False) 95 | os._exit(1) 96 | 97 | if node_name in self.nodes: 98 | self.node_name = node_name = node_name + '_' + str(dict_count_key_num(self.nodes, node_name)) 99 | ClusterLog.add_quick_log(ClusterLog.MESSAGE_NODE_ALREADY_IN_CLUSTER.format(node_name)).flush() 100 | 101 | self.session.hset(self.KEY_NODES, node_name, Config().NODE_IS_MASTER) 102 | message = ClusterLog.MESSAGE_JOIN_CLUSTER_SUCCESS.format(self.node_name, ClusterLog.get_print_nodes( 103 | self.get_nodes())) # 手动 get nodes 104 | self.publish_log_message(message) 105 | 106 | def left_cluster(self, node_name=None): 107 | node_name = node_name if node_name else self.node_name 108 | self.session.hdel(self.KEY_NODES, node_name) 109 | message = ClusterLog.MESSAGE_LEFT_CLUSTER.format(node_name, ClusterLog.get_print_nodes(self.get_nodes())) 110 | self.publish_log_message(message, node_name) 111 | 112 | def make_nodes_as_slave(self): 113 | """ 114 | 将所有节点设为主节点 115 | :return: 116 | """ 117 | for node in self.nodes: 118 | self.session.hset(self.KEY_NODES, node, self.KEY_SLAVE) 119 | 120 | def publish_log_message(self, message, node_name=None): 121 | """ 122 | 发布订阅消息 123 | :return: 124 | """ 125 | node_name = node_name if node_name else self.node_name 126 | message = ClusterLog.MESSAGE_SUBSCRIBE_NOTIFICATION.format(node_name, message) 127 | self.session.publish(self.KEY_CHANNEL_LOG, message) 128 | 129 | def publish_event(self, name, data={}): 130 | """ 131 | 发布事件消息 132 | :return: 133 | """ 134 | data = {'event': name, 'data': data} 135 | self.session.publish(self.KEY_CHANNEL_EVENT, json.dumps(data)) 136 | 137 | def get_nodes(self) -> dict: 138 | res = self.session.hgetall(self.KEY_NODES) 139 | res = res if res else {} 140 | self.nodes = res 141 | return res 142 | 143 | def refresh_data(self): 144 | """ 145 | 单独进程处理数据同步 146 | :return: 147 | """ 148 | while True: 149 | self.get_nodes() 150 | self.check_locks() 151 | self.check_nodes() 152 | self.check_master() 153 | stay_second(self.retry_time) 154 | 155 | def check_master(self): 156 | """ 157 | 检测主节点是否可用 158 | :return: 159 | """ 160 | master = self.have_master() 161 | if master == self.node_name: # 动态提升 162 | self.is_master = True 163 | else: 164 | self.is_master = False 165 | 166 | if not master: 167 | if Config().NODE_SLAVE_CAN_BE_MASTER: 168 | # 提升子节点为主节点 169 | slave = list(self.nodes)[0] 170 | self.session.hset(self.KEY_NODES, slave, self.KEY_MASTER) 171 | self.publish_log_message(ClusterLog.MESSAGE_ASCENDING_MASTER_NODE.format(slave, 172 | ClusterLog.get_print_nodes( 173 | self.get_nodes()))) 174 | return True 175 | else: 176 | self.publish_log_message(ClusterLog.MESSAGE_MASTER_DID_LOST.format(self.retry_time)) 177 | stay_second(self.retry_time) 178 | os._exit(1) # 退出整个程序 179 | 180 | def have_master(self): 181 | return dict_find_key_by_value(self.nodes, str(self.KEY_MASTER), False) 182 | 183 | def check_nodes(self): 184 | """ 185 | 检查节点是否存活 186 | :return: 187 | """ 188 | for node in self.nodes: 189 | if not self.session.exists(self.KEY_NODES_ALIVE_PREFIX + node): 190 | self.left_cluster(node) 191 | 192 | # def kick_out_from_nodes(self, node_name): 193 | # pass 194 | 195 | def keep_alive(self): 196 | while True: 197 | if self.node_name not in self.get_nodes(): # 已经被 kict out 重新加下 198 | self.join_cluster() 199 | self.session.set(self.KEY_NODES_ALIVE_PREFIX + self.node_name, Config().NODE_IS_MASTER, ex=self.lost_alive_time) 200 | stay_second(self.keep_alive_time) 201 | 202 | def subscribe(self): 203 | while True: 204 | try: 205 | message = self.pubsub.get_message() 206 | except RuntimeError as err: 207 | if 'args' in dir(err) and err.args[0].find('pubsub connection not set') >= 0: # 失去重连 208 | self.pubsub.subscribe(self.KEY_CHANNEL_LOG, self.KEY_CHANNEL_EVENT) 209 | continue 210 | if message: 211 | if message.get('type') == 'message' and message.get('channel') == self.KEY_CHANNEL_LOG and message.get( 212 | 'data'): 213 | msg = message.get('data') 214 | if self.node_name: 215 | msg = msg.replace(ClusterLog.MESSAGE_SUBSCRIBE_NOTIFICATION_PREFIX.format(self.node_name), '') 216 | ClusterLog.add_quick_log(msg).flush(publish=False) 217 | elif message.get('channel') == self.KEY_CHANNEL_EVENT: 218 | create_thread_and_run(self, 'handle_events', args=(message,)) 219 | stay_second(self.refresh_channel_time) 220 | 221 | def handle_events(self, message): 222 | # 这里应该分开处理,先都在这处理了 223 | if message.get('type') != 'message': return 224 | result = json.loads(message.get('data', {})) 225 | event_name = result.get('event') 226 | data = result.get('data') 227 | from py12306.helpers.event import Event 228 | method = getattr(Event(), event_name) 229 | if method: 230 | create_thread_and_run(Event(), event_name, Const.IS_TEST, kwargs={'data': data, 'callback': True}) 231 | 232 | def get_lock(self, key: str, timeout=1, info={}): 233 | timeout = int(time.time()) + timeout 234 | res = self.session.setnx(key, timeout) 235 | if res: 236 | if info: self.session.set_dict(self.lock_info_prefix + key.replace(self.KEY_PREFIX, ''), info) # 存储额外信息 237 | return True 238 | return False 239 | 240 | def get_lock_info(self, key, default={}): 241 | return self.session.get_dict(self.lock_info_prefix + key.replace(self.KEY_PREFIX, ''), default=default) 242 | 243 | def release_lock(self, key): 244 | self.session.delete(key) 245 | self.session.delete(self.lock_info_prefix + key.replace(self.KEY_PREFIX, '')) 246 | 247 | def check_locks(self): 248 | locks = self.session.keys(self.lock_prefix + '*') 249 | for key in locks: 250 | val = self.session.get(key) 251 | if val and int(val) <= time_int(): 252 | self.release_lock(key) 253 | 254 | @classmethod 255 | def get_user_cookie(cls, key, default=None): 256 | self = cls() 257 | res = self.session.hget(Cluster.KEY_USER_COOKIES, key) 258 | return pickle.loads(res.encode()) if res else default 259 | 260 | @classmethod 261 | def set_user_cookie(cls, key, value): 262 | self = cls() 263 | return self.session.hset(Cluster.KEY_USER_COOKIES, key, pickle.dumps(value, 0).decode()) 264 | 265 | @classmethod 266 | def set_user_info(cls, key, info): 267 | self = cls() 268 | return self.session.hset(Cluster.KEY_USER_INFOS, key, pickle.dumps(info, 0).decode()) 269 | 270 | @classmethod 271 | def get_user_info(cls, key, default=None): 272 | self = cls() 273 | res = self.session.hget(Cluster.KEY_USER_INFOS, key) 274 | return pickle.loads(res.encode()) if res else default 275 | -------------------------------------------------------------------------------- /py12306/cluster/redis.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pickle 3 | 4 | import redis 5 | 6 | from py12306.config import Config 7 | from py12306.helpers.func import * 8 | from py12306.log.redis_log import RedisLog 9 | from redis import Redis as PyRedis 10 | 11 | 12 | @singleton 13 | class Redis(PyRedis): 14 | # session = None 15 | 16 | def __init__(self, *args): 17 | if Config.is_cluster_enabled(): 18 | args = { 19 | 'host': Config().REDIS_HOST, 20 | 'port': Config().REDIS_PORT, 21 | 'db': 0, 22 | 'password': Config().REDIS_PASSWORD, 23 | 'decode_responses': True 24 | } 25 | super().__init__(**args) 26 | RedisLog.add_quick_log(RedisLog.MESSAGE_REDIS_INIT_SUCCESS) 27 | else: 28 | super().__init__(**args) 29 | return self 30 | 31 | def get(self, name, default=None): 32 | res = super().get(name) 33 | # if decode: res = res.decode() 34 | return res if res else default 35 | 36 | def set(self, name, value, ex=None, px=None, nx=False, xx=False): 37 | return super().set(name, available_value(value), ex=ex, px=px, nx=nx, xx=xx) 38 | 39 | def set_dict(self, name, value): 40 | return self.set_pickle(name, value) 41 | # return self.set(name, json.dumps(value)) 42 | 43 | def get_dict(self, name, default={}): 44 | return self.get_pickle(name, default) 45 | # res = self.get(name) 46 | # if res: 47 | # return json.loads(res) 48 | # return default 49 | 50 | def set_pickle(self, name, value): 51 | return self.set(name, pickle.dumps(value, 0).decode()) 52 | 53 | def get_pickle(self, name, default=None): 54 | res = self.get(name) 55 | return pickle.loads(res.encode()) if res else default 56 | 57 | # def smembers(self, name, default=[]): 58 | # res = super().smembers(name) 59 | # return [val.decode() for val in list(res)] if res else default 60 | -------------------------------------------------------------------------------- /py12306/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import re 4 | from os import path 5 | 6 | # 12306 账号 7 | from py12306.helpers.func import * 8 | 9 | 10 | @singleton 11 | class Config: 12 | IS_DEBUG = False 13 | 14 | USER_ACCOUNTS = [] 15 | # 查询任务 16 | QUERY_JOBS = [] 17 | # 查询间隔 18 | QUERY_INTERVAL = 1 19 | # 查询重试次数 20 | REQUEST_MAX_RETRY = 5 21 | # 用户心跳检测间隔 22 | USER_HEARTBEAT_INTERVAL = 120 23 | # 多线程查询 24 | QUERY_JOB_THREAD_ENABLED = 0 25 | # 打码平台账号 26 | AUTO_CODE_PLATFORM = '' 27 | #用户打码平台地址 28 | API_USER_CODE_QCR_API = '' 29 | AUTO_CODE_ACCOUNT = {'user': '', 'pwd': ''} 30 | # 输出日志到文件 31 | OUT_PUT_LOG_TO_FILE_ENABLED = 0 32 | OUT_PUT_LOG_TO_FILE_PATH = 'runtime/12306.log' 33 | 34 | SEAT_TYPES = {'特等座': 25, '商务座': 32, '一等座': 31, '二等座': 30, '软卧': 23, '硬卧': 28, '硬座': 29, '无座': 26, } 35 | 36 | ORDER_SEAT_TYPES = {'特等座': 'P', '商务座': 9, '一等座': 'M', '二等座': 'O', '软卧': 4, '硬卧': 3, '硬座': 1, '无座': 1} 37 | 38 | PROJECT_DIR = path.dirname(path.dirname(path.abspath(__file__))) + '/' 39 | 40 | # Query 41 | RUNTIME_DIR = PROJECT_DIR + 'runtime/' 42 | QUERY_DATA_DIR = RUNTIME_DIR + 'query/' 43 | USER_DATA_DIR = RUNTIME_DIR + 'user/' 44 | USER_PASSENGERS_FILE = RUNTIME_DIR + 'user/%s_passengers.json' 45 | 46 | STATION_FILE = PROJECT_DIR + 'data/stations.txt' 47 | CONFIG_FILE = PROJECT_DIR + 'env.py' 48 | 49 | # 语音验证码 50 | NOTIFICATION_BY_VOICE_CODE = 0 51 | NOTIFICATION_VOICE_CODE_TYPE = '' 52 | NOTIFICATION_VOICE_CODE_PHONE = '' 53 | NOTIFICATION_API_APP_CODE = '' 54 | 55 | # 集群配置 56 | CLUSTER_ENABLED = 0 57 | NODE_SLAVE_CAN_BE_MASTER = 1 58 | NODE_IS_MASTER = 1 59 | NODE_NAME = '' 60 | REDIS_HOST = '' 61 | REDIS_PORT = '6379' 62 | REDIS_PASSWORD = '' 63 | 64 | # 钉钉配置 65 | DINGTALK_ENABLED = 0 66 | DINGTALK_WEBHOOK = '' 67 | 68 | # Telegram推送配置 69 | TELEGRAM_ENABLED = 0 70 | TELEGRAM_BOT_API_URL = '' 71 | 72 | # Bark 推送配置 73 | BARK_ENABLED = 0 74 | BARK_PUSH_URL = '' 75 | 76 | # ServerChan和PushBear配置 77 | SERVERCHAN_ENABLED = 0 78 | SERVERCHAN_KEY = '8474-ca071ADSFADSF' 79 | PUSHBEAR_ENABLED = 0 80 | PUSHBEAR_KEY = 'SCUdafadsfasfdafdf45234234234' 81 | 82 | # 邮箱配置 83 | EMAIL_ENABLED = 0 84 | EMAIL_SENDER = '' 85 | EMAIL_RECEIVER = '' 86 | EMAIL_SERVER_HOST = '' 87 | EMAIL_SERVER_USER = '' 88 | EMAIL_SERVER_PASSWORD = '' 89 | 90 | WEB_ENABLE = 0 91 | WEB_USER = {} 92 | WEB_PORT = 8080 93 | WEB_ENTER_HTML_PATH = PROJECT_DIR + 'py12306/web/static/index.html' 94 | 95 | # CDN 96 | CDN_ENABLED = 0 97 | CDN_CHECK_TIME_OUT = 2 98 | CDN_ITEM_FILE = PROJECT_DIR + 'data/cdn.txt' 99 | CDN_ENABLED_AVAILABLE_ITEM_FILE = QUERY_DATA_DIR + 'available.json' 100 | 101 | CACHE_RAIL_ID_ENABLED = 0 102 | RAIL_EXPIRATION = '' 103 | RAIL_DEVICEID = '' 104 | 105 | # Default time out 106 | TIME_OUT_OF_REQUEST = 5 107 | 108 | envs = [] 109 | retry_time = 5 110 | last_modify_time = 0 111 | 112 | disallow_update_configs = [ 113 | 'CLUSTER_ENABLED', 114 | 'NODE_IS_MASTER', 115 | 'NODE_NAME', 116 | 'REDIS_HOST', 117 | 'REDIS_PORT', 118 | 'REDIS_PASSWORD', 119 | ] 120 | 121 | def __init__(self): 122 | self.init_envs() 123 | self.last_modify_time = get_file_modify_time(self.CONFIG_FILE) 124 | if Config().is_slave(): 125 | self.refresh_configs(True) 126 | else: 127 | create_thread_and_run(self, 'watch_file_change', False) 128 | 129 | @classmethod 130 | def run(cls): 131 | self = cls() 132 | self.start() 133 | 134 | # @classmethod 135 | # def keep_work(cls): 136 | # self = cls() 137 | 138 | def start(self): 139 | self.save_to_remote() 140 | create_thread_and_run(self, 'refresh_configs', wait=Const.IS_TEST) 141 | 142 | def refresh_configs(self, once=False): 143 | if not self.is_cluster_enabled(): return 144 | while True: 145 | remote_configs = self.get_remote_config() 146 | self.update_configs_from_remote(remote_configs, once) 147 | if once or Const.IS_TEST: return 148 | stay_second(self.retry_time) 149 | 150 | def get_remote_config(self): 151 | if not self.is_cluster_enabled(): return 152 | from py12306.cluster.cluster import Cluster 153 | return Cluster().session.get_pickle(Cluster().KEY_CONFIGS, {}) 154 | 155 | def save_to_remote(self): 156 | if not self.is_master(): return 157 | from py12306.cluster.cluster import Cluster 158 | Cluster().session.set_pickle(Cluster().KEY_CONFIGS, self.envs) 159 | 160 | def init_envs(self): 161 | self.envs = EnvLoader.load_with_file(self.CONFIG_FILE) 162 | self.update_configs(self.envs) 163 | 164 | def update_configs(self, envs): 165 | for key, value in envs: 166 | setattr(self, key, value) 167 | 168 | def watch_file_change(self): 169 | """ 170 | 监听配置文件修改 171 | :return: 172 | """ 173 | if Config().is_slave(): return 174 | from py12306.log.common_log import CommonLog 175 | while True: 176 | value = get_file_modify_time(self.CONFIG_FILE) 177 | if value > self.last_modify_time: 178 | self.last_modify_time = value 179 | CommonLog.add_quick_log(CommonLog.MESSAGE_CONFIG_FILE_DID_CHANGED).flush() 180 | envs = EnvLoader.load_with_file(self.CONFIG_FILE) 181 | self.update_configs_from_remote(envs) 182 | if Config().is_master(): # 保存配置 183 | self.save_to_remote() 184 | stay_second(self.retry_time) 185 | 186 | def update_configs_from_remote(self, envs, first=False): 187 | if envs == self.envs: return 188 | from py12306.query.query import Query 189 | from py12306.user.user import User 190 | from py12306.helpers.cdn import Cdn 191 | self.envs = envs 192 | for key, value in envs: 193 | if key in self.disallow_update_configs: continue 194 | if value != -1: 195 | old = getattr(self, key) 196 | setattr(self, key, value) 197 | if not first and old != value: 198 | if key == 'USER_ACCOUNTS': 199 | User().update_user_accounts(auto=True, old=old) 200 | elif key == 'QUERY_JOBS': 201 | Query().update_query_jobs(auto=True) # 任务修改 202 | elif key == 'QUERY_INTERVAL': 203 | Query().update_query_interval(auto=True) 204 | elif key == 'CDN_ENABLED': 205 | Cdn().update_cdn_status(auto=True) 206 | 207 | @staticmethod 208 | def is_master(): # 是不是 主 209 | from py12306.cluster.cluster import Cluster 210 | return Config().CLUSTER_ENABLED and (Config().NODE_IS_MASTER or Cluster().is_master) 211 | 212 | @staticmethod 213 | def is_slave(): # 是不是 从 214 | return Config().CLUSTER_ENABLED and not Config.is_master() 215 | 216 | @staticmethod 217 | def is_cluster_enabled(): 218 | return Config().CLUSTER_ENABLED 219 | 220 | @staticmethod 221 | def is_cdn_enabled(): 222 | return Config().CDN_ENABLED 223 | 224 | @staticmethod 225 | def is_cache_rail_id_enabled(): 226 | return Config().CACHE_RAIL_ID_ENABLED 227 | 228 | 229 | class EnvLoader: 230 | envs = [] 231 | 232 | def __init__(self): 233 | self.envs = [] 234 | 235 | @classmethod 236 | def load_with_file(cls, file): 237 | self = cls() 238 | if path.exists(file): 239 | env_content = open(file, encoding='utf8').read() 240 | content = re.sub(r'^([A-Z]+)_', r'self.\1_', env_content, flags=re.M) 241 | exec(content) 242 | return self.envs 243 | 244 | def __setattr__(self, key, value): 245 | super().__setattr__(key, value) 246 | if re.search(r'^[A-Z]+_', key): 247 | self.envs.append(([key, value])) 248 | -------------------------------------------------------------------------------- /py12306/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/exceptions/__init__.py -------------------------------------------------------------------------------- /py12306/helpers/OCR.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | 4 | from py12306.config import Config 5 | from py12306.helpers.api import API_FREE_CODE_QCR_API 6 | from py12306.helpers.request import Request 7 | from py12306.log.common_log import CommonLog 8 | from py12306.vender.ruokuai.main import RKClient 9 | 10 | 11 | class OCR: 12 | """ 13 | 图片识别 14 | """ 15 | session = None 16 | 17 | def __init__(self): 18 | self.session = Request() 19 | 20 | @classmethod 21 | def get_img_position(cls, img): 22 | """ 23 | 获取图像坐标 24 | :param img_path: 25 | :return: 26 | """ 27 | self = cls() 28 | if Config().AUTO_CODE_PLATFORM == 'free' or Config().AUTO_CODE_PLATFORM == 'user': 29 | return self.get_image_by_free_site(img) 30 | return self.get_img_position_by_ruokuai(img) 31 | 32 | def get_img_position_by_ruokuai(self, img): 33 | ruokuai_account = Config().AUTO_CODE_ACCOUNT 34 | soft_id = '119671' 35 | soft_key = '6839cbaca1f942f58d2760baba5ed987' 36 | rc = RKClient(ruokuai_account.get('user'), ruokuai_account.get('pwd'), soft_id, soft_key) 37 | result = rc.rk_create(img, 6113) 38 | if "Result" in result: 39 | return self.get_image_position_by_offset(list(result['Result'])) 40 | CommonLog.print_auto_code_fail(result.get("Error", CommonLog.MESSAGE_RESPONSE_EMPTY_ERROR)) 41 | return None 42 | 43 | def get_image_position_by_offset(self, offsets): 44 | positions = [] 45 | width = 75 46 | height = 75 47 | for offset in offsets: 48 | random_x = random.randint(-5, 5) 49 | random_y = random.randint(-5, 5) 50 | offset = int(offset) 51 | x = width * ((offset - 1) % 4 + 1) - width / 2 + random_x 52 | y = height * math.ceil(offset / 4) - height / 2 + random_y 53 | positions.append(int(x)) 54 | positions.append(int(y)) 55 | return positions 56 | 57 | def get_image_by_free_site(self, img): 58 | data = { 59 | 'img': img 60 | } 61 | if Config().AUTO_CODE_PLATFORM == 'free': 62 | response = self.session.post(API_FREE_CODE_QCR_API, data=data, timeout=30) 63 | else: 64 | response = self.session.post(Config().API_USER_CODE_QCR_API, data=data, timeout=30) 65 | result = response.json() 66 | if result.get('msg') == 'success': 67 | pos = result.get('result') 68 | return self.get_image_position_by_offset(pos) 69 | 70 | CommonLog.print_auto_code_fail(CommonLog.MESSAGE_GET_RESPONSE_FROM_FREE_AUTO_CODE) 71 | return None 72 | 73 | 74 | if __name__ == '__main__': 75 | pass 76 | # code_result = AuthCode.get_auth_code() 77 | -------------------------------------------------------------------------------- /py12306/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/helpers/__init__.py -------------------------------------------------------------------------------- /py12306/helpers/api.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | HOST_URL_OF_12306 = 'kyfw.12306.cn' 4 | BASE_URL_OF_12306 = 'https://' + HOST_URL_OF_12306 5 | 6 | LEFT_TICKETS = { 7 | "url": BASE_URL_OF_12306 + "/otn/{type}?leftTicketDTO.train_date={left_date}&leftTicketDTO.from_station={left_station}&leftTicketDTO.to_station={arrive_station}&purpose_codes=ADULT", 8 | } 9 | 10 | API_BASE_LOGIN = { 11 | "url": BASE_URL_OF_12306 + '/passport/web/login', 12 | } 13 | 14 | API_USER_LOGIN_CHECK = BASE_URL_OF_12306 + '/otn/login/conf' 15 | 16 | API_AUTH_QRCODE_BASE64_DOWNLOAD = { 17 | 'url': BASE_URL_OF_12306 + '/passport/web/create-qr64' 18 | } 19 | 20 | API_AUTH_QRCODE_CHECK = { 21 | 'url': BASE_URL_OF_12306 + '/passport/web/checkqr' 22 | } 23 | 24 | API_USER_LOGIN = { 25 | 'url': BASE_URL_OF_12306 + '/otn/login/userLogin' 26 | } 27 | 28 | API_AUTH_CODE_DOWNLOAD = { 29 | 'url': BASE_URL_OF_12306 + '/passport/captcha/captcha-image?login_site=E&module=login&rand=sjrand&_={random}' 30 | } 31 | API_AUTH_CODE_BASE64_DOWNLOAD = BASE_URL_OF_12306 + '/passport/captcha/captcha-image64?login_site=E&module=login&rand=sjrand&_={random}' 32 | API_AUTH_CODE_CHECK = { 33 | 'url': BASE_URL_OF_12306 + '/passport/captcha/captcha-check?answer={answer}&rand=sjrand&login_site=E&_={random}' 34 | } 35 | API_AUTH_UAMTK = { 36 | 'url': BASE_URL_OF_12306 + '/passport/web/auth/uamtk' 37 | } 38 | API_AUTH_UAMAUTHCLIENT = { 39 | 'url': BASE_URL_OF_12306 + '/otn/uamauthclient' 40 | } 41 | 42 | API_USER_INFO = { 43 | 'url': BASE_URL_OF_12306 + '/otn/modifyUser/initQueryUserInfoApi' 44 | } 45 | API_USER_PASSENGERS = BASE_URL_OF_12306 + '/otn/confirmPassenger/getPassengerDTOs' 46 | API_SUBMIT_ORDER_REQUEST = BASE_URL_OF_12306 + '/otn/leftTicket/submitOrderRequest' 47 | API_CHECK_ORDER_INFO = BASE_URL_OF_12306 + '/otn/confirmPassenger/checkOrderInfo' 48 | API_INITDC_URL = BASE_URL_OF_12306 + '/otn/confirmPassenger/initDc' # 生成订单时需要先请求这个页面 49 | API_GET_QUEUE_COUNT = BASE_URL_OF_12306 + '/otn/confirmPassenger/getQueueCount' 50 | API_CONFIRM_SINGLE_FOR_QUEUE = BASE_URL_OF_12306 + '/otn/confirmPassenger/confirmSingleForQueue' 51 | API_QUERY_ORDER_WAIT_TIME = BASE_URL_OF_12306 + '/otn/confirmPassenger/queryOrderWaitTime?{}' # 排队查询 52 | API_QUERY_INIT_PAGE = BASE_URL_OF_12306 + '/otn/leftTicket/init' 53 | # API_GET_BROWSER_DEVICE_ID = BASE_URL_OF_12306 + '/otn/HttpZF/logdevice' 54 | API_GET_BROWSER_DEVICE_ID = 'https://12306-rail-id-v2.pjialin.com/' 55 | API_FREE_CODE_QCR_API = 'https://12306-ocr.pjialin.com/check/' 56 | 57 | API_NOTIFICATION_BY_VOICE_CODE = 'http://ali-voice.showapi.com/sendVoice?' 58 | API_NOTIFICATION_BY_VOICE_CODE_DINGXIN = 'http://yuyin2.market.alicloudapi.com/dx/voice_notice' 59 | 60 | API_CHECK_CDN_AVAILABLE = 'https://{}/otn/dynamicJs/omseuuq' 61 | -------------------------------------------------------------------------------- /py12306/helpers/auth_code.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | 4 | from requests.exceptions import SSLError 5 | 6 | from py12306.config import Config 7 | from py12306.helpers.OCR import OCR 8 | from py12306.helpers.api import * 9 | from py12306.helpers.request import Request 10 | from py12306.helpers.func import * 11 | from py12306.log.common_log import CommonLog 12 | from py12306.log.user_log import UserLog 13 | 14 | 15 | class AuthCode: 16 | """ 17 | 验证码类 18 | """ 19 | session = None 20 | data_path = None 21 | retry_time = 5 22 | 23 | def __init__(self, session): 24 | self.data_path = Config().RUNTIME_DIR 25 | self.session = session 26 | 27 | @classmethod 28 | def get_auth_code(cls, session): 29 | self = cls(session) 30 | img = self.download_code() 31 | position = OCR.get_img_position(img) 32 | if not position: # 打码失败 33 | return self.retry_get_auth_code() 34 | 35 | answer = ','.join(map(str, position)) 36 | 37 | if not self.check_code(answer): 38 | return self.retry_get_auth_code() 39 | return position 40 | 41 | def retry_get_auth_code(self): # TODO 安全次数检测 42 | CommonLog.add_quick_log(CommonLog.MESSAGE_RETRY_AUTH_CODE.format(self.retry_time)).flush() 43 | time.sleep(self.retry_time) 44 | return self.get_auth_code(self.session) 45 | 46 | def download_code(self): 47 | url = API_AUTH_CODE_BASE64_DOWNLOAD.format(random=random.random()) 48 | # code_path = self.data_path + 'code.png' 49 | try: 50 | self.session.cookies.clear_session_cookies() 51 | UserLog.add_quick_log(UserLog.MESSAGE_DOWNLAODING_THE_CODE).flush() 52 | # response = self.session.save_to_file(url, code_path) # TODO 返回错误情况 53 | response = self.session.get(url) 54 | result = response.json() 55 | if result.get('image'): 56 | return result.get('image') 57 | raise SSLError('返回数据为空') 58 | except SSLError as e: 59 | UserLog.add_quick_log( 60 | UserLog.MESSAGE_DOWNLAOD_AUTH_CODE_FAIL.format(e, self.retry_time)).flush() 61 | time.sleep(self.retry_time) 62 | return self.download_code() 63 | 64 | def check_code(self, answer): 65 | """ 66 | 校验验证码 67 | :return: 68 | """ 69 | url = API_AUTH_CODE_CHECK.get('url').format(answer=answer, random=time_int()) 70 | response = self.session.get(url) 71 | result = response.json() 72 | if result.get('result_code') == '4': 73 | UserLog.add_quick_log(UserLog.MESSAGE_CODE_AUTH_SUCCESS).flush() 74 | return True 75 | else: 76 | # {'result_message': '验证码校验失败', 'result_code': '5'} 77 | UserLog.add_quick_log( 78 | UserLog.MESSAGE_CODE_AUTH_FAIL.format(result.get('result_message'))).flush() 79 | self.session.cookies.clear_session_cookies() 80 | 81 | return False 82 | 83 | 84 | if __name__ == '__main__': 85 | code_result = AuthCode.get_auth_code() 86 | -------------------------------------------------------------------------------- /py12306/helpers/cdn.py: -------------------------------------------------------------------------------- 1 | import random 2 | import json 3 | from datetime import timedelta 4 | from os import path 5 | 6 | from py12306.cluster.cluster import Cluster 7 | from py12306.config import Config 8 | from py12306.app import app_available_check 9 | from py12306.helpers.api import API_CHECK_CDN_AVAILABLE, HOST_URL_OF_12306 10 | from py12306.helpers.func import * 11 | from py12306.helpers.request import Request 12 | from py12306.log.common_log import CommonLog 13 | 14 | 15 | @singleton 16 | class Cdn: 17 | """ 18 | CDN 管理 19 | """ 20 | items = [] 21 | available_items = [] 22 | unavailable_items = [] 23 | recheck_available_items = [] 24 | recheck_unavailable_items = [] 25 | retry_time = 3 26 | is_ready = False 27 | is_finished = False 28 | is_ready_num = 10 # 当可用超过 10,已准备好 29 | is_alive = True 30 | is_recheck = False 31 | 32 | safe_stay_time = 0.2 33 | retry_num = 1 34 | thread_num = 5 35 | check_time_out = 3 36 | 37 | last_check_at = 0 38 | save_second = 5 39 | check_keep_second = 60 * 60 * 24 40 | 41 | def __init__(self): 42 | self.cluster = Cluster() 43 | self.init_config() 44 | create_thread_and_run(self, 'watch_cdn', False) 45 | 46 | def init_data(self): 47 | self.items = [] 48 | self.available_items = [] 49 | self.unavailable_items = [] 50 | self.is_finished = False 51 | self.is_ready = False 52 | self.is_recheck = False 53 | 54 | def init_config(self): 55 | self.check_time_out = Config().CDN_CHECK_TIME_OUT 56 | 57 | def update_cdn_status(self, auto=False): 58 | if auto: 59 | self.init_config() 60 | if Config().is_cdn_enabled(): 61 | self.run() 62 | else: 63 | self.destroy() 64 | 65 | @classmethod 66 | def run(cls): 67 | self = cls() 68 | app_available_check() 69 | self.is_alive = True 70 | self.start() 71 | pass 72 | 73 | def start(self): 74 | if not Config.is_cdn_enabled(): return 75 | self.load_items() 76 | CommonLog.add_quick_log(CommonLog.MESSAGE_CDN_START_TO_CHECK.format(len(self.items))).flush() 77 | self.restore_items() 78 | for i in range(self.thread_num): # 多线程 79 | create_thread_and_run(jobs=self, callback_name='check_available', wait=False) 80 | 81 | def load_items(self): 82 | with open(Config().CDN_ITEM_FILE, encoding='utf-8') as f: 83 | for line, val in enumerate(f): 84 | self.items.append(val.rstrip('\n')) 85 | 86 | def restore_items(self): 87 | """ 88 | 恢复已有数据 89 | :return: bool 90 | """ 91 | result = False 92 | if path.exists(Config().CDN_ENABLED_AVAILABLE_ITEM_FILE): 93 | with open(Config().CDN_ENABLED_AVAILABLE_ITEM_FILE, encoding='utf-8') as f: 94 | result = f.read() 95 | try: 96 | result = json.loads(result) 97 | except json.JSONDecodeError as e: 98 | result = {} 99 | 100 | # if Config.is_cluster_enabled(): # 集群不用同步 cdn 101 | # result = self.get_data_from_cluster() 102 | 103 | if result: 104 | self.last_check_at = result.get('last_check_at', '') 105 | if self.last_check_at: self.last_check_at = str_to_time(self.last_check_at) 106 | self.available_items = result.get('items', []) 107 | self.unavailable_items = result.get('fail_items', []) 108 | CommonLog.add_quick_log(CommonLog.MESSAGE_CDN_RESTORE_SUCCESS.format(self.last_check_at)).flush() 109 | return True 110 | return False 111 | 112 | # def get_data_from_cluster(self): 113 | # available_items = self.cluster.session.smembers(Cluster.KEY_CDN_AVAILABLE_ITEMS) 114 | # last_time = self.cluster.session.get(Cluster.KEY_CDN_LAST_CHECK_AT, '') 115 | # if available_items and last_time: 116 | # return {'items': available_items, 'last_check_at': last_time} 117 | # return False 118 | 119 | def is_need_to_recheck(self): 120 | """ 121 | 是否需要重新检查 cdn 122 | :return: 123 | """ 124 | if self.last_check_at and ( 125 | time_now() - self.last_check_at).seconds > self.check_keep_second: 126 | return True 127 | return False 128 | 129 | def get_unchecked_item(self): 130 | if not self.is_recheck: 131 | items = list(set(self.items) - set(self.available_items) - set(self.unavailable_items)) 132 | else: 133 | items = list(set(self.items) - set(self.recheck_available_items) - set(self.recheck_unavailable_items)) 134 | if items: return random.choice(items) 135 | return None 136 | 137 | def check_available(self): 138 | while True and self.is_alive: 139 | item = self.get_unchecked_item() 140 | if not item: return self.check_did_finished() 141 | self.check_item_available(item) 142 | 143 | def watch_cdn(self): 144 | """ 145 | 监控 cdn 状态,自动重新检测 146 | :return: 147 | """ 148 | while True: 149 | if self.is_alive and not self.is_recheck and self.is_need_to_recheck(): # 重新检测 150 | self.is_recheck = True 151 | self.is_finished = False 152 | CommonLog.add_quick_log( 153 | CommonLog.MESSAGE_CDN_START_TO_RECHECK.format(len(self.items), time_now())).flush() 154 | for i in range(self.thread_num): # 多线程 155 | create_thread_and_run(jobs=self, callback_name='check_available', wait=False) 156 | stay_second(self.retry_num) 157 | 158 | def destroy(self): 159 | """ 160 | 关闭 CDN 161 | :return: 162 | """ 163 | CommonLog.add_quick_log(CommonLog.MESSAGE_CDN_CLOSED).flush() 164 | self.is_alive = False 165 | self.init_data() 166 | 167 | def check_item_available(self, item, try_num=0): 168 | session = Request() 169 | response = session.get(API_CHECK_CDN_AVAILABLE.format(item), headers={'Host': HOST_URL_OF_12306}, 170 | timeout=self.check_time_out, 171 | verify=False) 172 | 173 | if response.status_code == 200: 174 | if not self.is_recheck: 175 | self.available_items.append(item) 176 | else: 177 | self.recheck_available_items.append(item) 178 | if not self.is_ready: self.check_is_ready() 179 | elif try_num < self.retry_num: # 重试 180 | stay_second(self.safe_stay_time) 181 | return self.check_item_available(item, try_num + 1) 182 | else: 183 | if not self.is_recheck: 184 | self.unavailable_items.append(item) 185 | else: 186 | self.recheck_unavailable_items.append(item) 187 | if not self.is_recheck and ( 188 | not self.last_check_at or (time_now() - self.last_check_at).seconds > self.save_second): 189 | self.save_available_items() 190 | stay_second(self.safe_stay_time) 191 | 192 | def check_did_finished(self): 193 | self.is_ready = True 194 | if not self.is_finished: 195 | self.is_finished = True 196 | if self.is_recheck: 197 | self.is_recheck = False 198 | self.available_items = self.recheck_available_items 199 | self.unavailable_items = self.recheck_unavailable_items 200 | self.recheck_available_items = [] 201 | self.recheck_unavailable_items = [] 202 | CommonLog.add_quick_log(CommonLog.MESSAGE_CDN_CHECKED_SUCCESS.format(len(self.available_items))).flush() 203 | self.save_available_items() 204 | 205 | def save_available_items(self): 206 | self.last_check_at = time_now() 207 | data = {'items': self.available_items, 'fail_items': self.unavailable_items, 208 | 'last_check_at': str(self.last_check_at)} 209 | with open(Config().CDN_ENABLED_AVAILABLE_ITEM_FILE, 'w') as f: 210 | f.write(json.dumps(data)) 211 | 212 | # if Config.is_master(): 213 | # self.cluster.session.sadd(Cluster.KEY_CDN_AVAILABLE_ITEMS, self.available_items) 214 | # self.cluster.session.set(Cluster.KEY_CDN_LAST_CHECK_AT, time_now()) 215 | 216 | def check_is_ready(self): 217 | if len(self.available_items) > self.is_ready_num: 218 | self.is_ready = True 219 | else: 220 | self.is_ready = False 221 | 222 | @classmethod 223 | def get_cdn(cls): 224 | self = cls() 225 | if self.is_ready and self.available_items: 226 | return random.choice(self.available_items) 227 | return None 228 | 229 | 230 | if __name__ == '__main__': 231 | # Const.IS_TEST = True 232 | Cdn.run() 233 | while not Cdn().is_finished: 234 | stay_second(1) 235 | -------------------------------------------------------------------------------- /py12306/helpers/event.py: -------------------------------------------------------------------------------- 1 | from py12306.helpers.func import * 2 | from py12306.config import Config 3 | 4 | 5 | @singleton 6 | class Event(): 7 | """ 8 | 处理事件 9 | """ 10 | # 事件 11 | KEY_JOB_DESTROY = 'job_destroy' 12 | KEY_USER_JOB_DESTROY = 'user_job_destroy' 13 | KEY_USER_LOADED = 'user_loaded' 14 | cluster = None 15 | 16 | def __init__(self): 17 | from py12306.cluster.cluster import Cluster 18 | self.cluster = Cluster() 19 | 20 | def job_destroy(self, data={}, callback=False): # 停止查询任务 21 | from py12306.query.query import Query 22 | if Config().is_cluster_enabled() and not callback: 23 | return self.cluster.publish_event(self.KEY_JOB_DESTROY, data) # 通知其它节点退出 24 | 25 | job = Query.job_by_name(data.get('name')) 26 | if job: 27 | job.destroy() 28 | 29 | def user_loaded(self, data={}, callback=False): # 用户初始化完成 30 | if Config().is_cluster_enabled() and not callback: 31 | return self.cluster.publish_event(self.KEY_USER_LOADED, data) # 通知其它节点退出 32 | from py12306.query.query import Query 33 | 34 | if not Config().is_cluster_enabled() or Config().is_master(): 35 | query = Query.wait_for_ready() 36 | for job in query.jobs: 37 | if job.account_key == data.get('key'): 38 | create_thread_and_run(job, 'check_passengers', Const.IS_TEST) # 检查乘客信息 防止提交订单时才检查 39 | stay_second(1) 40 | 41 | def user_job_destroy(self, data={}, callback=False): 42 | from py12306.user.user import User 43 | if Config().is_cluster_enabled() and not callback: 44 | return self.cluster.publish_event(self.KEY_JOB_DESTROY, data) # 通知其它节点退出 45 | 46 | user = User.get_user(data.get('key')) 47 | if user: 48 | user.destroy() 49 | -------------------------------------------------------------------------------- /py12306/helpers/func.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | import hashlib 4 | import json 5 | import os 6 | import random 7 | import threading 8 | import functools 9 | import time 10 | 11 | from time import sleep 12 | from types import MethodType 13 | 14 | 15 | def singleton(cls): 16 | """ 17 | 将一个类作为单例 18 | 来自 https://wiki.python.org/moin/PythonDecoratorLibrary#Singleton 19 | """ 20 | 21 | cls.__new_original__ = cls.__new__ 22 | 23 | @functools.wraps(cls.__new__) 24 | def singleton_new(cls, *args, **kw): 25 | it = cls.__dict__.get('__it__') 26 | if it is not None: 27 | return it 28 | 29 | cls.__it__ = it = cls.__new_original__(cls, *args, **kw) 30 | it.__init_original__(*args, **kw) 31 | return it 32 | 33 | cls.__new__ = singleton_new 34 | cls.__init_original__ = cls.__init__ 35 | cls.__init__ = object.__init__ 36 | 37 | return cls 38 | 39 | 40 | # 座位 # TODO 41 | # def get_number_by_name(name): 42 | # return config.SEAT_TYPES[name] 43 | 44 | 45 | # def get_seat_name_by_number(number): # TODO remove config 46 | # return [k for k, v in config.SEAT_TYPES.items() if v == number].pop() 47 | 48 | 49 | # 初始化间隔 50 | def init_interval_by_number(number): 51 | if isinstance(number, dict): 52 | min = float(number.get('min')) 53 | max = float(number.get('max')) 54 | else: 55 | min = number / 2 56 | max = number 57 | return { 58 | 'min': min, 59 | 'max': max 60 | } 61 | 62 | 63 | def get_interval_num(interval, decimal=2): 64 | return round(random.uniform(interval.get('min'), interval.get('max')), decimal) 65 | 66 | 67 | def stay_second(second, call_back=None): 68 | sleep(second) 69 | if call_back: 70 | return call_back() 71 | 72 | 73 | def sleep_forever(): 74 | """ 75 | 当不是主线程时,假象停止 76 | :return: 77 | """ 78 | if not is_main_thread(): 79 | while True: sleep(10000000) 80 | 81 | 82 | def is_main_thread(): 83 | return threading.current_thread() == threading.main_thread() 84 | 85 | 86 | def current_thread_id(): 87 | return threading.current_thread().ident 88 | 89 | 90 | def time_now(): 91 | return datetime.datetime.now() 92 | 93 | 94 | def timestamp_to_time(timestamp): 95 | time_struct = time.localtime(timestamp) 96 | return time.strftime('%Y-%m-%d %H:%M:%S', time_struct) 97 | 98 | 99 | def get_file_modify_time(filePath): 100 | timestamp = os.path.getmtime(filePath) 101 | return timestamp_to_time(timestamp) 102 | 103 | 104 | def get_file_total_line_num(file, encoding='utf-8'): 105 | with open(file, 'r', encoding=encoding) as f: 106 | return len(f.readlines()) 107 | 108 | 109 | def touch_file(path): 110 | with open(path, 'a'): pass 111 | 112 | 113 | def pick_file_lines(file, lines): 114 | return [x for i, x in enumerate(file) if i in lines] 115 | 116 | 117 | def str_to_time(str): 118 | return datetime.datetime.strptime(str, '%Y-%m-%d %H:%M:%S.%f') 119 | 120 | 121 | def time_int(): 122 | return int(time.time()) 123 | 124 | def time_int_ms(): 125 | return int(time.time() * 1000) 126 | 127 | def is_number(val): 128 | if isinstance(val, int): return val 129 | if isinstance(val, str): return val.isdigit() 130 | return False 131 | 132 | 133 | def create_thread_and_run(jobs, callback_name, wait=True, daemon=True, args=(), kwargs={}): 134 | threads = [] 135 | if not isinstance(jobs, list): jobs = [jobs] 136 | for job in jobs: 137 | thread = threading.Thread(target=getattr(job, callback_name), args=args, kwargs=kwargs) 138 | thread.setDaemon(daemon) 139 | thread.start() 140 | threads.append(thread) 141 | if wait: 142 | for thread in threads: thread.join() 143 | 144 | 145 | def jobs_do(jobs, do): 146 | if not isinstance(jobs, list): jobs = [jobs] 147 | for job in jobs: 148 | getattr(job, do)() 149 | 150 | 151 | def dict_find_key_by_value(data, value, default=None): 152 | result = [k for k, v in data.items() if v == value] 153 | return result.pop() if len(result) else default 154 | 155 | 156 | def objects_find_object_by_key_value(objects, key, value, default=None): 157 | result = [obj for obj in objects if getattr(obj, key) == value] 158 | return result.pop() if len(result) else default 159 | 160 | 161 | def dict_count_key_num(data: dict, key, like=False): 162 | count = 0 163 | for k in data.keys(): 164 | if like: 165 | if k.find(key) >= 0: count += 1 166 | elif k == key: 167 | count += 1 168 | return count 169 | 170 | 171 | def array_dict_find_by_key_value(data, key, value, default=None): 172 | result = [v for k, v in enumerate(data) if key in v and v[key] == value] 173 | return result.pop() if len(result) else default 174 | 175 | 176 | def get_true_false_text(value, true='', false=''): 177 | if value: return true 178 | return false 179 | 180 | 181 | def sleep_forever_when_in_test(): 182 | if Const.IS_TEST: sleep_forever() 183 | 184 | 185 | def expand_class(cls, key, value, keep_old=True): 186 | if (keep_old): 187 | setattr(cls, 'old_' + key, getattr(cls, key)) 188 | setattr(cls, key, MethodType(value, cls)) 189 | return cls 190 | 191 | 192 | def available_value(value): 193 | if isinstance(value, str) or isinstance(value, bytes): 194 | return value 195 | return str(value) 196 | 197 | 198 | def md5(value): 199 | return hashlib.md5(json.dumps(value).encode()).hexdigest() 200 | 201 | 202 | @singleton 203 | class Const: 204 | IS_TEST = False 205 | IS_TEST_NOTIFICATION = False 206 | -------------------------------------------------------------------------------- /py12306/helpers/notification.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | 3 | from py12306.config import Config 4 | from py12306.helpers.api import * 5 | from py12306.helpers.request import Request 6 | from py12306.log.common_log import CommonLog 7 | 8 | 9 | class Notification(): 10 | """ 11 | 通知类 12 | """ 13 | session = None 14 | 15 | def __init__(self): 16 | self.session = Request() 17 | 18 | @classmethod 19 | def voice_code(cls, phone, name='', content=''): 20 | self = cls() 21 | if Config().NOTIFICATION_VOICE_CODE_TYPE == 'dingxin': 22 | self.send_voice_code_of_dingxin(phone, name=name, info=content) 23 | else: 24 | self.send_voice_code_of_yiyuan(phone, name=name, content=content) 25 | 26 | @classmethod 27 | def dingtalk_webhook(cls, content=''): 28 | self = cls() 29 | self.send_dingtalk_by_webbook(content=content) 30 | 31 | @classmethod 32 | def send_email(cls, to, title='', content=''): 33 | self = cls() 34 | self.send_email_by_smtp(to, title, content) 35 | 36 | @classmethod 37 | def send_email_with_qrcode(cls, to, title='', qrcode_path=''): 38 | self = cls() 39 | self.send_email_by_smtp_with_qrcode(to, title, qrcode_path) 40 | 41 | @classmethod 42 | def send_to_telegram(cls, content=''): 43 | self = cls() 44 | self.send_to_telegram_bot(content=content) 45 | 46 | @classmethod 47 | def server_chan(cls, skey='', title='', content=''): 48 | self = cls() 49 | self.send_serverchan(skey=skey, title=title, content=content) 50 | 51 | @classmethod 52 | def push_bear(cls, skey='', title='', content=''): 53 | self = cls() 54 | self.send_pushbear(skey=skey, title=title, content=content) 55 | 56 | @classmethod 57 | def push_bark(cls, content=''): 58 | self = cls() 59 | self.push_to_bark(content) 60 | 61 | def send_voice_code_of_yiyuan(self, phone, name='', content=''): 62 | """ 63 | 发送语音验证码 64 | 购买地址 https://market.aliyun.com/products/57126001/cmapi019902.html?spm=5176.2020520132.101.5.37857218O6iJ3n 65 | :return: 66 | """ 67 | appcode = Config().NOTIFICATION_API_APP_CODE 68 | if not appcode: 69 | CommonLog.add_quick_log(CommonLog.MESSAGE_EMPTY_APP_CODE).flush() 70 | return False 71 | body = { 72 | 'userName': name, 73 | 'mailNo': content 74 | } 75 | params = { 76 | 'content': body, 77 | 'mobile': phone, 78 | 'sex': 2, 79 | 'tNum': 'T170701001056' 80 | } 81 | response = self.session.request(url=API_NOTIFICATION_BY_VOICE_CODE + urllib.parse.urlencode(params), 82 | method='GET', headers={'Authorization': 'APPCODE {}'.format(appcode)}) 83 | result = response.json() 84 | response_message = result.get('showapi_res_body.remark') 85 | if response.status_code in [400, 401, 403]: 86 | return CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_FORBID).flush() 87 | if response.status_code == 200 and result.get('showapi_res_body.flag'): 88 | CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_SEND_SUCCESS.format(response_message)).flush() 89 | return True 90 | else: 91 | return CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_SEND_FAIL.format(response_message)).flush() 92 | 93 | def send_voice_code_of_dingxin(self, phone, name='', info={}): 94 | """ 95 | 发送语音验证码 ( 鼎信 ) 96 | 购买地址 https://market.aliyun.com/products/56928004/cmapi026600.html?spm=5176.2020520132.101.2.51547218rkAXxy 97 | :return: 98 | """ 99 | appcode = Config().NOTIFICATION_API_APP_CODE 100 | if not appcode: 101 | CommonLog.add_quick_log(CommonLog.MESSAGE_EMPTY_APP_CODE).flush() 102 | return False 103 | data = { 104 | 'tpl_id': 'TP1901174', 105 | 'phone': phone, 106 | 'param': 'name:{name},job_name:{left_station}到{arrive_station}{set_type},orderno:{orderno}'.format( 107 | name=name, left_station=info.get('left_station'), arrive_station=info.get('arrive_station'), 108 | set_type=info.get('set_type'), orderno=info.get('orderno')) 109 | } 110 | response = self.session.request(url=API_NOTIFICATION_BY_VOICE_CODE_DINGXIN, method='POST', data=data, 111 | headers={'Authorization': 'APPCODE {}'.format(appcode)}) 112 | result = response.json() 113 | response_message = result.get('return_code') 114 | if response.status_code in [400, 401, 403]: 115 | return CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_FORBID).flush() 116 | if response.status_code == 200 and result.get('return_code') == '00000': 117 | CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_SEND_SUCCESS.format(response_message)).flush() 118 | return True 119 | else: 120 | return CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_SEND_FAIL.format(response_message)).flush() 121 | 122 | def send_email_by_smtp(self, to, title, content): 123 | import smtplib 124 | from email.message import EmailMessage 125 | to = to if isinstance(to, list) else [to] 126 | message = EmailMessage() 127 | message['Subject'] = title 128 | message['From'] = Config().EMAIL_SENDER 129 | message['To'] = to 130 | message.set_content(content) 131 | try: 132 | server = smtplib.SMTP(Config().EMAIL_SERVER_HOST) 133 | server.ehlo() 134 | server.starttls() 135 | server.login(Config().EMAIL_SERVER_USER, Config().EMAIL_SERVER_PASSWORD) 136 | server.send_message(message) 137 | server.quit() 138 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_EMAIL_SUCCESS).flush() 139 | except Exception as e: 140 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_EMAIL_FAIL.format(e)).flush() 141 | 142 | def send_email_by_smtp_with_qrcode(self, to, title, qrcode_path): 143 | import smtplib 144 | from email.mime.text import MIMEText 145 | from email.mime.multipart import MIMEMultipart 146 | from email.mime.image import MIMEImage 147 | to = to if isinstance(to, list) else [to] 148 | message = MIMEMultipart() 149 | message['Subject'] = title 150 | message['From'] = Config().EMAIL_SENDER 151 | message['To'] = ", ".join(to) 152 | htmlFile = """ 153 | 154 | 155 | 156 |

157 | 这是你的二维码 158 |

159 |

160 |

161 | 162 | 163 | """ 164 | htmlApart = MIMEText(htmlFile, 'html') 165 | imageFile = qrcode_path 166 | imageApart = MIMEImage(open(imageFile, 'rb').read(), imageFile.split('.')[-1]) 167 | imageApart.add_header('Content-ID', '<0>') 168 | message.attach(imageApart) 169 | message.attach(htmlApart) 170 | try: 171 | server = smtplib.SMTP(Config().EMAIL_SERVER_HOST) 172 | server.ehlo() 173 | server.starttls() 174 | server.login(Config().EMAIL_SERVER_USER, Config().EMAIL_SERVER_PASSWORD) 175 | server.send_message(message) 176 | server.quit() 177 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_EMAIL_WITH_QRCODE_SUCCESS).flush() 178 | self.push_bark(CommonLog.MESSAGE_SEND_EMAIL_WITH_QRCODE_SUCCESS) 179 | except Exception as e: 180 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_EMAIL_FAIL.format(e)).flush() 181 | 182 | def send_dingtalk_by_webbook(self, content): 183 | from dingtalkchatbot.chatbot import DingtalkChatbot 184 | webhook = Config().DINGTALK_WEBHOOK 185 | dingtalk = DingtalkChatbot(webhook) 186 | dingtalk.send_text(msg=content, is_at_all=True) 187 | pass 188 | 189 | def send_to_telegram_bot(self, content): 190 | bot_api_url = Config().TELEGRAM_BOT_API_URL 191 | if not bot_api_url: 192 | return False 193 | data = { 194 | 'text': content 195 | } 196 | response = self.session.request(url=bot_api_url, method='POST', data=data) 197 | result = response.json().get('result') 198 | response_status = result.get('statusCode') 199 | if response_status == 200: 200 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_TELEGRAM_SUCCESS).flush() 201 | else: 202 | response_error_message = result.get('description') 203 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_TELEGRAM_FAIL.format(response_error_message)).flush() 204 | 205 | def push_to_bark(self, content): 206 | bark_url = Config().BARK_PUSH_URL 207 | if not bark_url: 208 | return False 209 | 210 | response = self.session.request(url=bark_url + '/' + content, method='get') 211 | result = response.json() 212 | response_status = result.get('code') 213 | if response_status == 200: 214 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_BARK_SUCCESS).flush() 215 | else: 216 | response_error_message = result.get('message') 217 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_BARK_FAIL.format(response_error_message)).flush() 218 | 219 | def send_serverchan(self, skey, title, content): 220 | from lightpush import lightpush 221 | lgp = lightpush() 222 | lgp.set_single_push(key=skey) 223 | try: 224 | lgp.single_push(title, content) 225 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_SERVER_CHAN_SUCCESS).flush() 226 | except Exception as e: 227 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_SERVER_CHAN_FAIL.format(e)).flush() 228 | 229 | def send_pushbear(self, skey, title, content): 230 | from lightpush import lightpush 231 | lgp = lightpush() 232 | lgp.set_group_push(key=skey) 233 | try: 234 | lgp.group_push(title, content) 235 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_PUSH_BEAR_SUCCESS).flush() 236 | except Exception as e: 237 | CommonLog.add_quick_log(CommonLog.MESSAGE_SEND_PUSH_BEAR_SUCCESS.format(e)).flush() 238 | 239 | 240 | if __name__ == '__main__': 241 | name = '张三4' 242 | content = '你的车票 广州 到 深圳 购买成功,请登录 12306 进行支付' 243 | # Notification.voice_code('13800138000', name, content) 244 | # Notification.send_email('user@email.com', name, content) 245 | # Notification.dingtalk_webhook(content) 246 | Notification.voice_code('13800138000', name, { 247 | 'left_station': '广州', 248 | 'arrive_station': '深圳', 249 | 'set_type': '硬座', 250 | 'orderno': 'E123542' 251 | }) 252 | -------------------------------------------------------------------------------- /py12306/helpers/qrcode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import png 4 | 5 | 6 | def print_qrcode(path): 7 | """ 8 | 将二维码输出到控制台 9 | 需要终端尺寸足够大才能显示 10 | 11 | :param path: 二维码图片路径 (PNG 格式) 12 | :return: None 13 | """ 14 | reader = png.Reader(path) 15 | width, height, rows, info = reader.read() 16 | lines = list(rows) 17 | 18 | planes = info['planes'] # 通道数 19 | threshold = (2 ** info['bitdepth']) / 2 # 色彩阈值 20 | 21 | # 识别二维码尺寸 22 | x_flag = -1 # x 边距标志 23 | y_flag = -1 # y 边距标志 24 | x_white = -1 # 定位图案白块 x 坐标 25 | y_white = -1 # 定位图案白块 y 坐标 26 | 27 | i = y_flag 28 | while i < height: 29 | if y_white > 0 and x_white > 0: 30 | break 31 | j = x_flag 32 | while j < width: 33 | total = 0 34 | for k in range(planes): 35 | px = lines[i][j * planes + k] 36 | total += px 37 | avg = total / planes 38 | black = avg < threshold 39 | if y_white > 0 and x_white > 0: 40 | break 41 | if x_flag > 0 > x_white and not black: 42 | x_white = j 43 | if x_flag == -1 and black: 44 | x_flag = j 45 | if y_flag > 0 > y_white and not black: 46 | y_white = i 47 | if y_flag == -1 and black: 48 | y_flag = i 49 | if x_flag > 0 and y_flag > 0: 50 | i += 1 51 | j += 1 52 | i += 1 53 | 54 | assert y_white - y_flag == x_white - x_flag 55 | scale = y_white - y_flag 56 | 57 | assert width - x_flag == height - y_flag 58 | module_count = int((width - x_flag * 2) / scale) 59 | 60 | whole_white = '█' 61 | whole_black = ' ' 62 | down_black = '▀' 63 | up_black = '▄' 64 | 65 | dual_flag = False 66 | last_line = [] 67 | output = '\n' 68 | for i in range(module_count + 2): 69 | output += up_black 70 | output += '\n' 71 | i = y_flag 72 | while i < height - y_flag: 73 | if dual_flag: 74 | output += whole_white 75 | t = 0 76 | j = x_flag 77 | while j < width - x_flag: 78 | total = 0 79 | for k in range(planes): 80 | px = lines[i][j * planes + k] 81 | total += px 82 | avg = total / planes 83 | black = avg < threshold 84 | if dual_flag: 85 | last_black = last_line[t] 86 | if black and last_black: 87 | output += whole_black 88 | elif black and not last_black: 89 | output += down_black 90 | elif not black and last_black: 91 | output += up_black 92 | elif not black and not last_black: 93 | output += whole_white 94 | else: 95 | last_line[t:t+1] = [black] 96 | t = t + 1 97 | j += scale 98 | if dual_flag: 99 | output += whole_white + '\n' 100 | dual_flag = not dual_flag 101 | i += scale 102 | output += whole_white 103 | for i in range(module_count): 104 | output += up_black if last_line[i] else whole_white 105 | output += whole_white + '\n' 106 | print(output, flush=True) 107 | -------------------------------------------------------------------------------- /py12306/helpers/request.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from requests.exceptions import * 3 | 4 | from py12306.helpers.func import * 5 | from requests_html import HTMLSession, HTMLResponse 6 | 7 | requests.packages.urllib3.disable_warnings() 8 | 9 | 10 | class Request(HTMLSession): 11 | """ 12 | 请求处理类 13 | """ 14 | 15 | # session = {} 16 | def save_to_file(self, url, path): 17 | response = self.get(url, stream=True) 18 | with open(path, 'wb') as f: 19 | for chunk in response.iter_content(chunk_size=1024): 20 | f.write(chunk) 21 | return response 22 | 23 | @staticmethod 24 | def _handle_response(response, **kwargs) -> HTMLResponse: 25 | """ 26 | 扩充 response 27 | :param response: 28 | :param kwargs: 29 | :return: 30 | """ 31 | response = HTMLSession._handle_response(response, **kwargs) 32 | expand_class(response, 'json', Request.json) 33 | return response 34 | 35 | def add_response_hook(self, hook): 36 | hooks = self.hooks['response'] 37 | if not isinstance(hooks, list): 38 | hooks = [hooks] 39 | hooks.append(hook) 40 | self.hooks['response'] = hooks 41 | return self 42 | 43 | def json(self, default={}): 44 | """ 45 | 重写 json 方法,拦截错误 46 | :return: 47 | """ 48 | from py12306.app import Dict 49 | try: 50 | result = self.old_json() 51 | return Dict(result) 52 | except: 53 | return Dict(default) 54 | 55 | def request(self, *args, **kwargs): # 拦截所有错误 56 | try: 57 | if not 'timeout' in kwargs: 58 | from py12306.config import Config 59 | kwargs['timeout'] = Config().TIME_OUT_OF_REQUEST 60 | response = super().request(*args, **kwargs) 61 | return response 62 | except RequestException as e: 63 | from py12306.log.common_log import CommonLog 64 | if e.response: 65 | response = e.response 66 | else: 67 | response = HTMLResponse(HTMLSession) 68 | # response.status_code = 500 69 | expand_class(response, 'json', Request.json) 70 | response.reason = response.reason if response.reason else CommonLog.MESSAGE_RESPONSE_EMPTY_ERROR 71 | return response 72 | 73 | def cdn_request(self, url: str, cdn=None, method='GET', **kwargs): 74 | from py12306.helpers.api import HOST_URL_OF_12306 75 | from py12306.helpers.cdn import Cdn 76 | if not cdn: cdn = Cdn.get_cdn() 77 | url = url.replace(HOST_URL_OF_12306, cdn) 78 | 79 | return self.request(method, url, headers={'Host': HOST_URL_OF_12306}, verify=False, **kwargs) 80 | 81 | def dump_cookies(self): 82 | cookies = [] 83 | for _, item in self.cookies._cookies.items(): 84 | for _, urls in item.items(): 85 | for _, cookie in urls.items(): 86 | from http.cookiejar import Cookie 87 | assert isinstance(cookie, Cookie) 88 | if cookie.domain: 89 | cookies.append({ 90 | 'name': cookie.name, 91 | 'value': cookie.value, 92 | 'url': 'https://' + cookie.domain + cookie.path, 93 | }) 94 | return cookies 95 | -------------------------------------------------------------------------------- /py12306/helpers/station.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from py12306.config import Config 4 | from py12306.helpers.func import * 5 | 6 | 7 | @singleton 8 | class Station: 9 | stations = [] 10 | station_kvs = {} 11 | 12 | def __init__(self): 13 | if path.exists(Config().STATION_FILE): 14 | result = open(Config().STATION_FILE, encoding='utf-8').read() 15 | result = result.lstrip('@').split('@') 16 | for i in result: 17 | tmp_info = i.split('|') 18 | self.stations.append({ 19 | 'key': tmp_info[2], 20 | 'name': tmp_info[1], 21 | 'pinyin': tmp_info[3], 22 | 'id': tmp_info[5] 23 | }) 24 | self.station_kvs[tmp_info[1]] = tmp_info[2] 25 | 26 | @classmethod 27 | def get_station_by_name(cls, name): 28 | return cls.get_station_by(name, 'name') 29 | 30 | @classmethod 31 | def get_station_by(cls, value, field): 32 | self = cls() 33 | for station in self.stations: 34 | if station.get(field) == value: 35 | return station 36 | return None 37 | 38 | @classmethod 39 | def get_station_key_by_name(cls, name): 40 | self = cls() 41 | return self.station_kvs[name] 42 | 43 | @classmethod 44 | def get_station_name_by_key(cls, key): 45 | return cls.get_station_by(key, 'key').get('name') 46 | 47 | -------------------------------------------------------------------------------- /py12306/helpers/type.py: -------------------------------------------------------------------------------- 1 | from py12306.helpers.func import * 2 | 3 | 4 | @singleton 5 | class UserType: 6 | ADULT = 1 7 | CHILD = 2 8 | STUDENT = 3 9 | SOLDIER = 4 10 | 11 | dicts = { 12 | '成人': ADULT, 13 | '儿童': CHILD, 14 | '学生': STUDENT, 15 | '残疾军人、伤残人民警察': SOLDIER, 16 | } 17 | 18 | 19 | @singleton 20 | class OrderSeatType: 21 | dicts = { 22 | '特等座': 'P', 23 | '商务座': 9, 24 | '一等座': 'M', 25 | '二等座': 'O', 26 | '软卧': 4, 27 | '硬卧': 3, 28 | '动卧': 1, 29 | '软座': 2, 30 | '硬座': 1, 31 | '无座': 1, 32 | } 33 | 34 | 35 | @singleton 36 | class SeatType: 37 | NO_SEAT = 26 38 | dicts = { 39 | '特等座': 25, 40 | '商务座': 32, 41 | '一等座': 31, 42 | '二等座': 30, 43 | '软卧': 23, 44 | '硬卧': 28, 45 | '动卧': 33, 46 | '软座': 24, 47 | '硬座': 29, 48 | '无座': NO_SEAT, 49 | } 50 | -------------------------------------------------------------------------------- /py12306/log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/log/__init__.py -------------------------------------------------------------------------------- /py12306/log/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import io 4 | from contextlib import redirect_stdout 5 | 6 | from py12306.config import Config 7 | from py12306.helpers.func import * 8 | 9 | 10 | class BaseLog: 11 | logs = [] 12 | thread_logs = {} 13 | quick_log = [] 14 | 15 | @classmethod 16 | def add_log(cls, content=''): 17 | self = cls() 18 | # print('添加 Log 主进程{} 进程ID{}'.format(is_main_thread(), current_thread_id())) 19 | if is_main_thread(): 20 | self.logs.append(content) 21 | else: 22 | tmp_log = self.thread_logs.get(current_thread_id(), []) 23 | tmp_log.append(content) 24 | self.thread_logs[current_thread_id()] = tmp_log 25 | return self 26 | 27 | @classmethod 28 | def flush(cls, sep='\n', end='\n', file=None, exit=False, publish=True): 29 | from py12306.cluster.cluster import Cluster 30 | self = cls() 31 | logs = self.get_logs() 32 | # 输出到文件 33 | if file == None and Config().OUT_PUT_LOG_TO_FILE_ENABLED and not Const.IS_TEST: # TODO 文件无法写入友好提示 34 | file = open(Config().OUT_PUT_LOG_TO_FILE_PATH, 'a', encoding='utf-8') 35 | if not file: file = None 36 | # 输出日志到各个节点 37 | if publish and self.quick_log and Config().is_cluster_enabled() and Cluster().is_ready: # 38 | f = io.StringIO() 39 | with redirect_stdout(f): 40 | print(*logs, sep=sep, end='' if end == '\n' else end) 41 | out = f.getvalue() 42 | Cluster().publish_log_message(out) 43 | else: 44 | print(*logs, sep=sep, end=end, file=file) 45 | self.empty_logs(logs) 46 | if exit: sys.exit() 47 | 48 | def get_logs(self): 49 | if self.quick_log: 50 | logs = self.quick_log 51 | else: 52 | if is_main_thread(): 53 | logs = self.logs 54 | else: 55 | logs = self.thread_logs.get(current_thread_id()) 56 | return logs 57 | 58 | def empty_logs(self, logs=None): 59 | if self.quick_log: 60 | self.quick_log = [] 61 | else: 62 | if is_main_thread(): 63 | self.logs = [] 64 | else: 65 | if logs and self.thread_logs.get(current_thread_id()): del self.thread_logs[current_thread_id()] 66 | 67 | @classmethod 68 | def add_quick_log(cls, content=''): 69 | self = cls() 70 | self.quick_log.append(content) 71 | return self 72 | 73 | def notification(self, title, content=''): 74 | # if sys.platform == 'darwin': # 不太友好 先关闭,之前没考虑到 mac 下会请求权限 75 | # os.system( 'osascript -e \'tell app "System Events" to display notification "{content}" with title "{title}"\''.format( 76 | # title=title, content=content)) 77 | pass 78 | -------------------------------------------------------------------------------- /py12306/log/cluster_log.py: -------------------------------------------------------------------------------- 1 | from py12306.log.base import BaseLog 2 | from py12306.helpers.func import * 3 | 4 | 5 | @singleton 6 | class ClusterLog(BaseLog): 7 | # 这里如果不声明,会出现重复打印,目前不知道什么原因 8 | logs = [] 9 | thread_logs = {} 10 | quick_log = [] 11 | 12 | MESSAGE_JOIN_CLUSTER_SUCCESS = '# 节点 {} 成功加入到集群,当前节点列表 {} #' 13 | 14 | MESSAGE_LEFT_CLUSTER = '# 节点 {} 已离开集群,当前节点列表 {} #' 15 | 16 | MESSAGE_NODE_ALREADY_IN_CLUSTER = '# 当前节点已存在于集群中,自动分配新的节点名称 {} #' 17 | 18 | MESSAGE_SUBSCRIBE_NOTIFICATION_PREFIX = '{} )' 19 | MESSAGE_SUBSCRIBE_NOTIFICATION = MESSAGE_SUBSCRIBE_NOTIFICATION_PREFIX + '{}' 20 | 21 | MESSAGE_ASCENDING_MASTER_NODE = '# 已将 {} 提升为主节点,当前节点列表 {} #' 22 | 23 | MESSAGE_MASTER_DID_LOST = '# 主节点已退出,{} 秒后程序将自动退出 #' 24 | 25 | MESSAGE_MASTER_NODE_ALREADY_RUN = '# 启动失败,主节点 {} 已经在运行中 #' 26 | MESSAGE_MASTER_NODE_NOT_FOUND = '# 启动失败,请先启动主节点 #' 27 | 28 | MESSAGE_NODE_BECOME_MASTER_AGAIN = '# 节点 {} 已启动,已自动成为主节点 #' 29 | 30 | 31 | 32 | @staticmethod 33 | def get_print_nodes(nodes): 34 | message = ['{}{}'.format('*' if val == '1' else '', key) for key, val in nodes.items()] 35 | return '[ {} ]'.format(', '.join(message)) 36 | -------------------------------------------------------------------------------- /py12306/log/common_log.py: -------------------------------------------------------------------------------- 1 | from py12306.log.base import BaseLog 2 | from py12306.config import * 3 | from py12306.helpers.func import * 4 | 5 | 6 | @singleton 7 | class CommonLog(BaseLog): 8 | # 这里如果不声明,会出现重复打印,目前不知道什么原因 9 | logs = [] 10 | thread_logs = {} 11 | quick_log = [] 12 | 13 | MESSAGE_12306_IS_CLOSED = '当前时间: {} | 12306 休息时间,程序将在明天早上 6 点自动运行' 14 | MESSAGE_RETRY_AUTH_CODE = '{} 秒后重新获取验证码' 15 | 16 | MESSAGE_EMPTY_APP_CODE = '无法发送语音消息,未填写验证码接口 appcode' 17 | MESSAGE_VOICE_API_FORBID = '语音消息发送失败,请检查 appcode 是否填写正确或 套餐余额是否充足' 18 | MESSAGE_VOICE_API_SEND_FAIL = '语音消息发送失败,错误原因 {}' 19 | MESSAGE_VOICE_API_SEND_SUCCESS = '语音消息发送成功! 接口返回信息 {} ' 20 | 21 | MESSAGE_CHECK_AUTO_CODE_FAIL = '请配置打码账号的账号密码' 22 | MESSAGE_CHECK_EMPTY_USER_ACCOUNT = '请配置 12306 账号密码' 23 | 24 | MESSAGE_TEST_SEND_VOICE_CODE = '正在测试发送语音验证码...' 25 | MESSAGE_TEST_SEND_EMAIL = '正在测试发送邮件...' 26 | MESSAGE_TEST_SEND_DINGTALK = '正在测试发送钉钉消息...' 27 | MESSAGE_TEST_SEND_TELEGRAM = '正在测试推送到Telegram...' 28 | MESSAGE_TEST_SEND_SERVER_CHAN = '正在测试发送ServerChan消息...' 29 | MESSAGE_TEST_SEND_PUSH_BEAR = '正在测试发送PushBear消息...' 30 | MESSAGE_TEST_SEND_PUSH_BARK = '正在测试发送Bark消息...' 31 | 32 | MESSAGE_CONFIG_FILE_DID_CHANGED = '配置文件已修改,正在重新加载中\n' 33 | MESSAGE_API_RESPONSE_CAN_NOT_BE_HANDLE = '接口返回错误' 34 | 35 | MESSAGE_SEND_EMAIL_SUCCESS = '邮件发送成功,请检查收件箱' 36 | MESSAGE_SEND_EMAIL_FAIL = '邮件发送失败,请手动检查配置,错误原因 {}' 37 | 38 | MESSAGE_SEND_EMAIL_WITH_QRCODE_SUCCESS = '二维码邮件发送成功,请检查收件箱扫描登陆' 39 | 40 | MESSAGE_SEND_TELEGRAM_SUCCESS = 'Telegram推送成功' 41 | MESSAGE_SEND_TELEGRAM_FAIL = 'Telegram推送失败,错误原因 {}' 42 | 43 | MESSAGE_SEND_SERVER_CHAN_SUCCESS = '发送成功,请检查微信' 44 | MESSAGE_SEND_SERVER_CHAN_FAIL = 'ServerChan发送失败,请检查KEY' 45 | 46 | MESSAGE_SEND_PUSH_BEAR_SUCCESS = '发送成功,请检查微信' 47 | MESSAGE_SEND_PUSH_BEAR_FAIL = 'PushBear发送失败,请检查KEY' 48 | 49 | MESSAGE_SEND_BARK_SUCCESS = 'Bark推送成功' 50 | MESSAGE_SEND_BARK_FAIL = 'Bark推送失败,错误原因 {}' 51 | 52 | MESSAGE_OUTPUT_TO_FILE_IS_UN_ENABLE = '请先打开配置项中的:OUT_PUT_LOG_TO_FILE_ENABLED ( 输出到文件 )' 53 | 54 | MESSAGE_GET_RESPONSE_FROM_FREE_AUTO_CODE = '从免费打码获取结果失败' 55 | 56 | MESSAGE_RESPONSE_EMPTY_ERROR = '网络错误' 57 | 58 | MESSAGE_CDN_START_TO_CHECK = '正在筛选 {} 个 CDN...' 59 | MESSAGE_CDN_START_TO_RECHECK = '正在重新筛选 {} 个 CDN...当前时间 {}\n' 60 | MESSAGE_CDN_RESTORE_SUCCESS = 'CDN 恢复成功,上次检测 {}\n' 61 | MESSAGE_CDN_CHECKED_SUCCESS = '# CDN 检测完成,可用 CDN {} #\n' 62 | MESSAGE_CDN_CLOSED = '# CDN 已关闭 #' 63 | 64 | def __init__(self): 65 | super().__init__() 66 | self.init_data() 67 | 68 | def init_data(self): 69 | pass 70 | 71 | @classmethod 72 | def print_welcome(cls): 73 | self = cls() 74 | self.add_quick_log('######## py12306 购票助手,本程序为开源工具,请勿用于商业用途 ########') 75 | if Const.IS_TEST: 76 | self.add_quick_log() 77 | self.add_quick_log('当前为测试模式,程序运行完成后自动结束') 78 | if not Const.IS_TEST and Config().OUT_PUT_LOG_TO_FILE_ENABLED: 79 | self.add_quick_log() 80 | self.add_quick_log('日志已输出到文件中: {}'.format(Config().OUT_PUT_LOG_TO_FILE_PATH)) 81 | if Config().WEB_ENABLE: 82 | self.add_quick_log() 83 | self.add_quick_log('WEB 管理页面已开启,请访问 主机地址 + 端口 {} 进行查看'.format(Config().WEB_PORT)) 84 | 85 | self.add_quick_log() 86 | self.flush(file=False, publish=False) 87 | return self 88 | 89 | @classmethod 90 | def print_configs(cls): 91 | # 打印配置 92 | self = cls() 93 | enable = '已开启' 94 | disable = '未开启' 95 | self.add_quick_log('**** 当前配置 ****') 96 | self.add_quick_log('多线程查询: {}'.format(get_true_false_text(Config().QUERY_JOB_THREAD_ENABLED, enable, disable))) 97 | self.add_quick_log('CDN 状态: {}'.format(get_true_false_text(Config().CDN_ENABLED, enable, disable))).flush() 98 | self.add_quick_log('通知状态:') 99 | if Config().NOTIFICATION_BY_VOICE_CODE: 100 | self.add_quick_log( 101 | '语音验证码: {}'.format(get_true_false_text(Config().NOTIFICATION_BY_VOICE_CODE, enable, disable))) 102 | if Config().EMAIL_ENABLED: 103 | self.add_quick_log('邮件通知: {}'.format(get_true_false_text(Config().EMAIL_ENABLED, enable, disable))) 104 | if Config().DINGTALK_ENABLED: 105 | self.add_quick_log('钉钉通知: {}'.format(get_true_false_text(Config().DINGTALK_ENABLED, enable, disable))) 106 | if Config().TELEGRAM_ENABLED: 107 | self.add_quick_log('Telegram通知: {}'.format(get_true_false_text(Config().TELEGRAM_ENABLED, enable, disable))) 108 | if Config().SERVERCHAN_ENABLED: 109 | self.add_quick_log( 110 | 'ServerChan通知: {}'.format(get_true_false_text(Config().SERVERCHAN_ENABLED, enable, disable))) 111 | if Config().BARK_ENABLED: 112 | self.add_quick_log('Bark通知: {}'.format(get_true_false_text(Config().BARK_ENABLED, enable, disable))) 113 | if Config().PUSHBEAR_ENABLED: 114 | self.add_quick_log( 115 | 'PushBear通知: {}'.format(get_true_false_text(Config().PUSHBEAR_ENABLED, enable, disable))) 116 | self.add_quick_log().flush(sep='\t\t') 117 | self.add_quick_log('查询间隔: {} 秒'.format(Config().QUERY_INTERVAL)) 118 | self.add_quick_log('用户心跳检测间隔: {} 秒'.format(Config().USER_HEARTBEAT_INTERVAL)) 119 | self.add_quick_log('WEB 管理页面: {}'.format(get_true_false_text(Config().WEB_ENABLE, enable, disable))) 120 | if Config().is_cluster_enabled(): 121 | from py12306.cluster.cluster import Cluster 122 | self.add_quick_log('分布式查询: {}'.format(get_true_false_text(Config().is_cluster_enabled(), enable, enable))) 123 | self.add_quick_log('节点名称: {}'.format(Cluster().node_name)) 124 | self.add_quick_log('节点是否主节点: {}'.format(get_true_false_text(Config().is_master(), '是', '否'))) 125 | self.add_quick_log( 126 | '子节点提升为主节点: {}'.format(get_true_false_text(Config().NODE_SLAVE_CAN_BE_MASTER, enable, disable))) 127 | self.add_quick_log() 128 | self.flush() 129 | return self 130 | 131 | @classmethod 132 | def print_test_complete(cls): 133 | self = cls() 134 | self.add_quick_log('# 测试完成,请检查输出是否正确 #') 135 | self.flush(publish=False) 136 | return self 137 | 138 | @classmethod 139 | def print_auto_code_fail(cls, reason): 140 | self = cls() 141 | self.add_quick_log('打码失败: 错误原因 {reason}'.format(reason=reason)) 142 | self.flush() 143 | return self 144 | 145 | @classmethod 146 | def print_auth_code_info(cls, reason): 147 | self = cls() 148 | self.add_quick_log('打码信息: {reason}'.format(reason=reason)) 149 | self.flush() 150 | return self 151 | -------------------------------------------------------------------------------- /py12306/log/order_log.py: -------------------------------------------------------------------------------- 1 | from py12306.log.base import BaseLog 2 | from py12306.helpers.func import * 3 | 4 | 5 | @singleton 6 | class OrderLog(BaseLog): 7 | # 这里如果不声明,会出现重复打印,目前不知道什么原因 8 | logs = [] 9 | thread_logs = {} 10 | quick_log = [] 11 | 12 | MESSAGE_REQUEST_INIT_DC_PAGE_FAIL = '请求初始化订单页面失败' 13 | 14 | MESSAGE_SUBMIT_ORDER_REQUEST_FAIL = '提交订单失败,错误原因 {} \n' 15 | MESSAGE_SUBMIT_ORDER_REQUEST_SUCCESS = '提交订单成功' 16 | MESSAGE_CHECK_ORDER_INFO_FAIL = '检查订单失败,错误原因 {} \n' 17 | MESSAGE_CHECK_ORDER_INFO_SUCCESS = '检查订单成功' 18 | 19 | MESSAGE_GET_QUEUE_INFO_SUCCESS = '获取排队信息成功,目前排队人数 {}, 余票还剩余 {} 张' 20 | MESSAGE_GET_QUEUE_INFO_NO_SEAT = '接口返回实际为无票,跳过本次排队' 21 | MESSAGE_GET_QUEUE_COUNT_SUCCESS = '排队成功,你当前排在第 {} 位, 余票还剩余 {} 张' 22 | MESSAGE_GET_QUEUE_LESS_TICKET = '排队失败,目前排队人数已经超过余票张数' 23 | MESSAGE_GET_QUEUE_COUNT_FAIL = '排队失败,错误原因 {}' 24 | 25 | MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_SUCCESS = '# 提交订单成功!#' 26 | MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_ERROR = '出票失败,错误原因 {}' 27 | MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_FAIL = '提交订单失败,错误原因 {}' 28 | 29 | MESSAGE_QUERY_ORDER_WAIT_TIME_WAITING = '排队等待中,排队人数 {},预计还需要 {} 秒' 30 | MESSAGE_QUERY_ORDER_WAIT_TIME_FAIL = '排队失败,错误原因 {}' 31 | MESSAGE_QUERY_ORDER_WAIT_TIME_INFO = '第 {} 次排队,请耐心等待' 32 | 33 | MESSAGE_ORDER_SUCCESS_NOTIFICATION_TITLE = '车票购买成功!' 34 | MESSAGE_ORDER_SUCCESS_NOTIFICATION_CONTENT = '请及时登录12306账号[{}],打开 \'未完成订单\',在30分钟内完成支付!' 35 | MESSAGE_ORDER_SUCCESS_NOTIFICATION_INFO = '\t\t车次信息: {} {}[{}] -> {}[{}],乘车日期 {},席位:{},乘车人:{}' 36 | 37 | MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_VOICE_CODE_START_SEND = '正在发送语音通知...' 38 | MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_VOICE_CODE_CONTENT = '你的车票 {} 到 {} 购买成功,请登录 12306 进行支付' 39 | 40 | MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_EMAIL_CONTENT = '订单号 {},请及时登录12306账号[{}],打开 \'未完成订单\',在30分钟内完成支付!' 41 | 42 | MESSAGE_JOB_CLOSED = '当前任务已结束' 43 | 44 | @classmethod 45 | def print_passenger_did_deleted(cls, passengers): 46 | self = cls() 47 | result = [passenger.get('name') + '(' + passenger.get('type_text') + ')' for passenger in passengers] 48 | self.add_quick_log('# 删减后的乘客列表 {} #'.format(', '.join(result))) 49 | self.flush() 50 | return self 51 | 52 | @classmethod 53 | def print_ticket_did_ordered(cls, order_id): 54 | self = cls() 55 | self.add_quick_log('# 车票购买成功,订单号 {} #'.format(order_id)) 56 | self.flush() 57 | return self 58 | 59 | @classmethod 60 | def get_order_success_notification_info(cls, query): 61 | from py12306.query.job import Job 62 | assert isinstance(query, Job) 63 | passengers = [passenger.get( 64 | 'name') + '(' + passenger.get('type_text') + ')' for passenger in query.passengers] 65 | return cls.MESSAGE_ORDER_SUCCESS_NOTIFICATION_INFO.format(query.get_info_of_train_number(), 66 | query.get_info_of_left_station(), 67 | query.get_info_of_train_left_time(), 68 | query.get_info_of_arrive_station(), 69 | query.get_info_of_train_arrive_time(), 70 | query.get_info_of_left_date(), 71 | query.current_seat_name, 72 | ','.join(passengers)) 73 | -------------------------------------------------------------------------------- /py12306/log/query_log.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import datetime 3 | import json 4 | import sys 5 | from os import path 6 | 7 | from py12306.config import Config 8 | from py12306.cluster.cluster import Cluster 9 | from py12306.log.base import BaseLog 10 | from py12306.helpers.func import * 11 | 12 | 13 | @singleton 14 | class QueryLog(BaseLog): 15 | # 这里如果不声明,会出现重复打印,目前不知道什么原因 16 | logs = [] 17 | thread_logs = {} 18 | quick_log = [] 19 | 20 | data = { 21 | 'query_count': 0, 22 | 'last_time': '', 23 | } 24 | data_path = None 25 | 26 | LOG_INIT_JOBS = '' 27 | 28 | MESSAGE_GIVE_UP_CHANCE_CAUSE_TICKET_NUM_LESS_THAN_SPECIFIED = '余票数小于乘车人数,放弃此次提交机会' 29 | MESSAGE_QUERY_LOG_OF_EVERY_TRAIN = '{}' 30 | MESSAGE_QUERY_LOG_OF_TRAIN_INFO = '{} {}' 31 | MESSAGE_QUERY_START_BY_DATE = '出发日期 {}: {} - {}' 32 | 33 | MESSAGE_JOBS_DID_CHANGED = '任务已更新,正在重新加载...\n' 34 | 35 | MESSAGE_SKIP_ORDER = '跳过本次请求,节点 {} 用户 {} 正在处理该订单\n' 36 | 37 | MESSAGE_QUERY_JOB_BEING_DESTROY = '查询任务 {} 已结束\n' 38 | 39 | MESSAGE_INIT_PASSENGERS_SUCCESS = '初始化乘客成功' 40 | MESSAGE_CHECK_PASSENGERS = '查询任务 {} 正在验证乘客信息' 41 | 42 | MESSAGE_USER_IS_EMPTY_WHEN_DO_ORDER = '未配置自动下单账号,{} 秒后继续查询\n' 43 | MESSAGE_ORDER_USER_IS_EMPTY = '未找到下单账号,{} 秒后继续查询' 44 | 45 | cluster = None 46 | 47 | def __init__(self): 48 | super().__init__() 49 | self.data_path = Config().QUERY_DATA_DIR + 'status.json' 50 | self.cluster = Cluster() 51 | 52 | @classmethod 53 | def init_data(cls): 54 | self = cls() 55 | # 获取上次记录 56 | result = False 57 | if not Config.is_cluster_enabled() and path.exists(self.data_path): 58 | with open(self.data_path, encoding='utf-8') as f: 59 | result = f.read() 60 | try: 61 | result = json.loads(result) 62 | except json.JSONDecodeError as e: 63 | result = {} 64 | # self.add_quick_log('加载status.json失败, 文件内容为: {}.'.format(repr(result))) 65 | # self.flush() # 这里可以用不用提示 66 | 67 | if Config.is_cluster_enabled(): 68 | result = self.get_data_from_cluster() 69 | 70 | if result: 71 | self.data = {**self.data, **result} 72 | self.print_data_restored() 73 | 74 | def get_data_from_cluster(self): 75 | query_count = self.cluster.session.get(Cluster.KEY_QUERY_COUNT, 0) 76 | last_time = self.cluster.session.get(Cluster.KEY_QUERY_LAST_TIME, '') 77 | if query_count and last_time: 78 | return {'query_count': query_count, 'last_time': last_time} 79 | return False 80 | 81 | def refresh_data_of_cluster(self): 82 | return { 83 | 'query_count': self.cluster.session.incr(Cluster.KEY_QUERY_COUNT), 84 | 'last_time': self.cluster.session.set(Cluster.KEY_QUERY_LAST_TIME, time_now()), 85 | } 86 | 87 | @classmethod 88 | def print_init_jobs(cls, jobs): 89 | """ 90 | 输出初始化信息 91 | :return: 92 | """ 93 | self = cls() 94 | self.add_log('# 发现 {} 个任务 #'.format(len(jobs))) 95 | index = 1 96 | for job in jobs: 97 | self.add_log('================== 任务 {} =================='.format(index)) 98 | for station in job.stations: 99 | self.add_log('出发站:{} 到达站:{}'.format(station.get('left'), station.get('arrive'))) 100 | 101 | self.add_log('乘车日期:{}'.format(job.left_dates)) 102 | self.add_log('坐席:{}'.format(','.join(job.allow_seats))) 103 | self.add_log('乘车人:{}'.format(','.join(job.members))) 104 | if job.except_train_numbers: 105 | train_number_message = '排除 ' + ','.join(job.allow_train_numbers) 106 | else: 107 | train_number_message = ','.join(job.allow_train_numbers if job.allow_train_numbers else ['不筛选']) 108 | self.add_log('筛选车次:{}'.format(train_number_message)) 109 | self.add_log('任务名称:{}'.format(job.job_name)) 110 | # 乘车日期:['2019-01-24', '2019-01-25', '2019-01-26', '2019-01-27'] 111 | self.add_log('') 112 | index += 1 113 | 114 | self.flush() 115 | return self 116 | 117 | @classmethod 118 | def print_ticket_num_less_than_specified(cls, rest_num, job): 119 | self = cls() 120 | self.add_quick_log( 121 | '余票数小于乘车人数,当前余票数: {rest_num}, 实际人数 {actual_num}, 删减人车人数到: {take_num}'.format(rest_num=rest_num, 122 | actual_num=job.member_num, 123 | take_num=job.member_num_take)) 124 | self.flush() 125 | return self 126 | 127 | @classmethod 128 | def print_ticket_seat_available(cls, left_date, train_number, seat_type, rest_num): 129 | self = cls() 130 | self.add_quick_log( 131 | '[ 查询到座位可用 出发时间 {left_date} 车次 {train_number} 座位类型 {seat_type} 余票数量 {rest_num} ]'.format( 132 | left_date=left_date, 133 | train_number=train_number, 134 | seat_type=seat_type, 135 | rest_num=rest_num)) 136 | self.flush() 137 | return self 138 | 139 | @classmethod 140 | def print_ticket_available(cls, left_date, train_number, rest_num): 141 | self = cls() 142 | self.add_quick_log('检查完成 开始提交订单 '.format()) 143 | self.notification('查询到可用车票', '时间 {left_date} 车次 {train_number} 余票数量 {rest_num}'.format(left_date=left_date, 144 | train_number=train_number, 145 | rest_num=rest_num)) 146 | self.flush() 147 | return self 148 | 149 | @classmethod 150 | def print_query_error(cls, reason, code=None): 151 | self = cls() 152 | self.add_quick_log('查询余票请求失败') 153 | if code: 154 | self.add_quick_log('状态码 {} '.format(code)) 155 | if reason: 156 | self.add_quick_log('错误原因 {} '.format(reason)) 157 | self.flush(sep='\t') 158 | return self 159 | 160 | @classmethod 161 | def print_job_start(cls, job_name): 162 | self = cls() 163 | message = '>> 第 {query_count} 次查询 {job_name} {time}'.format( 164 | query_count=int(self.data.get('query_count', 0)) + 1, 165 | job_name=job_name, time=time_now().strftime("%Y-%m-%d %H:%M:%S")) 166 | self.add_log(message) 167 | self.refresh_data() 168 | if is_main_thread(): 169 | self.flush(publish=False) 170 | return self 171 | 172 | @classmethod 173 | def add_query_time_log(cls, time, is_cdn): 174 | return cls().add_log(('*' if is_cdn else '') + '耗时 %.2f' % time) 175 | 176 | @classmethod 177 | def add_stay_log(cls, second): 178 | self = cls() 179 | self.add_log('停留 {}'.format(second)) 180 | return self 181 | 182 | def print_data_restored(self): 183 | self.add_quick_log('============================================================') 184 | self.add_quick_log('|=== 查询记录恢复成功 上次查询 {last_date} ===|'.format(last_date=self.data.get('last_time'))) 185 | self.add_quick_log('============================================================') 186 | self.add_quick_log('') 187 | self.flush(publish=False) 188 | return self 189 | 190 | def refresh_data(self): 191 | if Config.is_cluster_enabled(): 192 | self.data = {**self.data, **self.refresh_data_of_cluster()} 193 | else: 194 | self.data['query_count'] += 1 195 | self.data['last_time'] = str(datetime.datetime.now()) 196 | self.save_data() 197 | 198 | def save_data(self): 199 | with open(self.data_path, 'w') as file: 200 | file.write(json.dumps(self.data)) 201 | -------------------------------------------------------------------------------- /py12306/log/redis_log.py: -------------------------------------------------------------------------------- 1 | from py12306.log.base import BaseLog 2 | from py12306.helpers.func import * 3 | 4 | 5 | @singleton 6 | class RedisLog(BaseLog): 7 | # 这里如果不声明,会出现重复打印,目前不知道什么原因 8 | logs = [] 9 | thread_logs = {} 10 | quick_log = [] 11 | 12 | MESSAGE_REDIS_INIT_SUCCESS = 'Redis 初始化成功' 13 | -------------------------------------------------------------------------------- /py12306/log/user_log.py: -------------------------------------------------------------------------------- 1 | from py12306.log.base import BaseLog 2 | from py12306.helpers.func import * 3 | 4 | 5 | @singleton 6 | class UserLog(BaseLog): 7 | # 这里如果不声明,会出现重复打印,目前不知道什么原因 8 | logs = [] 9 | thread_logs = {} 10 | quick_log = [] 11 | 12 | MESSAGE_DOWNLAOD_AUTH_CODE_FAIL = '验证码下载失败 错误原因: {} {} 秒后重试' 13 | MESSAGE_DOWNLAODING_THE_CODE = '正在下载验证码...' 14 | MESSAGE_CODE_AUTH_FAIL = '验证码验证失败 错误原因: {}' 15 | MESSAGE_CODE_AUTH_SUCCESS = '验证码验证成功 开始登录...' 16 | MESSAGE_QRCODE_DOWNLOADING = '正在下载二维码...' 17 | MESSAGE_QRCODE_DOWNLOADED = '二维码保存在: {},请使用手机客户端扫描' 18 | MESSAGE_QRCODE_FAIL = '二维码获取失败: {}, {} 秒后重试' 19 | MESSAGE_LOGIN_FAIL = '登录失败 错误原因: {}' 20 | MESSAGE_LOADED_USER = '正在尝试恢复用户: {}' 21 | MESSAGE_LOADED_USER_SUCCESS = '用户恢复成功: {}' 22 | MESSAGE_LOADED_USER_BUT_EXPIRED = '用户状态已过期,正在重新登录' 23 | MESSAGE_USER_HEARTBEAT_NORMAL = '用户 {} 心跳正常,下次检测 {} 秒后' 24 | 25 | MESSAGE_GET_USER_PASSENGERS_FAIL = '获取用户乘客列表失败,错误原因: {} {} 秒后重试' 26 | MESSAGE_TEST_GET_USER_PASSENGERS_FAIL = '测试获取用户乘客列表失败,错误原因: {} {} 秒后重试' 27 | MESSAGE_USER_PASSENGERS_IS_INVALID = '乘客信息校验失败,在账号 {} 中未找到该乘客: {}\n' 28 | 29 | # MESSAGE_WAIT_USER_INIT_COMPLETE = '未找到可用账号或用户正在初始化,{} 秒后重试' 30 | 31 | MESSAGE_USERS_DID_CHANGED = '\n用户信息已更新,正在重新加载...' 32 | 33 | MESSAGE_USER_BEING_DESTROY = '用户 {} 已退出' 34 | MESSAGE_USER_COOKIE_NOT_FOUND_FROM_REMOTE = '用户 {} 状态加载中...' 35 | 36 | MESSAGE_WAIT_USER_INIT_COMPLETE = '账号正在登录中,{} 秒后自动重试' 37 | 38 | def __init__(self): 39 | super().__init__() 40 | self.init_data() 41 | 42 | def init_data(self): 43 | pass 44 | 45 | @classmethod 46 | def print_init_users(cls, users): 47 | """ 48 | 输出初始化信息 49 | :return: 50 | """ 51 | self = cls() 52 | self.add_quick_log('# 发现 {} 个用户 #\n'.format(len(users))) 53 | self.flush() 54 | return self 55 | 56 | @classmethod 57 | def print_welcome_user(cls, user): 58 | self = cls() 59 | self.add_quick_log('# 欢迎回来,{} #\n'.format(user.get_name())) 60 | self.flush() 61 | return self 62 | 63 | @classmethod 64 | def print_start_login(cls, user): 65 | self = cls() 66 | self.add_quick_log('正在登录用户 {}'.format(user.user_name)) 67 | self.flush() 68 | return self 69 | 70 | @classmethod 71 | def print_user_passenger_init_success(cls, passengers): 72 | self = cls() 73 | result = [passenger.get('name') + '(' + passenger.get('type_text') + ')' for passenger in passengers] 74 | self.add_quick_log('# 乘客验证成功 {} #\n'.format(', '.join(result))) 75 | self.flush() 76 | return self 77 | 78 | @classmethod 79 | def print_user_expired(cls): 80 | return cls().add_quick_log(cls.MESSAGE_LOADED_USER_BUT_EXPIRED).flush() 81 | -------------------------------------------------------------------------------- /py12306/query/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/query/__init__.py -------------------------------------------------------------------------------- /py12306/query/job.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from datetime import datetime 3 | 4 | from py12306.app import app_available_check 5 | from py12306.cluster.cluster import Cluster 6 | from py12306.config import Config 7 | from py12306.helpers.api import LEFT_TICKETS 8 | from py12306.helpers.station import Station 9 | from py12306.helpers.type import OrderSeatType, SeatType 10 | from py12306.log.query_log import QueryLog 11 | from py12306.helpers.func import * 12 | from py12306.log.user_log import UserLog 13 | from py12306.order.order import Order 14 | from py12306.user.user import User 15 | from py12306.helpers.event import Event 16 | 17 | 18 | class Job: 19 | """ 20 | 查询任务 21 | """ 22 | id = 0 23 | is_alive = True 24 | job_name = None 25 | left_dates = [] 26 | left_date = None 27 | stations = [] 28 | left_station = '' 29 | arrive_station = '' 30 | left_station_code = '' 31 | arrive_station_code = '' 32 | from_time = timedelta(hours=0) 33 | to_time = timedelta(hours=24) 34 | 35 | account_key = 0 36 | allow_seats = [] 37 | current_seat = None 38 | current_seat_name = '' 39 | current_order_seat = None 40 | allow_train_numbers = [] 41 | except_train_numbers = [] 42 | members = [] 43 | member_num = 0 44 | member_num_take = 0 # 最终提交的人数 45 | passengers = [] 46 | allow_less_member = False 47 | retry_time = 3 48 | 49 | interval = {} 50 | interval_additional = 0 51 | interval_additional_max = 5 52 | 53 | query = None 54 | cluster = None 55 | ticket_info = {} 56 | is_cdn = False 57 | query_time_out = 3 58 | INDEX_TICKET_NUM = 11 59 | INDEX_TRAIN_NUMBER = 3 60 | INDEX_TRAIN_NO = 2 61 | INDEX_LEFT_DATE = 13 62 | INDEX_LEFT_STATION = 6 # 4 5 始发 终点 63 | INDEX_ARRIVE_STATION = 7 64 | INDEX_ORDER_TEXT = 1 # 下单文字 65 | INDEX_SECRET_STR = 0 66 | INDEX_LEFT_TIME = 8 67 | INDEX_ARRIVE_TIME = 9 68 | 69 | max_buy_time = 32 70 | 71 | def __init__(self, info, query): 72 | self.cluster = Cluster() 73 | self.query = query 74 | self.init_data(info) 75 | self.update_interval() 76 | 77 | def init_data(self, info): 78 | self.id = md5(info) 79 | self.left_dates = info.get('left_dates') 80 | self.stations = info.get('stations') 81 | self.stations = [self.stations] if isinstance(self.stations, dict) else self.stations 82 | if not self.job_name: # name 不能被修改 83 | self.job_name = info.get('job_name', 84 | '{} -> {}'.format(self.stations[0]['left'], self.stations[0]['arrive'])) 85 | 86 | self.account_key = str(info.get('account_key')) 87 | self.allow_seats = info.get('seats') 88 | self.allow_train_numbers = info.get('train_numbers') 89 | self.except_train_numbers = info.get('except_train_numbers') 90 | self.members = list(map(str, info.get('members'))) 91 | self.member_num = len(self.members) 92 | self.member_num_take = self.member_num 93 | self.allow_less_member = bool(info.get('allow_less_member')) 94 | period = info.get('period') 95 | if isinstance(period, dict): 96 | if 'from' in period: 97 | parts = period['from'].split(':') 98 | if len(parts) == 2: 99 | self.from_time = timedelta( 100 | hours=int(parts[0]), seconds=int(parts[1])) 101 | if 'to' in period: 102 | parts = period['to'].split(':') 103 | if len(parts) == 2: 104 | self.to_time = timedelta( 105 | hours=int(parts[0]), seconds=int(parts[1])) 106 | 107 | def update_interval(self): 108 | self.interval = self.query.interval 109 | 110 | def run(self): 111 | self.start() 112 | 113 | def start(self): 114 | """ 115 | 处理单个任务 116 | 根据日期循环查询, 展示处理时间 117 | :param job: 118 | :return: 119 | """ 120 | while True and self.is_alive: 121 | app_available_check() 122 | QueryLog.print_job_start(self.job_name) 123 | for station in self.stations: 124 | self.refresh_station(station) 125 | for date in self.left_dates: 126 | self.left_date = date 127 | response = self.query_by_date(date) 128 | self.handle_response(response) 129 | QueryLog.add_query_time_log(time=response.elapsed.total_seconds(), is_cdn=self.is_cdn) 130 | if not self.is_alive: return 131 | self.safe_stay() 132 | if is_main_thread(): 133 | QueryLog.flush(sep='\t\t', publish=False) 134 | if not Config().QUERY_JOB_THREAD_ENABLED: 135 | QueryLog.add_quick_log('').flush(publish=False) 136 | break 137 | else: 138 | QueryLog.add_log('\n').flush(sep='\t\t', publish=False) 139 | if Const.IS_TEST: return 140 | 141 | def judge_date_legal(self, date): 142 | date_now = datetime.datetime.now() 143 | date_query = datetime.datetime.strptime(str(date), "%Y-%m-%d") 144 | diff = (date_query - date_now).days 145 | if date_now.day == date_query.day: 146 | diff = 0 147 | if diff < 0: 148 | msg = '乘车日期错误,比当前时间还早!!' 149 | QueryLog.add_quick_log(msg).flush(publish=False) 150 | raise RuntimeError(msg) 151 | elif diff > self.max_buy_time: 152 | msg = '乘车日期错误,超出一个月预售期!!' 153 | QueryLog.add_quick_log(msg).flush(publish=False) 154 | raise RuntimeError(msg) 155 | else: 156 | return date_query.strftime("%Y-%m-%d") 157 | 158 | def query_by_date(self, date): 159 | """ 160 | 通过日期进行查询 161 | :return: 162 | """ 163 | date = self.judge_date_legal(date) 164 | from py12306.helpers.cdn import Cdn 165 | QueryLog.add_log(('\n' if not is_main_thread() else '') + QueryLog.MESSAGE_QUERY_START_BY_DATE.format(date, 166 | self.left_station, 167 | self.arrive_station)) 168 | url = LEFT_TICKETS.get('url').format(left_date=date, left_station=self.left_station_code, 169 | arrive_station=self.arrive_station_code, type=self.query.api_type) 170 | if Config.is_cdn_enabled() and Cdn().is_ready: 171 | self.is_cdn = True 172 | return self.query.session.cdn_request(url, timeout=self.query_time_out, allow_redirects=False) 173 | self.is_cdn = False 174 | return self.query.session.get(url, timeout=self.query_time_out, allow_redirects=False) 175 | 176 | def handle_response(self, response): 177 | """ 178 | 错误判断 179 | 余票判断 180 | 小黑屋判断 181 | 座位判断 182 | 乘车人判断 183 | :param result: 184 | :return: 185 | """ 186 | results = self.get_results(response) 187 | if not results: 188 | return False 189 | for result in results: 190 | self.ticket_info = ticket_info = result.split('|') 191 | if not self.is_trains_number_valid(): # 车次是否有效 192 | continue 193 | QueryLog.add_log(QueryLog.MESSAGE_QUERY_LOG_OF_EVERY_TRAIN.format(self.get_info_of_train_number())) 194 | if not self.is_has_ticket(ticket_info): 195 | continue 196 | allow_seats = self.allow_seats if self.allow_seats else list( 197 | Config.SEAT_TYPES.values()) # 未设置 则所有可用 TODO 合法检测 198 | self.handle_seats(allow_seats, ticket_info) 199 | if not self.is_alive: return 200 | 201 | def handle_seats(self, allow_seats, ticket_info): 202 | for seat in allow_seats: # 检查座位是否有票 203 | self.set_seat(seat) 204 | ticket_of_seat = ticket_info[self.current_seat] 205 | if not self.is_has_ticket_by_seat(ticket_of_seat): # 座位是否有效 206 | continue 207 | QueryLog.print_ticket_seat_available(left_date=self.get_info_of_left_date(), 208 | train_number=self.get_info_of_train_number(), seat_type=seat, 209 | rest_num=ticket_of_seat) 210 | if not self.is_member_number_valid(ticket_of_seat): # 乘车人数是否有效 211 | if self.allow_less_member: 212 | self.member_num_take = int(ticket_of_seat) 213 | QueryLog.print_ticket_num_less_than_specified(ticket_of_seat, self) 214 | else: 215 | QueryLog.add_quick_log( 216 | QueryLog.MESSAGE_GIVE_UP_CHANCE_CAUSE_TICKET_NUM_LESS_THAN_SPECIFIED).flush() 217 | continue 218 | if Const.IS_TEST: return 219 | # 检查完成 开始提交订单 220 | QueryLog.print_ticket_available(left_date=self.get_info_of_left_date(), 221 | train_number=self.get_info_of_train_number(), 222 | rest_num=ticket_of_seat) 223 | if User.is_empty(): 224 | QueryLog.add_quick_log(QueryLog.MESSAGE_USER_IS_EMPTY_WHEN_DO_ORDER.format(self.retry_time)) 225 | return stay_second(self.retry_time) 226 | 227 | order_result = False 228 | user = self.get_user() 229 | if not user: 230 | QueryLog.add_quick_log(QueryLog.MESSAGE_ORDER_USER_IS_EMPTY.format(self.retry_time)) 231 | return stay_second(self.retry_time) 232 | 233 | lock_id = Cluster.KEY_LOCK_DO_ORDER + '_' + user.key 234 | if Config().is_cluster_enabled(): 235 | if self.cluster.get_lock(lock_id, Cluster.lock_do_order_time, 236 | {'node': self.cluster.node_name}): # 获得下单锁 237 | order_result = self.do_order(user) 238 | if not order_result: # 下单失败,解锁 239 | self.cluster.release_lock(lock_id) 240 | else: 241 | QueryLog.add_quick_log( 242 | QueryLog.MESSAGE_SKIP_ORDER.format(self.cluster.get_lock_info(lock_id).get('node'), 243 | user.user_name)) 244 | stay_second(self.retry_time) # 防止过多重复 245 | else: 246 | order_result = self.do_order(user) 247 | 248 | # 任务已成功 通知集群停止任务 249 | if order_result: 250 | Event().job_destroy({'name': self.job_name}) 251 | 252 | def do_order(self, user): 253 | self.check_passengers() 254 | order = Order(user=user, query=self) 255 | return order.order() 256 | 257 | def get_results(self, response): 258 | """ 259 | 解析查询返回结果 260 | :param response: 261 | :return: 262 | """ 263 | if response.status_code != 200: 264 | QueryLog.print_query_error(response.reason, response.status_code) 265 | if self.interval_additional < self.interval_additional_max: 266 | self.interval_additional += self.interval.get('min') 267 | else: 268 | self.interval_additional = 0 269 | result = response.json().get('data.result') 270 | return result if result else False 271 | 272 | def is_has_ticket(self, ticket_info): 273 | return self.get_info_of_ticket_num() == 'Y' and self.get_info_of_order_text() == '预订' 274 | 275 | def is_has_ticket_by_seat(self, seat): 276 | return seat != '' and seat != '无' and seat != '*' 277 | 278 | def is_trains_number_valid(self): 279 | train_left_time = self.get_info_of_train_left_time() 280 | time_parts = train_left_time.split(':') 281 | left_time = timedelta( 282 | hours=int(time_parts[0]), seconds=int(time_parts[1])) 283 | if left_time < self.from_time or left_time > self.to_time: 284 | return False 285 | 286 | if self.except_train_numbers: 287 | return self.get_info_of_train_number().upper() not in map(str.upper, self.except_train_numbers) 288 | if self.allow_train_numbers: 289 | return self.get_info_of_train_number().upper() in map(str.upper, self.allow_train_numbers) 290 | return True 291 | 292 | def is_member_number_valid(self, seat): 293 | return seat == '有' or self.member_num <= int(seat) 294 | 295 | def destroy(self): 296 | """ 297 | 退出任务 298 | :return: 299 | """ 300 | from py12306.query.query import Query 301 | self.is_alive = False 302 | QueryLog.add_quick_log(QueryLog.MESSAGE_QUERY_JOB_BEING_DESTROY.format(self.job_name)).flush() 303 | # sys.exit(1) # 无法退出线程... 304 | # 手动移出jobs 防止单线程死循环 305 | index = Query().jobs.index(self) 306 | Query().jobs.pop(index) 307 | 308 | def safe_stay(self): 309 | origin_interval = get_interval_num(self.interval) 310 | interval = origin_interval + self.interval_additional 311 | QueryLog.add_stay_log( 312 | '%s + %s' % (origin_interval, self.interval_additional) if self.interval_additional else origin_interval) 313 | stay_second(interval) 314 | 315 | def set_passengers(self, passengers): 316 | UserLog.print_user_passenger_init_success(passengers) 317 | self.passengers = passengers 318 | 319 | def set_seat(self, seat): 320 | self.current_seat_name = seat 321 | self.current_seat = SeatType.dicts.get(seat) 322 | self.current_order_seat = OrderSeatType.dicts.get(seat) 323 | 324 | def get_user(self): 325 | user = User.get_user(self.account_key) 326 | # if not user.check_is_ready(): # 这里不需要检测了,后面获取乘客时已经检测过 327 | # # 328 | # pass 329 | return user 330 | 331 | def check_passengers(self): 332 | if not self.passengers: 333 | QueryLog.add_quick_log(QueryLog.MESSAGE_CHECK_PASSENGERS.format(self.job_name)).flush() 334 | passengers = User.get_passenger_for_members(self.members, self.account_key) 335 | if passengers: 336 | self.set_passengers(passengers) 337 | else: # 退出当前查询任务 338 | self.destroy() 339 | return True 340 | 341 | def refresh_station(self, station): 342 | self.left_station = station.get('left') 343 | self.arrive_station = station.get('arrive') 344 | self.left_station_code = Station.get_station_key_by_name(self.left_station) 345 | self.arrive_station_code = Station.get_station_key_by_name(self.arrive_station) 346 | 347 | # 提供一些便利方法 348 | def get_info_of_left_date(self): 349 | return self.ticket_info[self.INDEX_LEFT_DATE] 350 | 351 | def get_info_of_ticket_num(self): 352 | return self.ticket_info[self.INDEX_TICKET_NUM] 353 | 354 | def get_info_of_train_number(self): 355 | return self.ticket_info[self.INDEX_TRAIN_NUMBER] 356 | 357 | def get_info_of_train_no(self): 358 | return self.ticket_info[self.INDEX_TRAIN_NO] 359 | 360 | def get_info_of_left_station(self): 361 | return Station.get_station_name_by_key(self.ticket_info[self.INDEX_LEFT_STATION]) 362 | 363 | def get_info_of_arrive_station(self): 364 | return Station.get_station_name_by_key(self.ticket_info[self.INDEX_ARRIVE_STATION]) 365 | 366 | def get_info_of_order_text(self): 367 | return self.ticket_info[self.INDEX_ORDER_TEXT] 368 | 369 | def get_info_of_secret_str(self): 370 | return self.ticket_info[self.INDEX_SECRET_STR] 371 | 372 | def get_info_of_train_left_time(self): 373 | return self.ticket_info[self.INDEX_LEFT_TIME] 374 | 375 | def get_info_of_train_arrive_time(self): 376 | return self.ticket_info[self.INDEX_ARRIVE_TIME] 377 | -------------------------------------------------------------------------------- /py12306/query/query.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | from py12306.config import Config 3 | from py12306.cluster.cluster import Cluster 4 | from py12306.app import app_available_check 5 | from py12306.helpers.func import * 6 | from py12306.helpers.request import Request 7 | from py12306.log.query_log import QueryLog 8 | from py12306.query.job import Job 9 | from py12306.helpers.api import API_QUERY_INIT_PAGE, API_GET_BROWSER_DEVICE_ID 10 | 11 | 12 | @singleton 13 | class Query: 14 | """ 15 | 余票查询 16 | 17 | """ 18 | jobs = [] 19 | query_jobs = [] 20 | session = {} 21 | 22 | # 查询间隔 23 | interval = {} 24 | cluster = None 25 | 26 | is_in_thread = False 27 | retry_time = 3 28 | is_ready = False 29 | api_type = None # Query api url, Current know value leftTicket/queryX | leftTicket/queryZ 30 | 31 | def __init__(self): 32 | self.session = Request() 33 | self.request_device_id() 34 | self.cluster = Cluster() 35 | self.update_query_interval() 36 | self.update_query_jobs() 37 | self.get_query_api_type() 38 | 39 | def update_query_interval(self, auto=False): 40 | self.interval = init_interval_by_number(Config().QUERY_INTERVAL) 41 | if auto: 42 | jobs_do(self.jobs, 'update_interval') 43 | 44 | def update_query_jobs(self, auto=False): 45 | self.query_jobs = Config().QUERY_JOBS 46 | if auto: 47 | QueryLog.add_quick_log(QueryLog.MESSAGE_JOBS_DID_CHANGED).flush() 48 | self.refresh_jobs() 49 | if not Config().is_slave(): 50 | jobs_do(self.jobs, 'check_passengers') 51 | 52 | @classmethod 53 | def run(cls): 54 | self = cls() 55 | app_available_check() 56 | self.start() 57 | pass 58 | 59 | @classmethod 60 | def check_before_run(cls): 61 | self = cls() 62 | self.init_jobs() 63 | self.is_ready = True 64 | 65 | def start(self): 66 | # return # DEBUG 67 | QueryLog.init_data() 68 | stay_second(3) 69 | # 多线程 70 | while True: 71 | if Config().QUERY_JOB_THREAD_ENABLED: # 多线程 72 | if not self.is_in_thread: 73 | self.is_in_thread = True 74 | create_thread_and_run(jobs=self.jobs, callback_name='run', wait=Const.IS_TEST) 75 | if Const.IS_TEST: return 76 | stay_second(self.retry_time) 77 | else: 78 | if not self.jobs: break 79 | self.is_in_thread = False 80 | jobs_do(self.jobs, 'run') 81 | if Const.IS_TEST: return 82 | 83 | # while True: 84 | # app_available_check() 85 | # if Config().QUERY_JOB_THREAD_ENABLED: # 多线程 86 | # create_thread_and_run(jobs=self.jobs, callback_name='run') 87 | # else: 88 | # for job in self.jobs: job.run() 89 | # if Const.IS_TEST: return 90 | # self.refresh_jobs() # 刷新任务 91 | 92 | def refresh_jobs(self): 93 | """ 94 | 更新任务 95 | :return: 96 | """ 97 | allow_jobs = [] 98 | for job in self.query_jobs: 99 | id = md5(job) 100 | job_ins = objects_find_object_by_key_value(self.jobs, 'id', id) # [1 ,2] 101 | if not job_ins: 102 | job_ins = self.init_job(job) 103 | if Config().QUERY_JOB_THREAD_ENABLED: # 多线程重新添加 104 | create_thread_and_run(jobs=job_ins, callback_name='run', wait=Const.IS_TEST) 105 | allow_jobs.append(job_ins) 106 | 107 | for job in self.jobs: # 退出已删除 Job 108 | if job not in allow_jobs: job.destroy() 109 | 110 | QueryLog.print_init_jobs(jobs=self.jobs) 111 | 112 | def init_jobs(self): 113 | for job in self.query_jobs: 114 | self.init_job(job) 115 | QueryLog.print_init_jobs(jobs=self.jobs) 116 | 117 | def init_job(self, job): 118 | job = Job(info=job, query=self) 119 | self.jobs.append(job) 120 | return job 121 | 122 | def request_device_id(self, force_renew = False): 123 | """ 124 | 获取加密后的浏览器特征 ID 125 | :return: 126 | """ 127 | expire_time = self.session.cookies.get('RAIL_EXPIRATION') 128 | if not force_renew and expire_time and int(expire_time) - time_int_ms() > 0: 129 | return 130 | if 'pjialin' not in API_GET_BROWSER_DEVICE_ID: 131 | return self.request_device_id2() 132 | response = self.session.get(API_GET_BROWSER_DEVICE_ID) 133 | if response.status_code == 200: 134 | try: 135 | result = json.loads(response.text) 136 | response = self.session.get(b64decode(result['id']).decode()) 137 | if response.text.find('callbackFunction') >= 0: 138 | result = response.text[18:-2] 139 | result = json.loads(result) 140 | if not Config().is_cache_rail_id_enabled(): 141 | self.session.cookies.update({ 142 | 'RAIL_EXPIRATION': result.get('exp'), 143 | 'RAIL_DEVICEID': result.get('dfp'), 144 | }) 145 | else: 146 | self.session.cookies.update({ 147 | 'RAIL_EXPIRATION': Config().RAIL_EXPIRATION, 148 | 'RAIL_DEVICEID': Config().RAIL_DEVICEID, 149 | }) 150 | except: 151 | return self.request_device_id() 152 | else: 153 | return self.request_device_id() 154 | 155 | def request_device_id2(self): 156 | headers = { 157 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36" 158 | } 159 | self.session.headers.update(headers) 160 | response = self.session.get(API_GET_BROWSER_DEVICE_ID) 161 | if response.status_code == 200: 162 | try: 163 | if response.text.find('callbackFunction') >= 0: 164 | result = response.text[18:-2] 165 | result = json.loads(result) 166 | if not Config().is_cache_rail_id_enabled(): 167 | self.session.cookies.update({ 168 | 'RAIL_EXPIRATION': result.get('exp'), 169 | 'RAIL_DEVICEID': result.get('dfp'), 170 | }) 171 | else: 172 | self.session.cookies.update({ 173 | 'RAIL_EXPIRATION': Config().RAIL_EXPIRATION, 174 | 'RAIL_DEVICEID': Config().RAIL_DEVICEID, 175 | }) 176 | except: 177 | return self.request_device_id2() 178 | else: 179 | return self.request_device_id2() 180 | 181 | @classmethod 182 | def wait_for_ready(cls): 183 | self = cls() 184 | if self.is_ready: return self 185 | stay_second(self.retry_time) 186 | return self.wait_for_ready() 187 | 188 | @classmethod 189 | def job_by_name(cls, name) -> Job: 190 | self = cls() 191 | for job in self.jobs: 192 | if job.job_name == name: return job 193 | return None 194 | 195 | @classmethod 196 | def job_by_name(cls, name) -> Job: 197 | self = cls() 198 | return objects_find_object_by_key_value(self.jobs, 'job_name', name) 199 | 200 | @classmethod 201 | def job_by_account_key(cls, account_key) -> Job: 202 | self = cls() 203 | return objects_find_object_by_key_value(self.jobs, 'account_key', account_key) 204 | 205 | @classmethod 206 | def get_query_api_type(cls): 207 | import re 208 | self = cls() 209 | if self.api_type: 210 | return self.api_type 211 | response = self.session.get(API_QUERY_INIT_PAGE) 212 | if response.status_code == 200: 213 | res = re.search(r'var CLeftTicketUrl = \'(.*)\';', response.text) 214 | try: 215 | self.api_type = res.group(1) 216 | except Exception: 217 | pass 218 | if not self.api_type: 219 | QueryLog.add_quick_log('查询地址获取失败, 正在重新获取...').flush() 220 | sleep(get_interval_num(self.interval)) 221 | self.request_device_id(True) 222 | return cls.get_query_api_type() 223 | 224 | # def get_jobs_from_cluster(self): 225 | # jobs = self.cluster.session.get_dict(Cluster.KEY_JOBS) 226 | # return jobs 227 | # 228 | # def update_jobs_of_cluster(self): 229 | # if config.CLUSTER_ENABLED and config.NODE_IS_MASTER: 230 | # return self.cluster.session.set_dict(Cluster.KEY_JOBS, self.query_jobs) 231 | # 232 | # def refresh_jobs(self): 233 | # if not config.CLUSTER_ENABLED: return 234 | # jobs = self.get_jobs_from_cluster() 235 | # if jobs != self.query_jobs: 236 | # self.jobs = [] 237 | # self.query_jobs = jobs 238 | # QueryLog.add_quick_log(QueryLog.MESSAGE_JOBS_DID_CHANGED).flush() 239 | # self.init_jobs() 240 | -------------------------------------------------------------------------------- /py12306/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/user/__init__.py -------------------------------------------------------------------------------- /py12306/user/user.py: -------------------------------------------------------------------------------- 1 | from py12306.app import * 2 | from py12306.cluster.cluster import Cluster 3 | from py12306.helpers.event import Event 4 | from py12306.helpers.func import * 5 | from py12306.log.user_log import UserLog 6 | from py12306.user.job import UserJob 7 | 8 | 9 | @singleton 10 | class User: 11 | users = [] 12 | user_accounts = [] 13 | 14 | retry_time = 3 15 | cluster = None 16 | 17 | def __init__(self): 18 | self.cluster = Cluster() 19 | self.update_interval() 20 | self.update_user_accounts() 21 | 22 | def update_user_accounts(self, auto=False, old=None): 23 | self.user_accounts = Config().USER_ACCOUNTS 24 | if auto: 25 | UserLog.add_quick_log(UserLog.MESSAGE_USERS_DID_CHANGED).flush() 26 | self.refresh_users(old) 27 | 28 | def update_interval(self, auto=False): 29 | self.interval = Config().USER_HEARTBEAT_INTERVAL 30 | if auto: jobs_do(self.users, 'update_user') 31 | 32 | @classmethod 33 | def run(cls): 34 | self = cls() 35 | # app_available_check() 用户系统不休息 36 | self.start() 37 | pass 38 | 39 | def start(self): 40 | self.init_users() 41 | UserLog.print_init_users(users=self.users) 42 | # 多线程维护用户 43 | create_thread_and_run(jobs=self.users, callback_name='run', wait=Const.IS_TEST) 44 | 45 | def init_users(self): 46 | for account in self.user_accounts: 47 | self.init_user(account) 48 | 49 | def init_user(self, info): 50 | user = UserJob(info=info) 51 | self.users.append(user) 52 | return user 53 | 54 | def refresh_users(self, old): 55 | for account in self.user_accounts: 56 | key = account.get('key') 57 | old_account = array_dict_find_by_key_value(old, 'key', key) 58 | if old_account and account != old_account: 59 | user = self.get_user(key) 60 | user.init_data(account) 61 | elif not old_account: # 新用户 添加到 多线程 62 | new_user = self.init_user(account) 63 | create_thread_and_run(jobs=new_user, callback_name='run', wait=Const.IS_TEST) 64 | 65 | for account in old: # 退出已删除的用户 66 | if not array_dict_find_by_key_value(self.user_accounts, 'key', account.get('key')): 67 | Event().user_job_destroy({'key': account.get('key')}) 68 | 69 | @classmethod 70 | def is_empty(cls): 71 | self = cls() 72 | return not bool(self.users) 73 | 74 | @classmethod 75 | def get_user(cls, key) -> UserJob: 76 | self = cls() 77 | for user in self.users: 78 | if user.key == key: return user 79 | return None 80 | 81 | @classmethod 82 | def get_passenger_for_members(cls, members, key): 83 | """ 84 | 检测乘客信息 85 | :param passengers 86 | :return: 87 | """ 88 | self = cls() 89 | 90 | for user in self.users: 91 | assert isinstance(user, UserJob) 92 | if user.key == key and user.wait_for_ready(): 93 | return user.get_passengers_by_members(members) 94 | -------------------------------------------------------------------------------- /py12306/vender/ruokuai/main.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from hashlib import md5 3 | 4 | 5 | class RKClient(object): 6 | 7 | def __init__(self, username, password, soft_id, soft_key): 8 | self.username = username 9 | self.password = md5(password.encode('utf-8')).hexdigest() 10 | self.soft_id = soft_id 11 | self.soft_key = soft_key 12 | self.base_params = { 13 | 'username': self.username, 14 | 'password': self.password, 15 | 'softid': self.soft_id, 16 | 'softkey': self.soft_key, 17 | } 18 | self.headers = { 19 | 'Connection': 'Keep-Alive', 20 | 'Expect': '100-continue', 21 | 'User-Agent': 'ben', 22 | } 23 | 24 | def rk_create(self, image, im_type, timeout=20): 25 | """ 26 | im: 图片字节 27 | im_type: 题目类型 28 | """ 29 | params = { 30 | 'typeid': im_type, 31 | 'timeout': timeout, 32 | 'image': image 33 | } 34 | params.update(self.base_params) 35 | r = requests.post('http://api.ruokuai.com/create.json', data=params, timeout=timeout) 36 | return r.json() 37 | 38 | def rk_report_error(self, im_id): 39 | """ 40 | im_id:报错题目的ID 41 | """ 42 | params = { 43 | 'id': im_id, 44 | } 45 | params.update(self.base_params) 46 | r = requests.post('http://api.ruokuai.com/reporterror.json', data=params, headers=self.headers) 47 | return r.json() 48 | 49 | 50 | if __name__ == '__main__': 51 | rc = RKClient('username', 'password', 'soft_id', 'soft_key') 52 | # im = open('a.jpg', 'rb').read() 53 | # print rc.rk_create(im, 3040) 54 | 55 | -------------------------------------------------------------------------------- /py12306/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/__init__.py -------------------------------------------------------------------------------- /py12306/web/handler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/handler/__init__.py -------------------------------------------------------------------------------- /py12306/web/handler/app.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from flask import Blueprint, request, send_file 5 | from flask.json import jsonify 6 | from flask_jwt_extended import (jwt_required) 7 | 8 | from py12306.config import Config 9 | from py12306.query.query import Query 10 | from py12306.user.user import User 11 | 12 | app = Blueprint('app', __name__) 13 | 14 | 15 | @app.route('/', methods=['GET', 'POST']) 16 | def index(): 17 | file = Config().WEB_ENTER_HTML_PATH 18 | result = '' 19 | with open(file, 'r', encoding='utf-8') as f: 20 | result = f.read() 21 | config = { 22 | 'API_BASE_URL': '' # TODO 自定义 Host 23 | } 24 | result = re.sub(r''.format(json.dumps(config)), 25 | result) 26 | 27 | return result 28 | 29 | 30 | @app.route('/app/menus', methods=['GET']) 31 | @jwt_required 32 | def menus(): 33 | """ 34 | 菜单列表 35 | """ 36 | menus = [ 37 | {"id": 10, "name": "首页", "url": "/", "icon": "fa fa-tachometer-alt"}, 38 | {"id": 20, "name": "用户管理", "url": "/user", "icon": "fa fa-user"}, 39 | {"id": 30, "name": "查询任务", "url": "/query", "icon": "fa fa-infinity"}, 40 | {"id": 40, "name": "实时日志", "url": "/log/realtime", "icon": "fa fa-signature"}, 41 | {"id": 50, "name": "帮助", "url": "/help", "icon": "fa fa-search"} 42 | ] 43 | return jsonify(menus) 44 | 45 | 46 | @app.route('/app/actions', methods=['GET']) 47 | @jwt_required 48 | def actions(): 49 | """ 50 | 操作列表 51 | """ 52 | actions = [ 53 | {"text": "退出登录", "key": 'logout', "link": "", "icon": "fa fa-sign-out-alt"} 54 | ] 55 | return jsonify(actions) 56 | -------------------------------------------------------------------------------- /py12306/web/handler/log.py: -------------------------------------------------------------------------------- 1 | import linecache 2 | 3 | from flask import Blueprint, request 4 | from flask.json import jsonify 5 | from flask_jwt_extended import (jwt_required) 6 | 7 | from py12306.config import Config 8 | from py12306.helpers.func import get_file_total_line_num, pick_file_lines 9 | from py12306.log.common_log import CommonLog 10 | from py12306.query.query import Query 11 | from py12306.user.user import User 12 | 13 | log = Blueprint('log', __name__) 14 | 15 | 16 | @log.route('/log/output', methods=['GET']) 17 | @jwt_required 18 | def log_output(): 19 | """ 20 | 日志 21 | :return: 22 | """ 23 | last_line = int(request.args.get('line', 0)) 24 | limit = int(request.args.get('limit', 10)) 25 | max_old = 200 # 取最新时 往后再取的数 26 | file = Config().OUT_PUT_LOG_TO_FILE_PATH 27 | res = [] 28 | 29 | if last_line == -1: 30 | total_line = get_file_total_line_num(file) 31 | last_line = total_line - max_old if total_line > max_old else 0 32 | ranges = range(last_line, last_line + max_old + limit) 33 | # limit = max_old + limit 34 | else: 35 | ranges = range(last_line, last_line + limit) 36 | 37 | if Config().OUT_PUT_LOG_TO_FILE_ENABLED: 38 | with open(Config().OUT_PUT_LOG_TO_FILE_PATH, 'r', encoding='utf-8') as f: 39 | res = pick_file_lines(f, ranges) 40 | 41 | # linecache.updatecache(file) # 使用 linecache windows 平台会出来编码问题 暂时弃用 42 | # for i in ranges: 43 | # tmp = linecache.getline(file, last_line + i) 44 | # if tmp != '': res.append(tmp) 45 | last_line += len(res) 46 | else: 47 | res = CommonLog.MESSAGE_OUTPUT_TO_FILE_IS_UN_ENABLE 48 | return jsonify({ 49 | 'last_line': last_line, 50 | 'data': res 51 | }) 52 | -------------------------------------------------------------------------------- /py12306/web/handler/query.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request 2 | from flask.json import jsonify 3 | from flask_jwt_extended import (jwt_required) 4 | 5 | from py12306.config import Config 6 | from py12306.query.job import Job 7 | from py12306.query.query import Query 8 | 9 | query = Blueprint('query', __name__) 10 | 11 | 12 | @query.route('/query', methods=['GET']) 13 | @jwt_required 14 | def query_lists(): 15 | """ 16 | 查询任务列表 17 | :return: 18 | """ 19 | jobs = Query().jobs 20 | result = list(map(convert_job_to_info, jobs)) 21 | return jsonify(result) 22 | 23 | 24 | def convert_job_to_info(job: Job): 25 | return { 26 | 'name': job.job_name, 27 | 'left_dates': job.left_dates, 28 | 'stations': job.stations, 29 | 'members': job.members, 30 | 'member_num': job.member_num, 31 | 'allow_seats': job.allow_seats, 32 | 'allow_train_numbers': job.allow_train_numbers, 33 | 'except_train_numbers': job.except_train_numbers, 34 | 'allow_less_member': job.allow_less_member, 35 | 'passengers': job.passengers, 36 | } 37 | -------------------------------------------------------------------------------- /py12306/web/handler/stat.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request 2 | from flask.json import jsonify 3 | from flask_jwt_extended import (jwt_required) 4 | 5 | from py12306.config import Config 6 | from py12306.query.query import Query 7 | from py12306.user.user import User 8 | 9 | stat = Blueprint('stat', __name__) 10 | 11 | 12 | @stat.route('/stat/dashboard', methods=['GET']) 13 | @jwt_required 14 | def dashboard(): 15 | """ 16 | 状态统计 17 | 任务数量,用户数量,查询次数 18 | 节点信息(TODO) 19 | :return: 20 | """ 21 | from py12306.log.query_log import QueryLog 22 | query_job_count = len(Query().jobs) 23 | user_job_count = len(User().users) 24 | query_count = QueryLog().data.get('query_count') 25 | res = { 26 | 'query_job_count': query_job_count, 27 | 'user_job_count': user_job_count, 28 | 'query_count': query_count, 29 | } 30 | if Config().CDN_ENABLED: 31 | from py12306.helpers.cdn import Cdn 32 | res['cdn_count'] = len(Cdn().available_items) 33 | return jsonify(res) 34 | 35 | 36 | @stat.route('/stat/cluster', methods=['GET']) 37 | @jwt_required 38 | def clusters(): 39 | """ 40 | 节点统计 41 | 节点数量,主节点,子节点列表 42 | :return: 43 | """ 44 | from py12306.cluster.cluster import Cluster 45 | nodes = Cluster().nodes 46 | count = len(nodes) 47 | node_lists = list(nodes) 48 | master = [key for key, val in nodes.items() if int(val) == Cluster.KEY_MASTER] 49 | master = master[0] if master else '' 50 | 51 | return jsonify({ 52 | 'master': master, 53 | 'count': count, 54 | 'node_lists': ', '.join(node_lists) 55 | }) 56 | -------------------------------------------------------------------------------- /py12306/web/handler/user.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request 2 | from flask.json import jsonify 3 | from flask_jwt_extended import (jwt_required, create_access_token) 4 | 5 | from py12306.config import Config 6 | from py12306.helpers.func import str_to_time, timestamp_to_time 7 | from py12306.user.job import UserJob 8 | from py12306.user.user import User 9 | 10 | user = Blueprint('user', __name__) 11 | 12 | 13 | @user.route('/login', methods=['POST']) 14 | def login(): 15 | """ 16 | 用户登录 17 | :return: 18 | """ 19 | username = request.json.get('username', None) 20 | password = request.json.get('password', None) 21 | if username and password and username == Config().WEB_USER.get('username') and password == Config().WEB_USER.get( 22 | 'password'): 23 | access_token = create_access_token(identity=username) 24 | return jsonify(access_token=access_token) 25 | return jsonify({"msg": "用户名或密码错误"}), 422 26 | 27 | 28 | @user.route('/users', methods=['GET']) 29 | @jwt_required 30 | def users(): 31 | """ 32 | 用户任务列表 33 | :return: 34 | """ 35 | jobs = User().users 36 | result = list(map(convert_job_to_info, jobs)) 37 | return jsonify(result) 38 | 39 | 40 | @user.route('/user/info', methods=['GET']) 41 | @jwt_required 42 | def user_info(): 43 | """ 44 | 获取用户信息 45 | :return: 46 | """ 47 | result = { 48 | 'name': Config().WEB_USER.get('username') 49 | } 50 | return jsonify(result) 51 | 52 | 53 | def convert_job_to_info(job: UserJob): 54 | return { 55 | 'key': job.key, 56 | 'user_name': job.user_name, 57 | 'name': job.get_name(), 58 | 'is_ready': job.is_ready, 59 | 'is_loaded': job.user_loaded, # 是否成功加载 ready 是当前是否可用 60 | 'last_heartbeat': timestamp_to_time(job.last_heartbeat) if job.last_heartbeat else '-', 61 | 'login_num': job.login_num 62 | } 63 | -------------------------------------------------------------------------------- /py12306/web/static/fonts/element-icons.6f0a763.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/element-icons.6f0a763.ttf -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-brands-400.292a564.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-brands-400.292a564.woff -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-brands-400.87b76b9.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-brands-400.87b76b9.woff2 -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-brands-400.f83bc05.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-brands-400.f83bc05.ttf -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-brands-400.f902692.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-brands-400.f902692.eot -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-regular-400.732726c.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-regular-400.732726c.woff2 -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-regular-400.abde9e5.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-regular-400.abde9e5.ttf -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-regular-400.b4cfd51.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-regular-400.b4cfd51.woff -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-regular-400.d1ce381.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-regular-400.d1ce381.eot -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-solid-900.3b921c2.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-solid-900.3b921c2.eot -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-solid-900.bed3b0a.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-solid-900.bed3b0a.woff2 -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-solid-900.d751e66.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-solid-900.d751e66.ttf -------------------------------------------------------------------------------- /py12306/web/static/fonts/fa-solid-900.e0c419c.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pjialin/py12306/1d132cb219287abea83e6bf31c2dfde3d96e0f7e/py12306/web/static/fonts/fa-solid-900.e0c419c.woff -------------------------------------------------------------------------------- /py12306/web/static/img/avatar_default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 2754577-avatar-business-face-people 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /py12306/web/static/index.html: -------------------------------------------------------------------------------- 1 | py12306 购票助手
-------------------------------------------------------------------------------- /py12306/web/static/js/app.96ef02c9e5601eb5ebcb.js: -------------------------------------------------------------------------------- 1 | webpackJsonp([1],{"5ZdE":function(t,e){},E5Rs:function(t,e){},GpBP:function(t,e){},NHnr:function(t,e,a){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var n,s,r,i,o=a("7+uW"),l=a("zL8q"),c=a.n(l),u=(a("tvR6"),a("NYxO")),d=a("bOdI"),f=a.n(d),m={state:{sidebar:!0},mutations:(n={},f()(n,"TOGGLE_SIDEBAR",function(t,e){e=e||!t.sidebar,localStorage.sidebar=e,t.sidebar=e}),f()(n,"ALERT_NOTIFICATION",function(t,e){}),f()(n,"ALERT_MESSAGE",function(t,e){var a=e.text,n=e.type,s=void 0===n?"info":n;Object(l.Message)({message:a,type:s})}),n),actions:(s={},f()(s,"TOGGLE_SIDEBAR",function(t,e){(0,t.commit)("TOGGLE_SIDEBAR",e)}),f()(s,"ALERT_MESSAGE",function(t,e){(0,t.commit)("ALERT_MESSAGE",e)}),s),getters:{}},_={state:{user:{},token:null},mutations:(r={},f()(r,"LOGIN_SUCCESS",function(t,e){this.dispatch("UPDATE_TOKEN",e.access_token)}),f()(r,"UPDATE_TOKEN",function(t,e){localStorage.user_token=e,t.token=e}),f()(r,"LOAD_TOKEN",function(t){var e;(e=localStorage.getItem("user_token"))&&(t.token=e)}),f()(r,"LOGOUT_SUCCESS",function(t){delete localStorage.user_token}),r),actions:(i={},f()(i,"LOGIN_SUCCESS",function(t,e){(0,t.commit)("LOGIN_SUCCESS",e)}),f()(i,"UPDATE_TOKEN",function(t,e){(0,t.commit)("UPDATE_TOKEN",e)}),f()(i,"LOAD_TOKEN",function(t){(0,t.commit)("LOAD_TOKEN")}),f()(i,"LOGOUT_SUCCESS",function(t){(0,t.commit)("LOGOUT_SUCCESS")}),i),getters:{}};o.default.use(u.a);var p=new u.a.Store({modules:{common:m,user:_}}),h=a("/ocq"),v={name:"main-header",data:function(){return{app:{},actions:[]}},created:function(){this.getActions()},methods:{getActions:function(){var t=this;this.$api.get_actions().then(function(e){t.actions=e.data})},handleAction:function(t){"logout"==t.key&&(this.$store.dispatch("LOGOUT_SUCCESS"),this.$store.dispatch("ALERT_MESSAGE",{text:"退出成功",type:"success"}),this.$router.push("/login"))}}},g={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"nav-bar"},[a("el-row",[a("el-col",{attrs:{span:10}},[a("div",{staticClass:"logo-area vertical-center"},[a("h2",{staticClass:"no-margin vertical-center"},[t._v("PY 12306")])])]),t._v(" "),a("el-col",{attrs:{span:14}},[a("div",{staticClass:"actions float-right margin-right-1-rem"},[a("ul",{staticClass:"list-style-none"},t._l(t.actions,function(e){return a("li",{staticClass:"float-left margin-left-3-rem"},[a("a",{staticClass:"color-white vertical-center",attrs:{href:e.link},on:{click:function(a){a.preventDefault(),t.handleAction(e)}}},[e.icon?a("i",{staticClass:"font-size-14 margin-right-s5-rem",class:e.icon}):t._e(),t._v(" "),a("span",{domProps:{textContent:t._s(e.text)}})])])}))])])],1)],1)},staticRenderFns:[]};var b=a("VU/8")(v,g,!1,function(t){a("dXVw")},null,null).exports,C=a("Xxa5"),w=a.n(C),x=a("exGp"),y=a.n(x),k={name:"main-sidebar",data:function(){return{index:"0",loading:null,user:{},menus:[]}},created:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return t.handleLoading("on"),e.next=3,t.getUserInfo();case 3:return e.next=5,t.getMenus();case 5:t.handleLoading("off");case 6:case"end":return e.stop()}},e,t)}))()},watch:{$route:function(t,e){var a=this;this.$nextTick(function(t){a.updateMenus()})}},mounted:function(){},methods:{handleLoading:function(){"on"==(arguments.length>0&&void 0!==arguments[0]?arguments[0]:"on")?this.loading=this.$loading({lock:!0,text:"加载中..."}):this.loading.close()},getUserInfo:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.next=2,t.$api.get_user_info().then(function(e){t.user=e.data}).catch(function(e){t.handleLoading("off")});case 2:case"end":return e.stop()}},e,t)}))()},getMenus:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.next=2,t.$api.get_menus().then(function(e){t.updateMenus(e.data)}).catch(function(e){t.handleLoading("off")});case 2:case"end":return e.stop()}},e,t)}))()},updateMenus:function(t){var e=this;(t=t||this.menus).forEach(function(t){0===e.$route.path.indexOf(t.url)&&(e.index=t.id.toString())}),this.menus=t}}},E={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{attrs:{id:"menus"}},[a("div",{staticClass:"user-info margin-tb-3-rem"},[a("div",{staticClass:"text-align-center"},[a("div",{staticClass:"avatar"},[a("img",{staticClass:"border-circle",attrs:{src:t.user.avatar||"../../static/img/avatar_default.svg",alt:"",width:"60"}})]),t._v(" "),a("div",{staticClass:"name"},[a("span",{staticClass:"font-size-18",domProps:{textContent:t._s(t.user.name)}})])])]),t._v(" "),a("el-menu",{attrs:{router:"",collapse:!t.$store.state.common.sidebar,"default-active":t.index}},[t._l(t.menus,function(e){return[a("el-menu-item",{attrs:{index:e.id?e.id.toString():"",route:{path:e.url}}},[e.icon?a("i",{class:e.icon}):t._e(),t._v(" "),a("span",{attrs:{slot:"title"},domProps:{textContent:t._s(e.name)},slot:"title"})])]})],2)],1)},staticRenderFns:[]};var S={components:{MainSidebar:a("VU/8")(k,E,!1,function(t){a("YlHp")},null,null).exports,MainHeader:b},mounted:function(){},data:function(){return{}}},A={render:function(){var t=this.$createElement,e=this._self._c||t;return e("el-container",{attrs:{id:"body"}},[e("el-header",[e("main-header")],1),this._v(" "),e("el-container",{attrs:{id:"content"}},[e("el-aside",[e("main-sidebar")],1),this._v(" "),e("el-main",{attrs:{id:"content-body"}},[e("router-view")],1)],1)],1)},staticRenderFns:[]};var L=a("VU/8")(S,A,!1,function(t){a("GpBP")},null,null).exports,$={data:function(){return{dashboard_lists:[{name:"用户",key:"user_job_count",icon:"fa fa-user",icon_color:"#7DD43B"},{name:"任务",key:"query_job_count",icon:"fa fa-infinity",icon_color:"#F5A623"},{name:"查询次数",key:"query_count",icon:"fa fa-search",icon_color:"#4A90E2"}],dashboard:{},real_time_message_colors:["#18D4AD"],real_time_message_data:{columns:["Date","实时消息"],rows:[]},real_time_message_last_time:0,week_message_colors:["#fb7e70"],week_message_data:{columns:["Date","处理消息"],rows:[]},week_message_last_time:0,dataEmpty:!0,refreshTime:2}},mounted:function(){this.refreshData()},methods:{refreshData:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if("/"==t.$route.path){e.next=2;break}return e.abrupt("return");case 2:return e.next=4,t.getDashboard();case 4:setTimeout(t.refreshData,1e3*t.refreshTime);case 5:case"end":return e.stop()}},e,t)}))()},getDashboard:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.next=2,t.$api.get_dashboard().then(function(e){t.dashboard=e.data});case 2:case"end":return e.stop()}},e,t)}))()}}},T={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"container",attrs:{id:"home-index"}},[a("el-container",[a("el-row",{staticClass:"width-full"},[a("h2",{staticClass:"action-title"},[t._v("接入状态")]),t._v(" "),a("el-row",{staticClass:"system-state",attrs:{gutter:40}},t._l(t.dashboard_lists,function(e){return a("el-col",{key:e.key,attrs:{lg:6,md:8,sm:12}},[a("div",{staticClass:"card"},[a("div",{staticClass:"left"},[a("div",{staticClass:"name",domProps:{textContent:t._s(e.name)}}),t._v(" "),a("div",{staticClass:"value",domProps:{textContent:t._s(void 0!=t.dashboard[e.key]?t.dashboard[e.key]:"-")}})]),t._v(" "),a("div",{staticClass:"right"},[e.icon?a("span",{class:e.icon,style:e.icon_color?"background: "+e.icon_color:""}):t._e()])]),t._v(" "),a("div",{staticClass:"break-2-rem clear hidden-lg-and-up"})])})),t._v(" "),a("div",{staticClass:"break-2-rem clear hidden-md-and-down"})],1)],1)],1)},staticRenderFns:[]};var D=a("VU/8")($,T,!1,function(t){a("lIdD")},"data-v-65906f4e",null).exports,O={data:function(){return{info:{},loading_login:!1,rules:{username:[{required:!0,message:"请输入用户名",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"}]}}},mounted:function(){},methods:{doLogin:function(){var t=this;this.$refs.form.validate(function(e){e&&(t.loading_login=!0,t.$api.login(t.info).then(function(e){t.loading_login=!1,t.$store.dispatch("LOGIN_SUCCESS",e.data),t.$router.push("/")}).catch(function(e){t.loading_login=!1}))})}}},R={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"height-full vertical-center"},[a("div",{staticClass:"container width-full",attrs:{id:"login"}},[a("el-container",[a("el-row",{staticClass:"width-full",attrs:{type:"flex",justify:"center"}},[a("el-col",{attrs:{lg:10,md:12,sm:16}},[a("div",{staticClass:"card padding-2-rem padding-lr-3-rem text-align-center"},[a("h2",{staticClass:"card-title font-size-28"},[t._v("PY 12036")]),t._v(" "),a("el-form",{ref:"form",attrs:{model:t.info,rules:t.rules},nativeOn:{submit:function(e){return e.preventDefault(),t.doAdd(e)}}},[a("el-form-item",{attrs:{label:"用户名",prop:"username"}},[a("el-input",{model:{value:t.info.username,callback:function(e){t.$set(t.info,"username",e)},expression:"info.username"}})],1),t._v(" "),a("el-form-item",{attrs:{label:"密码",prop:"password"}},[a("el-input",{attrs:{type:"password"},model:{value:t.info.password,callback:function(e){t.$set(t.info,"password",e)},expression:"info.password"}})],1),t._v(" "),a("el-form-item",[a("div",{staticClass:"break-2-rem"}),t._v(" "),a("el-button",{attrs:{type:"primary",loading:t.loading_login,plain:""},on:{click:t.doLogin}},[t._v("登录\n ")])],1)],1)],1)])],1)],1)],1)])},staticRenderFns:[]};var U=a("VU/8")(O,R,!1,function(t){a("E5Rs")},null,null).exports,G={data:function(){return{empty:!1,lists:[],loading_lists:!1,retry_time:5,auto_refresh:!0}},mounted:function(){this.refreshData()},methods:{refreshData:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if("/user"==t.$route.path){e.next=2;break}return e.abrupt("return");case 2:if(!t.auto_refresh){e.next=5;break}return e.next=5,t.getLists();case 5:setTimeout(t.refreshData,1e3*t.retry_time);case 6:case"end":return e.stop()}},e,t)}))()},getLists:function(){var t=this;(!(arguments.length>0&&void 0!==arguments[0])||arguments[0])&&(this.loading_lists=!0),this.$api.get_users().then(function(e){!e.data||e.data.length<=0?t.empty=!0:t.empty=!1,t.lists=e.data,t.loading_lists=!1}).catch(function(e){t.loading_lists=!1})}}},N={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"container",attrs:{id:"account-index"}},[a("el-container",[a("el-row",{staticClass:"width-full"},[a("div",{staticClass:"action-group"},[a("h2",{staticClass:"action-title"},[t._v("用户管理")]),t._v(" "),a("div",{staticClass:"refresh-switch"},[a("span",{staticClass:"helper-text margin-right-s5-rem"},[t._v("自动刷新 "),a("span",{domProps:{textContent:t._s(t.retry_time)}}),t._v(" 秒")]),t._v(" "),a("el-switch",{model:{value:t.auto_refresh,callback:function(e){t.auto_refresh=e},expression:"auto_refresh"}})],1)]),t._v(" "),t.empty?a("el-col",{staticClass:"data"},[a("div",{staticClass:"card text-align-center padding-tb-6-rem"},[a("h2",{staticClass:"font-size-24 font-weight-normal color-text-secondary"},[t._v("没有正在运行的用户任务")]),t._v(" "),a("div",{staticClass:"break-3-rem"})])]):a("el-col",{staticClass:"data"},[a("div",{directives:[{name:"loading",rawName:"v-loading",value:t.loading_lists,expression:"loading_lists"}],staticClass:"card padding-tb-1-rem padding-lr-2-rem"},[a("el-table",{staticStyle:{width:"100%"},attrs:{data:t.lists}},[a("el-table-column",{attrs:{prop:"key",label:"KEY"}}),t._v(" "),a("el-table-column",{attrs:{prop:"user_name",label:"账号"}}),t._v(" "),a("el-table-column",{attrs:{prop:"name",label:"姓名"}}),t._v(" "),a("el-table-column",{attrs:{prop:"is_loaded",label:"是否加载成功"},scopedSlots:t._u([{key:"default",fn:function(e){return[e.row.is_loaded?a("el-tag",{attrs:{type:"success"}},[t._v("成功")]):a("el-tag",{attrs:{type:"danger"}},[t._v("失败")])]}}])}),t._v(" "),a("el-table-column",{attrs:{prop:"is_ready",label:"可用状态"},scopedSlots:t._u([{key:"default",fn:function(e){return[e.row.is_ready?a("el-tag",{attrs:{type:"success"}},[t._v("成功")]):a("el-tag",{attrs:{type:"danger"}},[t._v("失败")])]}}])}),t._v(" "),a("el-table-column",{attrs:{prop:"last_heartbeat",label:"最后心跳"},scopedSlots:t._u([{key:"default",fn:function(e){return[a("span",{staticClass:"time",domProps:{textContent:t._s(e.row.last_heartbeat)}})]}}])})],1)],1)])],1)],1)],1)},staticRenderFns:[]};var P=a("VU/8")(G,N,!1,function(t){a("pr7r")},"data-v-118e303f",null).exports,M={data:function(){return{lists:[],loading_lists:!1,line:-1,limit:10,retry_time:1,is_first_time:!0,auto_refresh:!0,able_to_scroll:!0}},mounted:function(){this.refreshData()},methods:{refreshData:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if("/log/realtime"==t.$route.path){e.next=2;break}return e.abrupt("return");case 2:if(!t.is_first_time&&!t.auto_refresh){e.next=5;break}return e.next=5,t.getLists(t.is_first_time);case 5:t.is_first_time=!1,setTimeout(t.refreshData,1e3*t.retry_time);case 7:case"end":return e.stop()}},e,t)}))()},getLists:function(){var t=this,e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];return y()(w.a.mark(function a(){return w.a.wrap(function(a){for(;;)switch(a.prev=a.next){case 0:return e&&(t.loading_lists=!0),a.next=3,t.$api.get_log_realtime({line:t.line,limit:t.limit}).then(function(e){e.data.data&&e.data.data.length&&(t.lists=t.lists.concat(e.data.data),t.$nextTick(function(){if(t.able_to_scroll){var e=t.$refs.logs;e.scrollTop=e.scrollHeight}}),t.line=e.data.last_line),t.loading_lists=!1}).catch(function(e){t.loading_lists=!1});case 3:case"end":return a.stop()}},a,t)}))()}}},j={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"container",attrs:{id:"log-realtime"}},[a("el-container",[a("el-row",{staticClass:"width-full"},[a("div",{staticClass:"action-group"},[a("h2",{staticClass:"action-title"},[t._v("实时日志")]),t._v(" "),a("div",{staticClass:"refresh-switch"},[a("span",{staticClass:"helper-text margin-right-s5-rem"},[t._v("自动刷新 "),a("span",{domProps:{textContent:t._s(t.retry_time)}}),t._v(" 秒")]),t._v(" "),a("el-switch",{model:{value:t.auto_refresh,callback:function(e){t.auto_refresh=e},expression:"auto_refresh"}})],1)]),t._v(" "),a("el-col",{staticClass:"data height-full"},[a("div",{directives:[{name:"loading",rawName:"v-loading",value:t.loading_lists,expression:"loading_lists"}],staticClass:"card padding-tb-1-rem padding-lr-2-rem height-full log-area",on:{mouseover:function(e){t.able_to_scroll=!1},mouseout:function(e){t.able_to_scroll=!0}}},[a("div",{ref:"logs",staticClass:"logs"},t._l(t.lists,function(e){return a("span",{staticClass:"display-block",domProps:{textContent:t._s(e)}})}))])])],1)],1)],1)},staticRenderFns:[]};var I=a("VU/8")(M,j,!1,function(t){a("aAyn")},"data-v-47d90518",null).exports,q={data:function(){return{empty:!1,lists:[],loading_lists:!1,retry_time:5,auto_refresh:!0}},mounted:function(){this.refreshData()},methods:{refreshData:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if("/query"==t.$route.path){e.next=2;break}return e.abrupt("return");case 2:if(!t.auto_refresh){e.next=5;break}return e.next=5,t.getLists();case 5:setTimeout(t.refreshData,1e3*t.retry_time);case 6:case"end":return e.stop()}},e,t)}))()},getLists:function(){var t=this,e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];return y()(w.a.mark(function a(){return w.a.wrap(function(a){for(;;)switch(a.prev=a.next){case 0:return e&&(t.loading_lists=!0),a.next=3,t.$api.get_query().then(function(e){!e.data||e.data.length<=0?t.empty=!0:t.empty=!1,t.lists=e.data,t.loading_lists=!1}).catch(function(e){t.loading_lists=!1});case 3:case"end":return a.stop()}},a,t)}))()}}},F={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"container",attrs:{id:"account-index"}},[a("el-container",[a("el-row",{staticClass:"width-full"},[a("div",{staticClass:"action-group"},[a("h2",{staticClass:"action-title"},[t._v("查询任务")]),t._v(" "),a("div",{staticClass:"refresh-switch"},[a("span",{staticClass:"helper-text margin-right-s5-rem"},[t._v("自动刷新 "),a("span",{domProps:{textContent:t._s(t.retry_time)}}),t._v(" 秒")]),t._v(" "),a("el-switch",{model:{value:t.auto_refresh,callback:function(e){t.auto_refresh=e},expression:"auto_refresh"}})],1)]),t._v(" "),t.empty?a("el-col",{staticClass:"data"},[a("div",{staticClass:"card text-align-center padding-tb-6-rem"},[a("h2",{staticClass:"font-size-24 font-weight-normal color-text-secondary"},[t._v("没有正在运行的查询任务")]),t._v(" "),a("div",{staticClass:"break-3-rem"})])]):a("el-col",{staticClass:"data"},[a("div",{directives:[{name:"loading",rawName:"v-loading",value:t.loading_lists,expression:"loading_lists"}],staticClass:"card padding-tb-1-rem padding-lr-2-rem"},[a("el-table",{staticStyle:{width:"100%"},attrs:{data:t.lists}},[a("el-table-column",{attrs:{prop:"name",label:"名称",width:"150"}}),t._v(" "),a("el-table-column",{attrs:{label:"出发日期"},scopedSlots:t._u([{key:"default",fn:function(e){return[t._v("\n "+t._s(e.row.left_dates.join(", "))+"\n ")]}}])}),t._v(" "),a("el-table-column",{attrs:{label:"乘客人数",width:"120"},scopedSlots:t._u([{key:"default",fn:function(e){return[a("el-tag",{attrs:{size:"medium"}},[t._v(t._s(e.row.member_num))])]}}])}),t._v(" "),a("el-table-column",{attrs:{label:"部分提交",width:"120"},scopedSlots:t._u([{key:"default",fn:function(e){return[a("el-switch",{attrs:{disabled:""},model:{value:e.row.allow_less_member,callback:function(a){t.$set(e.row,"allow_less_member",a)},expression:"scope.row.allow_less_member"}})]}}])}),t._v(" "),a("el-table-column",{attrs:{label:"座位"},scopedSlots:t._u([{key:"default",fn:function(e){return[t._v("\n "+t._s(e.row.allow_seats.join(", "))+"\n ")]}}])}),t._v(" "),a("el-table-column",{attrs:{label:"筛选车次"},scopedSlots:t._u([{key:"default",fn:function(e){return[t._v("\n "+t._s(e.row.allow_train_numbers.join(", "))+"\n ")]}}])})],1)],1)])],1)],1)],1)},staticRenderFns:[]};var V=a("VU/8")(q,F,!1,function(t){a("a7/l")},"data-v-4396a4e9",null).exports,H={render:function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"container",attrs:{id:"help-index"}},[a("el-container",[a("el-row",{staticClass:"width-full"},[a("div",{staticClass:"action-group"},[a("h2",{staticClass:"action-title"},[t._v("快捷访问")])]),t._v(" "),a("el-row",{staticClass:"quick-links",attrs:{gutter:40}},t._l(t.function_lists,function(e){return a("el-col",{key:e.key,attrs:{lg:6,md:8,sm:12}},[a("router-link",{attrs:{to:e.url}},[a("div",{staticClass:"card text-align-center color-text-secondary"},[a("div",{staticClass:"break-2-rem"}),t._v(" "),a("div",[a("span",{staticClass:"font-size-30",class:e.icon})]),t._v(" "),a("div",{staticClass:"break-s2-rem"}),t._v(" "),a("div",[a("span",{staticClass:"font-size-18",domProps:{textContent:t._s(e.name)}})])])]),t._v(" "),a("div",{staticClass:"break-2-rem clear hidden-lg-and-up"})],1)})),t._v(" "),a("div",{staticClass:"break-2-rem clear hidden-md-and-down"}),t._v(" "),a("div",{staticClass:"action-group"},[a("h2",{staticClass:"action-title"},[t._v("关于")])]),t._v(" "),a("el-row",{staticClass:"common-problem"},[a("el-col",{attrs:{span:24}},[a("div",{staticClass:"card padding-2-rem",domProps:{innerHTML:t._s(t.about)}})])],1)],1)],1)],1)},staticRenderFns:[]};var z=a("VU/8")({data:function(){return{function_lists:[{name:"帮助文档",url:"/help/readme",icon:"fa fa-book-open"}],about:'写这个程序最初只是为了给自己父母买张回家的票,开源是希望能帮助到更多的人,请勿用于任何商业行为。

github: https://github.com/pjialin/py12306'}},mounted:function(){},methods:{}},H,!1,function(t){a("VjHN")},"data-v-1f9d50cc",null).exports,K=a("HKE2"),B={data:function(){return{loading_readme:!1,info:""}},mounted:function(){this.getReadme()},methods:{getReadme:function(){var t=this;return y()(w.a.mark(function e(){return w.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return t.loading_readme=!0,e.next=3,t.$api.get_readme().then(function(e){var a=new K.Converter;t.info=a.makeHtml(e.data)});case 3:t.loading_readme=!1;case 4:case"end":return e.stop()}},e,t)}))()}}},Y={render:function(){var t=this.$createElement,e=this._self._c||t;return e("div",{staticClass:"container",attrs:{id:"readme-index"}},[e("el-container",[e("el-row",{staticClass:"width-full"},[e("div",{staticClass:"action-group"},[e("h2",{staticClass:"action-title"},[this._v("帮助文档")])]),this._v(" "),e("el-row",{directives:[{name:"loading",rawName:"v-loading",value:this.loading_readme,expression:"loading_readme"}]},[e("el-col",{attrs:{span:24}},[e("div",{staticClass:"card padding-2-rem"},[e("article",{staticClass:"markdown-body",domProps:{innerHTML:this._s(this.info)}})])])],1)],1)],1)],1)},staticRenderFns:[]};var Q=a("VU/8")(B,Y,!1,function(t){a("5ZdE"),a("n//Q")},"data-v-32a9e4aa",null).exports;o.default.use(h.a);var X=[{path:"/",component:L,meta:{auth:!0},children:[{path:"",component:D},{path:"user",component:P},{path:"log/realtime",component:I},{path:"query",component:V},{path:"help",component:z},{path:"help/readme",component:Q}]},{path:"/login",component:U}];p.dispatch("LOAD_TOKEN");var J=new h.a({routes:X});J.beforeEach(function(t,e,a){t.matched.some(function(t){return t.meta.auth})?p.state.user.token?a():a({path:"/login",query:{redirect:t.fullPath}}):a()});var W=J,Z=a("Dd8w"),tt=a.n(Z),et=a("pFYg"),at=a.n(et),nt=a("mvHQ"),st=a.n(nt),rt={shallow_copy:function(t){return JSON.parse(st()(t))},shallow_copy_object:function(t){var e={};for(var a in t)"object"==at()(t[a])?e[a]=this.shallow_copy_object(tt()({},t[a])):e[a]=t[a];return e},compare_object:function(t,e){return st()(t)===st()(e)},install:function(t){t.prototype.$util=this}},it=a("woOf"),ot=a.n(it),lt=a("//Fk"),ct=a.n(lt),ut=a("mtWM"),dt=a.n(ut),ft=function(t){mt[t.response.status]&&mt[t.response.status](t)},mt={422:function(t){var e=t.response.data.msg;p.dispatch("ALERT_MESSAGE",{text:e,type:"error"})},400:function(t){var e=t.response.data.msg;p.dispatch("ALERT_MESSAGE",{text:e,type:"error"})},401:function(t){p.dispatch("ALERT_MESSAGE",{text:"登录已过期,请重新登录",type:"warning"}),W.push("/login")},405:function(t){},500:function(t){}},_t={baseURL:window.config.API_BASE_URL},pt=dt.a.create(_t);pt.interceptors.request.use(function(t){return 0!=t.auth&&p.state.user.token&&(t.headers.Authorization="Bearer "+p.state.user.token),t},function(t){return ct.a.reject(t)}),pt.interceptors.response.use(function(t){return t},function(t){return ft(t),ct.a.reject(t)});var ht=pt,vt=ot()({install:function(t){t.prototype.$request=this}},ht),gt=(window.config,{get_user_info:function(){return vt.get("user/info")},get_menus:function(){return vt.get("app/menus")},get_actions:function(){return vt.get("app/actions")},login:function(t){return vt.post("login",t)},get_users:function(){return vt.get("users")},get_log_realtime:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return vt.get("log/output",{params:t})},get_query:function(){return vt.get("query")},get_dashboard:function(){return vt.get("stat/dashboard")},get_readme:function(){return vt.get("https://raw.githubusercontent.com/pjialin/py12306/master/README.md",{auth:!1,responseType:"text"})}}),bt=ot()(gt,{install:function(t){t.prototype.$api=this}}),Ct={render:function(){var t=this.$createElement,e=this._self._c||t;return e("div",{attrs:{id:"app"}},[e("router-view")],1)},staticRenderFns:[]};var wt=a("VU/8")({name:"App"},Ct,!1,function(t){a("xcaL")},null,null).exports;o.default.use(c.a),o.default.use(rt),o.default.use(bt),o.default.config.productionTip=!1,new o.default({el:"#app",router:W,store:p,components:{App:wt},template:""})},VjHN:function(t,e){},YlHp:function(t,e){},"a7/l":function(t,e){},aAyn:function(t,e){},dXVw:function(t,e){},lIdD:function(t,e){},"n//Q":function(t,e){},pr7r:function(t,e){},tvR6:function(t,e){},xcaL:function(t,e){}},["NHnr"]); -------------------------------------------------------------------------------- /py12306/web/static/js/manifest.82f431004cf9bb6ad2cb.js: -------------------------------------------------------------------------------- 1 | !function(r){var n=window.webpackJsonp;window.webpackJsonp=function(e,u,c){for(var f,i,p,a=0,l=[];a