183 Commits
v1.0.0 ... v2.0

Author SHA1 Message Date
Jalin
ee0d045845 Fix db 文件自动创建失败 2020-01-02 13:35:34 +08:00
Jalin
65fbb6fb5e 优化查询与参数获取 2020-01-02 11:06:35 +08:00
Jalin
1dee214ec1 更新 requirements.txt 2019-12-31 22:44:53 +08:00
Jalin
64c485e1d8 Add CHANGELOG.md 2019-12-31 22:39:57 +08:00
Jalin
daf0b7466c 增加 mysql 数据库支持 2019-12-31 22:39:45 +08:00
Jalin
8bfe1e75d2 Merge remote-tracking branch 'remotes/github/master' into v2.0
# Conflicts:
#	README.md
#	data/stations.txt
#	env.py.example
#	py12306/query/job.py
2019-12-31 21:53:16 +08:00
Jalin
4ff5bacd13 Switch to 2.0 2019-12-31 21:35:56 +08:00
pjialin
390fcecdd2 Merge pull request #317 from out0fmemory/master
增加时间判断逻辑
2019-12-30 10:27:30 +08:00
pjialin
12581dc03d Merge pull request #325 from ShengZhang2016/szhang01/fix_env_overwrite
[szhang01/fix_env_overwrite] Comment out unnecessary setting in env.py.example
2019-12-30 10:20:07 +08:00
zhangsheng
095dbfc21d [szhang01/fix_env_overwrite] Comment out unnecessary setting in env.py.example. 2019-12-29 01:13:15 -08:00
out0fmemory
eee53461cf 增加买票时间校验逻辑,防止出现日期不对,但是报的是302错误 2019-12-27 18:44:32 +08:00
out0fmemory
95e9e8a89b 修改默认日期到2020年 2019-12-27 18:43:56 +08:00
pjialin
998b387875 Merge pull request #288 from out0fmemory/master
修改人员的注释,存在不注意情况下有多余的人员信息
2019-12-24 18:56:18 +08:00
pjialin
9a42e0e8d7 Merge pull request #272 from wendingzhang/wendingzhang-patch-1
Add new stations
2019-12-24 18:52:45 +08:00
out0fmemory
681157c40b 修改人员的注释,存在不注意情况下有多余的人员信息 2019-12-24 18:25:58 +08:00
wending.zhang
4468aa2277 Add new stations 2019-12-23 14:17:23 +08:00
pjialin
c6ec668fad Update README.md 2019-12-21 12:24:28 +08:00
pjialin
3a795492de Update README.md 2019-12-17 17:45:33 +08:00
Jalin
006fd48d71 更新 12306 服务时间 2019-12-16 10:08:02 +08:00
pjialin
ee50ad213d Update README.md 2019-12-13 10:27:55 +08:00
pjialin
6c7c83e53d Merge pull request #233 from pjialin/fix/passenger-error
fix get passengers error
2019-11-25 14:44:45 +08:00
Jalin
6113b8519f fix get passengers error 2019-11-25 14:33:22 +08:00
pjialin
a3286fc2c2 Merge pull request #227 from pjialin/dependabot/pip/urllib3-1.24.2
Bump urllib3 from 1.24.1 to 1.24.2
2019-11-25 13:31:05 +08:00
dependabot[bot]
5bcb078124 Bump urllib3 from 1.24.1 to 1.24.2
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.24.1 to 1.24.2.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/master/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.24.1...1.24.2)

Signed-off-by: dependabot[bot] <support@github.com>
2019-11-01 02:00:09 +00:00
pjialin
7ec0cbd235 Merge pull request #225 from pjialin/dependabot/pip/werkzeug-0.15.3
Bump werkzeug from 0.14.1 to 0.15.3
2019-11-01 09:58:55 +08:00
dependabot[bot]
9531f774c2 Bump werkzeug from 0.14.1 to 0.15.3
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 0.14.1 to 0.15.3.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/master/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/0.14.1...0.15.3)

Signed-off-by: dependabot[bot] <support@github.com>
2019-10-21 18:01:18 +00:00
pjialin
e3038a3f21 增加本地打码仓库链接 2019-10-14 09:52:03 +08:00
pjialin
1ddd6cdd69 Merge pull request #221 from bnpzsx/强制购买成人票
支持学生直接购买成人票
2019-10-12 16:24:23 +08:00
Jalin
a258780185 Merge branch 'master' of github.com:pjialin/py12306 2019-10-12 15:27:35 +08:00
Jalin
5d0b14ce85 修复登录网络错误 #155 2019-10-12 15:26:55 +08:00
bnpzsx
db7e88a6a6 支持学生直接购买成人票 2019-10-01 11:07:13 +08:00
pjialin
0578f2e3a8 Merge pull request #203 from kbj/master
increase timeout of OCR
2019-09-29 15:58:58 +08:00
Weey
3b9b15deba update cdn list 2019-09-27 11:58:47 +08:00
Weey
91c0a44a32 set timeout of OCR 2019-09-19 21:59:07 +08:00
Jalin
f377c7a849 修复订单会重复提交 #183 #187 2019-09-17 16:11:42 +08:00
Jalin
7136474a6b Merge branch 'pr175' 2019-09-12 15:54:21 +08:00
Jalin
563b051799 更新 Readme 2019-09-12 15:38:37 +08:00
Jalin
3a31349662 验证码识别修改为免费打码 2019-09-12 15:34:50 +08:00
Jalin
cc67987bb3 修改 Device Id 获取方式 2019-09-12 15:23:03 +08:00
Jalin
64a52559f3 修复下单 enc_str 报错 #180 2019-09-12 12:03:21 +08:00
Jalin
4966d45f96 修复下单失败 #173 2019-09-10 22:15:51 +08:00
bnpzsx
cb8e0aada6 邮件通知修复
调用starttls()需要重新认证
2019-09-07 18:15:00 +08:00
Jalin
6e8cd9f902 修复登录失效 #168 2019-09-03 13:44:35 +08:00
Jalin
8f984cd751 优化验证码识别 2019-09-03 13:43:48 +08:00
Weey
0bd6417ff4 update readme 2019-09-02 21:54:10 +08:00
Weey
619a96ed5d update readme 2019-09-02 21:51:40 +08:00
Weey
83cc5e6d6c Merge remote-tracking branch 'upstream/master' 2019-09-02 21:37:02 +08:00
Jalin
b848a49720 Update readme 2019-07-29 10:22:18 +08:00
Weey
707fd5bd8a 打码方式使用机器学习 2019-06-29 11:07:34 +08:00
Jalin
8cabf157f6 Remove repeated code 2019-05-18 18:06:59 +08:00
Weey
eeea27403c 修正登陆失败的问题 2019-05-18 15:03:27 +08:00
Jalin
4ac5fc3403 update browser id algorithm 2019-04-05 14:01:52 +08:00
Jalin
e56b545392 fix login fail #151 2019-04-04 15:36:44 +08:00
Jalin
1e3ea59bb6 fix hooks may not been assignment #148 2019-04-02 16:47:45 +08:00
Jalin
42f2a640f6 修复登录失效问题 #144 2019-04-02 16:33:16 +08:00
Jalin
a3f185e3a8 修复登录失效 #144 2019-03-26 23:18:36 +08:00
Jalin
ad5e071080 修复获取查询接口正则匹配问题 #142 2019-03-15 10:23:28 +08:00
Jalin
2578be4965 更换免费打码接口地址 #140 2019-03-07 09:58:06 +08:00
Jalin
365453e0b3 增加动态获取查询地址 #121 2019-02-20 23:11:55 +08:00
Jalin
733d512f72 add bark to docker env example 2019-02-10 22:32:58 +08:00
Zzde
c430a65cdd 增加bark通知 2019-02-02 14:20:15 +08:00
Jalin
5a97d11a09 增加网络默认超时时间 2019-01-29 15:37:04 +08:00
Jalin
df7467e18d env.docker 增加时间筛选示例 2019-01-29 15:26:53 +08:00
Breeze Chen
969bc99e87 通知消息 增加 乘车人,发车时间、到达时间、账号等信息 2019-01-27 16:05:35 +08:00
Breeze Chen
9c63063d50 增加:发车时间段筛选 2019-01-27 15:34:06 +08:00
Jalin
08ad7598db 优化避免无座检测逻辑 2019-01-25 20:52:30 +08:00
Jalin
afd8c35afb 优化停留间隔,循环获取乘客问题 2019-01-25 14:10:27 +08:00
Jalin
419495ce84 查询时禁止 redirect, 修改 typo #113 2019-01-24 22:22:49 +08:00
Jalin
6a17dab7b0 增加 网络错误时,停留一段时间 #12 2019-01-22 23:12:25 +08:00
Jalin
55fb26176a 更新 docker-compose.yml 2019-01-22 22:34:52 +08:00
Jalin
61961e515c 优化错误提示 2019-01-22 22:16:15 +08:00
Jalin
fe5e82f2ff 优化用户检测流程 2019-01-22 21:53:41 +08:00
Jalin
dac614663d Merge branch 'master' of github.com:pjialin/py12306 2019-01-22 13:49:26 +08:00
Jalin
9476052000 修改 gitpd 使用 2019-01-22 13:49:14 +08:00
Jalin
1431c577f9 Merge branch 'pr98' 2019-01-22 12:51:16 +08:00
pjialin
0908da55c5 Merge pull request #93 from imhy123/master
使用expire判断节点下线
2019-01-22 12:19:14 +08:00
Jalin
c71547e3db Merge branch 'pr77' 2019-01-22 11:58:40 +08:00
Jalin
91763f3141 修改 docker-compose 使用说明 2019-01-22 11:58:26 +08:00
Jan Keromnes
5a1e34b6fa 更新 readme,配置Gitpod 2019-01-21 19:15:31 +00:00
spirithy
1a617db658 使用expire判断节点下线 2019-01-21 00:10:19 +08:00
Jalin
e8abbd57bb 增加 cdn 检测超时配置 2019-01-19 15:48:45 +08:00
Jalin
cc3426c05f 移除子节点乘客检测,web 增加 cdn 显示 2019-01-19 15:11:30 +08:00
Jalin
7762c88db6 增加节点订阅消息丢失重连 2019-01-19 11:56:34 +08:00
Jalin
51ab20f7cf 更新 readme,修复子节点用户初始化问题 2019-01-18 22:36:19 +08:00
Jalin
bdc670a86d 优化排队错误直接结束排队 2019-01-18 20:17:31 +08:00
Jalin
7c21310ea3 优化 cdn 优化下单逻辑 2019-01-18 19:31:30 +08:00
Jalin
7863cd2863 优化座位提示 2019-01-18 19:07:03 +08:00
Jalin
a9287eb7e8 增加通知详细信息 #43 #51 增加新的语音消息服务商 2019-01-18 18:58:37 +08:00
brucedone
bef3e95cec fix the readme typo 2019-01-18 18:32:44 +08:00
brucedone
f00f1922a5 add docker-compose deploy 2019-01-18 18:29:56 +08:00
Jalin
e91ad184bb 增加排除车次, 增加通过序号确定唯一联系人 #68 2019-01-18 18:11:50 +08:00
Jalin
556a8fc7c3 车次支持大小写 2019-01-18 17:29:00 +08:00
Jalin
bfff29cd67 增加未支付订单检测 #75 移除语音重复通知 2019-01-18 17:13:13 +08:00
Jalin
98e5d8f95a 增加 cdn 查询 2019-01-18 17:08:39 +08:00
Jalin
8032422f7d 优化提示 2019-01-18 10:39:01 +08:00
Jalin
a6081ec0b5 Merge https://github.com/a2r0n/py12306 into pr 2019-01-18 10:33:03 +08:00
Jalin
0681cef3db 修改名称错误 2019-01-18 10:31:15 +08:00
a2r0n
eb8d1f5a97 增加status.json加载失败时的容错处理. 2019-01-17 14:41:59 +08:00
Jalin
3e7616ccea 修改用词 2019-01-17 13:19:21 +08:00
Jalin
a21bf2264e 修复编码问题 优化提示 2019-01-17 11:21:14 +08:00
pjialin
1c4e832d87 Merge pull request #61 from kbj/master
修正提示文案
2019-01-16 23:05:31 +08:00
Weey
2f98504328 修正提示文案 2019-01-16 22:32:29 +08:00
Jalin
0da42b9f26 优化错误输出 2019-01-16 21:18:57 +08:00
Jalin
1387cbede0 修复编码不兼容 #14 #57 2019-01-16 19:17:10 +08:00
Jalin
59f5b8cb49 Merge branch 'master' of github.com:pjialin/py12306 2019-01-16 17:35:19 +08:00
Jalin
b46ca021a9 修复时间错误可能导致查询无票 2019-01-16 17:34:59 +08:00
pjialin
3b3dd8337d Merge pull request #54 from idealhack/patch-1
docs: update docker command example in README
2019-01-16 17:07:12 +08:00
Yang Li
059e5b5d1c docs: update docker command example in README 2019-01-16 16:58:57 +08:00
Jalin
0524f5fc27 修复 log 文件不会自动创建 2019-01-16 13:17:06 +08:00
pjialin
682613ca31 Merge pull request #48 from feng409/master
移除重复命令
2019-01-16 12:50:11 +08:00
chemf
2f61002562 移除重复命令 2019-01-16 12:38:43 +08:00
Jalin
c2ce769a92 Merge branch 'master' of github.com:pjialin/py12306 2019-01-15 23:25:01 +08:00
Jalin
ddfd38e0a9 增加邮箱 ssl 支持 #33 2019-01-15 23:24:26 +08:00
pjialin
af71d0a921 Merge pull request #42 from kbj/master
新增一个Telegram推送Bot地址
2019-01-15 23:20:25 +08:00
Jalin
1c98e2743a 修复提交订单成功后不停止排队 #35 2019-01-15 23:12:12 +08:00
Weey
d4d4ee2139 新增另外一个Telegram推送Bot 2019-01-15 22:34:08 +08:00
Jalin
8df3c4032d 修复不立即登录问题 2019-01-15 12:55:55 +08:00
Jalin
884b0f30db 更新 readme 2019-01-15 12:27:54 +08:00
Jalin
f3b0cbee24 修改 key 参数规则 2019-01-15 12:27:24 +08:00
Jalin
0d52efb1da Merge https://github.com/wonderful60/py12306 into pr 2019-01-15 12:02:30 +08:00
littlefatty.wong
4bd5cdb89e 给PushBear添加SC_KEY参数用于填写send_key 2019-01-15 11:57:51 +08:00
Jalin
bedeb20085 Merge https://github.com/wonderful60/py12306 into pr 2019-01-15 11:46:45 +08:00
littlefatty.wong
00d5ea4be6 将配置文件参数SKEY修改为S_KEY以符合参数规则 2019-01-15 11:35:23 +08:00
littlefatty.wong
2c9fa98efc add ServerChan and PushBear支持 2019-01-15 11:13:51 +08:00
Jalin
901864e12f Merge branch 'master' of github.com:pjialin/py12306 2019-01-15 10:06:33 +08:00
Jalin
09ba6fef40 增加软座 #17 2019-01-15 10:06:22 +08:00
pjialin
25d7169afe Merge pull request #23 from feng409/master
[FIX] 移除重复代码
2019-01-15 09:39:35 +08:00
chemf
ae70de23ae [FIX] 移除重复代码 2019-01-15 00:26:10 +08:00
Weey
45d3e91391 新增推送到Telegram 2019-01-14 22:34:32 +08:00
Echowxsy
80990eb02a 添加钉钉消息通知测试 2019-01-14 20:18:28 +08:00
Echowxsy
95925ba9e7 添加订单完成时钉钉通知入口 2019-01-14 20:18:28 +08:00
Echowxsy
8109d87c5f 实现钉钉通知方法 2019-01-14 20:18:28 +08:00
Echowxsy
dbe98ca867 添加钉钉配置文件 2019-01-14 20:18:28 +08:00
Echowxsy
ff8a09e3fb 添加钉钉消息通知依赖 2019-01-14 20:18:28 +08:00
Jalin
559dabe0b5 Merge branch 'master' of github.com:pjialin/py12306 2019-01-14 18:18:53 +08:00
Jalin
d0b61852d3 修复 bug #16,增加任务更新后重新检测乘客 2019-01-14 18:18:26 +08:00
pjialin
ef7ec01329 修改错字 2019-01-14 16:07:23 +08:00
Jalin
550c87d77a 增加集群状态支持 2019-01-14 16:06:27 +08:00
Jalin
26d0f5a1de 优化查询错误 2019-01-14 12:34:48 +08:00
Jalin
88ddc87f03 修复类型转换错误 2019-01-13 12:50:37 +08:00
Jalin
eb167538d2 更新 Docker 时区 为 Shanghai 2019-01-13 12:12:13 +08:00
Jalin
047024d239 新增免费打码 2019-01-13 11:56:08 +08:00
Jalin
751c01298f 优化程序流程 2019-01-13 11:02:02 +08:00
Jalin
ea10558bff 更新 requirements 2019-01-12 22:56:09 +08:00
Jalin
e252a41fd3 Merge branch 'master' of github.com:pjialin/py12306 2019-01-12 22:47:54 +08:00
Jalin
994c211e2f 增加 Web 管理页面 2019-01-12 22:47:27 +08:00
pjialin
54eb32d40f Merge pull request #4 from SCUTJcfeng/master
修复Windows signal模块没有SIGHUP的问题
2019-01-12 19:00:31 +08:00
fengjc
2396a40911 修复Windows signal模块没有SIGHUP的问题 2019-01-12 16:59:17 +08:00
Jalin
a3e1544072 优化结果 2019-01-12 13:42:09 +08:00
Jalin
e722eacbb8 增加用户,统计,应用接口 2019-01-12 13:09:59 +08:00
Jalin
3c982e4ac4 Merge branch 'master' into develop 2019-01-12 12:19:21 +08:00
Jalin
9b46ee8a85 增加验车防止用户重复状态失效 2019-01-12 12:18:39 +08:00
Jalin
0dc4eee1a8 增加 web 目录 2019-01-12 12:16:11 +08:00
Jalin
cb4fd195a1 修复 windows 下 signal.SIGHUP 错误 2019-01-12 11:08:38 +08:00
Jalin
a0f2bc7913 修复邮件发送者错误 2019-01-12 10:51:09 +08:00
Jalin
6004d78e90 增加邮件通知 2019-01-11 21:50:18 +08:00
pjialin
bcab8c4e30 增加 加群链接 2019-01-11 14:03:49 +08:00
Jalin
41f4628eb9 优化乘客检测 2019-01-11 13:44:42 +08:00
Jalin
df41b8d8c2 update readme 2019-01-11 12:32:22 +08:00
Jalin
111541a1e8 优化提示 2019-01-11 12:22:57 +08:00
Jalin
48a1f6a06a 增加动态加载配置 2019-01-11 01:42:04 +08:00
Jalin
a4b355bdf1 修改验证码为 image64 方式 2019-01-10 20:48:58 +08:00
Jalin
6c8d5e3142 update readme 2019-01-10 18:55:08 +08:00
pjialin
c52d91c5e8 Update README.md 2019-01-10 17:44:46 +08:00
Jalin
e510de878d 增加 Docker 使用说明 2019-01-10 17:41:51 +08:00
Jalin
e17b48a0ce 增加 docker 支持 2019-01-10 17:23:13 +08:00
Jalin
1be623032f 增加 redis key prefix 2019-01-10 14:50:17 +08:00
Jalin
0670e19b05 更新 requirements.txt 2019-01-10 13:56:20 +08:00
Jalin
a05f1a910a 支持分布式集群 2019-01-10 13:47:31 +08:00
Jalin
c3f3ba9ffc 修复并发锁问题 2019-01-10 13:01:05 +08:00
Jalin
0d7558afeb 优化事件处理 2019-01-10 11:06:07 +08:00
Jalin
f47368206c 增加事件通知 2019-01-10 02:13:19 +08:00
Jalin
632256caf7 优化同步处理 2019-01-09 22:20:54 +08:00
Jalin
668c4ae8ce 完成用户保持 2019-01-09 21:01:12 +08:00
Jalin
7d5b8e2b80 完成分布式查询 2019-01-09 11:14:49 +08:00
Jalin
8f681c5e30 优化输出信息 2019-01-08 19:12:44 +08:00
Jalin
6d18a8d11b 增加单个任务添加多个出发和到达车站 2019-01-08 17:28:08 +08:00
Jalin
6dc3005cd9 优化错误处理 2019-01-08 16:45:54 +08:00
Jalin
a96e08efeb 增加输出日志到文件 2019-01-08 12:08:06 +08:00
Jalin
a6feda0a41 添加 README 2019-01-08 11:02:33 +08:00
Jalin
2a734bdfe6 增加 requirements.txt 2019-01-08 02:14:16 +08:00
Jalin
8bcbaf4cde Merge branch 'master' of github.com:pjialin/py12306 2019-01-08 02:07:19 +08:00
pjialin
5b296694a9 Initial commit 2019-01-08 02:04:12 +08:00
62 changed files with 4135 additions and 2490 deletions

7
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,16 @@
# 更新日志
## [Unreleased]
## [V2.0.0] - 2019-12-31
### Added
- 重构代码结构
- 支持使用 sqlite, mysql 数据库
- 支持使用代理进行余票查询
- 添加小黑屋支持,避免重复下单
- 添加 Pipenv 支持
- 添加单元测试用例
### Removed
- 移除 CDN 支持
- 移除 Web 界面(待重写)

19
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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 管理页面
![Web 管理页面图片](https://github.com/pjialin/py12306/blob/master/data/images/web.png)
### 下单成功
![下单成功图片](https://github.com/pjialin/py12306/blob/master/data/images/order_success.png)
### 关于防封
目前查询和登录操作是分开的,查询是不依赖用户是否登录,放在 A 云 T 云容易被限制 ip建议在其它网络环境下运行
QQ 交流群 [780289875](https://jq.qq.com/?_wv=1027&k=5PgzDwV)TG 群 [Py12306 交流](https://t.me/joinchat/F3sSegrF3x8KAmsd1mTu7w)
### Online IDE
[![在 Gitpod 中打开](https://gitpod.io/button/open-in-gitpod.svg)](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
View 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'

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

BIN
data/images/web.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

1
data/stationList.json Executable file
View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View 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:

View File

@@ -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
View File

@@ -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
View 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
View 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
View 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-052020-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
View 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
View 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
View 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
View 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} 已退出 #')

View File

@@ -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,
}

View File

@@ -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()

View File

View File

@@ -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"
}
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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);

View File

@@ -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

View File

@@ -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 进行支付')

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View 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': ''})

View File

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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('_') + '__ _ _' # 不加后面请求会出错

View File

View File

@@ -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]

View File

@@ -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)

View File

View File

@@ -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

View File

@@ -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)

View File

@@ -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
View 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
View File

@@ -1,4 +0,0 @@
*
!.gitignore
!query
!user

View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -1,2 +0,0 @@
*
!.gitignore

41
tests/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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)