├── .gitignore ├── README.MD ├── TODO.MD ├── auth.py ├── conf.py.sample └── ticketchecker.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # others 92 | .idea/ 93 | conf.py 94 | pass_code.jpg 95 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # 12306 刷票脚本 2 | 3 | ## 注意事项 4 | 5 | - 脚本采用python3开发,请使用python3运行该脚本 6 | - 在刷到票后,采用 [Slack](https://slack.com/) 发送通知消息,因此请先创建Slack的Team。在创建好Team后,创建一个名叫`ticket`的channel,并申请一个Bot用于发消息。如希望采用其他的通知途径,请自行修改12306.py中的send_message实现。 7 | - 创建新Team https://slack.com/ 8 | - 创建Bot参考文档 [Bot Users](https://api.slack.com/bot-users) 9 | 10 | ## 使用方法 11 | 12 | - 安装依赖库 13 | ``` 14 | pip3 install requests 15 | pip3 install slackclient 16 | ``` 17 | - 复制`conf.py.sample`并修改文件名为`conf.py`, 18 | - 根据注释修改刷票条件 19 | - `python3 12306.py`启动脚本,验证脚本是否可以正确执行。 20 | - 注:你可以用supervisor来管理该脚本 21 | -------------------------------------------------------------------------------- /TODO.MD: -------------------------------------------------------------------------------- 1 | 目前这个脚本只能实现余票的提醒功能,但理论上要实现自动购票的功能并会太难。 2 | buy.py中给出了登陆的基础实现,不过考虑到实现所有功能所要付出的时间成本因此不打算继续了。 3 | 4 | 自动购票最大的障碍还是来自于12306的验证码。 5 | 6 | ## 验证码的处理思路 7 | 8 | ### 手动处理验证码 9 | 10 | 手动处理验证码应当是最简单有效的处理方式,当前缺点也很明显,无法做到全自动。 11 | Slack的API非常强大且易用,通过Slack的"Real Time Messaging API",我们可以利用Slack实现交互。 12 | 在需要输入验证码的时候,通过Slack将验证码推送到用户,用户在完成验证码输入后,系统自动处理之后的业务逻辑。 13 | 14 | ### 自动识别 15 | 16 | 要做好验证码的自动识别时间就比手动处理要麻烦多了。如果不是想卖给黄牛我个人是觉得没必要打自动识别的主意了。 17 | 18 | 12306的验证码可以分为2部分,最顶部的文字以及下方的8张图片。 19 | 20 | 文字的变形其实并不算太大,相信以现在OCR的水平识别率还是挺高的,重点是下发的8张图片。12306的验证码图小分辨率低,不说机器,要人来识别都不容易。如果纯粹根据机器学习来做图片识别,即使学习库再大效果也不会好到哪去。 21 | 22 | 最有效的还是"笨方法",让系统频繁的去请求12306的验证码,然后手工将所有图片打上Tag。考虑到12306的图库不会太小,给图片打Tag必然会有很大的工作量,如果没有利益驱使是百分百做不来的。 23 | 另一方面,即使前期做了非常多的准备工作也很难保证12306不会添加新的图片。在遇到不认识图片的时候最简单的方法自然是先将图片记录下来等待手工加Tag,另一方面重新刷新验证换个自己认识的。 24 | Google有提供上传图片进行搜索的功能,可以把图片上传到Google然后得到图片的关键字(当然精确度不会太高)。在图片资料库不够完整的时候也可以利用Google来猜些图。 -------------------------------------------------------------------------------- /auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | import requests 4 | 5 | from conf import USERNAME 6 | from conf import PASSWORD 7 | 8 | PASS_CODE_MAP = { 9 | "1": "40,50", 10 | "2": "120,50", 11 | "3": "180,50", 12 | "4": "260,50", 13 | "5": "40,120", 14 | "6": "120,120", 15 | "7": "180,120", 16 | "8": "260,120", 17 | } 18 | 19 | 20 | def image_index_to_pass_code(pass_code: str) -> str: 21 | pass_code_list = [] 22 | for index in pass_code: 23 | pass_code_list.append(PASS_CODE_MAP.get(index, "")) 24 | return ",".join(pass_code_list) 25 | 26 | 27 | class Auth(object): 28 | def __init__(self, session): 29 | super(Auth, self).__init__() 30 | self.session = session 31 | 32 | def get_pass_code(self) -> bool: 33 | url_get_pass_code = "https://kyfw.12306.cn/otn/passcodeNew/getPassCodeNew?module=login&rand=sjrand" 34 | # noinspection PyBroadException 35 | try: 36 | resp = self.session.get(url_get_pass_code, verify=False, timeout=10) 37 | with open("./pass_code.jpg", 'wb') as f: 38 | f.write(resp.content) 39 | return True 40 | except Exception: 41 | return False 42 | 43 | def get_rand_code(self, pass_code: str) -> str: 44 | rand_code = image_index_to_pass_code(pass_code) 45 | url_check_rand_code = "https://kyfw.12306.cn/otn/passcodeNew/checkRandCodeAnsyn" 46 | data = { 47 | 'rand': 'sjrand', 48 | 'randCode': rand_code, 49 | } 50 | # noinspection PyBroadException 51 | try: 52 | resp = self.session.post(url_check_rand_code, data=data, verify=False, timeout=10) 53 | if resp.json()['data']['result'] == '1': 54 | return rand_code 55 | except Exception: 56 | return "" 57 | 58 | def do_login(self, username: str, password: str, rand_code: str) -> bool: 59 | url_login = "http://kyfw.12306.cn/otn/login/loginAysnSuggest" 60 | data = { 61 | 'randCode': rand_code, 62 | 'userDTO.password': password, 63 | 'loginUserDTO.user_name': username, 64 | } 65 | 66 | # noinspection PyBroadException 67 | try: 68 | resp = self.session.post(url_login, data=data, verify=False, timeout=10) 69 | json_data = resp.json() 70 | if not json_data["status"]: 71 | print(",".join(json_data['messages'])) 72 | return json_data['data']['loginCheck'] == 'Y' 73 | except Exception: 74 | return False 75 | 76 | def login(self, username: str, password: str) -> bool: 77 | if not self.get_pass_code(): 78 | print("get pass code fail") 79 | return False 80 | 81 | pass_code = input("Please input image index: ") 82 | rand_code = self.get_rand_code(pass_code) 83 | 84 | if rand_code: 85 | print("pass code ok") 86 | else: 87 | print("pass code fail") 88 | return False 89 | 90 | if self.do_login(username, password, rand_code): 91 | print("login ok") 92 | else: 93 | print("login fail") 94 | return False 95 | return True 96 | 97 | 98 | if __name__ == '__main__': 99 | _session = requests.Session() 100 | auth = Auth(_session) 101 | auth.login(USERNAME, PASSWORD) 102 | -------------------------------------------------------------------------------- /conf.py.sample: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | SLACK_TOKEN = '' # slack bot的SLACK_API_TOKEN,如需要用其消息通知机制,请修改send_message实现 3 | 4 | TRAIN_DATES = ['2017-02-01', '2017-02-02', '2017-02-03', '2017-02-04'] # 需要抢票的日期 5 | 6 | FROM_STATIONS = ['WHN'] # 出发站点,城市对应的编码请自行根据 https://kyfw.12306.cn/otn/leftTicket/init 找到 7 | 8 | TO_STATIONS = ['HZH'] # 达到站 9 | 10 | TICKET_TYPES = ['swz', 'zy', 'ze', 'rw', 'yw'] # 车票类型,参考 TICKET_TYPE_MAP 11 | 12 | NEED_COUNT = 2 # 需要抢票的张数,低于这个数不提示 13 | 14 | USERNAME = "" 15 | 16 | PASSWORD = "" -------------------------------------------------------------------------------- /ticketchecker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: UTF-8 -*- 3 | from typing import List 4 | from typing import Mapping 5 | import time 6 | 7 | from slackclient import SlackClient 8 | from conf import SLACK_TOKEN 9 | from conf import TRAIN_DATES, FROM_STATIONS, TO_STATIONS, TICKET_TYPES, NEED_COUNT 10 | 11 | INTERVAL_FOR_QUERY = 6 12 | 13 | TICKET_TYPE_MAP = { 14 | 'swz': '商务座', 15 | 'tz': '特等座', 16 | 'zy': '一等座', 17 | 'ze': '二等座', 18 | 'gr': '高级软卧', 19 | 'rw': '软卧', 20 | 'yw': '硬卧', 21 | 'yz': '硬座', 22 | 'rz': '软座', 23 | 'wz': '无座', 24 | } 25 | 26 | 27 | def slack_send_message(message: str): 28 | sc = SlackClient(SLACK_TOKEN) 29 | 30 | sc.api_call( 31 | "chat.postMessage", 32 | channel="#ticket", 33 | text=message 34 | ) 35 | 36 | 37 | def send_message(message: str): 38 | slack_send_message(message) 39 | print(message) 40 | 41 | 42 | def send_notification( 43 | train_info: Mapping[str, str], ticket_type: str, train_date: str, 44 | from_station: str, to_station: str): 45 | message = ('日期:{train_date} 类型:{ticket_type} 车次:{train_code} ' 46 | '开车时间:{start_time} 到达时间:{arrive_time} 车票类型:{ticket_type} ' 47 | '余票:{left_ticket} ' 48 | '历时:{lishi} 出发:{from_station} 达到:{to_station}').format( 49 | train_date=train_date, from_station=from_station, to_station=to_station, 50 | start_time=train_info['start_time'], 51 | ticket_type=TICKET_TYPE_MAP.get(ticket_type, ""), 52 | left_ticket=train_info.get('%s_num' % ticket_type), 53 | lishi=train_info.get('lishi'), 54 | arrive_time=train_info.get('arrive_time'), 55 | train_code=train_info['station_train_code'], 56 | ) 57 | send_message(message) 58 | 59 | 60 | def get_left_ticket(ticket_type: str, train_info) -> int: 61 | """ 62 | str_count: --,无,有,1,12 63 | """ 64 | str_count = train_info.get('%s_num' % ticket_type) 65 | if str_count == '有': 66 | return 9999 67 | try: 68 | return int(str_count) 69 | except ValueError: 70 | return 0 71 | 72 | 73 | class TicketChecker(object): 74 | def __init__( 75 | self, session, train_dates: List[str], from_stations: List[str], 76 | to_stations: List[str], ticket_types: List[str], need_count: int): 77 | super(TicketChecker, self).__init__() 78 | self.session = session 79 | self.train_dates = train_dates 80 | self.from_stations = from_stations 81 | self.to_stations = to_stations 82 | self.ticket_types = ticket_types 83 | self.need_count = need_count 84 | 85 | def get_train_info( 86 | self, train_date: str, from_station: str, 87 | to_station: str) -> List[Mapping[str, str]]: 88 | # url = "https://kyfw.12306.cn/otn/leftTicket/queryZ" 89 | url = 'https://kyfw.12306.cn/otn/leftTicket/queryZ?' \ 90 | 'leftTicketDTO.train_date={train_date}' \ 91 | '&leftTicketDTO.from_station={from_station}' \ 92 | '&leftTicketDTO.to_station={to_station}' \ 93 | '&purpose_codes=ADULT' 94 | params = { 95 | 'train_date': train_date, # leftTicketDTO. 96 | 'from_station': from_station, # leftTicketDTO. 97 | 'to_station': to_station, # leftTicketDTO. 98 | 'purpose_codes': 'ADULT', 99 | } 100 | train_info_list = [] 101 | # noinspection PyBroadException 102 | try: 103 | # FIXME use params will fail on some server, the reason is unkonwn 104 | # r = requests.get(url, params=params, verify=False) 105 | url = url.format(**params) 106 | r = self.session.get(url, verify=False) 107 | return_data = r.json() 108 | train_info_list = [] 109 | for e in return_data['data']: 110 | e['queryLeftNewDTO']['secretStr'] = e['secretStr'] 111 | train_info_list = [e['queryLeftNewDTO'] for e in return_data['data']] 112 | print('get_train_info succ') 113 | except Exception: 114 | print('get_train_info fail') 115 | return train_info_list 116 | 117 | def get_ok_ticket_types(self, train_info: Mapping[str, str]) -> List[str]: 118 | ok_ticket_types = [] 119 | for ticket_type in self.ticket_types: 120 | left_ticket = get_left_ticket(ticket_type, train_info) 121 | if left_ticket > self.need_count: 122 | ok_ticket_types.append(ticket_type) 123 | return ok_ticket_types 124 | 125 | def get_ok_ticket_list(self, train_date, from_station, to_station): 126 | train_info_list = self.get_train_info(train_date, from_station, to_station) 127 | ticket_list = [] 128 | for train_info in train_info_list: 129 | ok_ticket_types = self.get_ok_ticket_types(train_info) 130 | for ticket_type in ok_ticket_types: 131 | ticket_list.append([train_info, ticket_type]) 132 | return ticket_list 133 | 134 | def check_ticket(self): 135 | for train_date in self.train_dates: 136 | for from_station in self.from_stations: 137 | for to_station in self.to_stations: 138 | time.sleep(INTERVAL_FOR_QUERY) # sleep 139 | ticket_list = self.get_ok_ticket_list(train_date, from_station, to_station) 140 | for ticket_info in ticket_list: 141 | send_notification(ticket_info[0], ticket_info[1], train_date, from_station, to_station) 142 | 143 | 144 | if __name__ == '__main__': 145 | import requests 146 | _session = requests.Session() 147 | ticket_checker = TicketChecker(_session, TRAIN_DATES, FROM_STATIONS, TO_STATIONS, TICKET_TYPES, NEED_COUNT) 148 | while True: 149 | ticket_checker.check_ticket() 150 | --------------------------------------------------------------------------------