├── test ├── __init__.py ├── timeTest.py ├── test_logger.py ├── getAppVersion.py └── testEncrypt.py ├── requirements.txt ├── Brewfile ├── resources └── imgs │ ├── wxqr.png │ └── zfbqr.jpeg ├── .github └── workflows │ ├── actionActive.txt │ └── autoReserve.yml ├── .gitignore ├── myConfig └── credentials ├── config.py ├── privateCrypt.py ├── encrypt.py ├── README.md ├── main.py ├── login.py └── process.py /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests~=2.31.0 2 | pycryptodome==3.17 -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | tap "homebrew/bundle" 2 | brew "git-lfs" 3 | brew "yarn" 4 | -------------------------------------------------------------------------------- /resources/imgs/wxqr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clearJSer/MaoTaiYYDS/HEAD/resources/imgs/wxqr.png -------------------------------------------------------------------------------- /resources/imgs/zfbqr.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clearJSer/MaoTaiYYDS/HEAD/resources/imgs/zfbqr.jpeg -------------------------------------------------------------------------------- /.github/workflows/actionActive.txt: -------------------------------------------------------------------------------- 1 | action 需要定时激活一下 2 | 2023-8-17 09:23:23123 3 | 2023-8-1 17:35:431122 4 | 2023-9-9 09:47:42 5 | 2023-9-20 09:39:34 6 | 2023-9-21 09:39:34 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/.gitignore 2 | /.idea/imaotai-master.iml 3 | /.idea/misc.xml 4 | /.idea/modules.xml 5 | /.idea/inspectionProfiles/profiles_settings.xml 6 | /.idea/inspectionProfiles/Project_Default.xml 7 | /.idea/vcs.xml 8 | /.idea/* 9 | /__pycache__/* 10 | -------------------------------------------------------------------------------- /test/timeTest.py: -------------------------------------------------------------------------------- 1 | import time 2 | import datetime 3 | 4 | # int(time.mktime(datetime.date.today().timetuple())) * 1000 5 | 6 | 7 | print(time.time()) 8 | print(time.mktime(datetime.date.today().timetuple())) 9 | print(int(time.mktime(datetime.date.today().timetuple())) * 1000) 10 | 11 | s = datetime.date.today().strftime("%Y%m%d") 12 | print(type(s)) 13 | 14 | -------------------------------------------------------------------------------- /test/test_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | print() 5 | DATE_FORMAT = "%m/%d/%Y %H:%M:%S %p" 6 | logging.basicConfig(level=logging.DEBUG, 7 | format='%(asctime)s %(filename)s : %(levelname)s %(message)s', # 定义输出log的格式 8 | stream=sys.stdout, 9 | datefmt=DATE_FORMAT) 10 | logging.info(f'hehe{1}') 11 | -------------------------------------------------------------------------------- /myConfig/credentials: -------------------------------------------------------------------------------- 1 | [iUtg1eUy10d+0ZDxLcSq3A==] 2 | hidemobile = 178****5017 3 | enddate = 9 4 | userid = GTIA1dD5DKshsTKWkUTjjw== 5 | province = 北京市 6 | city = 北京市 7 | token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJtdCIsImV4cCI6MTcwMTc1MTk1MywidXNlcklkIjoxMTI0MzEzMTQ0LCJkZXZpY2VJZCI6IjJGMjA3NUQwLUI2NkMtNDI4Ny1BOTAzLURCRkY2MzU4MzQyQSIsImlhdCI6MTY5OTE1OTk1M30.j_WshZJBf7EhnnkNuvm2VXMd7FlKNsl_P2ORzHlAB5A 8 | lat = 39.995098 9 | lng = 116.431712 10 | 11 | [REhrw1I+pC8/faEKX35Yrw==] 12 | hidemobile = 153****1979 13 | enddate = 9 14 | userid = bAG3fVlTlcZejU88gcPS/g== 15 | province = 北京市 16 | city = 北京市 17 | token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJtdCIsImV4cCI6MTcwMTc1MjA4NCwidXNlcklkIjoxMDY3NzA5NDI4LCJkZXZpY2VJZCI6IjJGMjA3NUQwLUI2NkMtNDI4Ny1BOTAzLURCRkY2MzU4MzQyQSIsImlhdCI6MTY5OTE2MDA4NH0.kRowk7wLodOxF2x3nX-lK_9KPSrw-g6xcdjQpfpX2T4 18 | lat = 39.995098 19 | lng = 116.431712 20 | 21 | -------------------------------------------------------------------------------- /test/getAppVersion.py: -------------------------------------------------------------------------------- 1 | import re 2 | import requests 3 | from bs4 import BeautifulSoup 4 | 5 | # mt_version = "".join(re.findall('new__latest__version">(.*?)

', 6 | # requests.get('https://apps.apple.com/cn/app/i%E8%8C%85%E5%8F%B0/id1600482450').text, 7 | # re.S)).replace("版本", '') 8 | # print(mt_version) 9 | 10 | # apple商店 i茅台 url 11 | apple_imaotai_url = "https://apps.apple.com/cn/app/i%E8%8C%85%E5%8F%B0/id1600482450" 12 | 13 | response = requests.get(apple_imaotai_url) 14 | # 用网页自带的编码反解码,防止中文乱码 15 | response.encoding = response.apparent_encoding 16 | html_text = response.text 17 | 18 | # 用bs获取指定的class更稳定,正则可能需要经常改动 19 | soup = BeautifulSoup(html_text, "html.parser") 20 | elements = soup.find_all(class_="whats-new__latest__version") 21 | 22 | print(elements) 23 | # 获取p标签内的文本内容 24 | version_text = elements[0].text 25 | print(version_text) 26 | # print(elements.text) 27 | # 这里先把没有直接替换“版本 ”,因为后面不知道空格会不会在,所以先替换文字,再去掉前后空格 28 | version_number = version_text.replace("版本", "").strip() 29 | print(version_number) -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ''' 4 | *********** 商品配置 *********** 5 | ''' 6 | ITEM_MAP = { 7 | "10213": "53%vol 500ml贵州茅台酒(癸卯兔年)", 8 | "10214": "53%vol 375ml×2贵州茅台酒(癸卯兔年)", 9 | "10056": "53%vol 500ml茅台1935", 10 | "2478": "53%vol 500ml贵州茅台酒(珍品)" 11 | } 12 | 13 | ITEM_CODES = ['10213', '10214'] # 需要预约的商品(默认只预约2个赚钱的茅子) 14 | 15 | ''' 16 | *********** 消息推送配置 *********** 17 | push plus 微信推送,具体使用参考 https://www.pushplus.plus 18 | 如没有配置则不推送消息 19 | 为了安全,这里使用的环境配置.git里面请自行百度如何添加secrets.pycharm也可以自主添加.如果你实在不会,就直接用明文吧(O.o) 20 | ''' 21 | PUSH_TOKEN = os.environ.get("PUSHPLUS_KEY") 22 | 23 | 24 | ''' 25 | *********** 地图配置 *********** 26 | 获取地点信息,这里用的高德api,需要自己去高德开发者平台申请自己的key 27 | ''' 28 | AMAP_KEY = os.environ.get("GAODE_KEY") 29 | 30 | 31 | ''' 32 | *********** 个人账户认证配置 *********** 33 | 个人用户 credentials 路径 34 | 不配置,使用默认路径,在项目目录中;如果需要配置,你自己应该也会配置路径 35 | 例如: CREDENTIALS_PATH = './myConfig/credentials' 36 | ''' 37 | CREDENTIALS_PATH = None 38 | 39 | 40 | ''' 41 | *********** 个人加解密密钥 *********** 42 | 为了解决credentials中手机号和token都暴露的问题,采用AES私钥加密,保障账号安全. 43 | 这里采用ECB,没有采用CBC.如果是固定iv,那加一层也没多大意义;如果是不固定iv,那每次添加账号判重的时候都认为不一样,除非你每次再把配置全部反解密,去校验去重,得不偿失. 44 | key用了SHA-256转化,所以这里可以配置任意字符串,不用遵守AES算法要求密钥长度必须是16、24或32字节 45 | 如果不会配置环境变量(建议学习)、不care安全性、非开源运行,你可以在这里明文指定,eg:PRIVATE_AES_KEY = '666666' 46 | ps:本来是写了判断是否配置密钥,可以自由选择明文保存的方式。但是还是为了安全性,限制了必须使用AES加密。哪怕是明文密钥。 47 | ''' 48 | PRIVATE_AES_KEY = os.environ.get("PRIVATE_AES_KEY") 49 | 50 | 51 | ''' 52 | *********** 预约规则配置 ************ 53 | 因为目前支持代提的还是少,所以建议默认预约最近的门店 54 | ''' 55 | _RULES = { 56 | 'MIN_DISTANCE': 0, # 预约你的位置最近的门店 57 | 'MAX_SALES': 1, # 预约本市出货量最大的门店 58 | } 59 | RESERVE_RULE = 0 # 在这里配置你的规则,只能选择其中一个 60 | -------------------------------------------------------------------------------- /privateCrypt.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from Crypto.Cipher import AES 3 | from Crypto.Util.Padding import pad, unpad 4 | from hashlib import sha256 5 | import config 6 | import logging 7 | 8 | 9 | def get_aes_key(): 10 | """ 11 | 获取config中你的AES私钥 12 | :return: 13 | """ 14 | private_key = config.PRIVATE_AES_KEY # 你的私钥 15 | if private_key is None: 16 | logging.error("!!!!请配置config.py中PRIVATE_AES_KEY(AES的私钥)") 17 | raise ValueError 18 | private_key_b = sha256(private_key.encode()).digest() # 使用SHA-256算法生成一个32字节的密钥 19 | return private_key_b 20 | 21 | 22 | def encrypt_aes_ecb(plain_str, key): 23 | """ 24 | 无偏移的AES加密 25 | :param plain_str: 需要加密的明文 26 | :param key: AES私钥 27 | :return: base64后的密文 28 | """ 29 | cipher = AES.new(key, AES.MODE_ECB) 30 | ciphertext = cipher.encrypt(pad(plain_str.encode(), AES.block_size)) 31 | return base64.b64encode(ciphertext).decode() 32 | 33 | 34 | def decrypt_aes_ecb(ciphertext, key): 35 | """ 36 | 无偏移的AES解密 37 | :param ciphertext: 需要解密的密文 38 | :param key: AES私钥 39 | :return: 解密后的明文 40 | """ 41 | ciphertext = base64.b64decode(ciphertext) 42 | cipher = AES.new(key, AES.MODE_ECB) 43 | plain_str = unpad(cipher.decrypt(ciphertext), AES.block_size) 44 | return plain_str.decode() 45 | 46 | 47 | ''' 48 | def encrypt_aes_cbc(plain_str, key): 49 | cipher = AES.new(key, AES.MODE_CBC) 50 | ciphertext = cipher.encrypt(pad(plain_str, AES.block_size)) 51 | iv = cipher.iv 52 | return base64.b64encode(iv + ciphertext).decode() 53 | 54 | 55 | def decrypt_aes_cbc(ciphertext, key): 56 | ciphertext = base64.b64decode(ciphertext) 57 | iv = ciphertext[:AES.block_size] 58 | ciphertext = ciphertext[AES.block_size:] 59 | cipher = AES.new(key, AES.MODE_CBC, iv) 60 | plain_str = unpad(cipher.decrypt(ciphertext), AES.block_size) 61 | return plain_str 62 | ''' 63 | -------------------------------------------------------------------------------- /encrypt.py: -------------------------------------------------------------------------------- 1 | from Crypto.Cipher import AES 2 | import base64 3 | 4 | 5 | class Encrypt: 6 | def __init__(self, key, iv): 7 | self.key = key.encode('utf-8') 8 | self.iv = iv.encode('utf-8') 9 | 10 | # @staticmethod 11 | def pkcs7padding(self, text): 12 | """明文使用PKCS7填充 """ 13 | bs = 16 14 | length = len(text) 15 | bytes_length = len(text.encode('utf-8')) 16 | padding_size = length if (bytes_length == length) else bytes_length 17 | padding = bs - padding_size % bs 18 | padding_text = chr(padding) * padding 19 | self.coding = chr(padding) 20 | return text + padding_text 21 | 22 | def aes_encrypt(self, content): 23 | """ AES加密 """ 24 | cipher = AES.new(self.key, AES.MODE_CBC, self.iv) 25 | # 处理明文 26 | content_padding = self.pkcs7padding(content) 27 | # 加密 28 | encrypt_bytes = cipher.encrypt(content_padding.encode('utf-8')) 29 | # 重新编码 30 | result = str(base64.b64encode(encrypt_bytes), encoding='utf-8') 31 | return result 32 | 33 | def aes_decrypt(self, content): 34 | """AES解密 """ 35 | cipher = AES.new(self.key, AES.MODE_CBC, self.iv) 36 | content = base64.b64decode(content) 37 | text = cipher.decrypt(content).decode('utf-8') 38 | return text.rstrip(self.coding) 39 | 40 | 41 | # if __name__ == '__main__': 42 | # key = 'ONxYDyNaCoyTzsp83JoQ3YYuMPHxk3j7' 43 | # iv = 'yNaCoyTzsp83JoQ3' 44 | # 45 | # ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 46 | # p_json = { 47 | # "CompanyName": "testmall", 48 | # "UserId": "test", 49 | # "Password": "grasp@101", 50 | # "TimeStamp": "2019-05-05 10:59:26" 51 | # } 52 | # a = Encrypt(key=key, iv=iv) 53 | # e = a.aes_encrypt(json.dumps(p_json)) 54 | # d = a.aes_decrypt(e) 55 | # print("加密:", e) 56 | # print("解密:", d) 57 | -------------------------------------------------------------------------------- /test/testEncrypt.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from Crypto.Cipher import AES 3 | from Crypto.Util.Padding import pad, unpad 4 | from hashlib import sha256 5 | import config 6 | 7 | 8 | private_key = config.PRIVATE_AES_KEY # 你的密钥 9 | private_key_b = sha256(private_key.encode()).digest() # 使用SHA-256算法生成一个32字节的密钥 10 | 11 | 12 | def encrypt_aes_ebc(plain_str, key): 13 | cipher = AES.new(key, AES.MODE_ECB) 14 | ciphertext = cipher.encrypt(pad(plain_str.encode(), AES.block_size)) 15 | return base64.b64encode(ciphertext).decode() 16 | 17 | 18 | def decrypt_aes_ebc(ciphertext, key): 19 | ciphertext = base64.b64decode(ciphertext) 20 | cipher = AES.new(key, AES.MODE_ECB) 21 | plain_str = unpad(cipher.decrypt(ciphertext), AES.block_size) 22 | return plain_str.decode() 23 | 24 | 25 | def encrypt_aes_cbc(plain_str, key): 26 | cipher = AES.new(key, AES.MODE_CBC) 27 | ciphertext = cipher.encrypt(pad(plain_str, AES.block_size)) 28 | iv = cipher.iv 29 | return base64.b64encode(iv + ciphertext).decode() 30 | 31 | 32 | def decrypt_aes_cbc(ciphertext, key): 33 | ciphertext = base64.b64decode(ciphertext) 34 | iv = ciphertext[:AES.block_size] 35 | ciphertext = ciphertext[AES.block_size:] 36 | cipher = AES.new(key, AES.MODE_CBC, iv) 37 | plain_str = unpad(cipher.decrypt(ciphertext), AES.block_size) 38 | return plain_str 39 | 40 | 41 | # 要加密的明文 42 | t_plain_str = '666' 43 | t_plain_str1 = '888' 44 | 45 | # 加密 46 | t_ciphertext = encrypt_aes_ebc(t_plain_str, private_key_b) 47 | print(t_plain_str + "加密后的密文:", t_ciphertext) 48 | 49 | t_ciphertext1 = encrypt_aes_ebc(t_plain_str1, private_key_b) 50 | print(t_plain_str1 + "加密后的密文:", t_ciphertext1) 51 | 52 | # 解密 53 | t_decrypted_plain_str = decrypt_aes_ebc(t_ciphertext, private_key_b) 54 | print("解密后的明文:", t_decrypted_plain_str) 55 | 56 | t_decrypted_plain_str1 = decrypt_aes_ebc(t_ciphertext1, private_key_b) 57 | print("解密后的明文:", t_decrypted_plain_str1) 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /.github/workflows/autoReserve.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: imaotai-action 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | paths: 10 | - ".github/workflows/**" 11 | schedule: 12 | - cron: "10 0,1 * * *" 13 | 14 | env: 15 | TZ: Asia/Shanghai 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | # runs-on: windows-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Set up Python 3.10 28 | uses: actions/setup-python@v3 29 | with: 30 | python-version: "3.10" 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install -r requirements.txt 35 | # pip install pycryptodome requests beautifulsoup4 36 | - name: Cache pip 37 | uses: actions/cache@v3 38 | with: 39 | path: ~/.cache/pip 40 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pip- 43 | 44 | - name: start run 45 | env: 46 | PUSHPLUS_KEY: ${{ secrets.PUSHPLUS_KEY }} 47 | PRIVATE_AES_KEY: ${{ secrets.PRIVATE_AES_KEY }} 48 | run: | 49 | python main.py 50 | 51 | # pip install flake8 pytest 52 | # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 53 | # - name: Lint with flake8 54 | # run: | 55 | # # stop the build if there are Python syntax errors or undefined names 56 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 57 | # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 58 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # i茅台预约工具----GitHub Actions版 3 | 4 |

5 | 6 | 7 | 8 | 9 | GitHub Stars 10 | 11 | 12 | GitHub Forks 13 | 14 | 15 | GitHub Closed Issues 16 | 17 | 18 | GitHub commit activity (branch) 19 | 20 | 21 | GitHub Last Commit 22 | 23 |

24 | 25 | 26 | ### 功能: 27 | - [x] 集成Github Actions 28 | - [x] 多账号配置 29 | - [x] 账号有效期管控 30 | - [x] 手机号加密保存 31 | - [x] 自动获取app版本 32 | - [x] 微信消息推送 33 | 34 | ### 原理: 35 | ```shell 36 | 1、登录获取验证码 37 | 2、输入验证码获取TOKEN 38 | 3、获取当日SESSION ID 39 | 4、根据配置文件预约CONFIG文件中,所在城市的i茅台商品 40 | ``` 41 | 42 | 43 | ### 使用方法: 44 | 45 | ### 1、安装依赖 46 | ```shell 47 | pip3 install --no-cache-dir -r requirements.txt 48 | ``` 49 | 50 | ### 2、修改config.py,按照你的需求修改相关配置,这里很重要,建议每个配置项都详细阅读。 51 | 52 | 53 | ### 3、按提示输入 预约位置、手机号、验证码 等,生成的token等。很长时间不再需要登录。支持多账号,支持加密。 54 | 1. 第一次使用先清空`./myConfig/credentials`中的信息,或者直接删除`credentials`文件也可以 55 | 2. 再去配置环境变量 `GAODE_KEY`,再运行`login.py`. 56 | ```shell 57 | python3 login.py 58 | # 都选择完之后可以去./myConfig/credentials中查看 59 | ``` 60 | 61 | ### 4、python3 main.py ,执行预约操作 62 | ```shell 63 | python3 main.py 64 | ``` 65 | 66 | ### 5、配置 Github actions,每日自动预约,省去自己买服务器的成本。 67 | - 先Fork本项目,再去自己的项目中配置`PUSHPLUS_KEY`和和`PRIVATE_AES_KEY` 68 | 69 | #### 欢迎请我喝咖啡(O.o),对我下班和周末时光的努力进行肯定,您的赞赏将会给我带来更多动力 70 | 71 | 72 | 73 | #### 感谢老板赞赏,排名不分先后 74 | 75 | - *辉 76 | - *困 77 | 78 | ## 特别感谢 79 | 技术思路:https://blog.csdn.net/weixin_47481826/article/details/128893239 80 | 81 | 初版代码:https://github.com/tianyagogogo/imaotai 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import sys 4 | 5 | import config 6 | import login 7 | import process 8 | import privateCrypt 9 | 10 | DATE_FORMAT = "%m/%d/%Y %H:%M:%S %p" 11 | TODAY = datetime.date.today().strftime("%Y%m%d") 12 | logging.basicConfig(level=logging.INFO, 13 | format='%(asctime)s %(filename)s : %(levelname)s %(message)s', # 定义输出log的格式 14 | stream=sys.stdout, 15 | datefmt=DATE_FORMAT) 16 | 17 | print(r''' 18 | ************************************** 19 | 欢迎使用i茅台自动预约工具 20 | 作者GitHub:https://github.com/3 9 7 1 7 9 4 5 9 21 | vx:L 3 9 7 1 7 9 4 5 9 加好友注明来意 22 | ************************************** 23 | ''') 24 | 25 | process.get_current_session_id() 26 | 27 | # 校验配置文件是否存在 28 | configs = login.config 29 | if len(configs.sections()) == 0: 30 | logging.error("配置文件未找到配置") 31 | sys.exit(1) 32 | aes_key = privateCrypt.get_aes_key() 33 | 34 | s_title = '茅台预约成功' 35 | s_content = "" 36 | 37 | for section in configs.sections(): 38 | if (configs.get(section, 'enddate') != 9) and (TODAY > configs.get(section, 'enddate')): 39 | continue 40 | mobile = privateCrypt.decrypt_aes_ecb(section, aes_key) 41 | province = configs.get(section, 'province') 42 | city = configs.get(section, 'city') 43 | token = configs.get(section, 'token') 44 | userId = privateCrypt.decrypt_aes_ecb(configs.get(section, 'userid'), aes_key) 45 | lat = configs.get(section, 'lat') 46 | lng = configs.get(section, 'lng') 47 | 48 | p_c_map, source_data = process.get_map(lat=lat, lng=lng) 49 | 50 | process.UserId = userId 51 | process.TOKEN = token 52 | process.init_headers(user_id=userId, token=token, lng=lng, lat=lat) 53 | # 根据配置中,要预约的商品ID,城市 进行自动预约 54 | try: 55 | for item in config.ITEM_CODES: 56 | max_shop_id = process.get_location_count(province=province, 57 | city=city, 58 | item_code=item, 59 | p_c_map=p_c_map, 60 | source_data=source_data, 61 | lat=lat, 62 | lng=lng) 63 | # print(f'max shop id : {max_shop_id}') 64 | if max_shop_id == '0': 65 | continue 66 | shop_info = source_data.get(str(max_shop_id)) 67 | title = config.ITEM_MAP.get(item) 68 | shopInfo = f'商品:{title};门店:{shop_info["name"]}' 69 | logging.info(shopInfo) 70 | reservation_params = process.act_params(max_shop_id, item) 71 | # 核心预约步骤 72 | r_success, r_content = process.reservation(reservation_params, mobile) 73 | # 为了防止漏掉推送异常,所有只要有一个异常,标题就显示失败 74 | if not r_success: 75 | s_title = '!!失败!!茅台预约' 76 | s_content = s_content + r_content + shopInfo + "\n" 77 | # 领取小茅运和耐力值 78 | process.getUserEnergyAward(mobile) 79 | except BaseException as e: 80 | print(e) 81 | logging.error(e) 82 | 83 | # 推送消息 84 | process.send_msg(s_title, s_content) 85 | -------------------------------------------------------------------------------- /login.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import config as cf 4 | import process 5 | import privateCrypt 6 | 7 | config = configparser.ConfigParser() # 类实例化 8 | 9 | 10 | def get_credentials_path(): 11 | if cf.CREDENTIALS_PATH is not None: 12 | return cf.CREDENTIALS_PATH 13 | else: 14 | home_path = os.getcwd() 15 | config_parent_path = os.path.join(home_path, 'myConfig') 16 | config_path = os.path.join(config_parent_path, 'credentials') 17 | if not os.path.exists(config_parent_path): 18 | os.mkdir(config_parent_path) 19 | return config_path 20 | 21 | 22 | path = get_credentials_path() 23 | # 这里config需要用encoding,以防跨平台乱码 24 | config.read(path, encoding="utf-8") 25 | sections = config.sections() 26 | 27 | 28 | def get_location(): 29 | while 1: 30 | location = input(f"请输入精确小区位置,例如[小区名称],为你自动预约附近的门店:").strip() 31 | selects = process.select_geo(location) 32 | 33 | a = 0 34 | for item in selects: 35 | formatted_address = item['formatted_address'] 36 | province = item['province'] 37 | print(f'{a} : [地区:{province},位置:{formatted_address}]') 38 | a += 1 39 | user_select = input(f"请选择位置序号,重新输入请输入[-]:").strip() 40 | if user_select == '-': 41 | continue 42 | select = selects[int(user_select)] 43 | formatted_address = select['formatted_address'] 44 | province = select['province'] 45 | print(f'已选择 地区:{province},[{formatted_address}]附近的门店') 46 | return select 47 | 48 | 49 | if __name__ == '__main__': 50 | 51 | aes_key = privateCrypt.get_aes_key() 52 | 53 | while 1: 54 | process.init_headers() 55 | location_select: dict = get_location() 56 | province = location_select['province'] 57 | city = location_select['city'] 58 | location: str = location_select['location'] 59 | 60 | mobile = input("输入手机号[13812341234]:").strip() 61 | process.get_vcode(mobile) 62 | code = input(f"输入 [{mobile}] 验证码[1234]:").strip() 63 | token, userId = process.login(mobile, code) 64 | 65 | endDate = input(f"输入 [{mobile}] 截止日期(必须是YYYYMMDD,20230819),如果不设置截止,请输入9:").strip() 66 | 67 | # 为了增加辨识度,这里做了隐私处理,不参与任何业务逻辑 68 | hide_mobile = mobile.replace(mobile[3:7], '****') 69 | # 因为加密了手机号和Userid,所以token就不做加密了 70 | encrypt_mobile = privateCrypt.encrypt_aes_ecb(mobile, aes_key) 71 | encrypt_userid = privateCrypt.encrypt_aes_ecb(str(userId), aes_key) 72 | 73 | if encrypt_mobile not in sections: 74 | config.add_section(encrypt_mobile) # 首先添加一个新的section 75 | 76 | config.set(encrypt_mobile, 'hidemobile', hide_mobile) 77 | config.set(encrypt_mobile, 'enddate', endDate) 78 | config.set(encrypt_mobile, 'userid', encrypt_userid) 79 | config.set(encrypt_mobile, 'province', str(province)) 80 | config.set(encrypt_mobile, 'city', str(city)) 81 | config.set(encrypt_mobile, 'token', str(token)) 82 | 83 | config.set(encrypt_mobile, 'lat', location.split(',')[1]) 84 | config.set(encrypt_mobile, 'lng', location.split(',')[0]) 85 | 86 | config.write(open(path, 'w+', encoding="utf-8")) # 保存数据 87 | 88 | condition = input(f"是否继续添加账号[y/n]:").strip() 89 | 90 | if condition.lower() == 'n': 91 | break 92 | -------------------------------------------------------------------------------- /process.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import math 4 | import os 5 | import random 6 | import re 7 | import time 8 | import config 9 | from encrypt import Encrypt 10 | import requests 11 | import hashlib 12 | import logging 13 | 14 | AES_KEY = 'qbhajinldepmucsonaaaccgypwuvcjaa' 15 | AES_IV = '2018534749963515' 16 | SALT = '2af72f100c356273d46284f6fd1dfc08' 17 | 18 | CURRENT_TIME = str(int(time.time() * 1000)) 19 | headers = {} 20 | 21 | ''' 22 | # 获取茅台APP的版本号,暂时没找到接口,采用爬虫曲线救国 23 | # 用bs获取指定的class更稳定,之前的正则可能需要经常改动 24 | def get_mt_version(): 25 | # apple商店 i茅台 url 26 | apple_imaotai_url = "https://apps.apple.com/cn/app/i%E8%8C%85%E5%8F%B0/id1600482450" 27 | response = requests.get(apple_imaotai_url) 28 | # 用网页自带的编码反解码,防止中文乱码 29 | response.encoding = response.apparent_encoding 30 | html_text = response.text 31 | soup = BeautifulSoup(html_text, "html.parser") 32 | elements = soup.find_all(class_="whats-new__latest__version") 33 | # 获取p标签内的文本内容 34 | version_text = elements[0].text 35 | # 这里先把没有直接替换“版本 ”,因为后面不知道空格会不会在,所以先替换文字,再去掉前后空格 36 | latest_mt_version = version_text.replace("版本", "").strip() 37 | return latest_mt_version 38 | 39 | 40 | mt_version = get_mt_version() 41 | ''' 42 | # 通过ios应用商店的api获取最新版本 43 | mt_version = json.loads(requests.get('https://itunes.apple.com/cn/lookup?id=1600482450').text)['results'][0]['version'] 44 | 45 | 46 | header_context = f''' 47 | MT-Lat: 28.499562 48 | MT-K: 1675213490331 49 | MT-Lng: 102.182324 50 | Host: app.moutai519.com.cn 51 | MT-User-Tag: 0 52 | Accept: */* 53 | MT-Network-Type: WIFI 54 | MT-Token: 1 55 | MT-Team-ID: 56 | MT-Info: 028e7f96f6369cafe1d105579c5b9377 57 | MT-Device-ID: 2F2075D0-B66C-4287-A903-DBFF6358342A 58 | MT-Bundle-ID: com.moutai.mall 59 | Accept-Language: en-CN;q=1, zh-Hans-CN;q=0.9 60 | MT-Request-ID: 167560018873318465 61 | MT-APP-Version: 1.3.7 62 | User-Agent: iOS;16.3;Apple;?unrecognized? 63 | MT-R: clips_OlU6TmFRag5rCXwbNAQ/Tz1SKlN8THcecBp/HGhHdw== 64 | Content-Length: 93 65 | Accept-Encoding: gzip, deflate, br 66 | Connection: keep-alive 67 | Content-Type: application/json 68 | userId: 2 69 | ''' 70 | 71 | 72 | # 初始化请求头 73 | def init_headers(user_id: str = '1', token: str = '2', lat: str = '29.83826', lng: str = '119.74375'): 74 | for k in header_context.strip().split("\n"): 75 | temp_l = k.split(': ') 76 | dict.update(headers, {temp_l[0]: temp_l[1]}) 77 | dict.update(headers, {"userId": user_id}) 78 | dict.update(headers, {"MT-Token": token}) 79 | dict.update(headers, {"MT-Lat": lat}) 80 | dict.update(headers, {"MT-Lng": lng}) 81 | dict.update(headers, {"MT-APP-Version": mt_version}) 82 | 83 | 84 | def signature(data: dict): 85 | keys = sorted(data.keys()) 86 | temp_v = '' 87 | for item in keys: 88 | temp_v += data[item] 89 | text = SALT + temp_v + CURRENT_TIME 90 | hl = hashlib.md5() 91 | hl.update(text.encode(encoding='utf8')) 92 | md5 = hl.hexdigest() 93 | return md5 94 | 95 | 96 | # 获取登录手机验证码 97 | def get_vcode(mobile: str): 98 | params = {'mobile': mobile} 99 | md5 = signature(params) 100 | dict.update(params, {'md5': md5, "timestamp": CURRENT_TIME, 'MT-APP-Version': mt_version}) 101 | responses = requests.post("https://app.moutai519.com.cn/xhr/front/user/register/vcode", json=params, 102 | headers=headers) 103 | if responses.status_code != 200: 104 | logging.info( 105 | f'get v_code : params : {params}, response code : {responses.status_code}, response body : {responses.text}') 106 | 107 | 108 | # 执行登录操作 109 | def login(mobile: str, v_code: str): 110 | params = {'mobile': mobile, 'vCode': v_code, 'ydToken': '', 'ydLogId': ''} 111 | md5 = signature(params) 112 | dict.update(params, {'md5': md5, "timestamp": CURRENT_TIME, 'MT-APP-Version': mt_version}) 113 | responses = requests.post("https://app.moutai519.com.cn/xhr/front/user/register/login", json=params, 114 | headers=headers) 115 | if responses.status_code != 200: 116 | logging.info( 117 | f'login : params : {params}, response code : {responses.status_code}, response body : {responses.text}') 118 | dict.update(headers, {'MT-Token': responses.json()['data']['token']}) 119 | dict.update(headers, {'userId': responses.json()['data']['userId']}) 120 | return responses.json()['data']['token'], responses.json()['data']['userId'] 121 | 122 | 123 | # 获取当日的session id 124 | def get_current_session_id(): 125 | # print("===============get_current_session_id") 126 | day_time = int(time.mktime(datetime.date.today().timetuple())) * 1000 127 | my_url = f"https://static.moutai519.com.cn/mt-backend/xhr/front/mall/index/session/get/{day_time}" 128 | # print(my_url) 129 | responses = requests.get(my_url) 130 | # print(responses.json()) 131 | if responses.status_code != 200: 132 | logging.warning( 133 | f'get_current_session_id : params : {day_time}, response code : {responses.status_code}, response body : {responses.text}') 134 | current_session_id = responses.json()['data']['sessionId'] 135 | dict.update(headers, {'current_session_id': str(current_session_id)}) 136 | 137 | 138 | # 获取最近或者出货量最大的店铺 139 | def get_location_count(province: str, 140 | city: str, 141 | item_code: str, 142 | p_c_map: dict, 143 | source_data: dict, 144 | lat: str = '29.83826', 145 | lng: str = '102.182324'): 146 | day_time = int(time.mktime(datetime.date.today().timetuple())) * 1000 147 | session_id = headers['current_session_id'] 148 | responses = requests.get( 149 | f"https://static.moutai519.com.cn/mt-backend/xhr/front/mall/shop/list/slim/v3/{session_id}/{province}/{item_code}/{day_time}") 150 | if responses.status_code != 200: 151 | logging.warning( 152 | f'get_location_count : params : {day_time}, response code : {responses.status_code}, response body : {responses.text}') 153 | shops = responses.json()['data']['shops'] 154 | 155 | if config.RESERVE_RULE == 0: 156 | return distance_shop(city, item_code, p_c_map, province, shops, source_data, lat, lng) 157 | 158 | if config.RESERVE_RULE == 1: 159 | return max_shop(city, item_code, p_c_map, province, shops) 160 | 161 | 162 | # 获取距离最近的店铺 163 | def distance_shop(city, 164 | item_code, 165 | p_c_map, 166 | province, 167 | shops, 168 | source_data, 169 | lat: str = '28.499562', 170 | lng: str = '102.182324'): 171 | # shop_ids = p_c_map[province][city] 172 | temp_list = [] 173 | for shop in shops: 174 | shopId = shop['shopId'] 175 | items = shop['items'] 176 | item_ids = [i['itemId'] for i in items] 177 | # if shopId not in shop_ids: 178 | # continue 179 | if str(item_code) not in item_ids: 180 | continue 181 | shop_info = source_data.get(shopId) 182 | # d = geodesic((lat, lng), (shop_info['lat'], shop_info['lng'])).km 183 | d = math.sqrt((float(lat) - shop_info['lat']) ** 2 + (float(lng) - shop_info['lng']) ** 2) 184 | # print(f"距离:{d}") 185 | temp_list.append((d, shopId)) 186 | 187 | # sorted(a,key=lambda x:x[0]) 188 | temp_list = sorted(temp_list, key=lambda x: x[0]) 189 | # logging.info(f"所有门店距离:{temp_list}") 190 | if len(temp_list) > 0: 191 | return temp_list[0][1] 192 | else: 193 | return '0' 194 | 195 | 196 | # 获取出货量最大的店铺 197 | def max_shop(city, item_code, p_c_map, province, shops): 198 | max_count = 0 199 | max_shop_id = '0' 200 | shop_ids = p_c_map[province][city] 201 | for shop in shops: 202 | shopId = shop['shopId'] 203 | items = shop['items'] 204 | 205 | if shopId not in shop_ids: 206 | continue 207 | for item in items: 208 | if item['itemId'] != str(item_code): 209 | continue 210 | if item['inventory'] > max_count: 211 | max_count = item['inventory'] 212 | max_shop_id = shopId 213 | logging.debug(f'item code {item_code}, max shop id : {max_shop_id}, max count : {max_count}') 214 | return max_shop_id 215 | 216 | 217 | encrypt = Encrypt(key=AES_KEY, iv=AES_IV) 218 | 219 | 220 | def act_params(shop_id: str, item_id: str): 221 | # { 222 | # "actParam": "a/v0XjWK/a/a+ZyaSlKKZViJHuh8tLw==", 223 | # "itemInfoList": [ 224 | # { 225 | # "count": 1, 226 | # "itemId": "2478" 227 | # } 228 | # ], 229 | # "shopId": "151510100019", 230 | # "sessionId": 508 231 | # } 232 | session_id = headers['current_session_id'] 233 | userId = headers['userId'] 234 | params = {"itemInfoList": [{"count": 1, "itemId": item_id}], 235 | "sessionId": int(session_id), 236 | "userId": userId, 237 | "shopId": shop_id 238 | } 239 | s = json.dumps(params) 240 | act = encrypt.aes_encrypt(s) 241 | params.update({"actParam": act}) 242 | return params 243 | 244 | 245 | # 消息推送 246 | def send_msg(title, content): 247 | if config.PUSH_TOKEN is None: 248 | return 249 | url = 'http://www.pushplus.plus/send' 250 | r = requests.get(url, params={'token': config.PUSH_TOKEN, 251 | 'title': title, 252 | 'content': content}) 253 | logging.info(f'通知推送结果:{r.status_code, r.text}') 254 | 255 | 256 | # 核心代码,执行预约 257 | def reservation(params: dict, mobile: str): 258 | params.pop('userId') 259 | responses = requests.post("https://app.moutai519.com.cn/xhr/front/mall/reservation/add", json=params, 260 | headers=headers) 261 | # if responses.status_code == 401: 262 | # send_msg('!!失败!!茅台预约', f'[{mobile}],登录token失效,需要重新登录') 263 | # raise RuntimeError 264 | 265 | msg = f'预约:{mobile};Code:{responses.status_code};Body:{responses.text};' 266 | logging.info(msg) 267 | 268 | # 如果是成功,推送消息简化;失败消息则全量推送 269 | if responses.status_code == 200: 270 | r_success = True 271 | msg = f'手机:{mobile};' 272 | else: 273 | r_success = False 274 | 275 | return r_success, msg 276 | 277 | 278 | # 用高德api获取地图信息 279 | def select_geo(i: str): 280 | # 校验高德api是否配置 281 | if config.AMAP_KEY is None: 282 | logging.error("!!!!请配置config.py中AMAP_KEY(高德地图的MapKey)") 283 | raise ValueError 284 | resp = requests.get(f"https://restapi.amap.com/v3/geocode/geo?key={config.AMAP_KEY}&output=json&address={i}") 285 | geocodes: list = resp.json()['geocodes'] 286 | return geocodes 287 | 288 | 289 | def get_map(lat: str = '28.499562', lng: str = '102.182324'): 290 | p_c_map = {} 291 | url = 'https://static.moutai519.com.cn/mt-backend/xhr/front/mall/resource/get' 292 | headers = { 293 | 'X-Requested-With': 'XMLHttpRequest', 294 | 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_1 like Mac OS X)', 295 | 'Referer': 'https://h5.moutai519.com.cn/gux/game/main?appConfig=2_1_2', 296 | 'Client-User-Agent': 'iOS;16.0.1;Apple;iPhone 14 ProMax', 297 | 'MT-R': 'clips_OlU6TmFRag5rCXwbNAQ/Tz1SKlN8THcecBp/HGhHdw==', 298 | 'Origin': 'https://h5.moutai519.com.cn', 299 | 'MT-APP-Version': mt_version, 300 | 'MT-Request-ID': f'{int(time.time() * 1000)}{random.randint(1111111, 999999999)}{int(time.time() * 1000)}', 301 | 'Accept-Language': 'zh-CN,zh-Hans;q=1', 302 | 'MT-Device-ID': f'{int(time.time() * 1000)}{random.randint(1111111, 999999999)}{int(time.time() * 1000)}', 303 | 'Accept': 'application/json, text/javascript, */*; q=0.01', 304 | 'mt-lng': f'{lng}', 305 | 'mt-lat': f'{lat}' 306 | } 307 | res = requests.get(url, headers=headers, ) 308 | mtshops = res.json().get('data', {}).get('mtshops_pc', {}) 309 | urls = mtshops.get('url') 310 | r = requests.get(urls) 311 | for k, v in dict(r.json()).items(): 312 | provinceName = v.get('provinceName') 313 | cityName = v.get('cityName') 314 | if not p_c_map.get(provinceName): 315 | p_c_map[provinceName] = {} 316 | if not p_c_map[provinceName].get(cityName, None): 317 | p_c_map[provinceName][cityName] = [k] 318 | else: 319 | p_c_map[provinceName][cityName].append(k) 320 | 321 | return p_c_map, dict(r.json()) 322 | 323 | 324 | # 领取耐力和小茅运 325 | def getUserEnergyAward(mobile: str): 326 | """ 327 | 领取耐力 328 | """ 329 | cookies = { 330 | 'MT-Device-ID-Wap': headers['MT-Device-ID'], 331 | 'MT-Token-Wap': headers['MT-Token'], 332 | 'YX_SUPPORT_WEBP': '1', 333 | } 334 | response = requests.post('https://h5.moutai519.com.cn/game/isolationPage/getUserEnergyAward', cookies=cookies, 335 | headers=headers, json={}) 336 | # response.json().get('message') if '无法领取奖励' in response.text else "领取奖励成功" 337 | logging.info( 338 | f'领取耐力 : mobile:{mobile} : response code : {response.status_code}, response body : {response.text}') 339 | --------------------------------------------------------------------------------