Compare commits
183 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee0d045845 | ||
|
|
65fbb6fb5e | ||
|
|
1dee214ec1 | ||
|
|
64c485e1d8 | ||
|
|
daf0b7466c | ||
|
|
8bfe1e75d2 | ||
|
|
4ff5bacd13 | ||
|
|
390fcecdd2 | ||
|
|
12581dc03d | ||
|
|
095dbfc21d | ||
|
|
eee53461cf | ||
|
|
95e9e8a89b | ||
|
|
998b387875 | ||
|
|
9a42e0e8d7 | ||
|
|
681157c40b | ||
|
|
4468aa2277 | ||
|
|
c6ec668fad | ||
|
|
3a795492de | ||
|
|
006fd48d71 | ||
|
|
ee50ad213d | ||
|
|
6c7c83e53d | ||
|
|
6113b8519f | ||
|
|
a3286fc2c2 | ||
|
|
5bcb078124 | ||
|
|
7ec0cbd235 | ||
|
|
9531f774c2 | ||
|
|
e3038a3f21 | ||
|
|
1ddd6cdd69 | ||
|
|
a258780185 | ||
|
|
5d0b14ce85 | ||
|
|
db7e88a6a6 | ||
|
|
0578f2e3a8 | ||
|
|
3b9b15deba | ||
|
|
91c0a44a32 | ||
|
|
f377c7a849 | ||
|
|
7136474a6b | ||
|
|
563b051799 | ||
|
|
3a31349662 | ||
|
|
cc67987bb3 | ||
|
|
64a52559f3 | ||
|
|
4966d45f96 | ||
|
|
cb8e0aada6 | ||
|
|
6e8cd9f902 | ||
|
|
8f984cd751 | ||
|
|
0bd6417ff4 | ||
|
|
619a96ed5d | ||
|
|
83cc5e6d6c | ||
|
|
b848a49720 | ||
|
|
707fd5bd8a | ||
|
|
8cabf157f6 | ||
|
|
eeea27403c | ||
|
|
4ac5fc3403 | ||
|
|
e56b545392 | ||
|
|
1e3ea59bb6 | ||
|
|
42f2a640f6 | ||
|
|
a3f185e3a8 | ||
|
|
ad5e071080 | ||
|
|
2578be4965 | ||
|
|
365453e0b3 | ||
|
|
733d512f72 | ||
|
|
c430a65cdd | ||
|
|
5a97d11a09 | ||
|
|
df7467e18d | ||
|
|
969bc99e87 | ||
|
|
9c63063d50 | ||
|
|
08ad7598db | ||
|
|
afd8c35afb | ||
|
|
419495ce84 | ||
|
|
6a17dab7b0 | ||
|
|
55fb26176a | ||
|
|
61961e515c | ||
|
|
fe5e82f2ff | ||
|
|
dac614663d | ||
|
|
9476052000 | ||
|
|
1431c577f9 | ||
|
|
0908da55c5 | ||
|
|
c71547e3db | ||
|
|
91763f3141 | ||
|
|
5a1e34b6fa | ||
|
|
1a617db658 | ||
|
|
e8abbd57bb | ||
|
|
cc3426c05f | ||
|
|
7762c88db6 | ||
|
|
51ab20f7cf | ||
|
|
bdc670a86d | ||
|
|
7c21310ea3 | ||
|
|
7863cd2863 | ||
|
|
a9287eb7e8 | ||
|
|
bef3e95cec | ||
|
|
f00f1922a5 | ||
|
|
e91ad184bb | ||
|
|
556a8fc7c3 | ||
|
|
bfff29cd67 | ||
|
|
98e5d8f95a | ||
|
|
8032422f7d | ||
|
|
a6081ec0b5 | ||
|
|
0681cef3db | ||
|
|
eb8d1f5a97 | ||
|
|
3e7616ccea | ||
|
|
a21bf2264e | ||
|
|
1c4e832d87 | ||
|
|
2f98504328 | ||
|
|
0da42b9f26 | ||
|
|
1387cbede0 | ||
|
|
59f5b8cb49 | ||
|
|
b46ca021a9 | ||
|
|
3b3dd8337d | ||
|
|
059e5b5d1c | ||
|
|
0524f5fc27 | ||
|
|
682613ca31 | ||
|
|
2f61002562 | ||
|
|
c2ce769a92 | ||
|
|
ddfd38e0a9 | ||
|
|
af71d0a921 | ||
|
|
1c98e2743a | ||
|
|
d4d4ee2139 | ||
|
|
8df3c4032d | ||
|
|
884b0f30db | ||
|
|
f3b0cbee24 | ||
|
|
0d52efb1da | ||
|
|
4bd5cdb89e | ||
|
|
bedeb20085 | ||
|
|
00d5ea4be6 | ||
|
|
2c9fa98efc | ||
|
|
901864e12f | ||
|
|
09ba6fef40 | ||
|
|
25d7169afe | ||
|
|
ae70de23ae | ||
|
|
45d3e91391 | ||
|
|
80990eb02a | ||
|
|
95925ba9e7 | ||
|
|
8109d87c5f | ||
|
|
dbe98ca867 | ||
|
|
ff8a09e3fb | ||
|
|
559dabe0b5 | ||
|
|
d0b61852d3 | ||
|
|
ef7ec01329 | ||
|
|
550c87d77a | ||
|
|
26d0f5a1de | ||
|
|
88ddc87f03 | ||
|
|
eb167538d2 | ||
|
|
047024d239 | ||
|
|
751c01298f | ||
|
|
ea10558bff | ||
|
|
e252a41fd3 | ||
|
|
994c211e2f | ||
|
|
54eb32d40f | ||
|
|
2396a40911 | ||
|
|
a3e1544072 | ||
|
|
e722eacbb8 | ||
|
|
3c982e4ac4 | ||
|
|
9b46ee8a85 | ||
|
|
0dc4eee1a8 | ||
|
|
cb4fd195a1 | ||
|
|
a0f2bc7913 | ||
|
|
6004d78e90 | ||
|
|
bcab8c4e30 | ||
|
|
41f4628eb9 | ||
|
|
df41b8d8c2 | ||
|
|
111541a1e8 | ||
|
|
48a1f6a06a | ||
|
|
a4b355bdf1 | ||
|
|
6c8d5e3142 | ||
|
|
c52d91c5e8 | ||
|
|
e510de878d | ||
|
|
e17b48a0ce | ||
|
|
1be623032f | ||
|
|
0670e19b05 | ||
|
|
a05f1a910a | ||
|
|
c3f3ba9ffc | ||
|
|
0d7558afeb | ||
|
|
f47368206c | ||
|
|
632256caf7 | ||
|
|
668c4ae8ce | ||
|
|
7d5b8e2b80 | ||
|
|
8f681c5e30 | ||
|
|
6d18a8d11b | ||
|
|
6dc3005cd9 | ||
|
|
a96e08efeb | ||
|
|
a6feda0a41 | ||
|
|
2a734bdfe6 | ||
|
|
8bcbaf4cde | ||
|
|
5b296694a9 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,10 @@
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
venv
|
||||
__pycache__
|
||||
env.py
|
||||
env.py
|
||||
docker-compose.yml
|
||||
config.toml
|
||||
config_test.toml
|
||||
data/db*
|
||||
16
CHANGELOG.md
Normal file
16
CHANGELOG.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 更新日志
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [V2.0.0] - 2019-12-31
|
||||
### Added
|
||||
- 重构代码结构
|
||||
- 支持使用 sqlite, mysql 数据库
|
||||
- 支持使用代理进行余票查询
|
||||
- 添加小黑屋支持,避免重复下单
|
||||
- 添加 Pipenv 支持
|
||||
- 添加单元测试用例
|
||||
|
||||
### Removed
|
||||
- 移除 CDN 支持
|
||||
- 移除 Web 界面(待重写)
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.6.6-slim
|
||||
|
||||
MAINTAINER <pjialin admin@pjialin.com>
|
||||
ENV TZ Asia/Shanghai
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
RUN mkdir -p /data/query /data/user
|
||||
VOLUME /data
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY config.toml.example config.toml
|
||||
|
||||
CMD [ "python", "main.py"]
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
18
Pipfile
Normal file
18
Pipfile
Normal file
@@ -0,0 +1,18 @@
|
||||
[[source]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
verify_ssl = true
|
||||
|
||||
[dev-packages]
|
||||
ipython = "*"
|
||||
|
||||
[packages]
|
||||
toml = "*"
|
||||
redis = "*"
|
||||
tortoise-orm = "==0.15.4"
|
||||
aiohttp = "*"
|
||||
aioredis = "*"
|
||||
aiomysql = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.6"
|
||||
415
Pipfile.lock
generated
Normal file
415
Pipfile.lock
generated
Normal file
@@ -0,0 +1,415 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "66b4286bb6eb49e7de326a6a02aa8bec298691068e41d9b73dd28ab3e6c044df"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.6"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"aiohttp": {
|
||||
"hashes": [
|
||||
"sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e",
|
||||
"sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326",
|
||||
"sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a",
|
||||
"sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654",
|
||||
"sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a",
|
||||
"sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4",
|
||||
"sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17",
|
||||
"sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec",
|
||||
"sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd",
|
||||
"sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48",
|
||||
"sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59",
|
||||
"sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.6.2"
|
||||
},
|
||||
"aiomysql": {
|
||||
"hashes": [
|
||||
"sha256:5fd798481f16625b424eec765c56d712ac78a51f3bd0175a3de94107aae43307",
|
||||
"sha256:d89ce25d44dadb43cf2d9e4603bd67b7a0ad12d5e67208de013629ba648df2ba"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.0.20"
|
||||
},
|
||||
"aioredis": {
|
||||
"hashes": [
|
||||
"sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a",
|
||||
"sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.1"
|
||||
},
|
||||
"aiosqlite": {
|
||||
"hashes": [
|
||||
"sha256:4f02314a42db6722dc26f2a6119c64e3f05f141f57bbf2b1e1f9fd741b6d7fb8"
|
||||
],
|
||||
"version": "==0.11.0"
|
||||
},
|
||||
"async-timeout": {
|
||||
"hashes": [
|
||||
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
|
||||
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
|
||||
],
|
||||
"version": "==3.0.1"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
|
||||
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
|
||||
],
|
||||
"version": "==19.3.0"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42",
|
||||
"sha256:0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04",
|
||||
"sha256:135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5",
|
||||
"sha256:19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54",
|
||||
"sha256:2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba",
|
||||
"sha256:291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57",
|
||||
"sha256:2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396",
|
||||
"sha256:2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12",
|
||||
"sha256:32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97",
|
||||
"sha256:3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43",
|
||||
"sha256:415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db",
|
||||
"sha256:42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3",
|
||||
"sha256:4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b",
|
||||
"sha256:4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579",
|
||||
"sha256:599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346",
|
||||
"sha256:5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159",
|
||||
"sha256:5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652",
|
||||
"sha256:62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e",
|
||||
"sha256:6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a",
|
||||
"sha256:6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506",
|
||||
"sha256:71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f",
|
||||
"sha256:74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d",
|
||||
"sha256:7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c",
|
||||
"sha256:7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20",
|
||||
"sha256:7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858",
|
||||
"sha256:8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc",
|
||||
"sha256:aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a",
|
||||
"sha256:ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3",
|
||||
"sha256:d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e",
|
||||
"sha256:d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410",
|
||||
"sha256:dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25",
|
||||
"sha256:e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b",
|
||||
"sha256:fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d"
|
||||
],
|
||||
"version": "==1.13.2"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"ciso8601": {
|
||||
"hashes": [
|
||||
"sha256:307342e8bb362ae41a3f3a089c11b374116823bce6fbe5d784e2a2dc37f2c753"
|
||||
],
|
||||
"version": "==2.1.2"
|
||||
},
|
||||
"cryptography": {
|
||||
"hashes": [
|
||||
"sha256:02079a6addc7b5140ba0825f542c0869ff4df9a69c360e339ecead5baefa843c",
|
||||
"sha256:1df22371fbf2004c6f64e927668734070a8953362cd8370ddd336774d6743595",
|
||||
"sha256:369d2346db5934345787451504853ad9d342d7f721ae82d098083e1f49a582ad",
|
||||
"sha256:3cda1f0ed8747339bbdf71b9f38ca74c7b592f24f65cdb3ab3765e4b02871651",
|
||||
"sha256:44ff04138935882fef7c686878e1c8fd80a723161ad6a98da31e14b7553170c2",
|
||||
"sha256:4b1030728872c59687badcca1e225a9103440e467c17d6d1730ab3d2d64bfeff",
|
||||
"sha256:58363dbd966afb4f89b3b11dfb8ff200058fbc3b947507675c19ceb46104b48d",
|
||||
"sha256:6ec280fb24d27e3d97aa731e16207d58bd8ae94ef6eab97249a2afe4ba643d42",
|
||||
"sha256:7270a6c29199adc1297776937a05b59720e8a782531f1f122f2eb8467f9aab4d",
|
||||
"sha256:73fd30c57fa2d0a1d7a49c561c40c2f79c7d6c374cc7750e9ac7c99176f6428e",
|
||||
"sha256:7f09806ed4fbea8f51585231ba742b58cbcfbfe823ea197d8c89a5e433c7e912",
|
||||
"sha256:90df0cc93e1f8d2fba8365fb59a858f51a11a394d64dbf3ef844f783844cc793",
|
||||
"sha256:971221ed40f058f5662a604bd1ae6e4521d84e6cad0b7b170564cc34169c8f13",
|
||||
"sha256:a518c153a2b5ed6b8cc03f7ae79d5ffad7315ad4569b2d5333a13c38d64bd8d7",
|
||||
"sha256:b0de590a8b0979649ebeef8bb9f54394d3a41f66c5584fff4220901739b6b2f0",
|
||||
"sha256:b43f53f29816ba1db8525f006fa6f49292e9b029554b3eb56a189a70f2a40879",
|
||||
"sha256:d31402aad60ed889c7e57934a03477b572a03af7794fa8fb1780f21ea8f6551f",
|
||||
"sha256:de96157ec73458a7f14e3d26f17f8128c959084931e8997b9e655a39c8fde9f9",
|
||||
"sha256:df6b4dca2e11865e6cfbfb708e800efb18370f5a46fd601d3755bc7f85b3a8a2",
|
||||
"sha256:ecadccc7ba52193963c0475ac9f6fa28ac01e01349a2ca48509667ef41ffd2cf",
|
||||
"sha256:fb81c17e0ebe3358486cd8cc3ad78adbae58af12fc2bf2bc0bb84e8090fa5ce8"
|
||||
],
|
||||
"version": "==2.8"
|
||||
},
|
||||
"hiredis": {
|
||||
"hashes": [
|
||||
"sha256:01b577f84c20ecc9c07fc4c184231b08e3c3942de096fa99978e053de231c423",
|
||||
"sha256:01ff0900134166961c9e339df77c33b72f7edc5cb41739f0babcd9faa345926e",
|
||||
"sha256:03ed34a13316d0c34213c4fd46e0fa3a5299073f4d4f08e93fed8c2108b399b3",
|
||||
"sha256:040436e91df5143aff9e0debb49530d0b17a6bd52200ce568621c31ef581b10d",
|
||||
"sha256:091eb38fbf968d1c5b703e412bbbd25f43a7967d8400842cee33a5a07b33c27b",
|
||||
"sha256:102f9b9dc6ed57feb3a7c9bdf7e71cb7c278fe8df1edfcfe896bc3e0c2be9447",
|
||||
"sha256:2b4b392c7e3082860c8371fab3ae762139090f9115819e12d9f56060f9ede05d",
|
||||
"sha256:2c9cc0b986397b833073f466e6b9e9c70d1d4dc2c2c1b3e9cae3a23102ff296c",
|
||||
"sha256:2fa65a9df683bca72073cd77709ddeb289ea2b114d3775d225fbbcc5faf808c5",
|
||||
"sha256:38437a681f17c975fd22349e72c29bc643f8e7eb2d6dc5df419eac59afa4d7ce",
|
||||
"sha256:3b3428fa3cf1ee178807b52c9bee8950ab94cd4eaa9bfae8c1bbae3c49501d34",
|
||||
"sha256:3dd8c2fae7f5494978facb0e93297dd627b1a3f536f3b070cf0a7d9157a07dcb",
|
||||
"sha256:4414a96c212e732723b5c3d7c04d386ebbb2ec359e1de646322cbc3f875cbd0d",
|
||||
"sha256:48c627581ad4ef60adbac980981407939acf13a0e18f093502c7b542223c4f19",
|
||||
"sha256:4a60e71625a2d78d8ab84dfb2fa2cfd9458c964b6e6c04fea76d9ade153fb371",
|
||||
"sha256:585ace09f434e43d8a8dbeb366865b1a044d7c06319b3c7372a0a00e63b860f4",
|
||||
"sha256:74b364b3f06c9cf0a53f7df611045bc9437ed972a283fa1f0b12537236d23ddc",
|
||||
"sha256:75c65c3850e89e9daa68d1b9bedd5806f177d60aa5a7b0953b4829481cfc1f72",
|
||||
"sha256:7f052de8bf744730a9120dbdc67bfeb7605a01f69fb8e7ba5c475af33c24e145",
|
||||
"sha256:8113a7d5e87ecf57cd4ae263cc9e429adb9a3e59f5a7768da5d3312a8d0a051a",
|
||||
"sha256:84857ce239eb8ed191ac78e77ff65d52902f00f30f4ee83bf80eb71da73b70e6",
|
||||
"sha256:8644a48ddc4a40b3e3a6b9443f396c2ee353afb2d45656c4fc68d04a82e8e3f7",
|
||||
"sha256:936aa565e673536e8a211e43ec43197406f24cd1f290138bd143765079c8ba00",
|
||||
"sha256:9afeb88c67bbc663b9f27385c496da056d06ad87f55df6e393e1516cfecb0461",
|
||||
"sha256:9d62cc7880110e4f83b0a51d218f465d3095e2751fbddd34e553dbd106a929ff",
|
||||
"sha256:a1fadd062fc8d647ff39220c57ea2b48c99bb73f18223828ec97f88fc27e7898",
|
||||
"sha256:a7754a783b1e5d6f627c19d099b178059c62f782ab62b4d8ba165b9fbc2ee34c",
|
||||
"sha256:aa59dd63bb3f736de4fc2d080114429d5d369dfb3265f771778e8349d67a97a4",
|
||||
"sha256:ae2ee0992f8de249715435942137843a93db204dd7db1e7cc9bdc5a8436443e8",
|
||||
"sha256:b36842d7cf32929d568f37ec5b3173b72b2ec6572dec4d6be6ce774762215aee",
|
||||
"sha256:bcbf9379c553b5facc6c04c1e5569b44b38ff16bcbf354676287698d61ee0c92",
|
||||
"sha256:cbccbda6f1c62ab460449d9c85fdf24d0d32a6bf45176581151e53cc26a5d910",
|
||||
"sha256:d0caf98dfb8af395d6732bd16561c0a2458851bea522e39f12f04802dbf6f502",
|
||||
"sha256:d6456afeddba036def1a36d8a2758eca53202308d83db20ab5d0b66590919627",
|
||||
"sha256:dbaef9a21a4f10bc281684ee4124f169e62bb533c2a92b55f8c06f64f9af7b8f",
|
||||
"sha256:dce84916c09aaece006272b37234ae84a8ed13abb3a4d341a23933b8701abfb5",
|
||||
"sha256:eb8c9c8b9869539d58d60ff4a28373a22514d40495911451343971cb4835b7a9",
|
||||
"sha256:efc98b14ee3a8595e40b1425e8d42f5fd26f11a7b215a81ef9259068931754f4",
|
||||
"sha256:fa2dc05b87d97acc1c6ae63f3e0f39eae5246565232484b08db6bf2dc1580678",
|
||||
"sha256:fe7d6ce9f6a5fbe24f09d95ea93e9c7271abc4e1565da511e1449b107b4d7848"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
||||
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
|
||||
],
|
||||
"version": "==2.8"
|
||||
},
|
||||
"multidict": {
|
||||
"hashes": [
|
||||
"sha256:0f04bf4c15d8417401a10a650c349ccc0285943681bfd87d3690587d7714a9b4",
|
||||
"sha256:15a61c0df2d32487e06f6084eabb48fd9e8b848315e397781a70caf9670c9d78",
|
||||
"sha256:3c5e2dcbe6b04cbb4303e47a896757a77b676c5e5db5528be7ff92f97ba7ab95",
|
||||
"sha256:5d2b32b890d9e933d3ced417924261802a857abdee9507b68c75014482145c03",
|
||||
"sha256:5e5fb8bfebf87f2e210306bf9dd8de2f1af6782b8b78e814060ae9254ab1f297",
|
||||
"sha256:63ba2be08d82ea2aa8b0f7942a74af4908664d26cb4ff60c58eadb1e33e7da00",
|
||||
"sha256:73740fcdb38f0adcec85e97db7557615b50ec4e5a3e73e35878720bcee963382",
|
||||
"sha256:78bed18e7f1eb21f3d10ff3acde900b4d630098648fe1d65bb4abfb3e22c4900",
|
||||
"sha256:a02fade7b5476c4f88efe9593ff2f3286698d8c6d715ba4f426954f73f382026",
|
||||
"sha256:aacbde3a8875352a640efa2d1b96e5244a29b0f8df79cbf1ec6470e86fd84697",
|
||||
"sha256:be813fb9e5ce41a5a99a29cdb857144a1bd6670883586f995b940a4878dc5238",
|
||||
"sha256:bfcad6da0b8839f01a819602aaa5c5a5b4c85ecbfae9b261a31df3d9262fb31e",
|
||||
"sha256:c2bfc0db3166e68515bc4a2b9164f4f75ae9c793e9635f8651f2c9ffc65c8dad",
|
||||
"sha256:c66d11870ae066499a3541963e6ce18512ca827c2aaeaa2f4e37501cee39ac5d",
|
||||
"sha256:cc7f2202b753f880c2e4123f9aacfdb94560ba893e692d24af271dac41f8b8d9",
|
||||
"sha256:d1f45e5bb126662ba66ee579831ce8837b1fd978115c9657e32eb3c75b92973d",
|
||||
"sha256:ed5f3378c102257df9e2dc9ce6468dabf68bee9ec34969cfdc472631aba00316"
|
||||
],
|
||||
"version": "==4.7.3"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3",
|
||||
"sha256:fdbae4e6f505ece28ab3b96f5c103a1d87e42845d249c33a097f14abb2bdad3e"
|
||||
],
|
||||
"version": "==2.19"
|
||||
},
|
||||
"pymysql": {
|
||||
"hashes": [
|
||||
"sha256:95f057328357e0e13a30e67857a8c694878b0175797a9a203ee7adbfb9b1ec5f",
|
||||
"sha256:9ec760cbb251c158c19d6c88c17ca00a8632bac713890e465b2be01fdc30713f"
|
||||
],
|
||||
"version": "==0.9.2"
|
||||
},
|
||||
"pypika": {
|
||||
"hashes": [
|
||||
"sha256:a4d80829a065047c39b7838aafb440761bd9a306977c0e6f78caf5eaa056c9e4"
|
||||
],
|
||||
"version": "==0.35.18"
|
||||
},
|
||||
"redis": {
|
||||
"hashes": [
|
||||
"sha256:3613daad9ce5951e426f460deddd5caf469e08a3af633e9578fc77d362becf62",
|
||||
"sha256:8d0fc278d3f5e1249967cba2eb4a5632d19e45ce5c09442b8422d15ee2c22cc2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.3.11"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
|
||||
"sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
|
||||
],
|
||||
"version": "==1.13.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
|
||||
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"tortoise-orm": {
|
||||
"hashes": [
|
||||
"sha256:90d1c4cc2038bcfd8c436106a2a5e667354208b2f4db8a63b96a05e6125e73f5"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.15.4"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2",
|
||||
"sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d",
|
||||
"sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"
|
||||
],
|
||||
"version": "==3.7.4.1"
|
||||
},
|
||||
"yarl": {
|
||||
"hashes": [
|
||||
"sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce",
|
||||
"sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6",
|
||||
"sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce",
|
||||
"sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae",
|
||||
"sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d",
|
||||
"sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f",
|
||||
"sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b",
|
||||
"sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b",
|
||||
"sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb",
|
||||
"sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462",
|
||||
"sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea",
|
||||
"sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70",
|
||||
"sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1",
|
||||
"sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a",
|
||||
"sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b",
|
||||
"sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080",
|
||||
"sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"
|
||||
],
|
||||
"version": "==1.4.2"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"appnope": {
|
||||
"hashes": [
|
||||
"sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0",
|
||||
"sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"
|
||||
],
|
||||
"markers": "sys_platform == 'darwin'",
|
||||
"version": "==0.1.0"
|
||||
},
|
||||
"backcall": {
|
||||
"hashes": [
|
||||
"sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4",
|
||||
"sha256:bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2"
|
||||
],
|
||||
"version": "==0.1.0"
|
||||
},
|
||||
"decorator": {
|
||||
"hashes": [
|
||||
"sha256:54c38050039232e1db4ad7375cfce6748d7b41c29e95a081c8a6d2c30364a2ce",
|
||||
"sha256:5d19b92a3c8f7f101c8dd86afd86b0f061a8ce4540ab8cd401fa2542756bce6d"
|
||||
],
|
||||
"version": "==4.4.1"
|
||||
},
|
||||
"ipython": {
|
||||
"hashes": [
|
||||
"sha256:0b67031a659fd7046cbf8c4efc2a0d514ee050434dfede76f540cfa6f806f65e",
|
||||
"sha256:cc70e2111d1629e31ef9ff445529f8bb2b4fc4f93bfe140c33fe18cf36fde072"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==7.11.0"
|
||||
},
|
||||
"ipython-genutils": {
|
||||
"hashes": [
|
||||
"sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8",
|
||||
"sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"
|
||||
],
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"jedi": {
|
||||
"hashes": [
|
||||
"sha256:1349c1e8c107095a55386628bb3b2a79422f3a2cab8381e34ce19909e0cf5064",
|
||||
"sha256:e909527104a903606dd63bea6e8e888833f0ef087057829b89a18364a856f807"
|
||||
],
|
||||
"version": "==0.15.2"
|
||||
},
|
||||
"parso": {
|
||||
"hashes": [
|
||||
"sha256:55cf25df1a35fd88b878715874d2c4dc1ad3f0eebd1e0266a67e1f55efccfbe1",
|
||||
"sha256:5c1f7791de6bd5dbbeac8db0ef5594b36799de198b3f7f7014643b0c5536b9d3"
|
||||
],
|
||||
"version": "==0.5.2"
|
||||
},
|
||||
"pexpect": {
|
||||
"hashes": [
|
||||
"sha256:2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1",
|
||||
"sha256:9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb"
|
||||
],
|
||||
"markers": "sys_platform != 'win32'",
|
||||
"version": "==4.7.0"
|
||||
},
|
||||
"pickleshare": {
|
||||
"hashes": [
|
||||
"sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca",
|
||||
"sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"
|
||||
],
|
||||
"version": "==0.7.5"
|
||||
},
|
||||
"prompt-toolkit": {
|
||||
"hashes": [
|
||||
"sha256:0278d2f51b5ceba6ea8da39f76d15684e84c996b325475f6e5720edc584326a7",
|
||||
"sha256:63daee79aa8366c8f1c637f1a4876b890da5fc92a19ebd2f7080ebacb901e990"
|
||||
],
|
||||
"version": "==3.0.2"
|
||||
},
|
||||
"ptyprocess": {
|
||||
"hashes": [
|
||||
"sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0",
|
||||
"sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"
|
||||
],
|
||||
"version": "==0.6.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
|
||||
"sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
|
||||
],
|
||||
"version": "==2.5.2"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
|
||||
"sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
|
||||
],
|
||||
"version": "==1.13.0"
|
||||
},
|
||||
"traitlets": {
|
||||
"hashes": [
|
||||
"sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44",
|
||||
"sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"
|
||||
],
|
||||
"version": "==4.3.3"
|
||||
},
|
||||
"wcwidth": {
|
||||
"hashes": [
|
||||
"sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",
|
||||
"sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"
|
||||
],
|
||||
"version": "==0.1.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
159
README.md
Normal file
159
README.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 🚂 py12306 购票助手
|
||||
分布式,多账号,多任务购票
|
||||
|
||||
## Features
|
||||
- [x] 多日期查询余票
|
||||
- [x] 自动打码下单
|
||||
- [x] 用户状态恢复
|
||||
- [x] 电话语音通知
|
||||
- [x] 多账号、多任务、多线程支持
|
||||
- [x] 单个任务多站点查询
|
||||
- [x] 分布式运行
|
||||
- [x] Docker 支持
|
||||
- [x] 动态修改配置文件
|
||||
- [x] 邮件通知
|
||||
- [x] Web 管理页面
|
||||
- [x] 微信消息通知
|
||||
- [ ] 代理池支持 ([pyproxy-async](https://github.com/pjialin/pyproxy-async))
|
||||
|
||||
## 使用
|
||||
py12306 需要运行在 python 3.6 以上版本(其它版本暂未测试)
|
||||
|
||||
**1. 安装依赖**
|
||||
```bash
|
||||
git clone https://github.com/pjialin/py12306
|
||||
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**2. 配置程序**
|
||||
```bash
|
||||
cp env.py.example env.py
|
||||
```
|
||||
自动打码
|
||||
|
||||
(若快已停止服务,目前只能设置**free**打码模式)
|
||||
free 已对接到打码共享平台,[https://py12306-helper.pjialin.com](https://py12306-helper.pjialin.com/),欢迎参与分享
|
||||
|
||||
语音通知
|
||||
|
||||
语音验证码使用的是阿里云 API 市场上的一个服务商,需要到 [https://market.aliyun.com/products/56928004/cmapi026600.html](https://market.aliyun.com/products/56928004/cmapi026600.html) 购买后将 appcode 填写到配置中
|
||||
|
||||
**3. 启动前测试**
|
||||
|
||||
目前提供了一些简单的测试,包括用户账号检测,乘客信息检测,车站检测等
|
||||
|
||||
开始测试 -t
|
||||
```bash
|
||||
python main.py -t
|
||||
```
|
||||
|
||||
测试通知消息 (语音, 邮件) -t -n
|
||||
```bash
|
||||
# 默认不会进行通知测试,要对通知进行测试需要加上 -n 参数
|
||||
python main.py -t -n
|
||||
```
|
||||
|
||||
**4. 运行程序**
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 参数列表
|
||||
|
||||
- -t 测试配置信息
|
||||
- -t -n 测试配置信息以及通知消息
|
||||
- -c 指定自定义配置文件位置
|
||||
|
||||
### 分布式集群
|
||||
|
||||
集群依赖于 redis,目前支持情况
|
||||
- 单台主节点多个子节点同时运行
|
||||
- 主节点宕机后自动切换提升子节点为主节点
|
||||
- 主节点恢复后自动恢复为真实主节点
|
||||
- 配置通过主节点同步到所有子节点
|
||||
- 主节点配置修改后无需重启子节点,支持自动更新
|
||||
- 子节点消息实时同步到主节点
|
||||
|
||||
**使用**
|
||||
|
||||
将配置文件的中 `CLUSTER_ENABLED` 打开即开启分布式
|
||||
|
||||
目前提供了一个单独的子节点配置文件 `env.slave.py.example` 将文件修改为 `env.slave.py`, 通过 `python main.py -c env.slave.py` 即可快速启动
|
||||
|
||||
|
||||
## Docker 使用
|
||||
**1. 将配置文件下载到本地**
|
||||
```bash
|
||||
docker run --rm pjialin/py12306 cat /config/env.py > env.py
|
||||
# 或
|
||||
curl https://raw.githubusercontent.com/pjialin/py12306/master/env.docker.py.example -o env.py
|
||||
```
|
||||
|
||||
**2. 修改好配置后运行**
|
||||
```bash
|
||||
docker run --rm --name py12306 -p 8008:8008 -d -v $(pwd):/config -v py12306:/data pjialin/py12306
|
||||
```
|
||||
当前目录会多一个 12306.log 的日志文件, `tail -f 12306.log`
|
||||
|
||||
### Docker-compose 中使用
|
||||
**1. 复制配置文件**
|
||||
```
|
||||
cp docker-compose.yml.example docker-compose.yml
|
||||
```
|
||||
|
||||
**2. 从 docker-compose 运行**
|
||||
|
||||
在`docker-compose.yml`所在的目录使用命令
|
||||
```
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Web 管理页面
|
||||
|
||||
目前支持用户和任务以及实时日志查看,更多功能后续会不断加入
|
||||
|
||||
**使用**
|
||||
|
||||
打开 Web 功能需要将配置中的 `WEB_ENABLE` 打开,启动程序后访问当前主机地址 + 端口号 (默认 8008) 即可,如 http://127.0.0.1:8008
|
||||
|
||||
## 更新
|
||||
- 19-01-10
|
||||
- 支持分布式集群
|
||||
- 19-01-11
|
||||
- 配置文件支持动态修改
|
||||
- 19-01-12
|
||||
- 新增免费打码
|
||||
- 19-01-14
|
||||
- 新增 Web 页面支持
|
||||
- 19-01-15
|
||||
- 新增 钉钉通知
|
||||
- 新增 Telegram 通知
|
||||
- 新增 ServerChan 和 PushBear 微信推送
|
||||
- 19-01-18
|
||||
- 新增 CDN 查询
|
||||
|
||||
## 截图
|
||||
### Web 管理页面
|
||||

|
||||
|
||||
### 下单成功
|
||||

|
||||
|
||||
### 关于防封
|
||||
目前查询和登录操作是分开的,查询是不依赖用户是否登录,放在 A 云 T 云容易被限制 ip,建议在其它网络环境下运行
|
||||
|
||||
QQ 交流群 [780289875](https://jq.qq.com/?_wv=1027&k=5PgzDwV),TG 群 [Py12306 交流](https://t.me/joinchat/F3sSegrF3x8KAmsd1mTu7w)
|
||||
|
||||
### Online IDE
|
||||
[](https://gitpod.io#https://github.com/pjialin/py12306)
|
||||
|
||||
## Thanks
|
||||
- 感谢大佬 [testerSunshine](https://github.com/testerSunshine/12306),借鉴了部分实现
|
||||
- 感谢所有提供 pr 的大佬
|
||||
- 感谢大佬 [zhaipro](https://github.com/zhaipro/easy12306) 的验证码本地识别模型与算法
|
||||
|
||||
## License
|
||||
|
||||
[Apache License.](https://github.com/pjialin/py12306/blob/master/LICENSE)
|
||||
|
||||
90
config.toml.example
Normal file
90
config.toml.example
Normal file
@@ -0,0 +1,90 @@
|
||||
[app]
|
||||
env = 'dev'
|
||||
debug = true
|
||||
query_interval = 1.5 # 查询间隔
|
||||
|
||||
[web]
|
||||
# Comming soon.
|
||||
ip = '0.0.0.0'
|
||||
port = 8081
|
||||
|
||||
[db]
|
||||
engine = 'sqlite' # 默认使用 sqlite,支持 mysql
|
||||
# host = '127.0.0.1'
|
||||
# port = 3306
|
||||
# user = 'user'
|
||||
# password = 'password'
|
||||
# database = 'py12306'
|
||||
|
||||
[[user]]
|
||||
enable = true
|
||||
id = 1 # 用户唯一 Id,数字,不可重复
|
||||
name= 'your 12306 account name'
|
||||
password = 'your 12306 password'
|
||||
|
||||
# 简单版本
|
||||
[[query]]
|
||||
id = 1
|
||||
user_id = 1
|
||||
left_dates = ['2020-01-25', '2019-01-26']
|
||||
members = ['宝玉', '宝钗']
|
||||
seats = ['硬卧', '硬座']
|
||||
train_numbers = ['K356', 'K1172', 'K4184']
|
||||
stations = ['北京', '深圳']
|
||||
|
||||
[[query]]
|
||||
enable = true # 是否启用,默认启用
|
||||
id = 2
|
||||
user_id = 0 # 将会使用指定的用户下单
|
||||
left_dates= [ '2020-01-25', '2020-01-26' ] # 出发日期
|
||||
stations = ['北京', '深圳', '北京', '广州'] # 车站 支持多个车站同时查询
|
||||
members = [ '宝玉', '黛玉' ] # 乘客姓名,会根据当前账号自动识别乘客类型 购买儿童票 设置两个相同的姓名即可,程序会自动识别 如 ['贾琏', '贾琏']
|
||||
allow_less_member = false # 是否允许余票不足时提交部分乘客
|
||||
seats = [ '硬卧', '硬座' ] # 筛选座位 有先后顺序 可用值: 一等座, 二等座, 商务座, 特等座, 硬座, 软座, 硬卧, 二等卧, 软卧, 一等卧, 高级软卧, 动卧, 高级动卧, 无座
|
||||
train_numbers = [ 'K356', 'K1172', 'K4184' ] # 筛选车次 可以为空,为空则所有车次都可以提交 如 []
|
||||
except_train_numbers = [] # 筛选车次,排除车次 train_numbers 和 except_train_numbers 不可同时存在
|
||||
periods = ['00:00','24:00'] # 筛选时间
|
||||
|
||||
[redis]
|
||||
enable = false
|
||||
host = '127.0.0.1'
|
||||
port = 6379
|
||||
db = 1
|
||||
password = ''
|
||||
|
||||
[proxy]
|
||||
enable = false
|
||||
rate = 90 # 使用代理的比例
|
||||
concurrent_num = 5
|
||||
url = 'http://127.0.0.1:8081/get_ip?&rule=12306'
|
||||
|
||||
[notifaction.ding_talk]
|
||||
# 钉钉通知 使用说明 https://ding-doc.dingtalk.com/doc#/serverapi2/qf2nxq
|
||||
enable = true
|
||||
access_token = ''
|
||||
|
||||
[notifaction.bark]
|
||||
# Bark 推送到ios设备 参考 https://www.v2ex.com/t/467407
|
||||
enable = false
|
||||
push_url = ''
|
||||
|
||||
[notifaction.email]
|
||||
# 邮箱配置
|
||||
enable = false
|
||||
sender = 'sender@example.com' # 邮件发送至
|
||||
to = 'receiver@example.com' # 邮件接受者 # 可以多个 [email1@gmail.com, email2@gmail.com]
|
||||
host = 'localhost' # 邮件服务 host
|
||||
user = ''
|
||||
password = ''
|
||||
|
||||
[notifaction.server_chan]
|
||||
# ServerChan 使用说明 http://sc.ftqq.com
|
||||
enable = false
|
||||
sckey = ''
|
||||
|
||||
[notifaction.ding_xing_voice]
|
||||
# 语音验证码 # 购买成功后到控制台找到 APPCODE 放在下面就可以了
|
||||
# 鼎信 https://market.aliyun.com/products/56928004/cmapi026600.html?spm=5176.2020520132.101.2.e27e7218KQttQS
|
||||
enable = false
|
||||
appcode = ''
|
||||
phone = '13800138000'
|
||||
BIN
data/images/order_success.png
Normal file
BIN
data/images/order_success.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
data/images/web.png
Normal file
BIN
data/images/web.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
1
data/stationList.json
Executable file
1
data/stationList.json
Executable file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
12
docker-compose.yml.example
Normal file
12
docker-compose.yml.example
Normal file
@@ -0,0 +1,12 @@
|
||||
version: "3.4"
|
||||
services:
|
||||
py12306:
|
||||
build: .
|
||||
volumes:
|
||||
- ./config.toml:/code/config.toml
|
||||
- py12306:/data
|
||||
ports:
|
||||
- 8008:8008
|
||||
|
||||
volumes:
|
||||
dp_py12306:
|
||||
@@ -1,90 +0,0 @@
|
||||
# encoding=utf8
|
||||
|
||||
# 12306 账号
|
||||
USER_ACCOUNTS = [
|
||||
{
|
||||
'key': 0, # 如使用多个账号 key 不能重复
|
||||
'user_name': 'your user name',
|
||||
'password': 'your password'
|
||||
},
|
||||
# {
|
||||
# 'key': 'wangwu',
|
||||
# 'user_name': 'wangwu@qq.com',
|
||||
# 'password': 'wangwu'
|
||||
# }
|
||||
]
|
||||
|
||||
# 查询间隔(指每一个任务中每一个日期的间隔 / 单位秒)
|
||||
# 默认取间隔/2 到 间隔之间的随机数 如设置为 1 间隔则为 0.5 ~ 1 之间的随机数
|
||||
# 接受字典形式 格式: {'min': 0.5, 'max': 1}
|
||||
QUERY_INTERVAL = 1
|
||||
|
||||
# 用户心跳检测间隔 格式同上
|
||||
USER_HEARTBEAT_INTERVAL = 120
|
||||
|
||||
# 多线程查询
|
||||
QUERY_JOB_THREAD_ENABLED = 0 # 是否开启多线程查询,开启后第个任务会单独分配线程处理
|
||||
|
||||
# 打码平台账号
|
||||
# 目前只支持若快打码,注册地址:http://www.ruokuai.com/login
|
||||
AUTO_CODE_ACCOUNT = {
|
||||
'user': 'your user name',
|
||||
'pwd': 'your password'
|
||||
}
|
||||
|
||||
# 语音验证码
|
||||
# 没找到比较好用的,现在用的这个是阿里云 API 市场上的,基本满足要求,价格也便宜
|
||||
# 购买成功后到控制台找到 APPCODE 放在下面就可以了
|
||||
# 地址:https://market.aliyun.com/products/57126001/cmapi019902.html
|
||||
NOTIFICATION_BY_VOICE_CODE = 1 # 开启语音验证码
|
||||
NOTIFICATION_API_APP_CODE = 'your app code'
|
||||
NOTIFICATION_VOICE_CODE_PHONE = 'your phone' # 接受通知的手机号
|
||||
|
||||
# 查询任务
|
||||
QUERY_JOBS = [
|
||||
{
|
||||
'account_key': 0, # 将会使用指定账号下单
|
||||
'left_dates': [ # 出发日期 :Array
|
||||
"2019-01-25",
|
||||
"2019-01-26",
|
||||
],
|
||||
'stations': { # 车站 :Dict
|
||||
'left': '北京',
|
||||
'arrive': '深圳',
|
||||
},
|
||||
'members': [ # 乘客姓名,会根据当前账号自动识别乘客类型 购买儿童票 设置两个相同的姓名即可,程序会自动识别 如 ['张三', '张三']
|
||||
"张三",
|
||||
"王五",
|
||||
],
|
||||
'allow_less_member': 0, # 是否允许余票不足时提交部分乘客
|
||||
'seats': [ # 筛选座位 有先后顺序 :Array
|
||||
# 可用值: 特等座, 商务座, 一等座, 二等座, 软卧, 硬卧, 硬座, 无座
|
||||
'硬卧',
|
||||
'硬座'
|
||||
],
|
||||
'train_numbers': [ # 筛选车次 可以为空,为空则所有车次都可以提交
|
||||
"K356",
|
||||
"K1172",
|
||||
"K4184"
|
||||
]
|
||||
|
||||
},
|
||||
# {
|
||||
# 'left_dates': [
|
||||
# "2019-01-27",
|
||||
# "2019-01-28"
|
||||
# ],
|
||||
# 'stations': {
|
||||
# 'left': '成都',
|
||||
# 'arrive': '广州',
|
||||
# },
|
||||
# 'members': [
|
||||
# "小王",
|
||||
# ],
|
||||
# 'allow_less_member': 0,
|
||||
# 'seats': [
|
||||
# '硬卧',
|
||||
# ],
|
||||
# 'train_numbers': []
|
||||
# }
|
||||
]
|
||||
40
main.py
40
main.py
@@ -1,42 +1,18 @@
|
||||
# encoding=utf8
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
from time import sleep
|
||||
|
||||
from py12306.helpers.func import *
|
||||
from py12306.helpers.app import *
|
||||
from py12306.log.common_log import CommonLog
|
||||
from py12306.query.query import Query
|
||||
from py12306.user.user import User
|
||||
sys.path.insert(0, 'py12306')
|
||||
|
||||
|
||||
def main():
|
||||
if '--test' in sys.argv or '-t' in sys.argv: test()
|
||||
CommonLog.print_welcome().print_configs()
|
||||
|
||||
App.run_check()
|
||||
User.run()
|
||||
Query.run()
|
||||
if not Const.IS_TEST:
|
||||
while True:
|
||||
sleep(1)
|
||||
|
||||
CommonLog.test_complete()
|
||||
version_check()
|
||||
from app.app import App
|
||||
App.start_run_loop()
|
||||
|
||||
|
||||
def test():
|
||||
"""
|
||||
功能检查
|
||||
包含:
|
||||
账号密码验证 (打码)
|
||||
座位验证
|
||||
乘客验证
|
||||
语音验证码验证
|
||||
:return:
|
||||
"""
|
||||
Const.IS_TEST = True
|
||||
if '--test-notification' in sys.argv or '-n' in sys.argv:
|
||||
Const.IS_TEST_NOTIFICATION = True
|
||||
pass
|
||||
def version_check():
|
||||
if sys.version_info.major < 3 or sys.version_info.minor < 6:
|
||||
sys.exit('# Pleause use a python version that must equal to or greater than 3.6 #')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
8
py12306/app/__init__.py
Normal file
8
py12306/app/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import warnings
|
||||
|
||||
# Disable aiohttp annoying warn
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning, module='aiohttp')
|
||||
# disable unittest warnnings
|
||||
default_filterwarnings = warnings.filterwarnings
|
||||
warnings.simplefilter = lambda *args, **kwargs: None if args[0] == 'default' and not \
|
||||
kwargs else default_filterwarnings(*args, **kwargs)
|
||||
229
py12306/app/app.py
Normal file
229
py12306/app/app.py
Normal file
@@ -0,0 +1,229 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import aioredis
|
||||
import toml
|
||||
from aioredis import Redis
|
||||
from tortoise import Tortoise
|
||||
|
||||
from lib.exceptions import LoadConfigFailException
|
||||
from lib.hammer import *
|
||||
from lib.helper import SuperDict
|
||||
|
||||
|
||||
class ConfigInstance:
|
||||
APP_NAME = 'py12306'
|
||||
DEBUG = False
|
||||
IS_IN_TEST = False
|
||||
PROJECT_DIR = os.path.abspath(__file__ + '/../../../') + '/'
|
||||
DATA_DIR = PROJECT_DIR + 'data/'
|
||||
CONFIG_FILE = PROJECT_DIR + 'config.toml'
|
||||
CONFIG_TEST_FILE = PROJECT_DIR + 'config_test.toml'
|
||||
QUERY_AVAILABLE = False
|
||||
|
||||
# 默认请求超时
|
||||
REQUEST_TIME_OUT = 5
|
||||
# 用户心跳检测间隔
|
||||
USER_HEARTBEAT_INTERVAL = 120
|
||||
# USER_HEARTBEAT_INTERVAL = 10
|
||||
|
||||
# Config
|
||||
REDIS = {
|
||||
'enable': False,
|
||||
'host': '127.0.0.1', 'port': 6379, 'db': 0, 'password': None, 'decode_responses': True
|
||||
}
|
||||
DATABASE = {
|
||||
'db_url': f'sqlite://{DATA_DIR}db.sqlite3',
|
||||
}
|
||||
Notifaction = {}
|
||||
|
||||
_configs = {}
|
||||
|
||||
def load(self):
|
||||
""" Load configs from toml file """
|
||||
for arg in sys.argv:
|
||||
if arg.find('unittest') >= 0:
|
||||
self.IS_IN_TEST = True
|
||||
file_path = self.CONFIG_FILE
|
||||
if self.IS_IN_TEST and os.path.isfile(self.CONFIG_TEST_FILE):
|
||||
file_path = self.CONFIG_TEST_FILE
|
||||
configs = toml.load(file_path)
|
||||
self._configs = SuperDict(configs)
|
||||
self.REDIS.update(configs.get('redis', {}))
|
||||
db = configs.get('db', {})
|
||||
if db and db.get('engine') in ['mysql']:
|
||||
db['db_url'] = f"{db.get('engine')}://{db.get('user')}:{db.get('password')}@{db.get('host')}:{db.get('port')}/{db.get('database')}"
|
||||
self.DATABASE.update(configs.get('db', {}))
|
||||
self.DEBUG = self._configs.get('app.debug', self.DEBUG)
|
||||
self.Notifaction: dict = self._configs.get('notifaction', self.Notifaction)
|
||||
return self
|
||||
|
||||
@property
|
||||
def node_num(self) -> int:
|
||||
if not Config.QUERY_AVAILABLE:
|
||||
return 0
|
||||
# TODO
|
||||
return 1
|
||||
|
||||
@property
|
||||
def redis_able(self) -> bool:
|
||||
return self.REDIS.get('enable', False)
|
||||
|
||||
@property
|
||||
def proxy_able(self) -> bool:
|
||||
return self.get('proxy.enable', False)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._configs.get(key, default)
|
||||
|
||||
|
||||
class App:
|
||||
__instance = False
|
||||
|
||||
def __new__(cls) -> Any:
|
||||
if cls.__instance:
|
||||
return cls.__instance
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.__class__.__instance = self
|
||||
self.__features = []
|
||||
|
||||
def start_run_loop(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(self.__run_loop())
|
||||
|
||||
async def __run_loop(self):
|
||||
self.print_welcome()
|
||||
await self.init_db()
|
||||
await self.__load_data()
|
||||
await self.__run_query_loop()
|
||||
ret = await asyncio.wait(self.__features, return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
async def __run_query_loop(self):
|
||||
""" 12306 查询相关
|
||||
) 服务时间检测
|
||||
"""
|
||||
from app.user import TrainUserManager
|
||||
from app.query import QueryTicketManager
|
||||
from app.order import OrderTicketManager
|
||||
self.__features.append(self.__service_check_loop())
|
||||
self.__features.append(TrainUserManager.share().run())
|
||||
self.__features.append(QueryTicketManager.share().run())
|
||||
self.__features.append(OrderTicketManager.share().run())
|
||||
|
||||
async def __service_check_loop(self):
|
||||
last_check = None
|
||||
while True:
|
||||
if not Config.DEBUG and not self.check_12306_service_time():
|
||||
if not last_check or (datetime.datetime.now() - last_check).seconds > 3600:
|
||||
Logger.info(f'程序运行中,当前时间: {datetime.datetime.now()} | 12306 休息时间,程序将在明天早上 6 点自动运行')
|
||||
last_check = datetime.datetime.now()
|
||||
Config.QUERY_AVAILABLE = False
|
||||
else:
|
||||
Config.QUERY_AVAILABLE = True
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def init_db(self):
|
||||
await Tortoise.init(
|
||||
db_url=Config.DATABASE['db_url'],
|
||||
modules={'models': ['app.models']})
|
||||
# Generate the schema
|
||||
await Tortoise.generate_schemas()
|
||||
|
||||
def check_12306_service_time(self):
|
||||
""" 服务时间检测 """
|
||||
now = datetime.datetime.now()
|
||||
if (now.hour >= 23 and now.minute >= 30) or now.hour < 6:
|
||||
return False
|
||||
return True
|
||||
|
||||
def print_welcome(self):
|
||||
Logger.info('######## py12306 购票助手,本程序为开源工具,请勿用于商业用途 ########')
|
||||
if Config.DEBUG:
|
||||
Logger.info('Debug 模式已启用')
|
||||
|
||||
async def __load_data(self):
|
||||
""" 加载配置数据
|
||||
) 加载用户
|
||||
) 加载查询
|
||||
"""
|
||||
from app.models import User
|
||||
from app.models import QueryJob
|
||||
try:
|
||||
users = Config.get('user', [])
|
||||
for _user in users:
|
||||
await User.load_from_config(_user)
|
||||
querys = Config.get('query', [])
|
||||
for _query in querys:
|
||||
await QueryJob.load_from_config(_query)
|
||||
except LoadConfigFailException as e:
|
||||
Logger.error(f'配置验证失败,{e.msg}')
|
||||
await self.exit()
|
||||
|
||||
async def exit(self, msg: str = ''):
|
||||
await Tortoise.close_connections()
|
||||
Logger.info('# 程序已退出 #')
|
||||
sys.exit(msg)
|
||||
|
||||
|
||||
# set up logger
|
||||
def __set_up_logger() -> logging.Logger:
|
||||
logger = logging.getLogger(Config.APP_NAME)
|
||||
logger.setLevel('DEBUG' if Config.DEBUG else 'INFO')
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
def __load_event():
|
||||
engine = AsyncioEventEngine
|
||||
if Config.redis_able:
|
||||
engine = RedisEventEngine
|
||||
return EventHammer(engine())
|
||||
|
||||
|
||||
def __load_cache():
|
||||
engine = AsyncioCacheEngine
|
||||
if Config.redis_able:
|
||||
engine = RedisCacheEngine
|
||||
return CacheHammer(engine())
|
||||
|
||||
|
||||
def __load_redis() -> Redis:
|
||||
if Config.redis_able:
|
||||
addrss = f"redis://{Config.REDIS['host']}:{Config.REDIS['port']}"
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
aioredis.create_redis_pool(addrss, db=Config.REDIS['db'], password=Config.REDIS['password']))
|
||||
return None
|
||||
|
||||
|
||||
def __load_notifaction():
|
||||
from app import notification as nt
|
||||
center = nt.NotificationCenter()
|
||||
conf = Config.Notifaction
|
||||
if conf.get('ding_talk.enable'):
|
||||
center.add_backend(nt.DingTalkNotifaction(conf['ding_talk']))
|
||||
if conf.get('bark.enable'):
|
||||
center.add_backend(nt.BarkNotifaction(conf['bark']))
|
||||
if conf.get('email.enable'):
|
||||
center.add_backend(nt.EmailNotifaction(conf['email']))
|
||||
if conf.get('server_chan.enable'):
|
||||
center.add_backend(nt.ServerChanNotifaction(conf['server_chan']))
|
||||
if conf.get('ding_xing_voice.enable'):
|
||||
center.add_backend(nt.DingXinVoiceNotifaction(conf['ding_xing_voice']))
|
||||
return center
|
||||
|
||||
|
||||
# load config
|
||||
Config = ConfigInstance().load()
|
||||
Redis = __load_redis()
|
||||
Logger = __set_up_logger()
|
||||
Event = __load_event()
|
||||
Cache = __load_cache()
|
||||
Notification = __load_notifaction()
|
||||
App = App()
|
||||
349
py12306/app/models.py
Normal file
349
py12306/app/models.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import datetime
|
||||
import random
|
||||
import string
|
||||
import urllib
|
||||
from typing import List
|
||||
|
||||
from tortoise import Model, fields
|
||||
|
||||
from lib.exceptions import LoadConfigFailException
|
||||
from lib.helper import json_friendly_loads, json_friendly_dumps, StationHelper
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
|
||||
class BaseModel(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if hasattr(self, 'hash_id'):
|
||||
self.hash_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=16))
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
async def refresh_from_db(self):
|
||||
new = await self.__class__.filter(id=self.id).first()
|
||||
self.__dict__.update(new.__dict__)
|
||||
|
||||
@classmethod
|
||||
async def get_or_create_instance(cls, **kwargs):
|
||||
ret = await cls.filter(**kwargs).first()
|
||||
if not ret:
|
||||
ret = cls()
|
||||
for k, v in kwargs.items():
|
||||
setattr(ret, k, v)
|
||||
return ret
|
||||
|
||||
|
||||
class FriendlyJSONField(fields.JSONField):
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(encoder=json_friendly_dumps, decoder=json_friendly_loads, **kwargs)
|
||||
|
||||
|
||||
class QueryJob(TimestampMixin, BaseModel):
|
||||
""" 查询任务 """
|
||||
|
||||
class Status:
|
||||
Normal = 'normal'
|
||||
WaitVerify = 'wait_verify'
|
||||
Finished = 'finished'
|
||||
Error = 'error'
|
||||
|
||||
hash_id = fields.CharField(max_length=16, default='', index=True, description='Hash')
|
||||
user = fields.ForeignKeyField('models.User', on_delete=fields.SET_NULL, null=True)
|
||||
name = fields.CharField(max_length=255, default='', description='任务名称')
|
||||
left_dates = FriendlyJSONField(default=[], description='出发日期')
|
||||
left_date = fields.DateField(default=None, null=True, description='当前出发日期')
|
||||
stations = FriendlyJSONField(default=[], description='查询车站')
|
||||
left_station = fields.CharField(max_length=255, default='', description='出发地')
|
||||
arrive_station = fields.CharField(max_length=255, default='', description='到达地')
|
||||
left_periods = FriendlyJSONField(default={}, description='出发时间')
|
||||
allow_train_numbers = FriendlyJSONField(default=[], description='筛选车次')
|
||||
execpt_train_numbers = FriendlyJSONField(default=[], description='筛选车次(反')
|
||||
allow_seats = FriendlyJSONField(default=[], description='筛选座位')
|
||||
members = FriendlyJSONField(default=[], description='乘车人员')
|
||||
member_num = fields.SmallIntField(default=0, description='人员数量')
|
||||
query_num = fields.IntField(default=0, description='查询次数')
|
||||
less_member = fields.BooleanField(default=False, description='提交部分乘客')
|
||||
status = fields.CharField(max_length=20, default=Status.WaitVerify, index=True, description='任务状态')
|
||||
enable = fields.BooleanField(default=True, description='启用状态')
|
||||
passengers = FriendlyJSONField(default={}, description='乘客列表')
|
||||
last_process_at = fields.DatetimeField(default=None, null=True, description='最后一次执行')
|
||||
last_error = fields.CharField(default='', max_length=500, description='最后一次错误')
|
||||
|
||||
class Meta:
|
||||
table = 'query_jobs'
|
||||
|
||||
@property
|
||||
def is_queryable(self) -> bool:
|
||||
""" 验证任务是否可查询 有一个可用即可"""
|
||||
for left_date in self.left_dates:
|
||||
if left_date < datetime.datetime.now().date():
|
||||
continue
|
||||
if left_date > (datetime.datetime.now().date() + datetime.timedelta(days=31)):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def current_is_queryable(self) -> bool:
|
||||
""" 验证当前任务是否可查询"""
|
||||
if self.left_date < datetime.datetime.now().date():
|
||||
return False
|
||||
if self.left_date > (datetime.datetime.now().date() + datetime.timedelta(days=30)):
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def query_num_next(self) -> int:
|
||||
self.query_num += 1
|
||||
return self.query_num
|
||||
|
||||
@classmethod
|
||||
def filter_available(cls):
|
||||
return cls.filter(enable=True, status=QueryJob.Status.Normal)
|
||||
|
||||
@property
|
||||
def name_text(self):
|
||||
""" 任务名称 """
|
||||
return f'ID {self.id} {self.name or self.route_time_text}'.strip()
|
||||
|
||||
@property
|
||||
def route_text(self):
|
||||
""" 行程文本 北京 - 深圳,北京 — 广州 """
|
||||
return ','.join([f'{station[0]} - {station[1]}' for station in self.stations])
|
||||
|
||||
@property
|
||||
def current_route_text(self) -> str:
|
||||
""" 行程文本 北京 - 深圳 """
|
||||
return f'{self.left_station} - {self.arrive_station}'
|
||||
|
||||
@property
|
||||
def left_time_text(self):
|
||||
""" 出发时间文本 2020-01-05,2020-01-06 """
|
||||
return ','.join([str(left_date) for left_date in self.left_dates])
|
||||
|
||||
@property
|
||||
def route_time_text(self):
|
||||
""" 路程与时间信息 """
|
||||
return f'{self.route_text} 出发时间 {self.left_time_text}'
|
||||
|
||||
@property
|
||||
def left_station_id(self):
|
||||
return StationHelper.id_by_cn(self.left_station)
|
||||
|
||||
@property
|
||||
def arrive_station_id(self):
|
||||
return StationHelper.id_by_cn(self.arrive_station)
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return self.enable and self.status == self.Status.Normal
|
||||
|
||||
@property
|
||||
def is_alive(self) -> bool:
|
||||
return self.last_process_at and (datetime.datetime.now() - self.last_process_at).seconds < 30
|
||||
|
||||
async def update_last_process_at(self) -> datetime.datetime:
|
||||
self.last_process_at = datetime.datetime.now()
|
||||
await self.save()
|
||||
return self.last_process_at
|
||||
|
||||
@classmethod
|
||||
async def load_from_config(cls, config: dict):
|
||||
if not config.get('id'):
|
||||
raise LoadConfigFailException('未指定查询任务 ID')
|
||||
user, user_id = None, config.get('user_id')
|
||||
if user_id:
|
||||
user = await User.filter(id=user_id).first()
|
||||
if not user:
|
||||
raise LoadConfigFailException(f'未找到查询订单关联用户 id {user_id}')
|
||||
else:
|
||||
user_id = None
|
||||
query = await cls.get_or_create_instance(id=config['id'])
|
||||
stations = config.get('stations', [])
|
||||
members = config.get('members', [])
|
||||
periods = config.get('periods', [])
|
||||
# 验证时间筛选
|
||||
if periods and len(periods) is not 2:
|
||||
raise LoadConfigFailException(f'乘车时间区间验证失败 {periods}')
|
||||
# 验证查询车站
|
||||
if not len(stations) or len(stations) % 2 is not 0:
|
||||
raise LoadConfigFailException('查询车站配置验证失败')
|
||||
stations = [stations[i:i + 2] for i in range(0, len(stations), 2)]
|
||||
for station in stations:
|
||||
if not StationHelper.id_by_cn(station[0]) or not StationHelper.id_by_cn(station[1]):
|
||||
raise LoadConfigFailException(f'未找到该车站 {station}')
|
||||
if not len(members):
|
||||
raise LoadConfigFailException('乘车人员验证失败')
|
||||
# 验证乘车时间
|
||||
left_dates_str_list = config.get('left_dates', [])
|
||||
if not left_dates_str_list:
|
||||
raise LoadConfigFailException('乘车日期验证失败')
|
||||
left_dates = []
|
||||
for left_date_str in left_dates_str_list:
|
||||
try:
|
||||
left_dates.append(datetime.datetime.strptime(left_date_str, '%Y-%m-%d').date())
|
||||
except Exception:
|
||||
raise LoadConfigFailException(f'乘车日期格式验证失败 {left_date_str}')
|
||||
|
||||
query.enable = config.get('enable', True)
|
||||
if query.user_id is not user_id: # 恢复为待验证状态
|
||||
query.status = query.Status.WaitVerify
|
||||
query.user_id = user_id or query.user_id
|
||||
query.allow_seats = config.get('seats', [])
|
||||
query.allow_train_numbers = list(map(str.upper, config.get('train_numbers', [])))
|
||||
query.execpt_train_numbers = list(
|
||||
map(str.upper, config.get('except_train_numbers', [])))
|
||||
query.left_periods = periods or query.left_periods
|
||||
query.members = members
|
||||
query.member_num = len(query.members)
|
||||
query.left_dates = left_dates
|
||||
query.stations = stations
|
||||
await query.save()
|
||||
return query
|
||||
|
||||
|
||||
class Ticket(TimestampMixin, BaseModel):
|
||||
""" Ticket """
|
||||
hash_id = fields.CharField(max_length=16, default='', index=True, description='Hash')
|
||||
left_date = fields.DateField(default=None, null=True)
|
||||
ticket_num = fields.CharField(default='', max_length=255, description='余票数量')
|
||||
train_number = fields.CharField(default='', max_length=255)
|
||||
train_no = fields.CharField(default='', max_length=255)
|
||||
left_station = fields.CharField(default='', max_length=255)
|
||||
arrive_station = fields.CharField(default='', max_length=255)
|
||||
order_text = fields.CharField(default='', max_length=255)
|
||||
secret_str = fields.CharField(default='', max_length=1000)
|
||||
left_time = fields.CharField(default='', max_length=255)
|
||||
arrive_time = fields.CharField(default='', max_length=255)
|
||||
# { 'name': seat, 'id': seat_id, 'raw': raw, 'order_id': TrainSeat.order_id[seat] }
|
||||
available_seat = FriendlyJSONField(default=[], description='可用座位')
|
||||
member_num_take = fields.SmallIntField(default=0, description='实际人员数量')
|
||||
raw = FriendlyJSONField(default={})
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
class Meta:
|
||||
table = 'tickets'
|
||||
|
||||
@property
|
||||
def left_date_order(self):
|
||||
return self.left_date.strftime('%Y-%m-%d')
|
||||
|
||||
@property
|
||||
def secret_str_unquote(self) -> str:
|
||||
return urllib.parse.unquote(self.secret_str)
|
||||
|
||||
@classmethod
|
||||
def parse_tickets_text(cls, tickts: List[str]):
|
||||
ret = []
|
||||
for tickt_str in tickts:
|
||||
ticket = cls.from_ticket_text(tickt_str)
|
||||
ret.append(ticket)
|
||||
return ret
|
||||
|
||||
@property
|
||||
def baby(self):
|
||||
""" 小黑屋 ID """
|
||||
return f"{self.left_date}_{self.train_number}_{self.available_seat.get('id')}"
|
||||
|
||||
@property
|
||||
def route_text(self):
|
||||
""" 行程文本 北京 - 深圳 """
|
||||
return f'{self.left_station} - {self.arrive_station}'
|
||||
|
||||
@property
|
||||
def detail_text(self):
|
||||
""" 车次详细信息 车次 G335 时间 23:15 - 14:25 硬卧 余票 10 """
|
||||
return f"车次 {self.train_number} 时间 {self.left_date} {self.left_time} - {self.arrive_time} {self.available_seat.get('name')} 余票 {self.ticket_num}"
|
||||
|
||||
@property
|
||||
def dark_room_text(self):
|
||||
""" 小黑屋信息 车次 G335 时间 2020-01-20 硬卧 """
|
||||
return f"车次 {self.train_number} 时间 {self.left_date} {self.available_seat.get('name')}"
|
||||
|
||||
@classmethod
|
||||
def from_ticket_text(cls, ticket_str):
|
||||
info = ticket_str.split('|')
|
||||
ticket = Ticket()
|
||||
ticket.left_date = datetime.datetime.strptime(info[13], '%Y%m%d').date()
|
||||
ticket.train_no = info[2]
|
||||
ticket.ticket_num = info[11]
|
||||
ticket.train_number = info[3]
|
||||
ticket.left_station = info[6]
|
||||
ticket.arrive_station = info[7]
|
||||
ticket.order_text = info[1]
|
||||
ticket.secret_str = info[0]
|
||||
ticket.left_time = info[8]
|
||||
ticket.arrive_time = info[9]
|
||||
ticket.raw = info
|
||||
return ticket
|
||||
|
||||
|
||||
class User(TimestampMixin, BaseModel):
|
||||
""" 12306 用户 """
|
||||
|
||||
user_id = fields.CharField(default='', max_length=50, description='12306 ID')
|
||||
name = fields.CharField(default='', max_length=255, description='用户名')
|
||||
password = fields.CharField(default='', max_length=255, description='密码')
|
||||
real_name = fields.CharField(default='', max_length=255, description='姓名')
|
||||
last_heartbeat = fields.DatetimeField(default=None, null=True, description='上次心跳')
|
||||
last_cookies = fields.TextField(default=None, null=True, description='上次 Cookie')
|
||||
passengers = FriendlyJSONField(default={}, description='乘客列表')
|
||||
enable = fields.BooleanField(default=True, description='启用状态')
|
||||
last_process_at = fields.DatetimeField(default=None, null=True, description='最后一次执行')
|
||||
|
||||
class Meta:
|
||||
table = 'users'
|
||||
|
||||
@property
|
||||
def is_alive(self) -> bool:
|
||||
return self.last_process_at and (datetime.datetime.now() - self.last_process_at).seconds < 60
|
||||
|
||||
@property
|
||||
def name_text(self) -> str:
|
||||
""" 任务名称 """
|
||||
return f'ID {self.id} {self.user_id}'
|
||||
|
||||
async def update_last_process_at(self) -> datetime.datetime:
|
||||
self.last_process_at = datetime.datetime.now()
|
||||
await self.save()
|
||||
return self.last_process_at
|
||||
|
||||
@classmethod
|
||||
async def load_from_config(cls, config: dict):
|
||||
if not config.get('id'):
|
||||
raise LoadConfigFailException('未指定用户 ID')
|
||||
user = await cls.get_or_create_instance(id=config['id'])
|
||||
user.enable = config.get('enable', True)
|
||||
user.name = config.get('name', '')
|
||||
user.password = config.get('password')
|
||||
await user.save()
|
||||
return user
|
||||
|
||||
|
||||
class Order(TimestampMixin, BaseModel):
|
||||
""" 订单 """
|
||||
|
||||
class Status:
|
||||
Wait = 'wait'
|
||||
DarkRoom = 'dark_room' # 小黑屋
|
||||
Error = 'error'
|
||||
Success = 'success'
|
||||
|
||||
user = fields.ForeignKeyField('models.User', on_delete=fields.SET_NULL, null=True)
|
||||
query_job = fields.ForeignKeyField('models.QueryJob', on_delete=fields.SET_NULL, null=True)
|
||||
ticket = fields.ForeignKeyField('models.Ticket', on_delete=fields.SET_NULL, null=True)
|
||||
status = fields.CharField(max_length=20, default=Status.Wait, index=True, description='任务状态')
|
||||
last_error = fields.CharField(default='', max_length=500, description='最后一次错误')
|
||||
|
||||
class Meta:
|
||||
table = 'orders'
|
||||
193
py12306/app/notification.py
Normal file
193
py12306/app/notification.py
Normal file
@@ -0,0 +1,193 @@
|
||||
import smtplib
|
||||
from abc import ABC, abstractmethod
|
||||
from email.message import EmailMessage
|
||||
from typing import List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.app import Logger
|
||||
from app.models import Order, Ticket
|
||||
from lib.helper import retry
|
||||
from lib.request import Session
|
||||
|
||||
|
||||
class NotifactionMessage:
|
||||
|
||||
def __init__(self, title: str = '', message: str = '', extra: dict = None) -> None:
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.extra = extra or {}
|
||||
|
||||
def to_str(self):
|
||||
return f"{self.title}\n\n{self.message}"
|
||||
|
||||
|
||||
class NotifactionAbstract(ABC):
|
||||
|
||||
def __init__(self, config: dict) -> None:
|
||||
super().__init__()
|
||||
self.session = Session.share()
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, message: NotifactionMessage) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
class NotificationCenter:
|
||||
""" 通知类 """
|
||||
|
||||
def __init__(self):
|
||||
self.backends: List[NotifactionAbstract] = []
|
||||
|
||||
def add_backend(self, backend: NotifactionAbstract):
|
||||
self.backends.append(backend)
|
||||
|
||||
async def order_success_notifation(self, order: Order):
|
||||
body = f"请及时登录12306账号[{order.user.name}],打开 '未完成订单',在30分钟内完成支付!" \
|
||||
# f"\n\n车次信息: {} {}[{}] -> {}[{}],乘车日期 {},席位:{},乘车人:{}"
|
||||
# TODO
|
||||
message = NotifactionMessage(title='车票购买成功!', message=body)
|
||||
message.extra = {
|
||||
'name': order.user.real_name,
|
||||
'left_station': '广州',
|
||||
'arrive_station': '深圳',
|
||||
'set_type': '硬座',
|
||||
'orderno': 'E123542'
|
||||
}
|
||||
await self.send_message(message)
|
||||
|
||||
async def ticket_available_notifation(self, ticket: Ticket):
|
||||
title = f'余票监控通知 {ticket.route_text}'
|
||||
body = f'{ticket.detail_text}'
|
||||
await self.send_message(NotifactionMessage(title=title, message=body))
|
||||
|
||||
async def send_message(self, message: NotifactionMessage):
|
||||
for backend in self.backends:
|
||||
await backend.send(message)
|
||||
|
||||
|
||||
class DingTalkNotifaction(NotifactionAbstract):
|
||||
""" 钉钉 """
|
||||
|
||||
@retry
|
||||
async def send(self, message: NotifactionMessage) -> bool:
|
||||
api_url = f"https://oapi.dingtalk.com/robot/send?access_token={self.config.get('access_token')}"
|
||||
data = {'msgtype': 'text', 'text': {'content': message.to_str()}}
|
||||
response = await self.session.request('POST', api_url, json=data)
|
||||
result = response.json()
|
||||
if result.get('errcode') == 0:
|
||||
Logger.info('钉钉 推送成功')
|
||||
return True
|
||||
Logger.info(f"钉钉 推送失败,{result.get('errmsg')}")
|
||||
return False
|
||||
|
||||
|
||||
class BarkNotifaction(NotifactionAbstract):
|
||||
""" Bark """
|
||||
|
||||
@retry
|
||||
async def send(self, message: NotifactionMessage) -> bool:
|
||||
api_url = f"{self.config.get('push_url')}/{message.to_str()}"
|
||||
response = await self.session.request('GET', api_url)
|
||||
result = response.json()
|
||||
if result.get('code') == 200:
|
||||
Logger.info('Bark 推送成功')
|
||||
return True
|
||||
Logger.info(f"Bark 推送失败,{result.get('message')}")
|
||||
return False
|
||||
|
||||
|
||||
class EmailNotifaction(NotifactionAbstract):
|
||||
""" Email """
|
||||
|
||||
@retry
|
||||
async def send(self, message: NotifactionMessage) -> bool:
|
||||
to = self.config.get('to')
|
||||
if not to:
|
||||
Logger.warning('未配置邮件接受用户')
|
||||
return False
|
||||
to = to if isinstance(to, list) else [to]
|
||||
email_message = EmailMessage()
|
||||
email_message['Subject'] = message.title
|
||||
email_message['From'] = self.config.get('sender', '')
|
||||
email_message['To'] = to
|
||||
email_message.set_content(message.message)
|
||||
try:
|
||||
server = smtplib.SMTP(self.config.get('host'))
|
||||
server.ehlo()
|
||||
server.starttls()
|
||||
server.login(self.config.get('user', ''), self.config.get('password', ''))
|
||||
server.send_message(email_message)
|
||||
server.quit()
|
||||
Logger.info('邮件发送成功,请检查收件箱')
|
||||
return True
|
||||
except Exception as e:
|
||||
Logger.error(f'邮件发送失败,请手动检查配置,错误原因 {e}')
|
||||
return False
|
||||
|
||||
|
||||
class ServerChanNotifaction(NotifactionAbstract):
|
||||
""" ServerChan
|
||||
http://sc.ftqq.com/3.version
|
||||
"""
|
||||
|
||||
@retry
|
||||
async def send(self, message: NotifactionMessage) -> bool:
|
||||
api_url = f"https://sc.ftqq.com/{self.config.get('sckey')}.send"
|
||||
data = {'text': message.title, 'desp': message.message}
|
||||
response = await self.session.request('POST', api_url, data=data)
|
||||
result = response.json()
|
||||
if result.get('errno') == 0:
|
||||
Logger.info('ServerChan 推送成功')
|
||||
return True
|
||||
Logger.info(f"ServerChan 推送失败,{result.get('error_message', result.get('errmsg'))}")
|
||||
return False
|
||||
|
||||
|
||||
class PushBearNotifaction(NotifactionAbstract):
|
||||
""" PushBear # 已失效
|
||||
http://pushbear.ftqq.com/admin/#/
|
||||
"""
|
||||
|
||||
@retry
|
||||
async def send(self, message: NotifactionMessage) -> bool:
|
||||
api_url = f"https://pushbear.ftqq.com/sub?sendkey={self.config.get('sendkey')}&"
|
||||
querys = {'text': message.title, 'desp': message.message}
|
||||
response = await self.session.request('GET', api_url + urlencode(querys))
|
||||
result = response.json()
|
||||
if result.get('code') == 0:
|
||||
Logger.info('PushBear 推送成功')
|
||||
return True
|
||||
Logger.info(f"PushBear 推送失败,{result.get('errmsg')}")
|
||||
return False
|
||||
|
||||
|
||||
class DingXinVoiceNotifaction(NotifactionAbstract):
|
||||
""" 发送语音验证码 ( 鼎信 )
|
||||
购买地址 https://market.aliyun.com/products/56928004/cmapi026600.html?spm=5176.2020520132.101.2.51547218rkAXxy
|
||||
"""
|
||||
|
||||
@retry
|
||||
async def send(self, message: NotifactionMessage) -> bool:
|
||||
phone = self.config.get('phone')
|
||||
if not phone:
|
||||
Logger.warning('未配置语音通知手机号')
|
||||
return False
|
||||
api_url = f"http://yuyin2.market.alicloudapi.com/dx/voice_notice"
|
||||
headers = {'Authorization': f"APPCODE {self.config.get('appcode')}"}
|
||||
data = {
|
||||
'tpl_id': 'TP1901174',
|
||||
'phone': phone,
|
||||
'param': f"name:{message.extra.get('name', '')},job_name:{message.extra.get('left_station', '')}"
|
||||
f"到{message.extra.get('arrive_station', '')}{message.extra.get('set_name', '')},"
|
||||
f"orderno:{message.extra.get('orderno', '')}"}
|
||||
response = await self.session.request('POST', api_url, headers=headers, data=data)
|
||||
result = response.json()
|
||||
if result.get('return_code') in [400, 401, 403]:
|
||||
Logger.error('语音消息发送失败,请检查 appcode 是否填写正确或 套餐余额是否充足')
|
||||
elif result.get('return_code') == '00000':
|
||||
Logger.info(f"语音消息发送成功!")
|
||||
return True
|
||||
Logger.info(f"语音消息发送失败,{result.get('return_code')}")
|
||||
return False
|
||||
407
py12306/app/order.py
Normal file
407
py12306/app/order.py
Normal file
@@ -0,0 +1,407 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
from app.app import Event, Cache, Notification
|
||||
from app.app import Logger, Config
|
||||
from app.models import QueryJob, User, Order, Ticket
|
||||
from app.notification import NotifactionMessage
|
||||
from app.user import TrainUser, TrainUserManager
|
||||
from lib.exceptions import RetryException
|
||||
from lib.helper import StationHelper, UserTypeHelper, TrainSeat, TaskManager, retry
|
||||
|
||||
|
||||
class OrderTicketManager(TaskManager):
|
||||
|
||||
async def run(self):
|
||||
self.fuatures.append(asyncio.ensure_future(self.subscribe_loop()))
|
||||
await self.wait()
|
||||
|
||||
@property
|
||||
async def task_total(self) -> int:
|
||||
return 0
|
||||
|
||||
async def subscribe_loop(self):
|
||||
while True:
|
||||
event = await Event.subscribe()
|
||||
if event.name == Event.EVENT_ORDER_TICKET:
|
||||
await self.handle_order_task(event.data)
|
||||
|
||||
async def handle_order_task(self, data: dict):
|
||||
""" 处理订单任务 """
|
||||
try:
|
||||
user_id = data['user_id']
|
||||
train_user: TrainUser = TrainUserManager.share().get_task(user_id)
|
||||
if not train_user or not train_user.is_ready:
|
||||
Logger.warning(f'用户 ID {user_id} 不可用,已跳过该下单任务')
|
||||
return
|
||||
# make order model
|
||||
query_job = QueryJob(**data['query_job'])
|
||||
ticket = Ticket(**data['ticket'])
|
||||
|
||||
Logger.info(f'# 任务 {query_job.current_route_text} 开始下单 #')
|
||||
future = asyncio.ensure_future(OrderTicket(query_job, ticket, train_user).order())
|
||||
self.fuatures.append(future)
|
||||
except Exception as e:
|
||||
Logger.error(f'订单任务解析失败,{e}')
|
||||
|
||||
|
||||
class OrderTicket:
|
||||
""" 处理下单 """
|
||||
|
||||
def __init__(self, query_job: QueryJob, ticket: Ticket, train_user: TrainUser):
|
||||
self.session = train_user.session
|
||||
self.query: QueryJob = query_job
|
||||
self.user: User = train_user.user
|
||||
self.ticket: Ticket = ticket
|
||||
self.train_user = train_user
|
||||
self._order = Order()
|
||||
# Init page
|
||||
self.global_repeat_submit_token = None
|
||||
self.ticket_info_for_passenger_form = None
|
||||
self.order_request_dto = None
|
||||
# Config
|
||||
self.max_queue_wait = 60 * 5
|
||||
self._passenger_ticket_str = ''
|
||||
self._old_passenger_str = ''
|
||||
self._order_id = ''
|
||||
|
||||
async def order(self):
|
||||
""" 开始下单 """
|
||||
try:
|
||||
while True:
|
||||
if self.train_user.is_ordering:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
if await Cache.in_dark_room(self.ticket.baby):
|
||||
self._order.status = self._order.Status.DarkRoom
|
||||
return
|
||||
break
|
||||
self.train_user.is_ordering = True
|
||||
return await self.normal_order()
|
||||
except Exception as err:
|
||||
self._order.status = self._order.Status.Error
|
||||
self._order.last_error = str(err)
|
||||
finally:
|
||||
self.train_user.is_ordering = False
|
||||
# 更新为正确的关联信息
|
||||
self.user = self.user
|
||||
self._order.query_job = await QueryJob.filter(hash_id=self.query.hash_id).first()
|
||||
if self._order_id and self._order.query_job: # 更新订单状态
|
||||
self._order.query_job.status = self._order.query_job.Status.Finished
|
||||
await self._order.query_job.save()
|
||||
self._order.status = self._order.Status.Success
|
||||
self._order.ticket = await Ticket.filter(hash_id=self.ticket.hash_id).first()
|
||||
await self._order.save()
|
||||
|
||||
async def normal_order(self):
|
||||
""" 下单
|
||||
) 提交下单请求
|
||||
"""
|
||||
try:
|
||||
order_request_res = await self.submit_order_request()
|
||||
if not order_request_res:
|
||||
return
|
||||
if not await self.request_init_dc_page():
|
||||
return
|
||||
if not await self.check_order_info():
|
||||
return
|
||||
if not await self.get_queue_count():
|
||||
return
|
||||
if not await self.confirm_order_queue():
|
||||
return
|
||||
order_status = await self.wait_order_queue()
|
||||
if order_status and self._order_id: # 发送通知
|
||||
await self.order_did_success(self._order_id)
|
||||
return True
|
||||
return False
|
||||
finally:
|
||||
pass
|
||||
|
||||
async def order_did_success(self, order_id: str):
|
||||
""" 下单成功 """
|
||||
# 加入小黑屋,防止重复下单
|
||||
await self._add_to_dark_room()
|
||||
title = f'# 车票购买成功,订单号 {order_id} #'
|
||||
content = f"请及时登录12306账号 [{self.user.user_id}],打开 '未完成订单',在30分钟内完成支付!\n"
|
||||
content += f"车次信息: {self.ticket.train_number} {self.query.left_station} -> {self.query.arrive_station},乘车日期 {self.ticket.left_date},席位:{self.ticket.available_seat.get('name')}"
|
||||
Logger.info(title)
|
||||
await Notification.send_message(NotifactionMessage(title, content, extra={
|
||||
'name': self.user.real_name,
|
||||
'left_station': self.ticket.left_station,
|
||||
'arrive_station': self.ticket.arrive_station,
|
||||
'set_type': self.ticket.available_seat.get('name'),
|
||||
'orderno': order_id
|
||||
}))
|
||||
await self.show_no_complete_orders()
|
||||
return True
|
||||
|
||||
@retry
|
||||
async def submit_order_request(self):
|
||||
""" 提交下单请求 """
|
||||
data = {
|
||||
'secretStr': self.ticket.secret_str_unquote, # 解密
|
||||
'train_date': self.ticket.left_date_order, # 出发时间
|
||||
'back_train_date': self.ticket.left_date_order, # 返程时间
|
||||
'tour_flag': 'dc', # 旅途类型
|
||||
'purpose_codes': 'ADULT', # 成人 | 学生
|
||||
'query_from_station_name': StationHelper.cn_by_id(self.query.left_station),
|
||||
'query_to_station_name': StationHelper.cn_by_id(self.query.arrive_station),
|
||||
}
|
||||
response = await self.session.otn_left_ticket_submit_order_request(data)
|
||||
result = response.json()
|
||||
if result.get('data') == 'N':
|
||||
Logger.info('提交订单成功')
|
||||
return True
|
||||
else:
|
||||
if str(result.get('messages', '')).find('未处理') >= 0: # 未处理订单
|
||||
Logger.error(f"提交订单失败,{result.get('messages')}")
|
||||
await self.show_no_complete_orders()
|
||||
return False
|
||||
Logger.error(f"提交订单失败,{result.get('messages', '未知错误')}")
|
||||
return False
|
||||
|
||||
@retry
|
||||
async def request_init_dc_page(self):
|
||||
"""
|
||||
请求下单页面 拿到 token
|
||||
:return:
|
||||
"""
|
||||
response = await self.train_user.session.otn_confirm_passenger_init_dc()
|
||||
html = response.text()
|
||||
token = re.search(r'var globalRepeatSubmitToken = \'(.+?)\'', html)
|
||||
form = re.search(r'var ticketInfoForPassengerForm *= *({.+\})', html)
|
||||
order = re.search(r'var orderRequestDTO *= *({.+\})', html)
|
||||
# 系统忙,请稍后重试
|
||||
if html.find('系统忙,请稍后重试') != -1:
|
||||
raise RetryException('请求初始化订单页面失败')
|
||||
try:
|
||||
self.global_repeat_submit_token = token.groups()[0]
|
||||
self.ticket_info_for_passenger_form = json.loads(form.groups()[0].replace("'", '"'))
|
||||
self.order_request_dto = json.loads(order.groups()[0].replace("'", '"'))
|
||||
except Exception:
|
||||
raise RetryException('请求初始化订单页面失败')
|
||||
|
||||
return True
|
||||
|
||||
@retry
|
||||
async def check_order_info(self):
|
||||
""" 检查下单信息 """
|
||||
self.make_passenger_ticket_str()
|
||||
data = { #
|
||||
'cancel_flag': 2,
|
||||
'bed_level_order_num': '000000000000000000000000000000',
|
||||
'passengerTicketStr': self._passenger_ticket_str,
|
||||
'oldPassengerStr': self._old_passenger_str,
|
||||
'tour_flag': 'dc',
|
||||
'randCode': '',
|
||||
'whatsSelect': '1',
|
||||
'_json_att': '',
|
||||
'REPEAT_SUBMIT_TOKEN': self.global_repeat_submit_token
|
||||
}
|
||||
response = await self.session.otn_confirm_passenger_check_order_info(data)
|
||||
result = response.json()
|
||||
if result.get('data.submitStatus'): # 成功
|
||||
# ifShowPassCode 需要验证码
|
||||
Logger.info('检查订单成功')
|
||||
if result.get('data.ifShowPassCode') != 'N':
|
||||
self.is_need_auth_code = True # TODO
|
||||
# if ( ticketInfoForPassengerForm.isAsync == ticket_submit_order.request_flag.isAsync & & ticketInfoForPassengerForm.queryLeftTicketRequestDTO.ypInfoDetail != "") { 不需要排队检测 js TODO
|
||||
return True
|
||||
else:
|
||||
if result.get('data.checkSeatNum'):
|
||||
error = '无法提交的订单! ' + result.get('data.errMsg')
|
||||
await self._add_to_dark_room()
|
||||
elif not result.get('data.isNoActive'):
|
||||
error = result.get('data.errMsg', result.get('messages.0'))
|
||||
else:
|
||||
error = '出票失败! ' + result.get('data.errMsg')
|
||||
Logger.error(f'检查订单失败,{error or response.reason}')
|
||||
return False
|
||||
|
||||
@retry
|
||||
async def get_queue_count(self):
|
||||
""" 获取队列人数 """
|
||||
data = { #
|
||||
'train_date': '{} 00:00:00 GMT+0800 (China Standard Time)'.format(
|
||||
self.ticket.left_date.strftime("%a %h %d %Y")),
|
||||
'train_no': self.ticket_info_for_passenger_form['queryLeftTicketRequestDTO']['train_no'],
|
||||
'stationTrainCode': self.ticket_info_for_passenger_form['queryLeftTicketRequestDTO']['station_train_code'],
|
||||
'seatType': self.ticket.available_seat.get('order_id'),
|
||||
'fromStationTelecode': self.ticket_info_for_passenger_form['queryLeftTicketRequestDTO']['from_station'],
|
||||
'toStationTelecode': self.ticket_info_for_passenger_form['queryLeftTicketRequestDTO']['to_station'],
|
||||
'leftTicket': self.ticket_info_for_passenger_form['leftTicketStr'],
|
||||
'purpose_codes': self.ticket_info_for_passenger_form['purpose_codes'],
|
||||
'train_location': self.ticket_info_for_passenger_form['train_location'],
|
||||
'_json_att': '',
|
||||
'REPEAT_SUBMIT_TOKEN': self.global_repeat_submit_token,
|
||||
}
|
||||
response = await self.session.otn_confirm_passenger_get_queue_count(data)
|
||||
result = response.json()
|
||||
if result.get('status', False): # 成功
|
||||
# "data": { "count": "66", "ticket": "0,73", "op_2": "false", "countT": "0", "op_1": "true" }
|
||||
# if result.get('isRelogin') == 'Y': # 重新登录 TODO
|
||||
ticket = result.get('data.ticket').split(',') # 余票列表
|
||||
# 这里可以判断 是真实是 硬座还是无座,避免自动分配到无座
|
||||
ticket_number = ticket[0] # 余票
|
||||
if ticket_number is not '充足' and int(ticket_number) <= 0:
|
||||
if self.ticket.available_seat.get('id') == TrainSeat.NO_SEAT: # 允许无座
|
||||
ticket_number = ticket[1]
|
||||
if not int(ticket_number): # 跳过无座
|
||||
Logger.error('接口返回实际为无票,跳过本次排队')
|
||||
await self._add_to_dark_room()
|
||||
return False
|
||||
|
||||
if result.get('data.op_2') == 'true':
|
||||
Logger.error('排队失败,目前排队人数已经超过余票张数')
|
||||
return False
|
||||
|
||||
current_position = int(result.get('data.countT', 0))
|
||||
Logger.info(f'获取排队信息成功,目前排队人数 {current_position}, 余票还剩余 {ticket_number} 张')
|
||||
return True
|
||||
else:
|
||||
# 加入小黑屋 TODO
|
||||
Logger.error(f"排队失败,错误原因: {result.get('messages', result.get('validateMessages', response.reason))}")
|
||||
return False
|
||||
|
||||
@retry
|
||||
async def confirm_order_queue(self):
|
||||
""" 确认排队 """
|
||||
data = { #
|
||||
'passengerTicketStr': self._passenger_ticket_str,
|
||||
'oldPassengerStr': self._old_passenger_str,
|
||||
'randCode': '',
|
||||
'purpose_codes': self.ticket_info_for_passenger_form['purpose_codes'],
|
||||
'key_check_isChange': self.ticket_info_for_passenger_form['key_check_isChange'],
|
||||
'leftTicketStr': self.ticket_info_for_passenger_form['leftTicketStr'],
|
||||
'train_location': self.ticket_info_for_passenger_form['train_location'],
|
||||
'choose_seats': '',
|
||||
'seatDetailType': '000',
|
||||
'whatsSelect': '1',
|
||||
'roomType': '00',
|
||||
'dwAll': 'N',
|
||||
'_json_att': '',
|
||||
'REPEAT_SUBMIT_TOKEN': self.global_repeat_submit_token,
|
||||
}
|
||||
|
||||
if self.is_need_auth_code:
|
||||
# TODO
|
||||
pass
|
||||
|
||||
response = await self.session.otn_confirm_passenger_confirm_single_for_queue(data)
|
||||
result = response.json()
|
||||
|
||||
if 'data' in result:
|
||||
# "data": { "submitStatus": true }
|
||||
if result.get('data.submitStatus'): # 成功
|
||||
Logger.info('# 确认排队成功!#')
|
||||
return True
|
||||
else:
|
||||
Logger.error(f"出票失败,{result.get('data.errMsg', response.reason)}")
|
||||
await self._add_to_dark_room()
|
||||
else:
|
||||
Logger.error(f"提交订单失败,{result.get('messages', response.reason)}")
|
||||
return False
|
||||
|
||||
@retry
|
||||
async def wait_order_queue(self):
|
||||
""" 等待订单排队结果 """
|
||||
wait_count = 0
|
||||
start_at = datetime.datetime.now()
|
||||
while (datetime.datetime.now() - start_at).seconds < self.max_queue_wait:
|
||||
# TODO 取消超时订单,待优化
|
||||
wait_count += 1
|
||||
querys = { #
|
||||
'random': str(random.random())[2:],
|
||||
'tourFlag': 'dc',
|
||||
'_json_att': '',
|
||||
'REPEAT_SUBMIT_TOKEN': self.global_repeat_submit_token,
|
||||
}
|
||||
response = await self.session.otn_confirm_passenger_query_order_wait_time(querys)
|
||||
result = response.json()
|
||||
if result.get('status') and 'data' in result:
|
||||
""" "data": { "queryOrderWaitTimeStatus": true, "count": 0, "waitTime": -1,
|
||||
"requestId": 6487958947291482523, "waitCount": 0, "tourFlag": "dc", "orderId": "E222646122"}
|
||||
"""
|
||||
result_data = result['data']
|
||||
order_id = result_data.get('orderId')
|
||||
if order_id: # 成功
|
||||
self._order_id = order_id
|
||||
return True
|
||||
elif 'waitTime' in result_data:
|
||||
# 计算等待时间
|
||||
wait_time = int(result_data.get('waitTime'))
|
||||
if wait_time == -1: # 成功
|
||||
# /otn/confirmPassenger/resultOrderForDcQueue 请求订单状态 目前不需要 # 不应该走到这
|
||||
return True
|
||||
elif wait_time == -100: # 重新获取订单号
|
||||
pass
|
||||
elif wait_time >= 0: # 等待
|
||||
Logger.info(f"排队等待中,排队人数 {result_data.get('waitCount', 0)},预计还需要 {wait_time} 秒")
|
||||
else:
|
||||
if wait_time == -2 or wait_time == -3: # -2 失败 -3 订单已撤销
|
||||
Logger.error(f"排队失败,错误原因, {result_data.get('msg')}")
|
||||
return False
|
||||
else: # 未知原因
|
||||
Logger.error(f"排队失败,错误原因, {result_data.get('msg', wait_time)}")
|
||||
return False
|
||||
|
||||
elif result_data.get('msg'): # 失败 对不起,由于您取消次数过多,今日将不能继续受理您的订票请求。1月8日您可继续使用订票功能。
|
||||
# TODO 需要增加判断 直接结束
|
||||
Logger.error(f"排队失败,错误原因, {result_data.get('msg')}")
|
||||
return False
|
||||
elif result.get('messages') or result.get('validateMessages'):
|
||||
Logger.error(f"排队失败,错误原因, {result.get('messages', result.get('validateMessages'))}")
|
||||
return False
|
||||
else:
|
||||
pass
|
||||
Logger.info(f'第 {wait_count} 次排队,请耐心等待')
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return False
|
||||
|
||||
async def _add_to_dark_room(self):
|
||||
Logger.warning(f'# 已将 {self.ticket.dark_room_text} 关入小黑屋 #')
|
||||
await Cache.add_dark_room(self.ticket.baby)
|
||||
|
||||
@retry
|
||||
async def show_no_complete_orders(self):
|
||||
""" 展示未完成订单 """
|
||||
response = await self.session.otn_query_my_order_no_complete()
|
||||
result = response.json()
|
||||
if result.get('status') is True:
|
||||
for order in result.get('data.orderDBList', []):
|
||||
text = f"\n# 待支付订单号 {order.get('sequence_no')} {order.get('order_date')} #\n"
|
||||
text += f"车次 {''.join(order.get('from_station_name_page', []))} - {''.join(order.get('to_station_name_page', []))} {order.get('train_code_page', '')} {order.get('start_train_date_page', '')} 开\n"
|
||||
for ticket in order.get('tickets'):
|
||||
text += f"\t- {ticket.get('passengerDTO.passenger_name', '')} {ticket.get('passengerDTO.passenger_id_type_name', '')} {ticket.get('seat_type_name', '')} {ticket.get('coach_no', '')}车{ticket.get('seat_name', '')} {ticket.get('ticket_type_name', '')} {ticket.get('str_ticket_price_page', '')}元 {ticket.get('ticket_status_name', '')}\n"
|
||||
Logger.info(text)
|
||||
|
||||
def make_passenger_ticket_str(self):
|
||||
""" 生成提交车次的内容 格式:
|
||||
1(seatType),0,1(车票类型:ticket_type_codes),张三(passenger_name),1(证件类型:passenger_id_type_code),xxxxxx(passenger_id_no),xxxx(mobile_no),N
|
||||
passengerTicketStr:
|
||||
张三(passenger_name),1(证件类型:passenger_id_type_code),xxxxxx(passenger_id_no),1_oldPassengerStr
|
||||
"""
|
||||
passenger_tickets = []
|
||||
old_passengers = []
|
||||
available_passengers = self.query.passengers
|
||||
if len(available_passengers) > self.ticket.member_num_take: # 删除人数
|
||||
available_passengers = available_passengers[0:self.ticket.member_num_take]
|
||||
del_ret = [passenger.get('name') + '(' + passenger.get('type_text') + ')' for passenger in
|
||||
available_passengers]
|
||||
Logger.info(f"# 删减后的乘客列表 {', '.join(del_ret)} #")
|
||||
|
||||
for passenger in available_passengers:
|
||||
tmp_str = f"{self.ticket.available_seat.get('order_id')},0,{passenger['type']},{passenger['name']}," \
|
||||
f"{passenger['id_card_type']},{passenger['id_card']},{passenger['mobile']},N,{passenger['enc_str']}_"
|
||||
passenger_tickets.append(tmp_str)
|
||||
|
||||
if int(passenger['type']) is not UserTypeHelper.CHILD:
|
||||
tmp_old_str = f"{passenger['name']},{passenger['id_card_type']},{passenger['id_card']},{passenger['type']}_"
|
||||
old_passengers.append(tmp_old_str)
|
||||
|
||||
self._passenger_ticket_str = ''.join(passenger_tickets).rstrip('_')
|
||||
self._old_passenger_str = ''.join(old_passengers).rstrip('_') + '__ _ _'
|
||||
325
py12306/app/query.py
Normal file
325
py12306/app/query.py
Normal file
@@ -0,0 +1,325 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import datetime
|
||||
import random
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from app.app import Event, Cache, Notification
|
||||
from app.app import Logger, Config
|
||||
from app.models import QueryJob, Ticket
|
||||
from lib.exceptions import RetryException
|
||||
from lib.hammer import EventItem
|
||||
from lib.helper import TrainSeat, TaskManager, number_of_time_period, retry
|
||||
from lib.request import TrainSession
|
||||
|
||||
|
||||
class QueryTicketManager(TaskManager):
|
||||
|
||||
async def run(self):
|
||||
Logger.info('正在加载查询任务...')
|
||||
while True:
|
||||
await self.make_tasks()
|
||||
self.clean_fuatures()
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
@property
|
||||
async def task_total(self):
|
||||
return await QueryJob.filter_available().count()
|
||||
|
||||
async def make_tasks(self):
|
||||
if await self.is_overflow: # 丢弃多余任务
|
||||
self.tasks.popitem()
|
||||
for query_job in await QueryJob.all():
|
||||
if self.get_task(query_job.id):
|
||||
if not query_job.is_available:
|
||||
self.stop_and_drop(query_job.id)
|
||||
Logger.debug(f'任务 {query_job.name_text} 不可用,已停止该任务')
|
||||
continue
|
||||
if query_job.status == query_job.Status.WaitVerify or not query_job.passengers: # 乘客验证
|
||||
Logger.debug(f'验证任务 {query_job.name_text} 乘客信息...')
|
||||
if not query_job.user_id:
|
||||
query_job.status = query_job.Status.Normal
|
||||
await query_job.save()
|
||||
else:
|
||||
await Event.publish(
|
||||
EventItem(Event.EVENT_VERIFY_QUERY_JOB, {'id': query_job.id, 'user_id': query_job.user_id}))
|
||||
continue
|
||||
if await self.is_full:
|
||||
continue
|
||||
if Config.redis_able and query_job.is_alive:
|
||||
Logger.debug(f'任务 {query_job.name_text} 正在运行中,已跳过')
|
||||
continue
|
||||
await self.handle_task(query_job)
|
||||
|
||||
async def handle_task(self, query: QueryJob):
|
||||
""" 添加查询任务 """
|
||||
if not query.is_queryable:
|
||||
Logger.debug(f'任务 {query.name_text} 未满足查询条件,已跳过')
|
||||
return False
|
||||
ticket = QueryTicket(query)
|
||||
Logger.info(f'# 查询任务 [{query.route_time_text}] 已添加到任务中 #')
|
||||
self.add_task(ticket.run(), query.id, ticket)
|
||||
|
||||
|
||||
class QueryTicket:
|
||||
""" 车票查询 """
|
||||
|
||||
def __init__(self, query: QueryJob):
|
||||
self.api_type: str = ''
|
||||
self.session = TrainSession(use_proxy=True, timeout=5)
|
||||
self.query: QueryJob = query
|
||||
self._last_process_at = query.last_process_at
|
||||
self._last_notifaction_at: Optional[datetime] = None
|
||||
self._is_stop = False
|
||||
self.__flag_num: int = 0 # 连续查询失败次数
|
||||
|
||||
@retry()
|
||||
async def get_query_api_type(self) -> str:
|
||||
""" 动态获取查询的接口, 如 leftTicket/query """
|
||||
if self.api_type:
|
||||
return self.api_type
|
||||
response = await self.session.otn_left_ticket_init()
|
||||
if response.status == 200:
|
||||
res = re.search(r'var CLeftTicketUrl = \'(.*)\';', response.text())
|
||||
try:
|
||||
self.api_type = res.group(1)
|
||||
Logger.info(f'更新查询接口地址: {self.api_type}')
|
||||
except (IndexError, AttributeError):
|
||||
raise RetryException('获取车票查询地址失败')
|
||||
return await self.get_query_api_type()
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
) 更新查询接口地址
|
||||
) 查询可用的 ticket
|
||||
) """
|
||||
await self.get_query_api_type()
|
||||
while self.is_runable:
|
||||
await self.query.refresh_from_db()
|
||||
# 检测同时运行可能导致任务重复
|
||||
if self.query.last_process_at != self._last_process_at:
|
||||
break
|
||||
self._last_process_at = await self.query.update_last_process_at()
|
||||
fuatures = []
|
||||
try:
|
||||
for _ in range(0, Config.get('proxy.concurrent_num', 1) if Config.proxy_able else 1):
|
||||
fuatures.append(asyncio.ensure_future(self.query_tickets()))
|
||||
await asyncio.wait(fuatures)
|
||||
# await self.query_tickets()
|
||||
except Exception as e:
|
||||
Logger.error(f'查询错误 {e}')
|
||||
finally:
|
||||
await self.query.save()
|
||||
# 下单 TODO
|
||||
# await asyncio.sleep(5)
|
||||
if Config.IS_IN_TEST:
|
||||
break
|
||||
|
||||
async def query_tickets(self):
|
||||
""" 余票查询 """
|
||||
query_num = self.query.query_num_next
|
||||
query = copy.deepcopy(self.query)
|
||||
Logger.info('')
|
||||
Logger.info(f">> 第 {query_num} 次查询 {query.route_text.replace('-', '👉')} {datetime.datetime.now()}")
|
||||
for left_date in query.left_dates:
|
||||
query.left_date = left_date
|
||||
if not query.current_is_queryable:
|
||||
continue
|
||||
for station in query.stations:
|
||||
query.left_station, query.arrive_station = station
|
||||
tickets, stay_interval = await self.get_available_tickets(query)
|
||||
for ticket in tickets:
|
||||
# 验证完成,准备下单
|
||||
Logger.info(
|
||||
f"[ 查询到座位可用 出发时间 {query.left_date} 车次 {ticket.train_number} 座位类型 {ticket.available_seat.get('name')} 余票数量 {ticket.ticket_num} ]")
|
||||
if not Config.IS_IN_TEST:
|
||||
await self._make_order_happen(query, ticket)
|
||||
await asyncio.sleep(stay_interval)
|
||||
|
||||
@retry()
|
||||
async def get_available_tickets(self, query: QueryJob):
|
||||
""" 查询余票 """
|
||||
available_tickets = []
|
||||
output_train_nums = []
|
||||
tickets = await self.get_tickets_from_query(query)
|
||||
for ticket in tickets:
|
||||
if self.verify_train_number(ticket, query):
|
||||
output_train_nums.append(ticket.train_number)
|
||||
if not self.is_ticket_valid(ticket):
|
||||
continue
|
||||
available_tickets.append(ticket)
|
||||
tabs = '\t'
|
||||
stay_interval = self.get_query_interval(len(tickets) > 0)
|
||||
output_train_nums = output_train_nums or ['无可下单车次']
|
||||
Logger.info(
|
||||
f"出发日期 {query.left_date}: {query.left_station} - {query.arrive_station} {tabs} 车次 "
|
||||
f"{tabs.join(output_train_nums)} {tabs} 停留 {stay_interval:.2f}")
|
||||
return available_tickets, stay_interval
|
||||
|
||||
@retry
|
||||
async def get_tickets_from_query(self, query: QueryJob) -> List[Ticket]:
|
||||
response = await self.session.otn_query_left_ticket(await self.get_query_api_type(), query)
|
||||
if response.status is not 200:
|
||||
Logger.error(f'车票查询失败, 状态码 {response.status}, {response.reason} 请求被拒绝')
|
||||
raise RetryException(wait_s=1, default=[])
|
||||
result = response.json().get('data.result')
|
||||
if not result:
|
||||
Logger.error(f'车票查询失败, {response.reason}')
|
||||
return []
|
||||
return Ticket.parse_tickets_text(result)
|
||||
|
||||
def is_ticket_valid(self, ticket: Ticket) -> bool:
|
||||
"""
|
||||
验证 Ticket 信息是否可用
|
||||
) 出发日期验证
|
||||
) 车票数量验证
|
||||
) 时间点验证(00:00 - 24:00)
|
||||
) 车次验证
|
||||
) 座位验证
|
||||
) 乘车人数验证
|
||||
:param ticket: 车票信息
|
||||
:param query: 查询条件
|
||||
:return:
|
||||
"""
|
||||
if not self.verify_ticket_num(ticket):
|
||||
return False
|
||||
|
||||
if not self.verify_period(ticket.left_time, self.query.left_periods):
|
||||
return False
|
||||
|
||||
if not self.verify_train_number(ticket, self.query):
|
||||
return False
|
||||
|
||||
if not self.verify_seat(ticket, self.query):
|
||||
return False
|
||||
if not self.verify_member_count(ticket, self.query):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def verify_period(period: str, available_periods: List[str]):
|
||||
""" 时间点验证(00:00 - 24:00) """
|
||||
if not available_periods:
|
||||
return True
|
||||
period = number_of_time_period(period)
|
||||
if period < number_of_time_period(available_periods[0]) or \
|
||||
period > number_of_time_period(available_periods[1]):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def verify_ticket_num(ticket: Ticket):
|
||||
""" 车票数量验证 """
|
||||
return ticket.ticket_num == 'Y' and ticket.order_text == '预订'
|
||||
|
||||
@classmethod
|
||||
def verify_seat(cls, ticket: Ticket, query: QueryJob) -> bool:
|
||||
""" 检查座位是否可用
|
||||
TODO 小黑屋判断 通过 车次 + 座位
|
||||
"""
|
||||
allow_seats = query.allow_seats
|
||||
for seat in allow_seats:
|
||||
seat_id = TrainSeat.ticket_id[seat]
|
||||
raw = ticket.raw[seat_id]
|
||||
if cls.verify_seat_text(raw):
|
||||
# TODO order model
|
||||
ticket.available_seat = {
|
||||
'name': seat,
|
||||
'id': seat_id,
|
||||
'raw': raw,
|
||||
'order_id': TrainSeat.order_id[seat]
|
||||
}
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def verify_seat_text(seat: str) -> bool:
|
||||
return seat != '' and seat != '无' and seat != '*'
|
||||
|
||||
@staticmethod
|
||||
def verify_member_count(ticket: Ticket, query: QueryJob) -> bool:
|
||||
""" 乘车人数验证 """
|
||||
# TODO 多座位类型判断
|
||||
ticket.member_num_take = query.member_num
|
||||
seat_raw = ticket.available_seat.get('raw', '')
|
||||
if not (seat_raw == '有' or query.member_num <= int(seat_raw)):
|
||||
rest_num = int(seat_raw)
|
||||
if query.less_member:
|
||||
ticket.member_num_take = rest_num
|
||||
Logger.info(f'余票数小于乘车人数,当前余票数: {rest_num}, 实际人数 {query.member_num}, 删减人车人数到: {ticket.member_num_take}')
|
||||
else:
|
||||
Logger.info(f'余票数 {rest_num} 小于乘车人数 {query.member_num},放弃此次提交机会')
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def verify_train_number(ticket: Ticket, query: QueryJob) -> bool:
|
||||
""" 车次验证 """
|
||||
if query.allow_train_numbers and ticket.train_number not in query.allow_train_numbers:
|
||||
return False
|
||||
if query.execpt_train_numbers and ticket.train_number in query.execpt_train_numbers:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_query_interval(self, flag: bool = True):
|
||||
""" 获取查询等待间隔,代理开启时无需等待 """
|
||||
if Config.proxy_able:
|
||||
return 0
|
||||
if flag:
|
||||
self.__flag_num = 0
|
||||
interval = Config.get('query_interval', 1)
|
||||
rand = random.randint(1, 10) * 0.05
|
||||
self.__flag_num += 1
|
||||
return round(interval + rand + self.__flag_num * 0.5, 2)
|
||||
|
||||
async def _make_order_happen(self, query: QueryJob, ticket: Ticket):
|
||||
""" 生成下单事件 """
|
||||
if await Cache.in_dark_room(ticket.baby):
|
||||
Logger.info(f'{ticket.train_number} 已关进小黑屋,跳过本次下单')
|
||||
return
|
||||
if query.user_id:
|
||||
# 这里尽量减少网络传输数据大小,只传递必要数据
|
||||
await Event.publish(EventItem(Event.EVENT_ORDER_TICKET, {
|
||||
'user_id': query.user_id,
|
||||
'query_job': {
|
||||
'hash_id': query.hash_id,
|
||||
'left_date': query.left_date,
|
||||
'left_station': query.left_station,
|
||||
'arrive_station': query.arrive_station,
|
||||
'passengers': query.passengers,
|
||||
},
|
||||
'ticket': {
|
||||
'left_date': ticket.left_date,
|
||||
'hash_id': ticket.hash_id,
|
||||
'train_number': ticket.train_number,
|
||||
'secret_str': ticket.secret_str,
|
||||
'available_seat': ticket.available_seat,
|
||||
'member_num_take': ticket.member_num_take,
|
||||
}
|
||||
}))
|
||||
else:
|
||||
# TODO
|
||||
if self._last_notifaction_at and (datetime.datetime.now() - self._last_notifaction_at).seconds < 60:
|
||||
Logger.info(f'{ticket.train_number} 通知间隔过短,跳过本次通知')
|
||||
else:
|
||||
self._last_notifaction_at = datetime.datetime.now()
|
||||
await Notification.ticket_available_notifation(ticket)
|
||||
Logger.info('余票提醒信息发送成功!')
|
||||
await ticket.save()
|
||||
await query.save()
|
||||
|
||||
def stop(self):
|
||||
if self.is_stoped:
|
||||
return
|
||||
self._is_stop = True
|
||||
Logger.info(f'# 任务 id {self.query.id},{self.query.left_station} - {self.query.arrive_station} 已退出 #')
|
||||
|
||||
@property
|
||||
def is_stoped(self):
|
||||
return self._is_stop
|
||||
|
||||
@property
|
||||
def is_runable(self):
|
||||
return not self._is_stop and self.query.is_queryable
|
||||
421
py12306/app/user.py
Normal file
421
py12306/app/user.py
Normal file
@@ -0,0 +1,421 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from base64 import b64decode
|
||||
from typing import List
|
||||
|
||||
from app.app import Config, Logger, Event, App
|
||||
from app.models import User, QueryJob
|
||||
from lib.exceptions import RetryException, PassengerNotFoundException
|
||||
from lib.helper import ShareInstance, UserTypeHelper, TaskManager, retry
|
||||
from lib.request import TrainSession, Session
|
||||
|
||||
|
||||
class CaptchaTool(ShareInstance):
|
||||
""" 登录验证码类 """
|
||||
|
||||
def __init__(self, session=None):
|
||||
self.session = session or TrainSession.share()
|
||||
|
||||
@retry()
|
||||
async def auth_and_get_answer(self):
|
||||
""" 验证验证码,获取答案 """
|
||||
captcha_image64 = await self.get_base64_code()
|
||||
identify_res = await self.identify_captcha(captcha_image64)
|
||||
if identify_res:
|
||||
ret = await self.verify_captcha_answer(identify_res)
|
||||
if ret:
|
||||
return identify_res
|
||||
# TODO retry
|
||||
return False
|
||||
|
||||
@retry()
|
||||
async def get_base64_code(self) -> str:
|
||||
Logger.debug('正在下载验证码...')
|
||||
response = await self.session.passport_captcha_image64()
|
||||
result = response.json()
|
||||
if result.get('image'):
|
||||
Logger.debug('验证码下载成功')
|
||||
return result.get('image')
|
||||
|
||||
raise RetryException()
|
||||
|
||||
@retry()
|
||||
async def identify_captcha(self, captcha_image64: str):
|
||||
Logger.debug('正在识别验证码...')
|
||||
response = await Session.share().identify_captcha(captcha_image64)
|
||||
result = response.json()
|
||||
if result.get('msg') == 'success':
|
||||
pos = result.get('result')
|
||||
ret = self.get_image_position_by_offset(pos)
|
||||
Logger.debug(f'验证码识别成功,{ret}')
|
||||
return ','.join(map(str, ret))
|
||||
|
||||
Logger.error(f'验证码识别失败,{response.reason}')
|
||||
return False
|
||||
|
||||
@retry()
|
||||
async def verify_captcha_answer(self, answer: str) -> bool:
|
||||
""" 校验验证码 """
|
||||
Logger.debug('正在校验验证码...')
|
||||
response = await self.session.passport_captcha_check(answer)
|
||||
result = response.json()
|
||||
if result.get('result_code') == '4':
|
||||
Logger.info('验证码验证成功')
|
||||
return True
|
||||
else:
|
||||
# {'result_message': '验证码校验失败', 'result_code': '5'}
|
||||
Logger.warning('验证码验证失败 错误原因: %s' % result.get('result_message'))
|
||||
# TODO clean session
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_image_position_by_offset(offsets) -> list:
|
||||
""" 坐标转换到像素 """
|
||||
positions = []
|
||||
width = 75
|
||||
height = 75
|
||||
for offset in offsets:
|
||||
random_x = random.randint(-5, 5)
|
||||
random_y = random.randint(-5, 5)
|
||||
offset = int(offset)
|
||||
x = width * ((offset - 1) % 4 + 1) - width / 2 + random_x
|
||||
y = height * math.ceil(offset / 4) - height / 2 + random_y
|
||||
positions.append(int(x))
|
||||
positions.append(int(y))
|
||||
return positions
|
||||
|
||||
|
||||
class TrainUserManager(TaskManager):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
async def run(self):
|
||||
self.fuatures.append(asyncio.ensure_future(self.subscribe_loop()))
|
||||
while True:
|
||||
await self.make_tasks()
|
||||
self.clean_fuatures()
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
@property
|
||||
async def task_total(self) -> int:
|
||||
return await User.filter(enable=True).count()
|
||||
|
||||
@property
|
||||
async def capacity_num(self) -> int:
|
||||
if not App.check_12306_service_time(): # debug 拦截,非服务时间登录不可用
|
||||
Logger.warning('12306 休息时间,已停用登录')
|
||||
return 0
|
||||
return await super().capacity_num
|
||||
|
||||
async def subscribe_loop(self):
|
||||
while True:
|
||||
event = await Event.subscribe()
|
||||
if event.name == Event.EVENT_VERIFY_QUERY_JOB:
|
||||
await self.verify_query_job(event.data)
|
||||
|
||||
async def make_tasks(self):
|
||||
if await self.is_overflow: # 丢弃多余任务
|
||||
self.tasks.popitem()
|
||||
for user in await User.all():
|
||||
if self.get_task(user.id):
|
||||
if not user.enable:
|
||||
self.stop_and_drop(user.id)
|
||||
Logger.debug(f'任务 {user.name_text} 不可用,已停止该任务')
|
||||
continue
|
||||
if await self.is_full:
|
||||
continue
|
||||
if Config.redis_able and user.is_alive:
|
||||
Logger.debug(f'任务 {user.name_text} 正在运行中已跳过')
|
||||
continue
|
||||
await self.handle_task(user)
|
||||
|
||||
async def handle_task(self, user: User):
|
||||
""" 添加任务 """
|
||||
train_user = TrainUser(user)
|
||||
Logger.info(f'# 用户 {user.name} 已添加到任务中 #')
|
||||
self.add_task(train_user.run(), user.id, train_user)
|
||||
|
||||
async def verify_query_job(self, data: dict):
|
||||
""" 验证查询任务信息
|
||||
) 乘客信息验证
|
||||
"""
|
||||
if not isinstance(data, dict) or not data.get('id'):
|
||||
return
|
||||
user_id = data.get('user_id', 0)
|
||||
task: TrainUser = self.get_task(user_id)
|
||||
if not task or not task.is_ready:
|
||||
return
|
||||
query_job = await QueryJob.filter(id=data['id']).first()
|
||||
if not query_job:
|
||||
return
|
||||
ret = task.verify_members(query_job)
|
||||
if ret:
|
||||
query_job.status = query_job.Status.Normal
|
||||
else:
|
||||
query_job.status = query_job.Status.Error
|
||||
query_job.last_error = '乘客验证失败'
|
||||
await query_job.save()
|
||||
return
|
||||
|
||||
|
||||
class TrainUser(ShareInstance):
|
||||
def __init__(self, user: User) -> None:
|
||||
super().__init__()
|
||||
self.session = TrainSession()
|
||||
self.user: User = user
|
||||
self._last_process_at = user.last_process_at
|
||||
self._is_ready = False
|
||||
self._is_stop = False
|
||||
self.is_ordering = False
|
||||
|
||||
async def run(self):
|
||||
await self.login_user()
|
||||
while not self.is_stoped:
|
||||
await self.user.refresh_from_db()
|
||||
# 检测同时运行可能导致任务重复
|
||||
if self.user.last_process_at != self._last_process_at:
|
||||
break
|
||||
self._last_process_at = await self.user.update_last_process_at()
|
||||
await self._heartbeat_check()
|
||||
await asyncio.sleep(Config.USER_HEARTBEAT_INTERVAL)
|
||||
if Config.IS_IN_TEST:
|
||||
break
|
||||
|
||||
@retry
|
||||
async def login_user(self):
|
||||
"""
|
||||
用户登录
|
||||
) 检查用户是否可以恢复
|
||||
) 获取浏览器 ID
|
||||
) 获取验证码识别结果
|
||||
) 请求登录
|
||||
) 获取用户详细信息
|
||||
:param user:
|
||||
:return:
|
||||
"""
|
||||
if await self._try_restore_user():
|
||||
return True
|
||||
data = {
|
||||
'username': self.user.name,
|
||||
'password': self.user.password,
|
||||
'appid': 'otn'
|
||||
}
|
||||
answer = await CaptchaTool(self.session).auth_and_get_answer()
|
||||
data['answer'] = answer
|
||||
await self.update_device_id()
|
||||
response = await self.session.passport_web_login(data)
|
||||
result = response.json()
|
||||
if result.get('result_code') == 0: # 登录成功
|
||||
return await self.handle_login_next_step()
|
||||
elif result.get('result_code') == 2: # 账号之内错误
|
||||
Logger.error(f"登录失败,错误原因: {result.get('result_message')}")
|
||||
else:
|
||||
Logger.error(f"登录失败,{result.get('result_message', '请求被限制')}")
|
||||
raise RetryException(wait_s=5)
|
||||
|
||||
async def handle_login_next_step(self):
|
||||
"""
|
||||
login 获得 cookie uamtk
|
||||
auth/uamtk 不请求,会返回 uamtk票据内容为空
|
||||
/otn/uamauthclient 能拿到用户名
|
||||
"""
|
||||
uamtk = await self.get_auth_uamtk()
|
||||
user_name = await self.get_auth_username(uamtk)
|
||||
await self.login_succeeded()
|
||||
self._welcome_user()
|
||||
return True
|
||||
|
||||
async def update_device_id(self):
|
||||
""" 获取加密后的浏览器特征 ID """
|
||||
response = await Session.share().browser_device_id_url()
|
||||
if response.status == 200:
|
||||
result = response.json()
|
||||
response = await self.session.browser_device_id(b64decode(result['id']).decode())
|
||||
text = response.text()
|
||||
if text.find('callbackFunction') >= 0:
|
||||
result = text[18:-2]
|
||||
result = json.loads(result)
|
||||
self.session.session.cookie_jar.update_cookies({
|
||||
'RAIL_EXPIRATION': result.get('exp'),
|
||||
'RAIL_DEVICEID': result.get('dfp'),
|
||||
})
|
||||
# TODO 错误处理
|
||||
return False
|
||||
|
||||
@retry
|
||||
async def get_auth_uamtk(self) -> str:
|
||||
""" 获取登录 uamtk """
|
||||
response = await self.session.passport_web_auth_uamtk()
|
||||
result = response.json()
|
||||
if result.get('newapptk'):
|
||||
return result.get('newapptk')
|
||||
raise RetryException('获取 uamtk 失败')
|
||||
|
||||
async def get_auth_username(self, uamtk: str):
|
||||
""" 获取登录用户名 """
|
||||
response = await self.session.otn_uamauthclient(uamtk)
|
||||
result = response.json()
|
||||
if result.get('username'):
|
||||
return result.get('username')
|
||||
raise RetryException('获取 username 失败')
|
||||
|
||||
async def login_succeeded(self):
|
||||
""" 登录成功
|
||||
) 更新用户信息
|
||||
"""
|
||||
await self.update_user_info()
|
||||
self._save_user_cookies()
|
||||
await self.user.save()
|
||||
self._is_ready = True
|
||||
|
||||
def _welcome_user(self):
|
||||
Logger.info(f'# 欢迎回来,{self.user.real_name} #')
|
||||
|
||||
def _save_user_cookies(self):
|
||||
self.user.last_cookies = self.session.cookie_dumps()
|
||||
|
||||
async def update_user_info(self):
|
||||
ret: dict = await self.get_user_info()
|
||||
self.user.real_name = ret.get('name', '')
|
||||
self.user.user_id = ret.get('user_name', '')
|
||||
# 更新最后心跳
|
||||
self.user.last_heartbeat = datetime.datetime.now()
|
||||
# 乘客列表
|
||||
self.user.passengers = await self.get_user_passengers()
|
||||
|
||||
@retry
|
||||
async def get_user_info(self) -> dict:
|
||||
""" 获取用户详情 """
|
||||
response = await self.session.otn_modify_user_init_query_user_info()
|
||||
result = response.json()
|
||||
user_info = result.get('data.userDTO.loginUserDTO')
|
||||
if not user_info:
|
||||
raise RetryException('获取用户详情失败,请检测用户是否登录')
|
||||
return user_info
|
||||
|
||||
@retry
|
||||
async def get_user_passengers(self):
|
||||
""" 获取乘客列表 """
|
||||
response = await self.session.otn_confirm_passenger_get_passenger()
|
||||
result = response.json()
|
||||
if result.get('data.normal_passengers'):
|
||||
return result.get('data.normal_passengers')
|
||||
Logger.error(f"获取用户乘客列表失败,{result.get('messages', response.reason)}")
|
||||
raise RetryException
|
||||
|
||||
async def _heartbeat_check(self):
|
||||
""" 心跳检测 """
|
||||
if (datetime.datetime.now() - await self.get_last_heartbeat()).seconds > Config.USER_HEARTBEAT_INTERVAL:
|
||||
return True
|
||||
if not await self.is_still_logged():
|
||||
self._is_ready = False
|
||||
self.user.last_cookies = None
|
||||
await self.user.save()
|
||||
return await self.login_user()
|
||||
|
||||
await self.login_succeeded()
|
||||
Logger.info(f'用户 {self.user.real_name} 心跳正常,下次检测 {Config.USER_HEARTBEAT_INTERVAL} 秒后')
|
||||
|
||||
async def _try_restore_user(self) -> bool:
|
||||
""" 尝试通过 Cookie 恢复用户 """
|
||||
if not self.user.last_cookies:
|
||||
return False
|
||||
self.session.cookie_loads(self.user.last_cookies)
|
||||
if not await self.is_still_logged():
|
||||
# 清空 Cookie
|
||||
self.session.cookie_clean()
|
||||
Logger.info('用户恢复失败,用户状态已过期,正在重新登录...')
|
||||
return False
|
||||
|
||||
await self.login_succeeded()
|
||||
Logger.info(f'# 用户恢复成功,欢迎回来,{self.user.real_name} #')
|
||||
return True
|
||||
|
||||
async def get_last_heartbeat(self) -> datetime.datetime:
|
||||
return self.user.last_heartbeat
|
||||
|
||||
@retry
|
||||
async def is_still_logged(self) -> bool:
|
||||
""" 验证当前登录状态 """
|
||||
response = await self.session.otn_login_conf()
|
||||
result = response.json()
|
||||
if response.status is not 200:
|
||||
raise RetryException
|
||||
if result.get('data.is_login') == 'Y':
|
||||
return True
|
||||
return False
|
||||
|
||||
def format_passengers(self, members: list) -> List[dict]:
|
||||
""" 获取格式化后的乘客列表
|
||||
[{
|
||||
name: '贾宝玉',
|
||||
type: 1,
|
||||
id_card: 0000000000000000000,
|
||||
type_text: '成人',
|
||||
enc_str: 'xxxxxxxx'
|
||||
}]
|
||||
"""
|
||||
ret = []
|
||||
_members_tmp = []
|
||||
for member in members:
|
||||
is_member_id = isinstance(member, int)
|
||||
|
||||
def find_passenger():
|
||||
for item in self.user.passengers:
|
||||
if not is_member_id and member[0] == '*':
|
||||
item['passenger_type'] = UserTypeHelper.ADULT
|
||||
if is_member_id and int(item.get('index_id', -1)) == member or \
|
||||
item.get('passenger_name') == member:
|
||||
if member in _members_tmp:
|
||||
item['passenger_type'] = UserTypeHelper.CHILD
|
||||
return item
|
||||
|
||||
passenger = find_passenger()
|
||||
if not passenger:
|
||||
raise PassengerNotFoundException(member)
|
||||
|
||||
_members_tmp.append(member)
|
||||
ret.append({
|
||||
'name': passenger.get('passenger_name'),
|
||||
'id_card': passenger.get('passenger_id_no'),
|
||||
'id_card_type': passenger.get('passenger_id_type_code'),
|
||||
'mobile': passenger.get('mobile_no'),
|
||||
'type': passenger.get('passenger_type'),
|
||||
'type_text': UserTypeHelper.dicts.get(int(passenger.get('passenger_type'))),
|
||||
'enc_str': passenger.get('allEncStr')
|
||||
})
|
||||
|
||||
return ret
|
||||
|
||||
def verify_members(self, query: QueryJob):
|
||||
try:
|
||||
passengers = self.format_passengers(query.members)
|
||||
if passengers:
|
||||
result = [passenger.get('name') + '(' + passenger.get('type_text') + ')' for passenger in passengers]
|
||||
Logger.info(f"# 乘客验证成功,{query.route_text} {', '.join(result)} #")
|
||||
query.passengers = passengers
|
||||
return True
|
||||
except PassengerNotFoundException as e:
|
||||
Logger.warning(f"# 乘客验证失败,账号 {self.user.name} 中未找到该乘客:{e} #")
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_stoped(self) -> bool:
|
||||
return self._is_stop
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
return self._is_ready
|
||||
|
||||
async def stop(self):
|
||||
if self.is_stoped:
|
||||
return
|
||||
self._is_ready = False
|
||||
self._is_stop = True
|
||||
self._save_user_cookies()
|
||||
await self.user.save()
|
||||
Logger.info(f'# 用户 {self.user.real_name} 已退出 #')
|
||||
@@ -1,78 +0,0 @@
|
||||
from os import path
|
||||
|
||||
# from py12306.helpers.config import Config
|
||||
|
||||
# 12306 账号
|
||||
USER_ACCOUNTS = []
|
||||
|
||||
# 查询任务
|
||||
QUERY_JOBS = []
|
||||
|
||||
# 查询间隔
|
||||
QUERY_INTERVAL = 1
|
||||
|
||||
# 用户心跳检测间隔
|
||||
USER_HEARTBEAT_INTERVAL = 120
|
||||
|
||||
# 多线程查询
|
||||
QUERY_JOB_THREAD_ENABLED = 0
|
||||
|
||||
# 打码平台账号
|
||||
AUTO_CODE_ACCOUNT = {
|
||||
'user': '',
|
||||
'pwd': ''
|
||||
}
|
||||
|
||||
SEAT_TYPES = {
|
||||
'特等座': 25,
|
||||
'商务座': 32,
|
||||
'一等座': 31,
|
||||
'二等座': 30,
|
||||
'软卧': 23,
|
||||
'硬卧': 28,
|
||||
'硬座': 29,
|
||||
'无座': 26,
|
||||
}
|
||||
|
||||
ORDER_SEAT_TYPES = {
|
||||
'特等座': 'P',
|
||||
'商务座': 9,
|
||||
'一等座': 'M',
|
||||
'二等座': 'O',
|
||||
'软卧': 4,
|
||||
'硬卧': 3,
|
||||
'硬座': 1,
|
||||
'无座': 1,
|
||||
}
|
||||
|
||||
PROJECT_DIR = path.dirname(path.dirname(path.abspath(__file__))) + '/'
|
||||
|
||||
# Query
|
||||
RUNTIME_DIR = PROJECT_DIR + 'runtime/'
|
||||
QUERY_DATA_DIR = RUNTIME_DIR + 'query/'
|
||||
USER_DATA_DIR = RUNTIME_DIR + 'user/'
|
||||
|
||||
STATION_FILE = PROJECT_DIR + 'data/stations.txt'
|
||||
CONFIG_FILE = PROJECT_DIR + 'env.py'
|
||||
|
||||
# 语音验证码
|
||||
NOTIFICATION_BY_VOICE_CODE = 0
|
||||
NOTIFICATION_VOICE_CODE_PHONE = ''
|
||||
NOTIFICATION_API_APP_CODE = ''
|
||||
|
||||
if path.exists(CONFIG_FILE):
|
||||
exec(open(CONFIG_FILE, encoding='utf8').read())
|
||||
|
||||
|
||||
class UserType:
|
||||
ADULT = 1
|
||||
CHILD = 2
|
||||
STUDENT = 3
|
||||
SOLDIER = 4
|
||||
|
||||
dicts = {
|
||||
'成人': ADULT,
|
||||
'儿童': CHILD,
|
||||
'学生': STUDENT,
|
||||
'残疾军人、伤残人民警察': SOLDIER,
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
from py12306 import config
|
||||
from py12306.log.common_log import CommonLog
|
||||
from py12306.vender.ruokuai.main import RKClient
|
||||
|
||||
|
||||
class OCR:
|
||||
"""
|
||||
图片识别
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_img_position(cls, img_path):
|
||||
"""
|
||||
获取图像坐标
|
||||
:param img_path:
|
||||
:return:
|
||||
"""
|
||||
self = cls()
|
||||
return self.get_img_position_by_ruokuai(img_path)
|
||||
|
||||
def get_img_position_by_ruokuai(self, img_path):
|
||||
ruokuai_account = config.AUTO_CODE_ACCOUNT
|
||||
soft_id = '119671'
|
||||
soft_key = '6839cbaca1f942f58d2760baba5ed987'
|
||||
rc = RKClient(ruokuai_account.get('user'), ruokuai_account.get('pwd'), soft_id, soft_key)
|
||||
im = open(img_path, 'rb').read()
|
||||
result = rc.rk_create(im, 6113)
|
||||
if "Result" in result:
|
||||
return self.get_image_position_by_offset(list(result['Result']))
|
||||
CommonLog.print_auto_code_fail(result.get("Error", '-'))
|
||||
return None
|
||||
|
||||
def get_image_position_by_offset(self, offsets):
|
||||
positions = []
|
||||
for offset in offsets:
|
||||
if offset == '1':
|
||||
y = 46
|
||||
x = 42
|
||||
elif offset == '2':
|
||||
y = 46
|
||||
x = 105
|
||||
elif offset == '3':
|
||||
y = 45
|
||||
x = 184
|
||||
elif offset == '4':
|
||||
y = 48
|
||||
x = 256
|
||||
elif offset == '5':
|
||||
y = 36
|
||||
x = 117
|
||||
elif offset == '6':
|
||||
y = 112
|
||||
x = 115
|
||||
elif offset == '7':
|
||||
y = 114
|
||||
x = 181
|
||||
elif offset == '8':
|
||||
y = 111
|
||||
x = 252
|
||||
else:
|
||||
pass
|
||||
positions.append(x)
|
||||
positions.append(y)
|
||||
return positions
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
# code_result = AuthCode.get_auth_code()
|
||||
@@ -1,398 +0,0 @@
|
||||
# coding=utf-8
|
||||
# 查询余票
|
||||
import time
|
||||
|
||||
BASE_URL_OF_12306 = 'https://kyfw.12306.cn'
|
||||
|
||||
LEFT_TICKETS = {
|
||||
"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",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.01,
|
||||
"is_logger": False,
|
||||
"is_json": True,
|
||||
"is_cdn": True,
|
||||
}
|
||||
|
||||
API_BASE_LOGIN = {
|
||||
"url": BASE_URL_OF_12306 + '/passport/web/login',
|
||||
"method": "post",
|
||||
"is_cdn": True,
|
||||
}
|
||||
|
||||
API_USER_CHECK = {
|
||||
"url": BASE_URL_OF_12306 + '/otn/login/checkUser',
|
||||
"method": "post",
|
||||
"is_cdn": True,
|
||||
}
|
||||
|
||||
API_AUTH_CODE_DOWNLOAD = {
|
||||
'url': BASE_URL_OF_12306 + '/passport/captcha/captcha-image?login_site=E&module=login&rand=sjrand&_={random}'
|
||||
}
|
||||
API_AUTH_CODE_CHECK = {
|
||||
'url': BASE_URL_OF_12306 + '/passport/captcha/captcha-check?answer={answer}&rand=sjrand&login_site=E&_={random}'
|
||||
}
|
||||
API_AUTH_UAMTK = {
|
||||
'url': BASE_URL_OF_12306 + '/passport/web/auth/uamtk'
|
||||
}
|
||||
API_AUTH_UAMAUTHCLIENT = {
|
||||
'url': BASE_URL_OF_12306 + '/otn/uamauthclient'
|
||||
}
|
||||
|
||||
API_USER_INFO = {
|
||||
'url': BASE_URL_OF_12306 + '/otn/modifyUser/initQueryUserInfoApi'
|
||||
}
|
||||
API_USER_PASSENGERS = BASE_URL_OF_12306 + '/otn/confirmPassenger/getPassengerDTOs'
|
||||
API_SUBMIT_ORDER_REQUEST = BASE_URL_OF_12306 + '/otn/leftTicket/submitOrderRequest'
|
||||
API_CHECK_ORDER_INFO = BASE_URL_OF_12306 + '/otn/confirmPassenger/checkOrderInfo'
|
||||
API_INITDC_URL = BASE_URL_OF_12306 + '/otn/confirmPassenger/initDc' # 生成订单时需要先请求这个页面
|
||||
API_GET_QUEUE_COUNT = BASE_URL_OF_12306 + '/otn/confirmPassenger/getQueueCount'
|
||||
API_CONFIRM_SINGLE_FOR_QUEUE = BASE_URL_OF_12306 + '/otn/confirmPassenger/confirmSingleForQueue'
|
||||
API_QUERY_ORDER_WAIT_TIME = BASE_URL_OF_12306 + '/otn/confirmPassenger/queryOrderWaitTime?{}' # 排队查询
|
||||
|
||||
API_NOTIFICATION_BY_VOICE_CODE = 'http://ali-voice.showapi.com/sendVoice?'
|
||||
|
||||
urls = {
|
||||
"auth": { # 登录接口
|
||||
"req_url": "/passport/web/auth/uamtk",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/passport?redirect=/otn/login/userLogin",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"Content-Type": 1,
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"login": { # 登录接口
|
||||
"req_url": "/passport/web/login",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/login/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"Content-Type": 1,
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
|
||||
},
|
||||
"left_ticket_init": { # 登录接口
|
||||
"req_url": "/otn/leftTicket/init",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/login/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"Content-Type": 1,
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
|
||||
},
|
||||
"getCodeImg": { # 登录验证码
|
||||
"req_url": "/passport/captcha/captcha-image?login_site=E&module=login&rand=sjrand&{0}",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/login/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"Content-Type": 1,
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
"not_decode": True,
|
||||
},
|
||||
"codeCheck": { # 验证码校验
|
||||
"req_url": "/passport/captcha/captcha-check",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/login/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"Content-Type": 1,
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"loginInit": { # 登录页面
|
||||
"req_url": "/otn/login/init",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/index/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 1,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
},
|
||||
"loginInitCdn": { # 登录页面
|
||||
"req_url": "/otn/login/init",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/index/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 1,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": False,
|
||||
"is_test_cdn": True,
|
||||
"is_json": False,
|
||||
},
|
||||
"getUserInfo": { # 获取用户信息
|
||||
"req_url": "/otn/index/initMy12306",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/passport?redirect=/otn/login/userLogin",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.01,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
},
|
||||
"userLogin": { # 用户登录
|
||||
"req_url": "/otn/login/userLogin",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/passport?redirect=/otn/login/userLogin",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"uamauthclient": { # 登录
|
||||
"req_url": "/otn/uamauthclient",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/passport?redirect=/otn/login/userLogin",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"Content-Type": 1,
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"initdc_url": { # 生成订单页面
|
||||
"req_url": "/otn/confirmPassenger/initDc",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 1,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
},
|
||||
"GetJS": { # 订单页面js
|
||||
"req_url": "/otn/HttpZF/GetJS",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
},
|
||||
"odxmfwg": { # 订单页面js
|
||||
"req_url": "/otn/dynamicJs/odxmfwg",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
},
|
||||
"get_passengerDTOs": { # 获取乘车人
|
||||
"req_url": "/otn/confirmPassenger/getPassengerDTOs",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.1,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"select_url": { # 查询余票
|
||||
"req_url": "/otn/{3}?leftTicketDTO.train_date={0}&leftTicketDTO.from_station={1}&leftTicketDTO.to_station={2}&purpose_codes=ADULT",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.01,
|
||||
"is_logger": False,
|
||||
"is_json": True,
|
||||
"is_cdn": True,
|
||||
},
|
||||
"check_user_url": { # 检查用户登录
|
||||
"req_url": "/otn/login/checkUser",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.3,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"submit_station_url": { # 提交订单
|
||||
"req_url": "/otn/leftTicket/submitOrderRequest",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"checkOrderInfoUrl": { # 检查订单信息规范
|
||||
"req_url": "/otn/confirmPassenger/checkOrderInfo",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"getQueueCountUrl": { # 剩余余票数
|
||||
"req_url": "/otn/confirmPassenger/getQueueCount",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"checkQueueOrderUrl": { # 订单队列排队
|
||||
"req_url": "/otn/confirmPassenger/confirmSingleForQueue",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"checkRandCodeAnsyn": { # 暂时没用到
|
||||
"req_url": "/otn/passcodeNew/checkRandCodeAnsyn",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"codeImgByOrder": { # 订单页面验证码
|
||||
"req_url": "/otn/passcodeNew/getPassCodeNew?module=passenger&rand=randp&{}",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
},
|
||||
"queryOrderWaitTimeUrl": { # 订单等待页面
|
||||
"req_url": "/otn/confirmPassenger/queryOrderWaitTime?random={0}&tourFlag=dc&_json_att=",
|
||||
"req_type": "get",
|
||||
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"queryMyOrderNoCompleteUrl": { # 订单查询页面
|
||||
"req_url": "/otn/queryOrder/queryMyOrderNoComplete",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/queryOrder/initNoComplete",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"initNoCompleteUrl": { # 获取订单列表
|
||||
"req_url": "/otn/queryOrder/initNoComplete",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/queryOrder/initNoComplete",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": False,
|
||||
"is_json": False,
|
||||
},
|
||||
"cancelNoCompleteMyOrder": { # 取消订单
|
||||
"req_url": "/otn/queryOrder/cancelNoCompleteMyOrder",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/queryOrder/initNoComplete",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"autoSubmitOrderRequest": { # 快速自动提交订单
|
||||
"req_url": "/otn/confirmPassenger/autoSubmitOrderRequest",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"Content-Type": 1,
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"getQueueCountAsync": { # 快速获取订单数据
|
||||
"req_url": "/otn/confirmPassenger/getQueueCountAsync",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init",
|
||||
"Host": "kyfw.12306.cn",
|
||||
"Content-Type": 1,
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"confirmSingleForQueueAsys": { # 快速订单排队
|
||||
"req_url": "/otn/confirmPassenger/confirmSingleForQueueAsys",
|
||||
"req_type": "post",
|
||||
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init",
|
||||
"Content-Type": 1,
|
||||
"Host": "kyfw.12306.cn",
|
||||
"re_try": 10,
|
||||
"re_time": 0.01,
|
||||
"s_time": 0.1,
|
||||
"is_logger": True,
|
||||
"is_json": True,
|
||||
},
|
||||
"cdn_host": {
|
||||
"req_url": "http://ping.chinaz.com/kyfw.12306.cn",
|
||||
"req_type": "post"
|
||||
},
|
||||
"cdn_list": {
|
||||
"req_url": "http://ping.chinaz.com/iframe.ashx?t=ping&callback=jQuery111304824429956769827_{}".format(
|
||||
int(round(time.time() * 1000))),
|
||||
"req_type": "post"
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
from py12306.helpers.func import *
|
||||
from py12306.config import *
|
||||
from py12306.helpers.notification import Notification
|
||||
from py12306.log.common_log import CommonLog
|
||||
from py12306.log.order_log import OrderLog
|
||||
|
||||
|
||||
def app_available_check():
|
||||
# return True # Debug
|
||||
now = time_now()
|
||||
if now.hour >= 23 or now.hour < 6:
|
||||
CommonLog.add_quick_log(CommonLog.MESSAGE_12306_IS_CLOSED.format(time_now())).flush()
|
||||
open_time = datetime.datetime(now.year, now.month, now.day, 6)
|
||||
if open_time < now:
|
||||
open_time += datetime.timedelta(1)
|
||||
sleep((open_time - now).seconds)
|
||||
return True
|
||||
|
||||
|
||||
class App:
|
||||
"""
|
||||
程序主类
|
||||
TODO 需要完善
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def check_auto_code(cls):
|
||||
if not config.AUTO_CODE_ACCOUNT.get('user') or not config.AUTO_CODE_ACCOUNT.get('pwd'):
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def check_user_account_is_empty(cls):
|
||||
if config.USER_ACCOUNTS:
|
||||
for account in config.USER_ACCOUNTS:
|
||||
if account:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def test_send_notifications(cls):
|
||||
if config.NOTIFICATION_BY_VOICE_CODE: # 语音通知
|
||||
CommonLog.add_quick_log(CommonLog.MESSAGE_TEST_SEND_VOICE_CODE).flush()
|
||||
Notification.voice_code(config.NOTIFICATION_VOICE_CODE_PHONE, '张三',
|
||||
OrderLog.MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_VOICE_CODE_CONTENT.format('北京',
|
||||
'深圳'))
|
||||
|
||||
@classmethod
|
||||
def run_check(cls):
|
||||
"""
|
||||
待优化
|
||||
:return:
|
||||
"""
|
||||
if not cls.check_auto_code():
|
||||
CommonLog.add_quick_log(CommonLog.MESSAGE_CHECK_AUTO_CODE_FAIL).flush(exit=True)
|
||||
if not cls.check_user_account_is_empty():
|
||||
CommonLog.add_quick_log(CommonLog.MESSAGE_CHECK_EMPTY_USER_ACCOUNT).flush(exit=True)
|
||||
if Const.IS_TEST_NOTIFICATION: cls.test_send_notifications()
|
||||
@@ -1,76 +0,0 @@
|
||||
import random
|
||||
import time
|
||||
|
||||
from requests.exceptions import SSLError
|
||||
|
||||
from py12306.helpers.OCR import OCR
|
||||
from py12306.helpers.api import API_AUTH_CODE_DOWNLOAD, API_AUTH_CODE_CHECK
|
||||
from py12306.helpers.request import Request
|
||||
from py12306.helpers.func import *
|
||||
from py12306.log.common_log import CommonLog
|
||||
from py12306.log.user_log import UserLog
|
||||
|
||||
|
||||
class AuthCode:
|
||||
"""
|
||||
验证码类
|
||||
"""
|
||||
session = None
|
||||
data_path = config.RUNTIME_DIR
|
||||
retry_time = 5
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
@classmethod
|
||||
def get_auth_code(cls, session):
|
||||
self = cls(session)
|
||||
img_path = self.download_code()
|
||||
position = OCR.get_img_position(img_path)
|
||||
if not position: # 打码失败
|
||||
return self.retry_get_auth_code()
|
||||
|
||||
answer = ','.join(map(str, position))
|
||||
if not self.check_code(answer):
|
||||
return self.retry_get_auth_code()
|
||||
return position
|
||||
|
||||
def retry_get_auth_code(self): # TODO 安全次数检测
|
||||
CommonLog.add_quick_log(CommonLog.MESSAGE_RETRY_AUTH_CODE.format(self.retry_time))
|
||||
time.sleep(self.retry_time)
|
||||
return self.get_auth_code(self.session)
|
||||
|
||||
def download_code(self):
|
||||
url = API_AUTH_CODE_DOWNLOAD.get('url').format(random=random.random())
|
||||
code_path = self.data_path + 'code.png'
|
||||
try:
|
||||
UserLog.add_quick_log(UserLog.MESSAGE_DOWNLAODING_THE_CODE).flush()
|
||||
response = self.session.save_to_file(url, code_path) # TODO 返回错误情况
|
||||
except SSLError as e:
|
||||
UserLog.add_quick_log(
|
||||
UserLog.MESSAGE_DOWNLAOD_AUTH_CODE_FAIL.format(e, self.retry_time)).flush()
|
||||
time.sleep(self.retry_time)
|
||||
return self.download_code()
|
||||
return code_path
|
||||
|
||||
def check_code(self, answer):
|
||||
"""
|
||||
校验验证码
|
||||
:return:
|
||||
"""
|
||||
url = API_AUTH_CODE_CHECK.get('url').format(answer=answer, random=random.random())
|
||||
response = self.session.get(url)
|
||||
result = response.json()
|
||||
if result.get('result_code') == '4':
|
||||
UserLog.add_quick_log(UserLog.MESSAGE_CODE_AUTH_SUCCESS).flush()
|
||||
return True
|
||||
else:
|
||||
UserLog.add_quick_log(
|
||||
UserLog.MESSAGE_CODE_AUTH_FAIL.format(result.get('result_message'), self.retry_time)).flush()
|
||||
self.session.cookies.clear_session_cookies()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
code_result = AuthCode.get_auth_code()
|
||||
@@ -1,14 +0,0 @@
|
||||
# from py12306.config import get_value_by_key
|
||||
from py12306.config import get_value_by_key
|
||||
|
||||
|
||||
class BaseConfig:
|
||||
AA = 'USER_ACCOUNTS'
|
||||
|
||||
|
||||
class Config(BaseConfig):
|
||||
|
||||
@classmethod
|
||||
def get(cls, key, default=None):
|
||||
self = cls()
|
||||
return get_value_by_key(key);
|
||||
@@ -1,126 +0,0 @@
|
||||
import datetime
|
||||
import random
|
||||
import threading
|
||||
import functools
|
||||
|
||||
from time import sleep
|
||||
|
||||
from py12306 import config
|
||||
|
||||
|
||||
|
||||
def singleton(cls):
|
||||
"""
|
||||
将一个类作为单例
|
||||
来自 https://wiki.python.org/moin/PythonDecoratorLibrary#Singleton
|
||||
"""
|
||||
|
||||
cls.__new_original__ = cls.__new__
|
||||
|
||||
@functools.wraps(cls.__new__)
|
||||
def singleton_new(cls, *args, **kw):
|
||||
it = cls.__dict__.get('__it__')
|
||||
if it is not None:
|
||||
return it
|
||||
|
||||
cls.__it__ = it = cls.__new_original__(cls, *args, **kw)
|
||||
it.__init_original__(*args, **kw)
|
||||
return it
|
||||
|
||||
cls.__new__ = singleton_new
|
||||
cls.__init_original__ = cls.__init__
|
||||
cls.__init__ = object.__init__
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
# 座位
|
||||
def get_seat_number_by_name(name):
|
||||
return config.SEAT_TYPES[name]
|
||||
|
||||
|
||||
def get_seat_name_by_number(number):
|
||||
return [k for k, v in config.SEAT_TYPES.items() if v == number].pop()
|
||||
|
||||
|
||||
# 初始化间隔
|
||||
def init_interval_by_number(number):
|
||||
if isinstance(number, dict):
|
||||
min = float(number.get('min'))
|
||||
max = float(number.get('max'))
|
||||
else:
|
||||
min = number / 2
|
||||
max = number
|
||||
return {
|
||||
'min': min,
|
||||
'max': max
|
||||
}
|
||||
|
||||
|
||||
def get_interval_num(interval, decimal=2):
|
||||
return round(random.uniform(interval.get('min'), interval.get('max')), decimal)
|
||||
|
||||
|
||||
def stay_second(second, call_back=None):
|
||||
sleep(second)
|
||||
if call_back:
|
||||
return call_back()
|
||||
|
||||
|
||||
def sleep_forever():
|
||||
"""
|
||||
当不是主线程时,假象停止
|
||||
:return:
|
||||
"""
|
||||
if not is_main_thread():
|
||||
while True: sleep(10000000)
|
||||
|
||||
|
||||
def is_main_thread():
|
||||
return threading.current_thread() == threading.main_thread()
|
||||
|
||||
|
||||
def current_thread_id():
|
||||
return threading.current_thread().ident
|
||||
|
||||
|
||||
def time_now():
|
||||
return datetime.datetime.now()
|
||||
|
||||
|
||||
def create_thread_and_run(jobs, callback_name, wait=True, daemon=True):
|
||||
threads = []
|
||||
if not isinstance(jobs, list):
|
||||
jobs = [jobs]
|
||||
for job in jobs:
|
||||
thread = threading.Thread(target=getattr(job, callback_name))
|
||||
thread.setDaemon(daemon)
|
||||
thread.start()
|
||||
threads.append(thread)
|
||||
if wait:
|
||||
for thread in threads: thread.join()
|
||||
|
||||
|
||||
def dict_find_key_by_value(data, value, default=None):
|
||||
result = [k for k, v in data.items() if v == value]
|
||||
return result.pop() if len(result) else default
|
||||
|
||||
|
||||
def array_dict_find_by_key_value(data, key, value, default=None):
|
||||
result = [v for k, v in enumerate(data) if key in v and v[key] == value]
|
||||
return result.pop() if len(result) else default
|
||||
|
||||
|
||||
def get_true_false_text(value, true='', false=''):
|
||||
if value: return true
|
||||
return false
|
||||
|
||||
|
||||
def sleep_forever_when_in_test():
|
||||
if Const.IS_TEST: sleep_forever()
|
||||
|
||||
|
||||
@singleton
|
||||
class Const:
|
||||
IS_TEST = False
|
||||
IS_TEST_NOTIFICATION = False
|
||||
@@ -1,64 +0,0 @@
|
||||
import urllib
|
||||
|
||||
from py12306 import config
|
||||
from py12306.helpers.api import *
|
||||
from py12306.helpers.request import Request
|
||||
from py12306.log.common_log import CommonLog
|
||||
|
||||
|
||||
class Notification():
|
||||
"""
|
||||
通知类
|
||||
"""
|
||||
session = None
|
||||
|
||||
def __init__(self):
|
||||
self.session = Request()
|
||||
|
||||
@classmethod
|
||||
def voice_code(cls, phone, name='', content=''):
|
||||
self = cls()
|
||||
self.send_voice_code_of_yiyuan(phone, name=name, content=content)
|
||||
|
||||
def send_voice_code_of_yiyuan(self, phone, name='', content=''):
|
||||
"""
|
||||
发送语音验证码
|
||||
购买地址 https://market.aliyun.com/products/57126001/cmapi019902.html?spm=5176.2020520132.101.5.37857218O6iJ3n
|
||||
:return:
|
||||
"""
|
||||
appcode = config.NOTIFICATION_API_APP_CODE
|
||||
if not appcode:
|
||||
CommonLog.add_quick_log(CommonLog.MESSAGE_EMPTY_APP_CODE).flush()
|
||||
return False
|
||||
body = {
|
||||
'userName': name,
|
||||
'mailNo': content
|
||||
}
|
||||
params = {
|
||||
'content': body,
|
||||
'mobile': phone,
|
||||
'sex': 2,
|
||||
'tNum': 'T170701001056'
|
||||
}
|
||||
response = self.session.request(url=API_NOTIFICATION_BY_VOICE_CODE + urllib.parse.urlencode(params),
|
||||
method='GET', headers={
|
||||
'Authorization': 'APPCODE {}'.format(appcode)
|
||||
})
|
||||
response_message = '-'
|
||||
result = {}
|
||||
try:
|
||||
result = response.json()
|
||||
response_message = result['showapi_res_body']['remark']
|
||||
except:
|
||||
pass
|
||||
if response.status_code == 401 or response.status_code == 403:
|
||||
return CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_FORBID).flush()
|
||||
if response.status_code == 200 and 'showapi_res_body' in result and result['showapi_res_body'].get('flag'):
|
||||
CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_SEND_SUCCESS.format(response_message)).flush()
|
||||
return True
|
||||
else:
|
||||
return CommonLog.add_quick_log(CommonLog.MESSAGE_VOICE_API_SEND_FAIL.format(response_message)).flush()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
Notification.voice_code('13800138000', '张三', '你的车票 广州 到 深圳 购买成功,请登录 12306 进行支付')
|
||||
@@ -1,20 +0,0 @@
|
||||
from requests_html import HTMLSession
|
||||
|
||||
|
||||
class Request(HTMLSession):
|
||||
"""
|
||||
请求处理类
|
||||
"""
|
||||
# session = {}
|
||||
|
||||
# def __init__(self, mock_browser=True, session=None):
|
||||
# super().__init__(mock_browser=mock_browser)
|
||||
# self.session = session if session else HTMLSession()
|
||||
pass
|
||||
|
||||
def save_to_file(self, url, path):
|
||||
response = self.get(url, stream=True)
|
||||
with open(path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
f.write(chunk)
|
||||
return response
|
||||
@@ -1,41 +0,0 @@
|
||||
from os import path
|
||||
from py12306.helpers.func import *
|
||||
|
||||
|
||||
@singleton
|
||||
class Station:
|
||||
stations = []
|
||||
|
||||
def __init__(self):
|
||||
if path.exists(config.STATION_FILE):
|
||||
result = open(config.STATION_FILE, encoding='utf-8').read()
|
||||
result = result.lstrip('@').split('@')
|
||||
for i in result:
|
||||
tmp_info = i.split('|')
|
||||
self.stations.append({
|
||||
'key': tmp_info[2],
|
||||
'name': tmp_info[1],
|
||||
'pinyin': tmp_info[3],
|
||||
'id': tmp_info[5]
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def get_station_by_name(cls, name):
|
||||
return cls.get_station_by(name, 'name')
|
||||
|
||||
@classmethod
|
||||
def get_station_by(cls, value, field):
|
||||
self = cls()
|
||||
for station in self.stations:
|
||||
if station.get(field) == value:
|
||||
return station
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_station_key_by_name(cls, name):
|
||||
return cls.get_station_by_name(name).get('key')
|
||||
|
||||
@classmethod
|
||||
def get_station_name_by_key(cls, key):
|
||||
return cls.get_station_by(key, 'key').get('name')
|
||||
|
||||
30
py12306/lib/exceptions.py
Normal file
30
py12306/lib/exceptions.py
Normal file
@@ -0,0 +1,30 @@
|
||||
class RetryException(Exception):
|
||||
|
||||
def __init__(self, msg='', default=None, wait_s: int = 0, *args: object) -> None:
|
||||
self.msg = msg
|
||||
self.default = default
|
||||
self.wait_s = wait_s
|
||||
super().__init__(msg, *args)
|
||||
|
||||
|
||||
class MaxRetryException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NeedToImplementException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PassengerNotFoundException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InstanceAlreadyInitedException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LoadConfigFailException(Exception):
|
||||
|
||||
def __init__(self, msg: str, *args: object) -> None:
|
||||
super().__init__(*args)
|
||||
self.msg = msg
|
||||
262
py12306/lib/hammer.py
Normal file
262
py12306/lib/hammer.py
Normal file
@@ -0,0 +1,262 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from abc import abstractmethod, ABC
|
||||
from typing import Any, Optional, Dict
|
||||
|
||||
from aioredis import Channel
|
||||
|
||||
from lib.helper import json_friendly_loads, json_friendly_dumps
|
||||
|
||||
|
||||
class EventItem:
|
||||
|
||||
def __init__(self, name: str, data: Any) -> None:
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.data = data
|
||||
|
||||
def dumps(self) -> str:
|
||||
return json_friendly_dumps({'name': self.name, 'data': self.data})
|
||||
|
||||
|
||||
class EventAbstract(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def publish(self, item: EventItem):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def subscribe(self) -> EventItem:
|
||||
pass
|
||||
|
||||
|
||||
class RedisEventEngine(EventAbstract):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
from app.app import Redis, Config
|
||||
self.redis = Redis
|
||||
self._channel_name = Config.APP_NAME
|
||||
self._msg_queue = asyncio.Queue()
|
||||
self._subscribed = False
|
||||
|
||||
async def publish(self, item: EventItem):
|
||||
await self.redis.publish(self._channel_name, item.dumps())
|
||||
|
||||
async def subscribe(self) -> EventItem:
|
||||
if not self._subscribed:
|
||||
self._subscribed = True
|
||||
ret = await self.redis.subscribe(self._channel_name)
|
||||
asyncio.ensure_future(self._handle_msg(ret[0]))
|
||||
return await self._msg_queue.get()
|
||||
|
||||
async def _handle_msg(self, channel: Channel):
|
||||
while await channel.wait_message():
|
||||
msg = await channel.get()
|
||||
await self._msg_queue.put(EventItem(**json_friendly_loads(msg)))
|
||||
|
||||
|
||||
class AsyncioEventEngine(EventAbstract):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._queue_lists = []
|
||||
|
||||
async def publish(self, item: EventItem):
|
||||
for queue in self._queue_lists:
|
||||
await queue.put(item)
|
||||
|
||||
async def subscribe(self) -> EventItem:
|
||||
queue = asyncio.Queue()
|
||||
self._queue_lists.append(queue)
|
||||
return await queue.get()
|
||||
|
||||
|
||||
class EventHammer:
|
||||
EVENT_ORDER_TICKET = 'order_ticket'
|
||||
EVENT_VERIFY_QUERY_JOB = 'verify_query_job'
|
||||
|
||||
def __init__(self, engine: EventAbstract) -> None:
|
||||
super().__init__()
|
||||
self.engine: Optional[EventAbstract] = engine
|
||||
|
||||
async def publish(self, item: EventItem):
|
||||
await self.engine.publish(item)
|
||||
|
||||
async def subscribe(self) -> EventItem:
|
||||
return await self.engine.subscribe()
|
||||
|
||||
|
||||
# // 缓存
|
||||
class CacheAbstract(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def set(self, key: str, val: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, key: str, default: Any = None):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def lpush(self, key: str, val):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def lget(self, key: str, default: Any = None) -> list:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def sadd(self, key: str, val):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def sget(self, key: str, default: Any = None) -> set:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def hset(self, key: str, field: str, val: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def hget(self, key: str, field: str, default: Any = None):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def hdel(self, key: str, field: str):
|
||||
pass
|
||||
|
||||
|
||||
class RedisCacheEngine(CacheAbstract):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
from app.app import Redis
|
||||
self.redis = Redis
|
||||
|
||||
async def set(self, key: str, val: str):
|
||||
return await self.redis.set(key, val)
|
||||
|
||||
async def get(self, key: str, default: Any = None):
|
||||
ret = await self.redis.get(key)
|
||||
if not ret:
|
||||
return default
|
||||
return ret.decode()
|
||||
|
||||
async def lpush(self, key: str, val):
|
||||
pass
|
||||
|
||||
async def lget(self, key: str, default: Any = None) -> list:
|
||||
pass
|
||||
|
||||
async def sadd(self, key: str, val):
|
||||
pass
|
||||
|
||||
async def sget(self, key: str, default: Any = None) -> set:
|
||||
pass
|
||||
|
||||
async def hset(self, key: str, field: str, val: str):
|
||||
return await self.redis.hset(key, field, val)
|
||||
|
||||
async def hget(self, key: str, field: str, default: Any = None):
|
||||
ret = await self.redis.hget(key, field) or default
|
||||
if not ret:
|
||||
return default
|
||||
return ret.decode()
|
||||
|
||||
async def hdel(self, key: str, field: str):
|
||||
return await self.redis.hdel(key, field)
|
||||
|
||||
|
||||
class AsyncioCacheEngine(CacheAbstract):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._queue = asyncio.Queue()
|
||||
self.string_dict = {}
|
||||
self.list_items: Dict[str, list] = {}
|
||||
self.set_items: Dict[str, set] = {}
|
||||
self.hash_items: Dict[str, dict] = {}
|
||||
|
||||
async def set(self, key: str, val: str):
|
||||
self.string_dict[key] = val
|
||||
|
||||
async def get(self, key: str, default: Any = None):
|
||||
self.string_dict.get(key, default)
|
||||
|
||||
async def lpush(self, key: str, val):
|
||||
if key not in self.list_items:
|
||||
self.list_items[key] = []
|
||||
self.list_items[key].append(key)
|
||||
|
||||
async def lget(self, key: str, default: Any = None) -> list:
|
||||
if key not in self.list_items:
|
||||
return default
|
||||
return self.list_items[key]
|
||||
|
||||
async def sadd(self, key: str, val):
|
||||
if key not in self.set_items:
|
||||
self.set_items[key] = set()
|
||||
self.set_items[key].add(key)
|
||||
|
||||
async def sget(self, key: str, default: Any = None) -> set:
|
||||
if key not in self.set_items:
|
||||
return default
|
||||
return self.set_items[key]
|
||||
|
||||
async def hset(self, key: str, field: str, val: str):
|
||||
if key not in self.hash_items:
|
||||
self.hash_items[key] = {}
|
||||
self.hash_items[key][field] = val
|
||||
|
||||
async def hget(self, key: str, field: str, default: Any = None):
|
||||
if key not in self.hash_items:
|
||||
return default
|
||||
return self.hash_items[key].get(field, default)
|
||||
|
||||
async def hdel(self, key: str, field: str):
|
||||
if key not in self.hash_items:
|
||||
return
|
||||
if field in self.hash_items[key]:
|
||||
del self.hash_items[key][field]
|
||||
|
||||
|
||||
class CacheHammer:
|
||||
KEY_DARK_ROOM = 'dark_room'
|
||||
|
||||
def __init__(self, engine: CacheAbstract) -> None:
|
||||
super().__init__()
|
||||
self.engine: Optional[CacheAbstract] = engine
|
||||
|
||||
async def set(self, key: str, val: str):
|
||||
return await self.engine.set(key, val)
|
||||
|
||||
async def get(self, key: str, default: Any = None):
|
||||
return await self.engine.get(key, default)
|
||||
|
||||
async def hset(self, key: str, field: str, val: str):
|
||||
return await self.engine.hset(key, field, val)
|
||||
|
||||
async def hget(self, key: str, field: str, default: Any = None):
|
||||
return await self.engine.hget(key, field, default)
|
||||
|
||||
async def hdel(self, key: str, field: str):
|
||||
return await self.engine.hdel(key, field)
|
||||
|
||||
async def add_dark_room(self, baby: str):
|
||||
return await self.hset(self.KEY_DARK_ROOM, baby, json_friendly_dumps({
|
||||
'id': baby,
|
||||
'created_at': datetime.datetime.now()
|
||||
}))
|
||||
|
||||
async def in_dark_room(self, baby: str) -> bool:
|
||||
_baby = await self.hget(self.KEY_DARK_ROOM, baby)
|
||||
if not _baby:
|
||||
return False
|
||||
_baby = json_friendly_loads(_baby)
|
||||
# 超过限制时长
|
||||
if not isinstance(_baby.get('created_at'), datetime.datetime) or \
|
||||
(datetime.datetime.now() - _baby['created_at']).seconds > 60:
|
||||
await self.hdel(self.KEY_DARK_ROOM, baby)
|
||||
return False
|
||||
return True
|
||||
314
py12306/lib/helper.py
Normal file
314
py12306/lib/helper.py
Normal file
@@ -0,0 +1,314 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import math
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Coroutine, Any, Union
|
||||
from functools import wraps
|
||||
from lib.exceptions import RetryException, MaxRetryException
|
||||
|
||||
|
||||
def retry(num: int = 3):
|
||||
""" Retry a func """
|
||||
from app.app import Logger
|
||||
|
||||
__retry_num_key = '__retry_num'
|
||||
__call = None
|
||||
if hasattr(num, '__call__'):
|
||||
__call = num
|
||||
num = 3
|
||||
|
||||
def decorator(func):
|
||||
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
retry_num = kwargs.get(__retry_num_key, num)
|
||||
try:
|
||||
if __retry_num_key in kwargs:
|
||||
del kwargs[__retry_num_key]
|
||||
return await func(*args, **kwargs)
|
||||
except RetryException as err:
|
||||
if retry_num > 0:
|
||||
kwargs[__retry_num_key] = retry_num - 1
|
||||
Logger.warning(
|
||||
f"重试 {func.__name__}{f',{err.msg}' if err.msg else ''}, 剩余次数 {kwargs[__retry_num_key]}")
|
||||
if err.wait_s:
|
||||
await asyncio.sleep(err.wait_s)
|
||||
return await async_wrapper(*args, **kwargs)
|
||||
if err.default:
|
||||
return err.default
|
||||
raise MaxRetryException(*err.args) from err
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
retry_num = kwargs.get(__retry_num_key, num)
|
||||
try:
|
||||
if __retry_num_key in kwargs:
|
||||
del kwargs[__retry_num_key]
|
||||
return func(*args, **kwargs)
|
||||
except RetryException as err:
|
||||
if retry_num > 0:
|
||||
kwargs[__retry_num_key] = retry_num - 1
|
||||
Logger.warning(
|
||||
f"重试 {func.__name__}{f',{err.msg}' if err.msg else ''}, 剩余次数 {kwargs[__retry_num_key]}")
|
||||
if err.wait_s:
|
||||
time.sleep(err.wait_s)
|
||||
return wrapper(*args, **kwargs)
|
||||
if err.default:
|
||||
return err.default
|
||||
raise MaxRetryException(*err.args) from err
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
return wrapper
|
||||
|
||||
if __call:
|
||||
return decorator(__call)
|
||||
return decorator
|
||||
|
||||
|
||||
def number_of_time_period(period: str) -> int:
|
||||
"""
|
||||
Example: 23:00 -> 2300
|
||||
:param period:
|
||||
:return:
|
||||
"""
|
||||
return int(period.replace(':', ''))
|
||||
|
||||
|
||||
def md5(value):
|
||||
import hashlib
|
||||
return hashlib.md5(json.dumps(value).encode()).hexdigest()
|
||||
|
||||
|
||||
def run_async(coro: Coroutine):
|
||||
"""
|
||||
Simple async runner
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
def json_encoder(obj: Any):
|
||||
""" JSON 序列化, 修复时间 """
|
||||
if isinstance(obj, datetime.datetime):
|
||||
return obj.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if isinstance(obj, datetime.date):
|
||||
return obj.strftime('%Y-%m-%d')
|
||||
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
def json_decoder(obj: Any):
|
||||
""" JSON 反序列化,加载时间 """
|
||||
ret = obj
|
||||
if isinstance(obj, list):
|
||||
obj = enumerate(obj)
|
||||
elif isinstance(obj, dict):
|
||||
obj = obj.items()
|
||||
else:
|
||||
return obj
|
||||
|
||||
for key, item in obj:
|
||||
if isinstance(item, (list, dict)):
|
||||
ret[key] = json_decoder(item)
|
||||
elif isinstance(item, str):
|
||||
try:
|
||||
if len(item) is 10:
|
||||
ret[key] = datetime.datetime.strptime(item, '%Y-%m-%d').date()
|
||||
else:
|
||||
ret[key] = datetime.datetime.strptime(item, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
ret[key] = item
|
||||
else:
|
||||
ret[key] = item
|
||||
return ret
|
||||
|
||||
|
||||
class JSONDecoder(json.JSONDecoder):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.parse_array = self.__parse_array
|
||||
self.object_hook = json_decoder
|
||||
self.scan_once = json.scanner.py_make_scanner(self)
|
||||
|
||||
def __parse_array(self, *args, **kwargs):
|
||||
values, end = json.decoder.JSONArray(*args, **kwargs)
|
||||
return self.object_hook(values), end
|
||||
|
||||
|
||||
def json_friendly_loads(obj: Any, **kwargs):
|
||||
return json.loads(obj, cls=JSONDecoder, **kwargs)
|
||||
|
||||
|
||||
def json_friendly_dumps(obj: Any, **kwargs):
|
||||
return json.dumps(obj, ensure_ascii=False, default=json_encoder, **kwargs)
|
||||
|
||||
|
||||
def str_to_date(_str: str):
|
||||
if isinstance(_str, datetime.date):
|
||||
return _str
|
||||
if len(_str) is 10:
|
||||
return datetime.datetime.strptime(_str, '%Y-%m-%d').date()
|
||||
else:
|
||||
return datetime.datetime.strptime(_str, '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
def lmap(*args, **kwargs):
|
||||
return list(map(*args, **kwargs))
|
||||
|
||||
|
||||
class ShareInstance:
|
||||
__session = None
|
||||
|
||||
@classmethod
|
||||
def share(cls):
|
||||
if not cls.__session or cls is not cls.__session.__class__:
|
||||
cls.__session = cls()
|
||||
return cls.__session
|
||||
|
||||
|
||||
class SuperDict(dict):
|
||||
def get(self, key, default=None, sep='.'):
|
||||
keys = key.split(sep)
|
||||
for i, key in enumerate(keys):
|
||||
try:
|
||||
value = self[key]
|
||||
if len(keys[i + 1:]) and isinstance(value, SuperDict):
|
||||
return value.get(sep.join(keys[i + 1:]), default=default, sep=sep)
|
||||
return value
|
||||
except KeyError:
|
||||
return self.dict_to_dict(default)
|
||||
|
||||
def __getitem__(self, k):
|
||||
return self.dict_to_dict(super().__getitem__(k))
|
||||
|
||||
@classmethod
|
||||
def dict_to_dict(cls, value):
|
||||
return SuperDict(value) if isinstance(value, dict) else value
|
||||
|
||||
|
||||
class StationHelper(ShareInstance):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._stations = []
|
||||
self.version = ''
|
||||
from app.app import Config
|
||||
if os.path.exists(Config.DATA_DIR + 'stationList.json'):
|
||||
with open(Config.DATA_DIR + 'stationList.json', 'r', encoding='utf-8') as f:
|
||||
ret = json.load(f)
|
||||
self._stations = ret['stationList']
|
||||
self.version = ret['version_no']
|
||||
|
||||
@classmethod
|
||||
def stations(cls):
|
||||
return cls.share()._stations
|
||||
|
||||
@classmethod
|
||||
def cn_by_id(cls, _id: str):
|
||||
self = cls.share()
|
||||
for station in self._stations:
|
||||
if station.get('id') == _id:
|
||||
return station.get('value')
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def id_by_cn(cls, cn: str):
|
||||
for station in cls.share()._stations:
|
||||
if station.get('value') == cn:
|
||||
return station.get('id')
|
||||
return ''
|
||||
|
||||
|
||||
class UserTypeHelper:
|
||||
ADULT = 1
|
||||
CHILD = 2
|
||||
STUDENT = 3
|
||||
SOLDIER = 4
|
||||
|
||||
dicts = {
|
||||
ADULT: '成人',
|
||||
CHILD: '儿童',
|
||||
STUDENT: '学生',
|
||||
SOLDIER: '残疾军人、伤残人民警察'
|
||||
}
|
||||
|
||||
|
||||
class TrainSeat:
|
||||
NO_SEAT = 26
|
||||
|
||||
ticket = {'一等座': 'ZY', '二等座': 'ZE', '商务座': 'SWZ', '特等座': 'TZ', '硬座': 'YZ', '软座': 'RZ', '硬卧': 'YW', '二等卧': 'YW',
|
||||
'软卧': 'RW', '一等卧': 'RW', '高级软卧': 'GR', '动卧': 'SRRB', '高级动卧': 'YYRW', '无座': 'WZ'}
|
||||
ticket_id = {'一等座': 31, '二等座': 30, '商务座': 32, '特等座': 25, '硬座': 29, '软座': 24, '硬卧': 28, '二等卧': 28,
|
||||
'软卧': 23, '一等卧': 23, '高级软卧': 21, '动卧': 33, '高级动卧': -1, '无座': 26}
|
||||
order_id = {'棚车': '0', '硬座': '1', '软座': '2', '硬卧': '3', '软卧': '4', '包厢硬卧': '5', '高级软卧': '6', '一等软座': '7',
|
||||
'二等软座': '8', '商务座': '9', '高级动卧': 'A', '混编硬座': 'B', '混编硬卧': 'C', '包厢软座': 'D', '特等软座': 'E', '动卧': 'F',
|
||||
'二人软包': 'G', '一人软包': 'H', '一等卧': 'I', '二等卧': 'J', '混编软座': 'K', '混编软卧': 'L', '一等座': 'M', '二等座': 'O',
|
||||
'特等座': 'P', '观光座': 'Q', '一等包座': 'S', '无座': 'WZ'}
|
||||
|
||||
|
||||
class TaskManager(ABC, ShareInstance):
|
||||
__session = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.fuatures = []
|
||||
self.tasks = {}
|
||||
self.interval = 5
|
||||
|
||||
@abstractmethod
|
||||
async def run(self):
|
||||
""" """
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
async def task_total(self) -> int:
|
||||
""" 任务总数 """
|
||||
pass
|
||||
|
||||
async def wait(self):
|
||||
if self.fuatures:
|
||||
await asyncio.wait(self.fuatures)
|
||||
|
||||
@property
|
||||
async def capacity_num(self) -> int:
|
||||
from app.app import Config
|
||||
if not Config.node_num:
|
||||
return 0
|
||||
return math.ceil((await self.task_total) / Config.node_num)
|
||||
|
||||
@property
|
||||
def task_num(self) -> int:
|
||||
return len(self.tasks)
|
||||
|
||||
def add_task(self, future, _id: Union[str, int], data: Any):
|
||||
future = asyncio.ensure_future(future)
|
||||
self.fuatures.append(future)
|
||||
self.tasks[_id] = data
|
||||
return self
|
||||
|
||||
@property
|
||||
async def is_full(self) -> bool:
|
||||
return self.task_num >= await self.capacity_num
|
||||
|
||||
@property
|
||||
async def is_overflow(self) -> bool:
|
||||
return self.task_num > await self.capacity_num
|
||||
|
||||
def get_task(self, _key, default=None):
|
||||
return self.tasks.get(_key, default)
|
||||
|
||||
def stop_and_drop(self, _key):
|
||||
task = self.tasks[_key]
|
||||
task.stop()
|
||||
del self.tasks[_key]
|
||||
|
||||
def clean_fuatures(self):
|
||||
[self.fuatures.remove(fut) for fut in self.fuatures if fut.done()]
|
||||
240
py12306/lib/request.py
Normal file
240
py12306/lib/request.py
Normal file
@@ -0,0 +1,240 @@
|
||||
import asyncio
|
||||
import json
|
||||
import pickle
|
||||
import random
|
||||
from json import JSONDecoder
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from concurrent.futures._base import TimeoutError
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import TraceRequestStartParams, ClientSession, ClientResponse, ClientProxyConnectionError
|
||||
from app.models import QueryJob
|
||||
from lib.exceptions import RetryException
|
||||
from lib.helper import ShareInstance, SuperDict, run_async
|
||||
|
||||
|
||||
class ProxyHelepr(ShareInstance):
|
||||
|
||||
def __init__(self, config: Optional[dict] = None) -> None:
|
||||
super().__init__()
|
||||
from app.app import Config
|
||||
self._config: dict = config or Config.get('proxy')
|
||||
|
||||
async def get_proxy(self):
|
||||
if self._config.get('enable') is not True or not self._config.get('url'):
|
||||
return None
|
||||
response = await Session.share().get(self._config['url'])
|
||||
result = response.json()
|
||||
if result.get('http'):
|
||||
return result.get('http')
|
||||
return None
|
||||
|
||||
|
||||
class Session(ShareInstance):
|
||||
|
||||
def __init__(self, use_proxy: bool = False, timeout: int = 0):
|
||||
trace_config = aiohttp.TraceConfig()
|
||||
trace_config.on_request_start.append(self.on_request_start)
|
||||
trace_config.on_request_end.append(self.on_request_end)
|
||||
params = {}
|
||||
if timeout:
|
||||
params['timeout'] = aiohttp.ClientTimeout(total=timeout)
|
||||
self.session = aiohttp.ClientSession(trace_configs=[trace_config], **params)
|
||||
self.use_proxy = use_proxy
|
||||
self.api_base = ''
|
||||
|
||||
@classmethod
|
||||
def share_session(cls):
|
||||
self = cls.share()
|
||||
return self.session
|
||||
|
||||
async def on_request_start(self, session: ClientSession, trace_config_ctx: SimpleNamespace,
|
||||
params: TraceRequestStartParams):
|
||||
pass # TODO
|
||||
|
||||
async def on_request_end(self, session: ClientSession, trace_config_ctx: SimpleNamespace,
|
||||
params: TraceRequestStartParams):
|
||||
pass # TODO
|
||||
|
||||
async def overwrite_text(self, response, *args, **kwargs) -> Any:
|
||||
ret = await ClientResponse.text(response, *args, **kwargs)
|
||||
|
||||
def wrap():
|
||||
return ret
|
||||
|
||||
return wrap
|
||||
|
||||
def overwrite_json(self, response: ClientResponse) -> Any:
|
||||
def wrap(loads: JSONDecoder = json.loads):
|
||||
if hasattr(response, 'text_json'):
|
||||
return response.text_json
|
||||
try:
|
||||
load_dict = loads(response.text())
|
||||
except Exception:
|
||||
load_dict = {}
|
||||
ret = SuperDict.dict_to_dict(load_dict)
|
||||
response.text_json = ret
|
||||
return ret
|
||||
|
||||
return wrap
|
||||
|
||||
def cookie_dumps(self) -> str:
|
||||
return pickle.dumps(self.session.cookie_jar._cookies, False).decode()
|
||||
|
||||
def cookie_loads(self, cookies: str):
|
||||
self.session.cookie_jar._cookies = pickle.loads(cookies.encode())
|
||||
|
||||
def cookie_clean(self):
|
||||
self.session.cookie_jar.clear()
|
||||
|
||||
async def request(self, method: Any, url: str, headers=None, data: Any = None, use_proxy: bool = None,
|
||||
**kwargs: Any) -> ClientResponse:
|
||||
from app.app import Logger, Config
|
||||
proxy = kwargs.get('proxy', None)
|
||||
if proxy is None and (use_proxy is True or (use_proxy is None and self.use_proxy)):
|
||||
if random.randint(1, 100) <= Config.get('proxy.rate', 100):
|
||||
kwargs['proxy'] = await ProxyHelepr.share().get_proxy()
|
||||
# if kwargs.get('proxy'): # TODO move to event
|
||||
# Logger.debug(f"使用代理 {kwargs['proxy']}")
|
||||
default_headers = {
|
||||
'User-Agent': f'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
f'Chrome/78.0.{random.randint(3900, 3999)}.97 Safari/537.36'
|
||||
}
|
||||
if headers:
|
||||
default_headers.update(headers)
|
||||
parsed_url = urlparse(url)
|
||||
if not parsed_url.scheme:
|
||||
url = f'{self.api_base}{url}'
|
||||
try:
|
||||
response = await self.session.request(method, url, headers=default_headers, data=data, **kwargs)
|
||||
except Exception as e:
|
||||
err = f'{e.__class__.__qualname__} {method} {url}'
|
||||
if isinstance(e, TimeoutError):
|
||||
err = '请求超时 '
|
||||
if isinstance(e, ClientProxyConnectionError):
|
||||
err = f"代理连接失败 {kwargs.get('proxy')}"
|
||||
Logger.error(f'请求错误, {err}')
|
||||
raise RetryException(e, wait_s=1)
|
||||
response.text = await self.overwrite_text(response)
|
||||
response.json = self.overwrite_json(response)
|
||||
return response
|
||||
|
||||
async def get(self, url: str, headers=None, use_proxy: bool = None, **kwargs: Any) -> ClientResponse:
|
||||
return await self.request('GET', url=url, headers=headers, use_proxy=use_proxy, **kwargs)
|
||||
|
||||
async def post(self, url: str, headers=None, data: Any = None, use_proxy: bool = None,
|
||||
**kwargs: Any) -> ClientResponse:
|
||||
return await self.request('POST', url=url, headers=headers, data=data, use_proxy=use_proxy, **kwargs)
|
||||
|
||||
async def identify_captcha(self, captcha_image64: str):
|
||||
data = {
|
||||
'img': captcha_image64
|
||||
}
|
||||
# TODO timeout
|
||||
return await self.request('POST', f'https://12306-ocr.pjialin.com/check/', data=data)
|
||||
|
||||
async def browser_device_id_url(self):
|
||||
return await self.request('GET', f'https://12306-rail-id-v2.pjialin.com/')
|
||||
|
||||
def __del__(self):
|
||||
from app.app import Config
|
||||
if Config.IS_IN_TEST:
|
||||
run_async(self.session.close())
|
||||
else:
|
||||
asyncio.ensure_future(self.session.close())
|
||||
|
||||
|
||||
class TrainSession(Session):
|
||||
""" 请求处理类 """
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.api_base = 'https://kyfw.12306.cn'
|
||||
|
||||
async def otn_left_ticket_init(self):
|
||||
return await self.request('GET', '/otn/leftTicket/init', use_proxy=False)
|
||||
|
||||
async def otn_query_left_ticket(self, api_type: str, queryjob: QueryJob):
|
||||
query = {'leftTicketDTO.train_date': queryjob.left_date, 'leftTicketDTO.from_station': queryjob.left_station_id,
|
||||
'leftTicketDTO.to_station': queryjob.arrive_station_id, 'purpose_codes': 'ADULT'}
|
||||
url = f'/otn/{api_type}?{urlencode(query)}'
|
||||
return await self.request('GET', url, allow_redirects=False)
|
||||
|
||||
async def passport_captcha_image64(self):
|
||||
rand_str = random.random()
|
||||
return await self.request('GET',
|
||||
f'/passport/captcha/captcha-image64?login_site=E&module=login&rand=sjrand&_={rand_str}')
|
||||
|
||||
async def passport_captcha_check(self, answer: str):
|
||||
""" 验证码校验 """
|
||||
rand_str = random.random()
|
||||
return await self.request('GET',
|
||||
f'/passport/captcha/captcha-check?answer={answer}&rand=sjrand&login_site=E&_={rand_str}')
|
||||
|
||||
async def browser_device_id(self, url: str):
|
||||
""" 获取浏览器特征 ID """
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36'
|
||||
}
|
||||
return await self.request('GET', url, headers=headers)
|
||||
|
||||
async def passport_web_login(self, data: dict):
|
||||
""" 登录 """
|
||||
return await self.request('POST', '/passport/web/login', data=data)
|
||||
|
||||
async def passport_web_auth_uamtk(self):
|
||||
""" 登录获取 uamtk """
|
||||
data = {'appid': 'otn'}
|
||||
headers = {
|
||||
'Referer': 'https://kyfw.12306.cn/otn/passport?redirect=/otn/login/userLogin',
|
||||
'Origin': 'https://kyfw.12306.cn'
|
||||
}
|
||||
return await self.request('POST', '/passport/web/auth/uamtk', data=data, headers=headers)
|
||||
|
||||
async def otn_uamauthclient(self, uamtk: str):
|
||||
""" 登录获取 username """
|
||||
data = {'tk': uamtk}
|
||||
return await self.request('POST', '/otn/uamauthclient', data=data)
|
||||
|
||||
async def otn_modify_user_init_query_user_info(self):
|
||||
""" 获取用户详情信息 """
|
||||
return await self.request('GET', '/otn/modifyUser/initQueryUserInfoApi')
|
||||
|
||||
async def otn_confirm_passenger_get_passenger(self):
|
||||
""" 获取乘客列表 """
|
||||
return await self.request('POST', '/otn/confirmPassenger/getPassengerDTOs')
|
||||
|
||||
async def otn_login_conf(self):
|
||||
""" 获取登录状态 """
|
||||
return await self.request('GET', '/otn/login/conf')
|
||||
|
||||
async def otn_left_ticket_submit_order_request(self, data: dict):
|
||||
""" 提交下单请求 """
|
||||
return await self.request('POST', '/otn/leftTicket/submitOrderRequest', data=data)
|
||||
|
||||
async def otn_confirm_passenger_init_dc(self):
|
||||
""" 获取下单 token """
|
||||
data = {'_json_att': ''}
|
||||
return await self.request('POST', '/otn/confirmPassenger/initDc', data=data)
|
||||
|
||||
async def otn_confirm_passenger_check_order_info(self, data: dict):
|
||||
""" 检查下单信息 """
|
||||
return await self.request('POST', '/otn/confirmPassenger/checkOrderInfo', data=data)
|
||||
|
||||
async def otn_confirm_passenger_get_queue_count(self, data: dict):
|
||||
""" 检查下单信息 """
|
||||
return await self.request('POST', '/otn/confirmPassenger/getQueueCount', data=data)
|
||||
|
||||
async def otn_confirm_passenger_confirm_single_for_queue(self, data: dict):
|
||||
""" 确认排队 """
|
||||
return await self.request('POST', '/otn/confirmPassenger/confirmSingleForQueue', data=data)
|
||||
|
||||
async def otn_confirm_passenger_query_order_wait_time(self, querys: dict):
|
||||
""" 查询排队结果 """
|
||||
return await self.request('GET', f'/otn/confirmPassenger/queryOrderWaitTime?{urlencode(querys)}')
|
||||
|
||||
async def otn_query_my_order_no_complete(self):
|
||||
""" 查询未完成订单 """
|
||||
return await self.request('POST', f'/otn/queryOrder/queryMyOrderNoComplete', data={'_json_att': ''})
|
||||
@@ -1,54 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from py12306.helpers.func import *
|
||||
|
||||
class BaseLog:
|
||||
logs = []
|
||||
thread_logs = {}
|
||||
quick_log = []
|
||||
|
||||
@classmethod
|
||||
def add_log(cls, content=''):
|
||||
self = cls()
|
||||
# print('添加 Log 主进程{} 进程ID{}'.format(is_main_thread(), current_thread_id()))
|
||||
if is_main_thread():
|
||||
self.logs.append(content)
|
||||
else:
|
||||
tmp_log = self.thread_logs.get(current_thread_id(), [])
|
||||
tmp_log.append(content)
|
||||
self.thread_logs[current_thread_id()] = tmp_log
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def flush(cls, sep='\n', end='\n', file=None, exit=False):
|
||||
self = cls()
|
||||
if self.quick_log:
|
||||
logs = self.quick_log
|
||||
else:
|
||||
if is_main_thread():
|
||||
logs = self.logs
|
||||
else:
|
||||
logs = self.thread_logs.get(current_thread_id())
|
||||
print(*logs, sep=sep, end=end, file=file)
|
||||
if self.quick_log:
|
||||
self.quick_log = []
|
||||
else:
|
||||
if is_main_thread():
|
||||
self.logs = []
|
||||
else:
|
||||
if logs: del self.thread_logs[current_thread_id()]
|
||||
if exit:
|
||||
sys.exit()
|
||||
|
||||
@classmethod
|
||||
def add_quick_log(cls, content = ''):
|
||||
self = cls()
|
||||
self.quick_log.append(content)
|
||||
return self
|
||||
|
||||
def notification(self, title, content=''):
|
||||
if sys.platform == 'darwin':
|
||||
os.system(
|
||||
'osascript -e \'tell app "System Events" to display notification "{content}" with title "{title}"\''.format(
|
||||
title=title, content=content))
|
||||
@@ -1,71 +0,0 @@
|
||||
from py12306.log.base import BaseLog
|
||||
from py12306.config import *
|
||||
from py12306.helpers.func import *
|
||||
|
||||
|
||||
@singleton
|
||||
class CommonLog(BaseLog):
|
||||
# 这里如果不声明,会出现重复打印,目前不知道什么原因
|
||||
logs = []
|
||||
thread_logs = {}
|
||||
quick_log = []
|
||||
|
||||
MESSAGE_12306_IS_CLOSED = '当前时间: {} | 12306 休息时间,程序将在明天早上 6 点自动运行'
|
||||
MESSAGE_RETRY_AUTH_CODE = '{} 秒后重新获取验证码'
|
||||
|
||||
MESSAGE_EMPTY_APP_CODE = '无法发送语音消息,未填写验证码接口 appcode'
|
||||
MESSAGE_VOICE_API_FORBID = '语音消息发送失败,请检查 appcode 是否填写正确或 套餐余额是否充足'
|
||||
MESSAGE_VOICE_API_SEND_FAIL = '语音消息发送失败,错误原因 {}'
|
||||
MESSAGE_VOICE_API_SEND_SUCCESS = '语音消息发送成功! 接口返回信息 {} '
|
||||
|
||||
MESSAGE_CHECK_AUTO_CODE_FAIL = '请配置打码账号的账号密码'
|
||||
MESSAGE_CHECK_EMPTY_USER_ACCOUNT = '请配置 12306 账号密码'
|
||||
|
||||
MESSAGE_TEST_SEND_VOICE_CODE = '正在测试发送语音验证码...'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.init_data()
|
||||
|
||||
def init_data(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def print_welcome(cls):
|
||||
self = cls()
|
||||
self.add_quick_log('######## py12306 购票助手,本程序为开源工具,请勿用于商业用途 ########')
|
||||
if Const.IS_TEST:
|
||||
self.add_quick_log()
|
||||
self.add_quick_log('当前为测试模式,程序运行完成后自动结束')
|
||||
self.add_quick_log()
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_configs(cls):
|
||||
# 打印配置
|
||||
self = cls()
|
||||
enable = '已开启'
|
||||
disable = '未开启'
|
||||
self.add_quick_log('**** 当前配置 ****')
|
||||
self.add_quick_log('多线程查询: {}'.format(get_true_false_text(config.QUERY_JOB_THREAD_ENABLED, enable, disable)))
|
||||
self.add_quick_log('语音验证码: {}'.format(get_true_false_text(config.QUERY_JOB_THREAD_ENABLED, enable, disable)))
|
||||
self.add_quick_log('查询间隔: {} 秒'.format(config.QUERY_INTERVAL))
|
||||
self.add_quick_log('用户心跳检测间隔: {} 秒'.format(config.USER_HEARTBEAT_INTERVAL))
|
||||
self.add_quick_log()
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def test_complete(cls):
|
||||
self = cls()
|
||||
self.add_quick_log('# 测试完成,请检查输出是否正确 #')
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_auto_code_fail(cls, reason):
|
||||
self = cls()
|
||||
self.add_quick_log('打码失败: 错误原因 {reason}'.format(reason=reason))
|
||||
self.flush()
|
||||
return self
|
||||
@@ -1,52 +0,0 @@
|
||||
from py12306.log.base import BaseLog
|
||||
from py12306.helpers.func import *
|
||||
|
||||
|
||||
@singleton
|
||||
class OrderLog(BaseLog):
|
||||
# 这里如果不声明,会出现重复打印,目前不知道什么原因
|
||||
logs = []
|
||||
thread_logs = {}
|
||||
quick_log = []
|
||||
|
||||
|
||||
MESSAGE_REQUEST_INIT_DC_PAGE_FAIL = '请求初始化订单页面失败'
|
||||
|
||||
MESSAGE_SUBMIT_ORDER_REQUEST_FAIL = '提交订单失败,错误原因 {} \n'
|
||||
MESSAGE_SUBMIT_ORDER_REQUEST_SUCCESS = '提交订单成功'
|
||||
MESSAGE_CHECK_ORDER_INFO_FAIL = '检查订单失败,错误原因 {} \n'
|
||||
MESSAGE_CHECK_ORDER_INFO_SUCCESS = '检查订单成功'
|
||||
|
||||
MESSAGE_GET_QUEUE_COUNT_SUCCESS = '排队成功,你当前排在第 {} 位, 余票还剩余 {} 张'
|
||||
MESSAGE_GET_QUEUE_COUNT_FAIL = '排队失败,错误原因 {}'
|
||||
|
||||
MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_SUCCESS = '# 提交订单成功!#'
|
||||
MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_ERROR = '提交订单出错,错误原因 {}'
|
||||
MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_FAIL = '提交订单失败,错误原因 {}'
|
||||
|
||||
MESSAGE_QUERY_ORDER_WAIT_TIME_WAITING = '排队等待中,预计还需要 {} 秒'
|
||||
MESSAGE_QUERY_ORDER_WAIT_TIME_FAIL = '排队失败,错误原因 {}'
|
||||
MESSAGE_QUERY_ORDER_WAIT_TIME_INFO = '第 {} 次排队,请耐心等待'
|
||||
|
||||
MESSAGE_ORDER_SUCCESS_NOTIFICATION_TITLE = '车票购买成功!'
|
||||
MESSAGE_ORDER_SUCCESS_NOTIFICATION_CONTENT = '请及时器登录12306,打开 \'未完成订单\',在30分钟内完成支付!'
|
||||
|
||||
MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_VOICE_CODE_START_SEND = '正在发送语音通知, 第 {} 次'
|
||||
MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_VOICE_CODE_CONTENT = '你的车票 {} 到 {} 购买成功,请登录 12306 进行支付'
|
||||
|
||||
MESSAGE_JOB_CLOSED = '当前任务已结束'
|
||||
|
||||
@classmethod
|
||||
def print_passenger_did_deleted(cls, passengers):
|
||||
self = cls()
|
||||
result = [passenger.get('name') + '(' + passenger.get('type_text') + ')' for passenger in passengers]
|
||||
self.add_quick_log('# 删减后的乘客列表 {} #'.format(', '.join(result)))
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_ticket_did_ordered(cls, order_id):
|
||||
self = cls()
|
||||
self.add_quick_log('# 车票购买成功,订单号 {} #'.format(order_id))
|
||||
self.flush()
|
||||
return self
|
||||
@@ -1,141 +0,0 @@
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
from os import path
|
||||
from py12306.log.base import BaseLog
|
||||
from py12306.helpers.func import *
|
||||
|
||||
|
||||
@singleton
|
||||
class QueryLog(BaseLog):
|
||||
# 这里如果不声明,会出现重复打印,目前不知道什么原因
|
||||
logs = []
|
||||
thread_logs = {}
|
||||
quick_log = []
|
||||
|
||||
data = {
|
||||
'query_count': 1,
|
||||
'last_time': '',
|
||||
}
|
||||
data_path = config.QUERY_DATA_DIR + 'status.json'
|
||||
|
||||
LOG_INIT_JOBS = ''
|
||||
|
||||
MESSAGE_GIVE_UP_CHANCE_CAUSE_TICKET_NUM_LESS_THAN_SPECIFIED = '余票数小于乘车人数,放弃此次提交机会'
|
||||
MESSAGE_QUERY_LOG_OF_EVERY_TRAIN = '{}-{}'
|
||||
MESSAGE_QUERY_LOG_OF_TRAIN_INFO = '{} {}'
|
||||
MESSAGE_QUERY_START_BY_DATE = '出发日期 {}: {} - {}'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.init_data()
|
||||
|
||||
def init_data(self):
|
||||
# 获取上次记录
|
||||
if Const.IS_TEST: return
|
||||
if path.exists(self.data_path):
|
||||
with open(self.data_path, encoding='utf-8') as f:
|
||||
result = f.read()
|
||||
if result:
|
||||
result = json.loads(result)
|
||||
self.data = {**self.data, **result}
|
||||
self.print_data_restored()
|
||||
|
||||
@classmethod
|
||||
def print_init_jobs(cls, jobs):
|
||||
"""
|
||||
输出初始化信息
|
||||
:return:
|
||||
"""
|
||||
self = cls()
|
||||
self.add_log('# 发现 {} 个任务 #'.format(len(jobs)))
|
||||
index = 1
|
||||
for job in jobs:
|
||||
self.add_log('================== 任务 {} =================='.format(index))
|
||||
self.add_log('出发站:{} 到达站:{}'.format(job.left_station, job.arrive_station))
|
||||
self.add_log('乘车日期:{}'.format(job.left_dates))
|
||||
self.add_log('坐席:{}'.format(','.join(job.allow_seats)))
|
||||
self.add_log('乘车人:{}'.format(','.join(job.members)))
|
||||
self.add_log('筛选车次:{}'.format(','.join(job.allow_train_numbers if job.allow_train_numbers else ['不筛选'])))
|
||||
# 乘车日期:['2019-01-24', '2019-01-25', '2019-01-26', '2019-01-27']
|
||||
self.add_log('')
|
||||
index += 1
|
||||
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_ticket_num_less_than_specified(cls, rest_num, job):
|
||||
self = cls()
|
||||
self.add_quick_log(
|
||||
'余票数小于乘车人数,当前余票数: {rest_num}, 实际人数 {actual_num}, 删减人车人数到: {take_num}'.format(rest_num=rest_num,
|
||||
actual_num=job.member_num,
|
||||
take_num=job.member_num_take))
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_ticket_seat_available(cls, left_date, train_number, seat_type, rest_num):
|
||||
self = cls()
|
||||
self.add_quick_log(
|
||||
'[ 查询到座位可用 出发时间 {left_date} 车次 {train_number} 座位类型 {seat_type} 余票数量 {rest_num} ]'.format(
|
||||
left_date=left_date,
|
||||
train_number=train_number,
|
||||
seat_type=seat_type,
|
||||
rest_num=rest_num))
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_ticket_available(cls, left_date, train_number, rest_num):
|
||||
self = cls()
|
||||
self.add_quick_log('检查完成 开始提交订单 '.format())
|
||||
self.notification('查询到可用车票', '时间 {left_date} 车次 {train_number} 余票数量 {rest_num}'.format(left_date=left_date,
|
||||
train_number=train_number,
|
||||
rest_num=rest_num))
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_query_error(cls, reason, code=None):
|
||||
self = cls()
|
||||
self.add_quick_log('查询余票请求失败')
|
||||
if code:
|
||||
self.add_quick_log('状态码{} '.format(code))
|
||||
if reason:
|
||||
self.add_quick_log('错误原因{} '.format(reason))
|
||||
self.flush(sep='\t')
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_job_start(cls):
|
||||
self = cls()
|
||||
self.add_log('=== 正在进行第 {query_count} 次查询 === {time}'.format(query_count=self.data.get('query_count'),
|
||||
time=datetime.datetime.now()))
|
||||
self.refresh_data()
|
||||
if is_main_thread():
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def add_stay_log(cls, second):
|
||||
self = cls()
|
||||
self.add_log('安全停留 {}'.format(second))
|
||||
return self
|
||||
|
||||
def print_data_restored(self):
|
||||
self.add_quick_log('============================================================')
|
||||
self.add_quick_log('|=== 查询记录恢复成功 上次查询 {last_date} ===|'.format(last_date=self.data.get('last_time')))
|
||||
self.add_quick_log('============================================================')
|
||||
self.add_quick_log('')
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
def refresh_data(self):
|
||||
self.data['query_count'] += 1
|
||||
self.data['last_time'] = str(datetime.datetime.now())
|
||||
self.save_data()
|
||||
|
||||
def save_data(self):
|
||||
with open(self.data_path, 'w') as file:
|
||||
file.write(json.dumps(self.data))
|
||||
@@ -1,65 +0,0 @@
|
||||
from py12306.log.base import BaseLog
|
||||
from py12306.helpers.func import *
|
||||
|
||||
|
||||
@singleton
|
||||
class UserLog(BaseLog):
|
||||
# 这里如果不声明,会出现重复打印,目前不知道什么原因
|
||||
logs = []
|
||||
thread_logs = {}
|
||||
quick_log = []
|
||||
|
||||
MESSAGE_DOWNLAOD_AUTH_CODE_FAIL = '验证码下载失败 错误原因: {} {} 秒后重试'
|
||||
MESSAGE_DOWNLAODING_THE_CODE = '正在下载验证码...'
|
||||
MESSAGE_CODE_AUTH_FAIL = '验证码验证失败 错误原因: {} {} 秒后重试'
|
||||
MESSAGE_CODE_AUTH_SUCCESS = '验证码验证成功 开始登录...'
|
||||
MESSAGE_LOGIN_FAIL = '登录失败 错误原因: {}'
|
||||
MESSAGE_LOADED_USER = '正在尝试恢复用户: {}'
|
||||
MESSAGE_LOADED_USER_SUCCESS = '用户恢复成功: {}'
|
||||
MESSAGE_LOADED_USER_BUT_EXPIRED = '用户状态已过期,正在重新登录'
|
||||
MESSAGE_USER_HEARTBEAT_NORMAL = '用户 {} 心跳正常,下次检测 {} 秒后'
|
||||
|
||||
MESSAGE_GET_USER_PASSENGERS_FAIL = '获取用户乘客列表失败,错误原因: {} {} 秒后重试'
|
||||
MESSAGE_USER_PASSENGERS_IS_INVALID = '乘客信息校验失败,在账号 {} 中未找到该乘客: {}'
|
||||
|
||||
MESSAGE_WAIT_USER_INIT_COMPLETE = '未找到可用账号或用户正在初始化,{} 秒后重试'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.init_data()
|
||||
|
||||
def init_data(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def print_init_users(cls, users):
|
||||
"""
|
||||
输出初始化信息
|
||||
:return:
|
||||
"""
|
||||
self = cls()
|
||||
self.add_log('# 发现 {} 个用户 #'.format(len(users)))
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_welcome_user(cls, user):
|
||||
self = cls()
|
||||
self.add_log('# 欢迎回来,{} #'.format(user.get_name()))
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_start_login(cls, user):
|
||||
self = cls()
|
||||
self.add_log('正在登录用户 {}'.format(user.user_name))
|
||||
self.flush()
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def print_user_passenger_init_success(cls, passengers):
|
||||
self = cls()
|
||||
result = [passenger.get('name') + '(' + passenger.get('type_text') + ')' for passenger in passengers]
|
||||
self.add_quick_log('# 乘客验证成功 {} #'.format(', '.join(result)))
|
||||
self.flush()
|
||||
return self
|
||||
@@ -1,359 +0,0 @@
|
||||
import urllib
|
||||
import random
|
||||
|
||||
from py12306.config import UserType
|
||||
from py12306.helpers.api import *
|
||||
from py12306.helpers.app import *
|
||||
from py12306.helpers.func import *
|
||||
from py12306.helpers.notification import Notification
|
||||
from py12306.log.order_log import OrderLog
|
||||
from py12306.log.user_log import UserLog
|
||||
|
||||
|
||||
# from py12306.query.job import Job
|
||||
# from py12306.user.job import UserJob
|
||||
|
||||
|
||||
class Order:
|
||||
"""
|
||||
处理下单
|
||||
"""
|
||||
session = None
|
||||
query_ins = None
|
||||
user_ins = None
|
||||
|
||||
passenger_ticket_str = ''
|
||||
old_passenger_str = ''
|
||||
|
||||
is_need_auth_code = False
|
||||
|
||||
max_queue_wait = 120
|
||||
current_queue_wait = 0
|
||||
retry_time = 3
|
||||
wait_queue_interval = 3
|
||||
|
||||
order_id = 0
|
||||
|
||||
notification_sustain_time = 60 * 30 # 通知持续时间 30 分钟
|
||||
notification_interval = 5 * 60 # 通知间隔
|
||||
|
||||
def __init__(self, query, user):
|
||||
self.session = user.session
|
||||
# assert isinstance(query, Job) # 循环引用
|
||||
# assert isinstance(user, UserJob)
|
||||
self.query_ins = query
|
||||
self.user_ins = user
|
||||
|
||||
self.make_passenger_ticket_str()
|
||||
|
||||
def order(self):
|
||||
"""
|
||||
开始下单
|
||||
下单模式 暂时不清楚,使用正常步骤下单
|
||||
:return:
|
||||
"""
|
||||
self.normal_order()
|
||||
pass
|
||||
|
||||
def normal_order(self):
|
||||
if not self.submit_order_request(): return
|
||||
if not self.user_ins.request_init_dc_page(): return
|
||||
if not self.check_order_info(): return
|
||||
if not self.get_queue_count(): return
|
||||
if not self.confirm_single_for_queue(): return
|
||||
order_id = self.query_order_wait_time()
|
||||
if order_id: # 发送通知
|
||||
self.order_id = order_id
|
||||
self.order_did_success()
|
||||
|
||||
def order_did_success(self):
|
||||
OrderLog.print_ticket_did_ordered(self.order_id)
|
||||
OrderLog.notification(OrderLog.MESSAGE_ORDER_SUCCESS_NOTIFICATION_TITLE,
|
||||
OrderLog.MESSAGE_ORDER_SUCCESS_NOTIFICATION_CONTENT)
|
||||
self.send_notification()
|
||||
|
||||
def send_notification(self):
|
||||
num = 0 # 通知次数
|
||||
sustain_time = self.notification_sustain_time
|
||||
while sustain_time: # TODO 后面直接查询有没有待支付的订单就可以
|
||||
num += 1
|
||||
if config.NOTIFICATION_BY_VOICE_CODE: # 语音通知
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_VOICE_CODE_START_SEND.format(num))
|
||||
Notification.voice_code(config.NOTIFICATION_VOICE_CODE_PHONE, self.user_ins.get_name(),
|
||||
OrderLog.MESSAGE_ORDER_SUCCESS_NOTIFICATION_OF_VOICE_CODE_CONTENT.format(
|
||||
self.query_ins.left_station, self.query_ins.arrive_station))
|
||||
sustain_time -= self.notification_interval
|
||||
sleep(self.notification_interval)
|
||||
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_JOB_CLOSED)
|
||||
# 结束运行
|
||||
while True: sleep(self.retry_time)
|
||||
|
||||
def submit_order_request(self):
|
||||
data = {
|
||||
'secretStr': urllib.parse.unquote(self.query_ins.get_info_of_secret_str()), # 解密
|
||||
'train_date': self.query_ins.left_date, # 出发时间
|
||||
'back_train_date': self.query_ins.left_date, # 返程时间
|
||||
'tour_flag': 'dc', # 旅途类型
|
||||
'purpose_codes': 'ADULT', # 成人 | 学生
|
||||
'query_from_station_name': self.query_ins.left_station,
|
||||
'query_to_station_name': self.query_ins.arrive_station,
|
||||
}
|
||||
response = self.session.post(API_SUBMIT_ORDER_REQUEST, data)
|
||||
result = response.json()
|
||||
if result.get('data') == 'N':
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_SUBMIT_ORDER_REQUEST_SUCCESS).flush()
|
||||
return True
|
||||
else:
|
||||
OrderLog.add_quick_log(
|
||||
OrderLog.MESSAGE_SUBMIT_ORDER_REQUEST_FAIL.format(result.get('messages', '-'))).flush()
|
||||
return False
|
||||
|
||||
def check_order_info(self):
|
||||
"""
|
||||
cancel_flag=2
|
||||
bed_level_order_num=000000000000000000000000000000
|
||||
passengerTicketStr=
|
||||
tour_flag=dc
|
||||
randCode=
|
||||
whatsSelect=1
|
||||
_json_att=
|
||||
REPEAT_SUBMIT_TOKEN=458bf1b0a69431f34f9d2e9d3a11cfe9
|
||||
:return:
|
||||
"""
|
||||
data = { #
|
||||
'cancel_flag': 2,
|
||||
'bed_level_order_num': '000000000000000000000000000000',
|
||||
'passengerTicketStr': self.passenger_ticket_str,
|
||||
'oldPassengerStr': self.old_passenger_str,
|
||||
'tour_flag': 'dc',
|
||||
'randCode': '',
|
||||
'whatsSelect': '1',
|
||||
'_json_att': '',
|
||||
'REPEAT_SUBMIT_TOKEN': self.user_ins.global_repeat_submit_token
|
||||
}
|
||||
response = self.session.post(API_CHECK_ORDER_INFO, data)
|
||||
result = response.json()
|
||||
if 'data' in result and result['data'].get('submitStatus'): # 成功
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_CHECK_ORDER_INFO_SUCCESS).flush()
|
||||
if result['data'].get("ifShowPassCode") != 'N':
|
||||
self.is_need_auth_code = True
|
||||
return True
|
||||
else:
|
||||
result_data = result.get('data', {})
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_CHECK_ORDER_INFO_FAIL.format(
|
||||
result_data.get('errMsg', result.get('messages', '-'))
|
||||
)).flush()
|
||||
return False
|
||||
|
||||
def get_queue_count(self):
|
||||
"""
|
||||
获取队列人数
|
||||
train_date Mon Jan 01 2019 00:00:00 GMT+0800 (China Standard Time)
|
||||
train_no 630000Z12208
|
||||
stationTrainCode Z122
|
||||
seatType 4
|
||||
fromStationTelecode GZQ
|
||||
toStationTelecode RXW
|
||||
leftTicket CmDJZYrwUoJ1jFNonIgPzPFdMBvSSE8xfdUwvb2lq8CCWn%2Bzk1vM3roJaHk%3D
|
||||
purpose_codes 00
|
||||
train_location QY
|
||||
_json_att
|
||||
REPEAT_SUBMIT_TOKEN 0977caf26f25d1da43e3213eb35ff87c
|
||||
:return:
|
||||
"""
|
||||
data = { #
|
||||
'train_date': '{} 00:00:00 GMT+0800 (China Standard Time)'.format(
|
||||
datetime.datetime.today().strftime("%a %h %d %Y")),
|
||||
'train_no': self.user_ins.ticket_info_for_passenger_form['queryLeftTicketRequestDTO']['train_no'],
|
||||
'stationTrainCode': self.user_ins.ticket_info_for_passenger_form['queryLeftTicketRequestDTO'][
|
||||
'station_train_code'],
|
||||
'seatType': self.query_ins.current_order_seat,
|
||||
'fromStationTelecode': self.user_ins.ticket_info_for_passenger_form['queryLeftTicketRequestDTO'][
|
||||
'from_station'],
|
||||
'toStationTelecode': self.user_ins.ticket_info_for_passenger_form['queryLeftTicketRequestDTO'][
|
||||
'to_station'],
|
||||
'leftTicket': self.user_ins.ticket_info_for_passenger_form['leftTicketStr'],
|
||||
'purpose_codes': self.user_ins.ticket_info_for_passenger_form['purpose_codes'],
|
||||
'train_location': self.user_ins.ticket_info_for_passenger_form['train_location'],
|
||||
'_json_att': '',
|
||||
'REPEAT_SUBMIT_TOKEN': self.user_ins.global_repeat_submit_token,
|
||||
}
|
||||
response = self.session.post(API_GET_QUEUE_COUNT, data)
|
||||
result = response.json()
|
||||
if 'data' in result and ('countT' in result['data'] or 'ticket' in result['data']): # 成功
|
||||
"""
|
||||
"data": {
|
||||
"count": "66",
|
||||
"ticket": "0,73",
|
||||
"op_2": "false",
|
||||
"countT": "0",
|
||||
"op_1": "true"
|
||||
}
|
||||
"""
|
||||
ticket = result['data']['ticket'].split(',') # 暂不清楚具体作用
|
||||
ticket_number = sum(map(int, ticket))
|
||||
current_position = int(data.get('countT', 0))
|
||||
OrderLog.add_quick_log(
|
||||
OrderLog.MESSAGE_GET_QUEUE_COUNT_SUCCESS.format(current_position, ticket_number)).flush()
|
||||
return True
|
||||
else:
|
||||
# 加入小黑屋
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_GET_QUEUE_COUNT_FAIL.format(
|
||||
result.get('messages', result.get('validateMessages', '-')))).flush()
|
||||
return False
|
||||
|
||||
def confirm_single_for_queue(self):
|
||||
"""
|
||||
确认排队
|
||||
passengerTicketStr
|
||||
oldPassengerStr
|
||||
randCode
|
||||
purpose_codes 00
|
||||
key_check_isChange FEE6C6634A3EAA93E1E6CFC39A99E555A92E438436F18AFF78837CDB
|
||||
leftTicketStr CmDJZYrwUoJ1jFNonIgPzPFdMBvSSE8xfdUwvb2lq8CCWn%2Bzk1vM3roJaHk%3D
|
||||
train_location QY
|
||||
choose_seats
|
||||
seatDetailType 000
|
||||
whatsSelect 1
|
||||
roomType 00
|
||||
dwAll N
|
||||
_json_att
|
||||
REPEAT_SUBMIT_TOKEN 0977caf26f25d1da43e3213eb35ff87c
|
||||
:return:
|
||||
"""
|
||||
data = { #
|
||||
'passengerTicketStr': self.passenger_ticket_str,
|
||||
'oldPassengerStr': self.old_passenger_str,
|
||||
'randCode': '',
|
||||
'purpose_codes': self.user_ins.ticket_info_for_passenger_form['purpose_codes'],
|
||||
'key_check_isChange': self.user_ins.ticket_info_for_passenger_form['key_check_isChange'],
|
||||
'leftTicketStr': self.user_ins.ticket_info_for_passenger_form['leftTicketStr'],
|
||||
'train_location': self.user_ins.ticket_info_for_passenger_form['train_location'],
|
||||
'choose_seats': '',
|
||||
'seatDetailType': '000',
|
||||
'whatsSelect': '1',
|
||||
'roomType': '00',
|
||||
'dwAll': 'N',
|
||||
'_json_att': '',
|
||||
'REPEAT_SUBMIT_TOKEN': self.user_ins.global_repeat_submit_token,
|
||||
}
|
||||
|
||||
if self.is_need_auth_code: # 目前好像是都不需要了,有问题再处理
|
||||
pass
|
||||
|
||||
response = self.session.post(API_CONFIRM_SINGLE_FOR_QUEUE, data)
|
||||
result = response.json()
|
||||
|
||||
if 'data' in result:
|
||||
"""
|
||||
"data": {
|
||||
"submitStatus": true
|
||||
}
|
||||
"""
|
||||
if result['data'].get('submitStatus'): # 成功
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_SUCCESS).flush()
|
||||
return True
|
||||
else:
|
||||
# 加入小黑屋 TODO
|
||||
OrderLog.add_quick_log(
|
||||
OrderLog.MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_ERROR.format(result['data'].get('errMsg', '-'))).flush()
|
||||
else:
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_CONFIRM_SINGLE_FOR_QUEUE_FAIL.format(
|
||||
result.get('messages', '-'))).flush()
|
||||
return False
|
||||
|
||||
def query_order_wait_time(self):
|
||||
"""
|
||||
排队查询
|
||||
random 1546849953542
|
||||
tourFlag dc
|
||||
_json_att
|
||||
REPEAT_SUBMIT_TOKEN 0977caf26f25d1da43e3213eb35ff87c
|
||||
:return:
|
||||
"""
|
||||
self.current_queue_wait = self.max_queue_wait
|
||||
while self.current_queue_wait:
|
||||
self.current_queue_wait -= 1
|
||||
# TODO 取消超时订单,待优化
|
||||
data = { #
|
||||
'random': str(random.random())[2:],
|
||||
'tourFlag': 'dc',
|
||||
'_json_att': '',
|
||||
'REPEAT_SUBMIT_TOKEN': self.user_ins.global_repeat_submit_token,
|
||||
}
|
||||
|
||||
response = self.session.get(API_QUERY_ORDER_WAIT_TIME.format(urllib.parse.urlencode(data)))
|
||||
result = response.json()
|
||||
|
||||
if result.get('status') and 'data' in result:
|
||||
"""
|
||||
"data": {
|
||||
"queryOrderWaitTimeStatus": true,
|
||||
"count": 0,
|
||||
"waitTime": -1,
|
||||
"requestId": 6487958947291482523,
|
||||
"waitCount": 0,
|
||||
"tourFlag": "dc",
|
||||
"orderId": "E222646122"
|
||||
}
|
||||
"""
|
||||
result_data = result['data']
|
||||
order_id = result_data.get('orderId')
|
||||
if order_id: # 成功
|
||||
return order_id
|
||||
elif result_data.get('waitTime') and result_data.get('waitTime') >= 0:
|
||||
OrderLog.add_quick_log(
|
||||
OrderLog.MESSAGE_QUERY_ORDER_WAIT_TIME_WAITING.format(result_data.get('waitTime'))).flush()
|
||||
elif result_data.get('msg'): # 失败 对不起,由于您取消次数过多,今日将不能继续受理您的订票请求。1月8日您可继续使用订票功能。
|
||||
# TODO 需要增加判断 直接结束
|
||||
OrderLog.add_quick_log(
|
||||
OrderLog.MESSAGE_QUERY_ORDER_WAIT_TIME_FAIL.format(result_data.get('msg', '-'))).flush()
|
||||
stay_second(self.retry_time)
|
||||
return False
|
||||
elif result.get('messages') or result.get('validateMessages'):
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_QUERY_ORDER_WAIT_TIME_FAIL.format(
|
||||
result.get('messages', result.get('validateMessages')))).flush()
|
||||
else:
|
||||
pass
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_QUERY_ORDER_WAIT_TIME_INFO.format(self.current_queue_wait)).flush()
|
||||
stay_second(self.wait_queue_interval)
|
||||
|
||||
return False
|
||||
|
||||
def make_passenger_ticket_str(self):
|
||||
"""
|
||||
生成提交车次的内容
|
||||
格式:
|
||||
1(seatType),0,1(车票类型:ticket_type_codes),张三(passenger_name),1(证件类型:passenger_id_type_code),xxxxxx(passenger_id_no),xxxx(mobile_no),N
|
||||
passengerTicketStr:
|
||||
张三(passenger_name),1(证件类型:passenger_id_type_code),xxxxxx(passenger_id_no),1_
|
||||
oldPassengerStr
|
||||
:return:
|
||||
"""
|
||||
passenger_tickets = []
|
||||
old_passengers = []
|
||||
available_passengers = self.query_ins.passengers
|
||||
if len(available_passengers) > self.query_ins.member_num_take: # 删除人数
|
||||
available_passengers = available_passengers[0:self.query_ins.member_num_take]
|
||||
OrderLog.print_passenger_did_deleted(available_passengers)
|
||||
|
||||
for passenger in available_passengers:
|
||||
tmp_str = '{seat_type},0,{passenger_type},{passenger_name},{passenger_id_card_type},{passenger_id_card},{passenger_mobile},N_'.format(
|
||||
seat_type=self.query_ins.current_order_seat, passenger_type=passenger['type'],
|
||||
passenger_name=passenger['name'],
|
||||
passenger_id_card_type=passenger['id_card_type'], passenger_id_card=passenger['id_card'],
|
||||
passenger_mobile=passenger['mobile']
|
||||
)
|
||||
passenger_tickets.append(tmp_str)
|
||||
|
||||
if int(passenger['type']) != UserType.CHILD:
|
||||
tmp_old_str = '{passenger_name},{passenger_id_card_type},{passenger_id_card},{passenger_type}_'.format(
|
||||
passenger_name=passenger['name'],
|
||||
passenger_id_card_type=passenger['id_card_type'], passenger_id_card=passenger['id_card'],
|
||||
passenger_type=passenger['type'],
|
||||
)
|
||||
old_passengers.append(tmp_old_str)
|
||||
|
||||
self.passenger_ticket_str = ''.join(passenger_tickets).rstrip('_')
|
||||
self.old_passenger_str = ''.join(old_passengers).rstrip('_') + '__ _ _' # 不加后面请求会出错
|
||||
@@ -1,231 +0,0 @@
|
||||
from py12306.helpers.api import LEFT_TICKETS
|
||||
from py12306.helpers.station import Station
|
||||
from py12306.log.query_log import QueryLog
|
||||
from py12306.helpers.func import *
|
||||
from py12306.log.user_log import UserLog
|
||||
from py12306.order.order import Order
|
||||
from py12306.user.user import User
|
||||
|
||||
|
||||
class Job:
|
||||
"""
|
||||
查询任务
|
||||
"""
|
||||
|
||||
left_dates = []
|
||||
left_date = None
|
||||
left_station = ''
|
||||
arrive_station = ''
|
||||
left_station_code = ''
|
||||
arrive_station_code = ''
|
||||
|
||||
account_key = 0
|
||||
allow_seats = []
|
||||
current_seat = None
|
||||
current_order_seat = None
|
||||
allow_train_numbers = []
|
||||
members = []
|
||||
member_num = 0
|
||||
member_num_take = 0 # 最终提交的人数
|
||||
passengers = []
|
||||
allow_less_member = False
|
||||
|
||||
interval = {}
|
||||
|
||||
query = None
|
||||
ticket_info = {}
|
||||
INDEX_TICKET_NUM = 11
|
||||
INDEX_TRAIN_NUMBER = 3
|
||||
INDEX_TRAIN_NO = 2
|
||||
INDEX_LEFT_DATE = 13
|
||||
INDEX_LEFT_STATION = 6 # 4 5 始发 终点
|
||||
INDEX_ARRIVE_STATION = 7
|
||||
INDEX_ORDER_TEXT = 1 # 下单文字
|
||||
INDEX_SECRET_STR = 0
|
||||
|
||||
def __init__(self, info, query):
|
||||
self.left_dates = info.get('left_dates')
|
||||
self.left_station = info.get('stations').get('left')
|
||||
self.arrive_station = info.get('stations').get('arrive')
|
||||
self.left_station_code = Station.get_station_key_by_name(self.left_station)
|
||||
self.arrive_station_code = Station.get_station_key_by_name(self.arrive_station)
|
||||
|
||||
self.account_key = info.get('account_key')
|
||||
self.allow_seats = info.get('seats')
|
||||
self.allow_train_numbers = info.get('train_numbers')
|
||||
self.members = info.get('members')
|
||||
self.member_num = len(self.members)
|
||||
self.member_num_take = self.member_num
|
||||
self.allow_less_member = bool(info.get('allow_less_member'))
|
||||
|
||||
self.interval = query.interval
|
||||
self.query = query
|
||||
|
||||
def run(self):
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
处理单个任务
|
||||
根据日期循环查询
|
||||
|
||||
展示处理时间
|
||||
:param job:
|
||||
:return:
|
||||
"""
|
||||
QueryLog.print_job_start()
|
||||
for date in self.left_dates:
|
||||
self.left_date = date
|
||||
response = self.query_by_date(date)
|
||||
self.handle_response(response)
|
||||
self.safe_stay()
|
||||
if is_main_thread():
|
||||
QueryLog.flush(sep='\t\t')
|
||||
if is_main_thread():
|
||||
QueryLog.add_quick_log('').flush()
|
||||
else:
|
||||
QueryLog.add_log('\n').flush(sep='\t\t')
|
||||
|
||||
def query_by_date(self, date):
|
||||
"""
|
||||
通过日期进行查询
|
||||
:return:
|
||||
"""
|
||||
QueryLog.add_log(
|
||||
('\n' if not is_main_thread() else '') + QueryLog.MESSAGE_QUERY_START_BY_DATE.format(date,
|
||||
self.left_station,
|
||||
self.arrive_station))
|
||||
url = LEFT_TICKETS.get('url').format(left_date=date, left_station=self.left_station_code,
|
||||
arrive_station=self.arrive_station_code, type='leftTicket/queryZ')
|
||||
|
||||
return self.query.session.get(url)
|
||||
|
||||
def handle_response(self, response):
|
||||
"""
|
||||
错误判断
|
||||
余票判断
|
||||
小黑屋判断
|
||||
座位判断
|
||||
乘车人判断
|
||||
:param result:
|
||||
:return:
|
||||
"""
|
||||
results = self.get_results(response)
|
||||
if not results:
|
||||
return False
|
||||
for result in results:
|
||||
self.ticket_info = ticket_info = result.split('|')
|
||||
if not self.is_trains_number_valid(ticket_info): # 车次是否有效
|
||||
continue
|
||||
QueryLog.add_log(QueryLog.MESSAGE_QUERY_LOG_OF_EVERY_TRAIN.format(self.get_info_of_train_number(),
|
||||
self.get_info_of_ticket_num()))
|
||||
if not self.is_has_ticket(ticket_info):
|
||||
continue
|
||||
allow_seats = self.allow_seats if self.allow_seats else list(
|
||||
config.SEAT_TYPES.values()) # 未设置 则所有可用 TODO 合法检测
|
||||
self.handle_seats(allow_seats, ticket_info)
|
||||
|
||||
def handle_seats(self, allow_seats, ticket_info):
|
||||
for seat in allow_seats: # 检查座位是否有票
|
||||
self.set_seat(seat)
|
||||
ticket_of_seat = ticket_info[self.current_seat]
|
||||
if not self.is_has_ticket_by_seat(ticket_of_seat): # 座位是否有效
|
||||
continue
|
||||
QueryLog.print_ticket_seat_available(left_date=self.get_info_of_left_date(),
|
||||
train_number=self.get_info_of_train_number(), seat_type=seat,
|
||||
rest_num=ticket_of_seat)
|
||||
if not self.is_member_number_valid(ticket_of_seat): # 乘车人数是否有效
|
||||
if self.allow_less_member:
|
||||
self.member_num_take = int(ticket_of_seat)
|
||||
QueryLog.print_ticket_num_less_than_specified(ticket_of_seat, self)
|
||||
else:
|
||||
QueryLog.add_quick_log(
|
||||
QueryLog.MESSAGE_GIVE_UP_CHANCE_CAUSE_TICKET_NUM_LESS_THAN_SPECIFIED).flush()
|
||||
continue
|
||||
if Const.IS_TEST: return
|
||||
# 检查完成 开始提交订单
|
||||
QueryLog.print_ticket_available(left_date=self.get_info_of_left_date(),
|
||||
train_number=self.get_info_of_train_number(),
|
||||
rest_num=ticket_of_seat)
|
||||
self.check_passengers()
|
||||
order = Order(user=self.get_user(), query=self)
|
||||
order.order()
|
||||
|
||||
def get_results(self, response):
|
||||
"""
|
||||
解析查询返回结果
|
||||
:param response:
|
||||
:return:
|
||||
"""
|
||||
if response.status_code != 200:
|
||||
QueryLog.print_query_error(response.reason, response.status_code)
|
||||
try:
|
||||
result_data = response.json().get('data', {})
|
||||
result = result_data.get('result', [])
|
||||
except:
|
||||
pass # TODO
|
||||
return result if result else False
|
||||
|
||||
def is_has_ticket(self, ticket_info):
|
||||
return self.get_info_of_ticket_num() == 'Y' and self.get_info_of_order_text() == '预订'
|
||||
|
||||
def is_has_ticket_by_seat(self, seat):
|
||||
return seat != '' and seat != '无' and seat != '*'
|
||||
|
||||
def is_trains_number_valid(self, ticket_info):
|
||||
if self.allow_train_numbers:
|
||||
return self.get_info_of_train_number() in self.allow_train_numbers
|
||||
return True
|
||||
|
||||
def is_member_number_valid(self, seat):
|
||||
return seat == '有' or self.member_num <= int(seat)
|
||||
|
||||
def safe_stay(self):
|
||||
interval = get_interval_num(self.interval)
|
||||
QueryLog.add_stay_log(interval)
|
||||
stay_second(interval)
|
||||
|
||||
def set_passengers(self, passengers):
|
||||
UserLog.print_user_passenger_init_success(passengers)
|
||||
self.passengers = passengers
|
||||
|
||||
def set_seat(self, seat):
|
||||
self.current_seat = get_seat_number_by_name(seat)
|
||||
self.current_order_seat = config.ORDER_SEAT_TYPES[seat]
|
||||
|
||||
def get_user(self):
|
||||
user = User.get_user(self.account_key)
|
||||
if not user.check_is_ready():
|
||||
# TODO user is not ready
|
||||
pass
|
||||
return user
|
||||
|
||||
def check_passengers(self):
|
||||
if not self.passengers:
|
||||
User.check_members(self.members, self.account_key, call_back=self.set_passengers)
|
||||
return True
|
||||
|
||||
# 提供一些便利方法
|
||||
def get_info_of_left_date(self):
|
||||
return self.ticket_info[self.INDEX_LEFT_DATE]
|
||||
|
||||
def get_info_of_ticket_num(self):
|
||||
return self.ticket_info[self.INDEX_TICKET_NUM]
|
||||
|
||||
def get_info_of_train_number(self):
|
||||
return self.ticket_info[self.INDEX_TRAIN_NUMBER]
|
||||
|
||||
def get_info_of_train_no(self):
|
||||
return self.ticket_info[self.INDEX_TRAIN_NO]
|
||||
|
||||
def get_info_of_left_station(self):
|
||||
return Station.get_station_name_by_key(self.ticket_info[self.INDEX_LEFT_STATION])
|
||||
|
||||
def get_info_of_arrive_station(self):
|
||||
return Station.get_station_name_by_key(self.ticket_info[self.INDEX_ARRIVE_STATION])
|
||||
|
||||
def get_info_of_order_text(self):
|
||||
return self.ticket_info[self.INDEX_ORDER_TEXT]
|
||||
|
||||
def get_info_of_secret_str(self):
|
||||
return self.ticket_info[self.INDEX_SECRET_STR]
|
||||
@@ -1,52 +0,0 @@
|
||||
import threading
|
||||
|
||||
from requests_html import HTMLSession
|
||||
|
||||
from py12306.helpers.app import app_available_check
|
||||
from py12306.helpers.func import *
|
||||
from py12306.log.query_log import QueryLog
|
||||
from py12306.query.job import Job
|
||||
|
||||
|
||||
class Query:
|
||||
"""
|
||||
余票查询
|
||||
|
||||
"""
|
||||
jobs = []
|
||||
session = {}
|
||||
|
||||
# 查询间隔
|
||||
interval = {}
|
||||
|
||||
def __init__(self):
|
||||
self.interval = init_interval_by_number(config.QUERY_INTERVAL)
|
||||
self.session = HTMLSession()
|
||||
|
||||
@classmethod
|
||||
def run(cls):
|
||||
self = cls()
|
||||
app_available_check()
|
||||
self.start()
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
# return # DEBUG
|
||||
self.init_jobs()
|
||||
QueryLog.print_init_jobs(jobs=self.jobs)
|
||||
stay_second(1)
|
||||
|
||||
while True:
|
||||
app_available_check()
|
||||
if config.QUERY_JOB_THREAD_ENABLED: # 多线程
|
||||
create_thread_and_run(jobs=self.jobs, callback_name='run')
|
||||
else:
|
||||
for job in self.jobs:
|
||||
job.run()
|
||||
if Const.IS_TEST: return
|
||||
|
||||
def init_jobs(self):
|
||||
jobs = config.QUERY_JOBS
|
||||
for job in jobs:
|
||||
job = Job(info=job, query=self)
|
||||
self.jobs.append(job)
|
||||
@@ -1,273 +0,0 @@
|
||||
import json
|
||||
import pickle
|
||||
import re
|
||||
from os import path
|
||||
|
||||
from py12306.config import *
|
||||
from py12306.helpers.api import *
|
||||
from py12306.helpers.app import *
|
||||
from py12306.helpers.auth_code import AuthCode
|
||||
from py12306.helpers.func import *
|
||||
from py12306.helpers.request import Request
|
||||
from py12306.log.order_log import OrderLog
|
||||
from py12306.log.user_log import UserLog
|
||||
|
||||
|
||||
class UserJob:
|
||||
heartbeat = 60 * 2 # 心跳保持时长
|
||||
heartbeat_interval = 5
|
||||
key = None
|
||||
user_name = ''
|
||||
password = ''
|
||||
user = None
|
||||
info = {} # 用户信息
|
||||
last_heartbeat = None
|
||||
is_ready = False
|
||||
passengers = []
|
||||
retry_time = 5
|
||||
|
||||
# Init page
|
||||
global_repeat_submit_token = None
|
||||
ticket_info_for_passenger_form = None
|
||||
order_request_dto = None
|
||||
|
||||
def __init__(self, info, user):
|
||||
self.session = Request()
|
||||
self.heartbeat = user.heartbeat
|
||||
|
||||
self.key = info.get('key')
|
||||
self.user_name = info.get('user_name')
|
||||
self.password = info.get('password')
|
||||
self.user = user
|
||||
|
||||
def run(self):
|
||||
# load user
|
||||
if not Const.IS_TEST:
|
||||
self.load_user()
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
检测心跳
|
||||
:return:
|
||||
"""
|
||||
while True:
|
||||
app_available_check()
|
||||
self.check_heartbeat()
|
||||
if Const.IS_TEST: return
|
||||
sleep(self.heartbeat_interval)
|
||||
|
||||
def check_heartbeat(self):
|
||||
# 心跳检测
|
||||
if self.last_heartbeat and (time_now() - self.last_heartbeat).seconds < self.heartbeat:
|
||||
return True
|
||||
if self.is_first_time() or not self.check_user_is_login():
|
||||
self.handle_login()
|
||||
|
||||
self.is_ready = True
|
||||
UserLog.add_quick_log(UserLog.MESSAGE_USER_HEARTBEAT_NORMAL.format(self.get_name(), self.heartbeat)).flush()
|
||||
self.last_heartbeat = time_now()
|
||||
|
||||
# def init_cookies
|
||||
def is_first_time(self):
|
||||
return not path.exists(self.get_cookie_path())
|
||||
|
||||
def handle_login(self):
|
||||
UserLog.print_start_login(user=self)
|
||||
self.login()
|
||||
|
||||
def login(self):
|
||||
"""
|
||||
获取验证码结果
|
||||
:return 权限校验码
|
||||
"""
|
||||
data = {
|
||||
'username': self.user_name,
|
||||
'password': self.password,
|
||||
'appid': 'otn'
|
||||
}
|
||||
answer = AuthCode.get_auth_code(self.session)
|
||||
data['answer'] = answer
|
||||
response = self.session.post(API_BASE_LOGIN.get('url'), data)
|
||||
result = response.json()
|
||||
if result.get('result_code') == 0: # 登录成功
|
||||
"""
|
||||
login 获得 cookie uamtk
|
||||
auth/uamtk 不请求,会返回 uamtk票据内容为空
|
||||
/otn/uamauthclient 能拿到用户名
|
||||
"""
|
||||
new_tk = self.auth_uamtk()
|
||||
user_name = self.auth_uamauthclient(new_tk)
|
||||
self.update_user_info({'user_name': user_name})
|
||||
self.login_did_success()
|
||||
elif result.get('result_code') == 2: # 账号之内错误
|
||||
# 登录失败,用户名或密码为空
|
||||
# 密码输入错误
|
||||
UserLog.add_quick_log(UserLog.MESSAGE_LOGIN_FAIL.format(result.get('result_message')))
|
||||
else:
|
||||
UserLog.add_quick_log(
|
||||
UserLog.MESSAGE_LOGIN_FAIL.format(result.get('result_message', result.get('message', '-'))))
|
||||
|
||||
return False
|
||||
|
||||
pass
|
||||
|
||||
def check_user_is_login(self):
|
||||
response = self.session.get(API_USER_CHECK.get('url'))
|
||||
is_login = response.json().get('data').get('flag', False)
|
||||
if is_login:
|
||||
self.save_user()
|
||||
return is_login
|
||||
|
||||
def auth_uamtk(self):
|
||||
response = self.session.post(API_AUTH_UAMTK.get('url'), {'appid': 'otn'})
|
||||
result = response.json()
|
||||
if result.get('newapptk'):
|
||||
return result.get('newapptk')
|
||||
# TODO 处理获取失败情况
|
||||
return False
|
||||
|
||||
def auth_uamauthclient(self, tk):
|
||||
response = self.session.post(API_AUTH_UAMAUTHCLIENT.get('url'), {'tk': tk})
|
||||
result = response.json()
|
||||
if result.get('username'):
|
||||
return result.get('username')
|
||||
# TODO 处理获取失败情况
|
||||
return False
|
||||
|
||||
def login_did_success(self):
|
||||
"""
|
||||
用户登录成功
|
||||
:return:
|
||||
"""
|
||||
self.welcome_user()
|
||||
self.save_user()
|
||||
self.get_user_info()
|
||||
pass
|
||||
|
||||
def welcome_user(self):
|
||||
UserLog.print_welcome_user(self)
|
||||
pass
|
||||
|
||||
def get_cookie_path(self):
|
||||
return config.USER_DATA_DIR + self.user_name + '.cookie'
|
||||
|
||||
def update_user_info(self, info):
|
||||
self.info = {**self.info, **info}
|
||||
|
||||
def get_name(self):
|
||||
return self.info.get('user_name')
|
||||
|
||||
def save_user(self):
|
||||
with open(self.get_cookie_path(), 'wb') as f:
|
||||
pickle.dump(self.session.cookies, f)
|
||||
|
||||
def did_loaded_user(self):
|
||||
"""
|
||||
恢复用户成功
|
||||
:return:
|
||||
"""
|
||||
UserLog.add_quick_log(UserLog.MESSAGE_LOADED_USER.format(self.user_name))
|
||||
if self.check_user_is_login():
|
||||
UserLog.add_quick_log(UserLog.MESSAGE_LOADED_USER_SUCCESS.format(self.user_name))
|
||||
self.get_user_info()
|
||||
UserLog.print_welcome_user(self)
|
||||
else:
|
||||
UserLog.add_quick_log(UserLog.MESSAGE_LOADED_USER_BUT_EXPIRED)
|
||||
|
||||
def get_user_info(self):
|
||||
response = self.session.get(API_USER_INFO.get('url'))
|
||||
result = response.json()
|
||||
user_data = result.get('data')
|
||||
if user_data.get('userDTO') and user_data['userDTO'].get('loginUserDTO'):
|
||||
user_data = user_data['userDTO']['loginUserDTO']
|
||||
self.update_user_info({**user_data, **{'user_name': user_data['name']}})
|
||||
return True
|
||||
return None
|
||||
|
||||
def load_user(self):
|
||||
cookie_path = self.get_cookie_path()
|
||||
if path.exists(cookie_path):
|
||||
with open(self.get_cookie_path(), 'rb') as f:
|
||||
self.session.cookies.update(pickle.load(f))
|
||||
self.did_loaded_user()
|
||||
return True
|
||||
return None
|
||||
|
||||
def check_is_ready(self):
|
||||
return self.is_ready
|
||||
|
||||
def get_user_passengers(self):
|
||||
if self.passengers: return self.passengers
|
||||
response = self.session.post(API_USER_PASSENGERS)
|
||||
result = response.json()
|
||||
if result.get('data') and result.get('data').get('normal_passengers'):
|
||||
self.passengers = result.get('data').get('normal_passengers')
|
||||
return self.passengers
|
||||
else:
|
||||
UserLog.add_quick_log(
|
||||
UserLog.MESSAGE_GET_USER_PASSENGERS_FAIL.format(result.get('messages', '-'), self.retry_time))
|
||||
stay_second(self.retry_time)
|
||||
return self.get_user_passengers()
|
||||
|
||||
def get_passengers_by_members(self, members):
|
||||
"""
|
||||
获取格式化后的乘客信息
|
||||
:param members:
|
||||
:return:
|
||||
[{
|
||||
name: '项羽',
|
||||
type: 1,
|
||||
id_card: 0000000000000000000,
|
||||
type_text: '成人'
|
||||
}]
|
||||
"""
|
||||
self.get_user_passengers()
|
||||
results = []
|
||||
for member in members:
|
||||
child_check = array_dict_find_by_key_value(results, 'name', member)
|
||||
if child_check:
|
||||
new_member = child_check.copy()
|
||||
new_member['type'] = UserType.CHILD
|
||||
new_member['type_text'] = dict_find_key_by_value(UserType.dicts, int(new_member['type']))
|
||||
else:
|
||||
passenger = array_dict_find_by_key_value(self.passengers, 'passenger_name', member)
|
||||
if not passenger:
|
||||
UserLog.add_quick_log(
|
||||
UserLog.MESSAGE_USER_PASSENGERS_IS_INVALID.format(self.user_name, member)).flush(
|
||||
exit=True) # TODO 需要优化
|
||||
new_member = {
|
||||
'name': passenger.get('passenger_name'),
|
||||
'id_card': passenger.get('passenger_id_no'),
|
||||
'id_card_type': passenger.get('passenger_id_type_code'),
|
||||
'mobile': passenger.get('mobile_no'),
|
||||
'type': passenger.get('passenger_type'),
|
||||
'type_text': dict_find_key_by_value(UserType.dicts, int(passenger.get('passenger_type')))
|
||||
}
|
||||
results.append(new_member)
|
||||
|
||||
return results
|
||||
|
||||
def request_init_dc_page(self):
|
||||
"""
|
||||
请求下单页面 拿到 token
|
||||
:return:
|
||||
"""
|
||||
data = {'_json_att': ''}
|
||||
response = self.session.post(API_INITDC_URL, data)
|
||||
html = response.text
|
||||
token = re.search(r'var globalRepeatSubmitToken = \'(.+?)\'', html)
|
||||
form = re.search(r'var ticketInfoForPassengerForm *= *(\{.+\})', html)
|
||||
order = re.search(r'var orderRequestDTO *= *(\{.+\})', html)
|
||||
# 系统忙,请稍后重试
|
||||
if html.find('系统忙,请稍后重试') != -1:
|
||||
OrderLog.add_quick_log(OrderLog.MESSAGE_REQUEST_INIT_DC_PAGE_FAIL).flush() # 重试无用,直接跳过
|
||||
return False
|
||||
try:
|
||||
self.global_repeat_submit_token = token.groups()[0]
|
||||
self.ticket_info_for_passenger_form = json.loads(form.groups()[0].replace("'", '"'))
|
||||
self.order_request_dto = json.loads(order.groups()[0].replace("'", '"'))
|
||||
except:
|
||||
pass # TODO Error
|
||||
|
||||
return True
|
||||
@@ -1,61 +0,0 @@
|
||||
from py12306.helpers.app import *
|
||||
from py12306.helpers.func import *
|
||||
from py12306.log.user_log import UserLog
|
||||
from py12306.user.job import UserJob
|
||||
|
||||
|
||||
@singleton
|
||||
class User:
|
||||
heartbeat = 60 * 2
|
||||
users = []
|
||||
|
||||
retry_time = 3
|
||||
|
||||
def __init__(self):
|
||||
self.interval = config.USER_HEARTBEAT_INTERVAL
|
||||
|
||||
@classmethod
|
||||
def run(cls):
|
||||
self = cls()
|
||||
app_available_check()
|
||||
self.start()
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
self.init_users()
|
||||
UserLog.print_init_users(users=self.users)
|
||||
# 多线程维护用户
|
||||
create_thread_and_run(jobs=self.users, callback_name='run', wait=False)
|
||||
|
||||
def init_users(self):
|
||||
accounts = config.USER_ACCOUNTS
|
||||
for account in accounts:
|
||||
user = UserJob(info=account, user=self)
|
||||
self.users.append(user)
|
||||
|
||||
@classmethod
|
||||
def get_user(cls, key):
|
||||
self = cls()
|
||||
for user in self.users:
|
||||
if user.key == key:
|
||||
return user
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def check_members(cls, members, key, call_back):
|
||||
"""
|
||||
检测乘客信息
|
||||
:param passengers:
|
||||
:return:
|
||||
"""
|
||||
self = cls()
|
||||
|
||||
for user in self.users:
|
||||
assert isinstance(user, UserJob)
|
||||
if user.key == key and user.check_is_ready():
|
||||
passengers = user.get_passengers_by_members(members)
|
||||
return call_back(passengers)
|
||||
|
||||
UserLog.add_quick_log(UserLog.MESSAGE_WAIT_USER_INIT_COMPLETE.format(self.retry_time)).flush()
|
||||
stay_second(self.retry_time)
|
||||
return self.check_members(members, key, call_back)
|
||||
@@ -1,54 +0,0 @@
|
||||
import requests
|
||||
from hashlib import md5
|
||||
|
||||
|
||||
class RKClient(object):
|
||||
|
||||
def __init__(self, username, password, soft_id, soft_key):
|
||||
self.username = username
|
||||
self.password = md5(password.encode('utf-8')).hexdigest()
|
||||
self.soft_id = soft_id
|
||||
self.soft_key = soft_key
|
||||
self.base_params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'softid': self.soft_id,
|
||||
'softkey': self.soft_key,
|
||||
}
|
||||
self.headers = {
|
||||
'Connection': 'Keep-Alive',
|
||||
'Expect': '100-continue',
|
||||
'User-Agent': 'ben',
|
||||
}
|
||||
|
||||
def rk_create(self, im, im_type, timeout=60):
|
||||
"""
|
||||
im: 图片字节
|
||||
im_type: 题目类型
|
||||
"""
|
||||
params = {
|
||||
'typeid': im_type,
|
||||
'timeout': timeout,
|
||||
}
|
||||
params.update(self.base_params)
|
||||
files = {'image': ('a.jpg', im)}
|
||||
r = requests.post('http://api.ruokuai.com/create.json', data=params, files=files, headers=self.headers)
|
||||
return r.json()
|
||||
|
||||
def rk_report_error(self, im_id):
|
||||
"""
|
||||
im_id:报错题目的ID
|
||||
"""
|
||||
params = {
|
||||
'id': im_id,
|
||||
}
|
||||
params.update(self.base_params)
|
||||
r = requests.post('http://api.ruokuai.com/reporterror.json', data=params, headers=self.headers)
|
||||
return r.json()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
rc = RKClient('username', 'password', 'soft_id', 'soft_key')
|
||||
im = open('a.jpg', 'rb').read()
|
||||
# print rc.rk_create(im, 3040)
|
||||
|
||||
23
requirements.txt
Normal file
23
requirements.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
aiohttp==3.6.2
|
||||
aiomysql==0.0.20
|
||||
aioredis==1.3.1
|
||||
aiosqlite==0.11.0
|
||||
async-timeout==3.0.1
|
||||
attrs==19.3.0
|
||||
cffi==1.13.2
|
||||
chardet==3.0.4
|
||||
ciso8601==2.1.2
|
||||
cryptography==2.8
|
||||
hiredis==1.0.1
|
||||
idna==2.8
|
||||
multidict==4.7.3
|
||||
pycparser==2.19
|
||||
pymysql==0.9.2
|
||||
pypika==0.35.18
|
||||
redis==3.3.11
|
||||
six==1.13.0
|
||||
toml==0.10.0
|
||||
tortoise-orm==0.15.4
|
||||
typing-extensions==3.7.4.1
|
||||
yarl==1.4.2
|
||||
4
runtime/.gitignore
vendored
4
runtime/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
!query
|
||||
!user
|
||||
2
runtime/query/.gitignore
vendored
2
runtime/query/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
runtime/user/.gitignore
vendored
2
runtime/user/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
41
tests/__init__.py
Normal file
41
tests/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from unittest import TestCase
|
||||
|
||||
from tortoise import Tortoise
|
||||
|
||||
from app.app import Config, App
|
||||
|
||||
|
||||
def async_test(func):
|
||||
""" Async test support """
|
||||
|
||||
@wraps(func)
|
||||
def warp(*args, **kwargs):
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(func(*args, **kwargs))
|
||||
|
||||
return warp
|
||||
|
||||
|
||||
async def __init_db():
|
||||
await Tortoise.init(db_url='sqlite://:memory:', modules={'models': ['app.models']})
|
||||
await Tortoise.generate_schemas()
|
||||
|
||||
|
||||
@async_test
|
||||
async def __init_test():
|
||||
await __init_db()
|
||||
Config.load()
|
||||
await App._App__load_data()
|
||||
|
||||
|
||||
__init_test()
|
||||
|
||||
|
||||
class BaseTest(TestCase):
|
||||
@classmethod
|
||||
@async_test
|
||||
async def tearDownClass(cls) -> None:
|
||||
super().tearDownClass()
|
||||
await Tortoise.close_connections()
|
||||
108
tests/test_helper.py
Normal file
108
tests/test_helper.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from app.app import Event, Cache
|
||||
from lib.exceptions import RetryException, MaxRetryException
|
||||
from lib.hammer import EventItem
|
||||
from lib.helper import StationHelper, json_friendly_loads, retry
|
||||
from lib.request import Session
|
||||
from tests import BaseTest, async_test
|
||||
|
||||
|
||||
class HelperTests(BaseTest):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
@async_test
|
||||
async def test_async_retry(self):
|
||||
@retry(4)
|
||||
async def test():
|
||||
raise RetryException()
|
||||
|
||||
with self.assertRaises(MaxRetryException):
|
||||
await test()
|
||||
|
||||
def test_retry(self):
|
||||
@retry()
|
||||
def test():
|
||||
raise RetryException()
|
||||
|
||||
with self.assertRaises(MaxRetryException):
|
||||
test()
|
||||
|
||||
def test_json_friendly_loads(self):
|
||||
ret = json_friendly_loads('["2019-01-25 08:01:56", "2019-12-26"]')
|
||||
self.assertEqual(ret[0], datetime.datetime(2019, 1, 25, 8, 1, 56))
|
||||
self.assertEqual(ret[1], datetime.datetime(2019, 12, 26).date())
|
||||
|
||||
|
||||
class RequestTests(BaseTest):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.session = Session.share()
|
||||
|
||||
@async_test
|
||||
async def test_requset(self):
|
||||
ret = await self.session.request('GET', 'http://httpbin.org/get')
|
||||
result = ret.json()
|
||||
self.assertEqual(result.get('headers.Host'), 'httpbin.org')
|
||||
|
||||
def test_cookie_dumps_and_loads(self):
|
||||
self.session.session.cookie_jar.update_cookies({
|
||||
'test': 'val'
|
||||
})
|
||||
ret = self.session.cookie_dumps()
|
||||
new_session = Session()
|
||||
new_session.cookie_loads(ret)
|
||||
for cookie in self.session.session.cookie_jar:
|
||||
self.assertIn(cookie, new_session.session.cookie_jar)
|
||||
|
||||
|
||||
class StationHelperTests(BaseTest):
|
||||
|
||||
def test_stations(self):
|
||||
ret = StationHelper.stations()
|
||||
self.assertGreater(len(ret), 1)
|
||||
|
||||
def test_cn_by_id(self):
|
||||
ret = StationHelper.cn_by_id('CUW')
|
||||
self.assertEqual(ret, '重庆北')
|
||||
|
||||
|
||||
class EventHammerTests(BaseTest):
|
||||
|
||||
@async_test
|
||||
async def test_main(self):
|
||||
item = EventItem('test', 'data')
|
||||
|
||||
async def subscribe():
|
||||
ret = await Event.subscribe()
|
||||
self.assertEqual(ret.dumps(), item.dumps())
|
||||
|
||||
asyncio.ensure_future(subscribe())
|
||||
second = 5
|
||||
while second:
|
||||
await Event.publish(item)
|
||||
await asyncio.sleep(1)
|
||||
second -= 1
|
||||
|
||||
|
||||
class CacheHammerTests(BaseTest):
|
||||
|
||||
@async_test
|
||||
async def test_set_get(self):
|
||||
await Cache.set('test', 'val')
|
||||
ret = await Cache.get('test')
|
||||
self.assertEqual(ret, 'val')
|
||||
ret = await Cache.get('__test', 'default')
|
||||
self.assertEqual(ret, 'default')
|
||||
|
||||
@async_test
|
||||
async def test_hash(self):
|
||||
await Cache.hset('user', 'name', 'li')
|
||||
ret = await Cache.hget('user', 'name')
|
||||
self.assertEqual(ret, 'li')
|
||||
await Cache.hdel('user', 'name')
|
||||
ret = await Cache.hget('user', 'name')
|
||||
self.assertEqual(ret, None)
|
||||
65
tests/test_notifaction.py
Normal file
65
tests/test_notifaction.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from app.app import Config
|
||||
from app.notification import *
|
||||
from tests import BaseTest, async_test
|
||||
|
||||
|
||||
class NotifactionTests(BaseTest):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.message = NotifactionMessage('title', 'body')
|
||||
self.ding_talk_config = Config.Notifaction.get('ding_talk', {})
|
||||
self.bark_config = Config.Notifaction.get('bark', {})
|
||||
self.email_config = Config.Notifaction.get('email', {})
|
||||
self.server_chan_config = Config.Notifaction.get('server_chan', {})
|
||||
self.push_bear_config = Config.Notifaction.get('push_bear', {})
|
||||
self.ding_xing_voice_config = Config.Notifaction.get('ding_xing_voice', {})
|
||||
|
||||
@async_test
|
||||
async def test_ding_talk(self):
|
||||
if not self.ding_talk_config:
|
||||
return
|
||||
ret = await DingTalkNotifaction(self.ding_talk_config).send(self.message)
|
||||
self.assertTrue(ret)
|
||||
|
||||
@async_test
|
||||
async def test_bark(self):
|
||||
if not self.bark_config:
|
||||
return
|
||||
ret = await BarkNotifaction(self.bark_config).send(self.message)
|
||||
self.assertTrue(ret)
|
||||
|
||||
@async_test
|
||||
async def test_email(self):
|
||||
if not self.email_config:
|
||||
return
|
||||
ret = await EmailNotifaction(self.email_config).send(self.message)
|
||||
self.assertTrue(ret)
|
||||
|
||||
@async_test
|
||||
async def test_server_chan(self):
|
||||
if not self.server_chan_config:
|
||||
return
|
||||
ret = await ServerChanNotifaction(self.server_chan_config).send(self.message)
|
||||
self.assertTrue(ret)
|
||||
|
||||
@async_test
|
||||
async def test_push_bear(self):
|
||||
if not self.push_bear_config:
|
||||
return
|
||||
ret = await PushBearNotifaction(self.push_bear_config).send(self.message)
|
||||
self.assertTrue(ret)
|
||||
|
||||
@async_test
|
||||
async def test_ding_xing_voice(self):
|
||||
if not self.ding_xing_voice_config:
|
||||
return
|
||||
self.message.extra = {
|
||||
'name': '贾政',
|
||||
'left_station': '广州',
|
||||
'arrive_station': '深圳',
|
||||
'set_name': '硬座',
|
||||
'orderno': 'E123542'
|
||||
}
|
||||
ret = await DingXinVoiceNotifaction(self.ding_xing_voice_config).send(self.message)
|
||||
self.assertTrue(ret)
|
||||
9
tests/test_order.py
Normal file
9
tests/test_order.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from tests import BaseTest
|
||||
|
||||
|
||||
class OrderTicketTests(BaseTest):
|
||||
# TODO
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
pass
|
||||
103
tests/test_query.py
Normal file
103
tests/test_query.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import copy
|
||||
from app.models import QueryJob, Ticket
|
||||
from app.query import QueryTicket
|
||||
from tests import BaseTest, async_test
|
||||
|
||||
|
||||
class QueryTicketTests(BaseTest):
|
||||
|
||||
@async_test
|
||||
async def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.query = await QueryJob.first()
|
||||
self.query_ticket = QueryTicket(self.query)
|
||||
# init query
|
||||
self.query.left_date = self.query.left_dates[0]
|
||||
self.query.left_station, self.query.arrive_station = self.query.stations[0]
|
||||
|
||||
@async_test
|
||||
async def test_get_query_api_type(self):
|
||||
ret = await self.query_ticket.get_query_api_type()
|
||||
self.assertIn(ret, ['leftTicket/query', 'leftTicket/queryO', 'leftTicket/queryZ'])
|
||||
|
||||
@async_test
|
||||
async def test_query_tickets(self):
|
||||
ret = await self.query_ticket.query_tickets()
|
||||
|
||||
@async_test
|
||||
async def test_get_available_tickets(self):
|
||||
ret = await self.query_ticket.get_available_tickets(self.query)
|
||||
for ticket in ret[0]:
|
||||
self.assertIsInstance(ticket, Ticket)
|
||||
self.assertTrue(ret[1] >= 0)
|
||||
|
||||
@async_test
|
||||
async def test_get_tickets_from_query(self):
|
||||
ret = await self.query_ticket.get_tickets_from_query(self.query)
|
||||
for ticket in ret:
|
||||
self.assertIsInstance(ticket, Ticket)
|
||||
|
||||
@async_test
|
||||
async def test_is_ticket_valid(self):
|
||||
tickets = await self.query_ticket.get_tickets_from_query(self.query)
|
||||
for ticket in tickets:
|
||||
ret = self.query_ticket.is_ticket_valid(ticket)
|
||||
self.assertIsInstance(ret, bool)
|
||||
|
||||
def test_verify_period(self):
|
||||
query = copy.deepcopy(self.query)
|
||||
query.left_periods = ['08:00', '16:00']
|
||||
ret = QueryTicket.verify_period('12:00', query.left_periods)
|
||||
self.assertEqual(ret, True)
|
||||
ret = QueryTicket.verify_period('16:00', query.left_periods)
|
||||
self.assertEqual(ret, True)
|
||||
ret = QueryTicket.verify_period('16:01', query.left_periods)
|
||||
self.assertEqual(ret, False)
|
||||
|
||||
def test_verify_ticket_num(self):
|
||||
ticket = Ticket()
|
||||
ticket.ticket_num = 'Y'
|
||||
ticket.order_text = '预订'
|
||||
ret = self.query_ticket.verify_ticket_num(ticket)
|
||||
self.assertEqual(ret, True)
|
||||
|
||||
def test_verify_seat(self):
|
||||
query = copy.deepcopy(self.query)
|
||||
query.allow_seats = ['硬座', '二等座'] # 29, 30
|
||||
ticket = Ticket()
|
||||
ticket.raw = {29: '*', 30: '有'}
|
||||
ret = self.query_ticket.verify_seat(ticket, query)
|
||||
self.assertEqual(ret, True)
|
||||
self.assertEqual(ticket.available_seat.get('id'), 30)
|
||||
|
||||
def test_verify_train_number(self):
|
||||
query = copy.deepcopy(self.query)
|
||||
query.allow_train_numbers = ['G427', 'G429', 'T175']
|
||||
ticket = Ticket()
|
||||
ticket.train_number = 'G427'
|
||||
ret = self.query_ticket.verify_train_number(ticket, query)
|
||||
self.assertEqual(True, ret)
|
||||
ticket.train_number = 'B427'
|
||||
ret = self.query_ticket.verify_train_number(ticket, query)
|
||||
self.assertEqual(False, ret)
|
||||
|
||||
def test_verify_member_count(self):
|
||||
query = copy.deepcopy(self.query)
|
||||
query.member_num = 5
|
||||
ticket = Ticket()
|
||||
ticket.available_seat = {'name': '二等座', 'id': 30, 'raw': '3', 'order_id': 'O'}
|
||||
ret = self.query_ticket.verify_member_count(ticket, query)
|
||||
self.assertEqual(False, ret)
|
||||
query.less_member = True
|
||||
ret = self.query_ticket.verify_member_count(ticket, query)
|
||||
self.assertEqual(True, ret)
|
||||
|
||||
def test_get_query_interval(self):
|
||||
ret = self.query_ticket.get_query_interval()
|
||||
self.assertTrue(ret >= 0)
|
||||
|
||||
def test_action(self):
|
||||
self.assertEqual(self.query_ticket.is_runable, True)
|
||||
self.query_ticket.stop()
|
||||
self.assertEqual(self.query_ticket.is_runable, False)
|
||||
self.assertEqual(self.query_ticket.is_stoped, True)
|
||||
63
tests/test_user.py
Normal file
63
tests/test_user.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from app.models import User
|
||||
from app.user import TrainUser, CaptchaTool, TrainUserManager
|
||||
from tests import BaseTest, async_test
|
||||
|
||||
|
||||
class TestCaptchaTool(BaseTest):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.captcha_tool = CaptchaTool.share()
|
||||
|
||||
@async_test
|
||||
async def test_get_base64_code(self):
|
||||
ret = await self.captcha_tool.get_base64_code()
|
||||
self.assertIsInstance(ret, str)
|
||||
|
||||
@async_test
|
||||
async def test_identify_captcha(self):
|
||||
captcha_image64 = await self.captcha_tool.get_base64_code()
|
||||
ret = await self.captcha_tool.identify_captcha(captcha_image64)
|
||||
self.assertIsInstance(ret, str)
|
||||
|
||||
@async_test
|
||||
async def test_verify_captcha_answer(self):
|
||||
captcha_image64 = await self.captcha_tool.get_base64_code()
|
||||
ret = await self.captcha_tool.identify_captcha(captcha_image64)
|
||||
ret = await self.captcha_tool.verify_captcha_answer(ret)
|
||||
self.assertTrue(ret)
|
||||
|
||||
|
||||
class TestUser(BaseTest):
|
||||
|
||||
@async_test
|
||||
async def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = await User.first()
|
||||
self.train_user = TrainUser(self.user)
|
||||
|
||||
@async_test
|
||||
async def test_login_user(self):
|
||||
self.user.last_cookies = {}
|
||||
ret = await self.train_user.login_user()
|
||||
self.assertTrue(ret)
|
||||
|
||||
@async_test
|
||||
async def test_update_device_id(self):
|
||||
await self.train_user.update_device_id()
|
||||
cookies = self.train_user.session.session.cookie_jar._cookies
|
||||
self.assertIsInstance(cookies.get('').get('RAIL_DEVICEID').value, str)
|
||||
self.assertIsInstance(cookies.get('').get('RAIL_EXPIRATION').value, str)
|
||||
|
||||
@async_test
|
||||
async def test_get_user_info(self):
|
||||
await self.train_user.login_user()
|
||||
ret = await self.train_user.get_user_info()
|
||||
self.assertIn('name', ret)
|
||||
self.assertIn('user_name', ret)
|
||||
|
||||
@async_test
|
||||
async def test_get_user_passengers(self):
|
||||
await self.train_user.login_user()
|
||||
ret = await self.train_user.get_user_passengers()
|
||||
self.assertGreater(len(ret), 1)
|
||||
Reference in New Issue
Block a user