├── 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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
--------------------------------------------------------------------------------