Skip to content

Commit bcfd9cb

Browse files
committed
feat: 新增预约2025年蛇年茅台1月活动脚本
1 parent 5ca7f36 commit bcfd9cb

File tree

1 file changed

+384
-0
lines changed

1 file changed

+384
-0
lines changed
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
"""
2+
7、预约申购_蛇年茅台 - 2025年1月1日 - 1月5日 活动
3+
4+
9点到10点预约,18 点公布名单。
5+
无需添加蛇茅的商品ID和店铺ID,会从接口自动获取。
6+
如果查询到支持预约蛇茅的店铺数量大于1,会自动选择离你最近的店铺。不支持库存最多的模式。
7+
8+
通知:运行结果会调用青龙面板的通知渠道。
9+
10+
配置环境变量:KEN_IMAOTAI_ENV
11+
-- 在旧版本青龙(例如 v2.13.8)中,使用 $ 作为分隔符时会出现解析环境变量失败,此时可以把 `$` 分隔符换为 `#` 作为分隔符。
12+
-- 📣 怕出错?**建议直接使用 `#` 作为分隔符即可** (2024-10-15 更新支持)。
13+
内容格式为:PHONE_NUMBER$USER_ID$DEVICE_ID$MT_VERSION$PRODUCT_ID_LIST$SHOP_ID^SHOP_MODE^PROVINCE^CITY$LAT$LNG$TOKEN$COOKIE
14+
解释:手机号码$用户ID$设备ID$版本号$商品ID列表$店铺ID店铺缺货时自动采用的模式^省份^城市$纬度$经度$TOKEN$COOKIE
15+
多个用户时使用 & 连接
16+
17+
说明:^SHOP_MODE^PROVINCE^CITY 为可选
18+
19+
常量。
20+
- PHONE_NUMBER: 用户的手机号码。 --- 自己手机号码
21+
- CODE: 短信验证码。 --- 运行 1_generate_code.py 获取
22+
- DEVICE_ID: 设备的唯一标识符。 --- 运行 1_generate_code.py 获取
23+
- MT_VERSION: 应用程序的版本号。 --- 运行 1_generate_code.py 获取
24+
- USER_ID: 用户的唯一标识符。 --- 运行 2_login.py 获取
25+
- TOKEN: 用于身份验证的令牌。 --- 运行 2_login.py 获取
26+
- COOKIE: 用于会话管理的Cookie。 --- 运行 2_login.py 获取
27+
- PRODUCT_ID_LIST: 商品ID列表,表示用户想要预约的商品。--- 运行 3_retrieve_shop_and_product_info.py 获取
28+
- SHOP_ID: 店铺的唯一标识符。 --- 运行 3_retrieve_shop_and_product_info.py 获取
29+
可设置为 AUTO,则根据 SHOP_MODE 的值来选择店铺 ID。
30+
- SHOP_MODE:店铺缺货模式,可选值为NEAREST(距离最近)或INVENTORY(库存最多)。设置该值时,需要同时设置 PROVINCE 和 CITY。
31+
非必填,但 SHOP_ID 设置 AUTO 时为必填,需要同时设置 SHOP_MODE、PROVINCE 和 CITY。
32+
- PROVINCE: 用户所在的省份。 --- 与 3_retrieve_shop_and_product_info.py 填写的省份一致
33+
非必填,但 SHOP_MODE 设置为 NEAREST 或 INVENTORY 时为必填。
34+
- CITY: 用户所在的城市。 --- 与 3_retrieve_shop_and_product_info.py 填写的城市一致
35+
非必填,但 SHOP_MODE 设置为 NEAREST 或 INVENTORY 时为必填。
36+
- LAT: 用户所在位置的纬度。 --- 运行 3_retrieve_shop_and_product_info.py 获取
37+
- LNG: 用户所在位置的经度。 --- 运行 3_retrieve_shop_and_product_info.py 获取
38+
39+
"""
40+
41+
import datetime
42+
import time
43+
import requests
44+
import json
45+
import logging
46+
import base64
47+
import os
48+
import ast
49+
import io
50+
import math
51+
import re
52+
53+
from Crypto.Cipher import AES
54+
from Crypto.Util.Padding import pad
55+
from notify import send
56+
57+
# 1月1日-1月5日 9:12 开始预约
58+
'''
59+
cron: 12 9 1-5 1 *
60+
new Env("7_预约申购_蛇年茅台-1月1日-1月5日")
61+
'''
62+
63+
# 创建 StringIO 对象
64+
log_stream = io.StringIO()
65+
66+
# 配置 logging
67+
logger = logging.getLogger()
68+
logger.setLevel(logging.INFO)
69+
70+
# 创建控制台 Handler
71+
console_handler = logging.StreamHandler()
72+
console_handler.setFormatter(
73+
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
74+
75+
# 创建 StringIO Handler
76+
stream_handler = logging.StreamHandler(log_stream)
77+
# stream_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
78+
79+
# 将两个 Handler 添加到 logger
80+
logger.addHandler(console_handler)
81+
logger.addHandler(stream_handler)
82+
83+
# 当天零点的时间戳
84+
timestamp_today = None
85+
86+
# 调试模式
87+
DEBUG = True
88+
89+
# 读取 KEN_IMAOTAI_ENV 环境变量
90+
KEN_IMAOTAI_ENV = os.getenv('KEN_IMAOTAI_ENV', '')
91+
92+
# 加密 KEY
93+
ENCRYPT_KEY = "qbhajinldepmucsonaaaccgypwuvcjaa"
94+
# 加密 IV
95+
ENCRYPT_IV = "2018534749963515"
96+
97+
# 解析 KEN_IMAOTAI_ENV 环境变量并保存到 user 列表
98+
users = []
99+
if KEN_IMAOTAI_ENV:
100+
env_list = KEN_IMAOTAI_ENV.split('&')
101+
for env in env_list:
102+
try:
103+
# 使用 re.split() 分割字符串,支持 '#' 和 '$'
104+
split_values = re.split(r'[#$]', env)
105+
106+
PHONE_NUMBER, USER_ID, DEVICE_ID, MT_VERSION, PRODUCT_ID_LIST, SHOP_INFO, LAT, LNG, TOKEN, COOKIE = split_values
107+
108+
SHOP_MODE = ''
109+
PROVINCE = ''
110+
CITY = ''
111+
112+
if '^' in SHOP_INFO:
113+
parts = SHOP_INFO.split('^')
114+
if len(parts) > 1:
115+
# 检测 parts 长度是否为 4,否则抛出异常
116+
if len(parts) != 4:
117+
raise Exception(
118+
"🚫 店铺缺货模式值错误,请检查是否为 SHOP_ID^SHOP_MODE^PROVINCE^CITY"
119+
)
120+
SHOP_ID, SHOP_MODE, PROVINCE, CITY = parts
121+
# 检测 SHOP_MODE 是否为 NEAREST 或 INVENTORY
122+
if SHOP_MODE not in ['NEAREST', 'INVENTORY']:
123+
raise Exception(
124+
"🚫 店铺缺货模式值错误,请检查 SHOP_MODE 值是否为 NEAREST(<默认> 距离最近) 或 INVENTORY(库存最多)"
125+
)
126+
# 如果 SHOP_MODE 值合法,则需要配合检测 PROVINCE 和 CITY 是否为空(接口需要用到这些值)
127+
if not PROVINCE or not CITY:
128+
raise Exception(
129+
"🚫 店铺缺货模式值为 NEAREST 或 INVENTORY 时,需要同时设置 PROVINCE 和 CITY"
130+
)
131+
else:
132+
logging.warning(
133+
"🚨🚨 建议根据环境变量格式,设置 SHOP_ID^SHOP_MODE^PROVINCE^CITY 值,否则无法在指定店铺缺货时自动预约其他店铺!🚨🚨"
134+
)
135+
# 如果 SHOP_INFO 没有 ^ 符号,则 SHOP_ID 为 SHOP_INFO
136+
SHOP_ID = SHOP_INFO
137+
138+
# 如果 SHOP_ID 为 AUTO,检查 SHOP_MODE 是否为空
139+
if SHOP_ID == 'AUTO' and not SHOP_MODE:
140+
raise Exception(
141+
"🚫 店铺缺货模式值错误,SHOP_ID 值为 AUTO 时,需设置 SHOP_MODE、PROVINCE 和 CITY 值 "
142+
)
143+
144+
user = {
145+
'PHONE_NUMBER': PHONE_NUMBER.strip(),
146+
'USER_ID': USER_ID.strip(),
147+
'DEVICE_ID': DEVICE_ID.strip(),
148+
'MT_VERSION': MT_VERSION.strip(),
149+
'PRODUCT_ID_LIST': ast.literal_eval(PRODUCT_ID_LIST.strip()),
150+
'SHOP_ID': SHOP_ID.strip(),
151+
'SHOP_MODE': SHOP_MODE.strip(),
152+
'PROVINCE': PROVINCE.strip(),
153+
'CITY': CITY.strip(),
154+
'LAT': LAT.strip(),
155+
'LNG': LNG.strip(),
156+
'TOKEN': TOKEN.strip(),
157+
'COOKIE': COOKIE.strip()
158+
}
159+
# 检查字段是否完整且有值,不检查 SHOP_MODE、PROVICE、CITY 字段(PROVICE 和 CITY 用于 SHOP_MODE 里,而 SHOP_MODE 可选)
160+
required_fields = [
161+
'PHONE_NUMBER', 'USER_ID', 'DEVICE_ID', 'MT_VERSION',
162+
'PRODUCT_ID_LIST', 'SHOP_ID', 'LAT', 'LNG', 'TOKEN', 'COOKIE'
163+
]
164+
if all(user.get(field) for field in required_fields):
165+
# 判断 PRODUCT_ID_LIST 长度是否大于 0
166+
if len(user['PRODUCT_ID_LIST']) > 0:
167+
users.append(user)
168+
else:
169+
raise Exception("🚫 预约商品列表 - PRODUCT_ID_LIST 值为空,请添加后重试")
170+
else:
171+
logging.info(f"🚫 用户信息不完整: {user}")
172+
except Exception as e:
173+
errText = f"🚫 KEN_IMAOTAI_ENV 环境变量格式错误: {e}"
174+
send("i茅台预约日志:", errText)
175+
raise Exception(errText)
176+
177+
logging.info("找到以下用户配置:")
178+
# 输出用户信息
179+
for index, user in enumerate(users):
180+
if DEBUG:
181+
logging.info(f"用户 {index + 1}: {user}")
182+
continue
183+
logging.info(f"用户 {index + 1}: 📞 {user['PHONE_NUMBER']}")
184+
185+
else:
186+
errText = "🚫 KEN_IMAOTAI_ENV 环境变量未定义"
187+
send("i茅台预约日志:", errText)
188+
raise Exception(errText)
189+
190+
191+
# DEBUG 控制日志输出
192+
def debug_log(message):
193+
if DEBUG:
194+
logging.info(message)
195+
196+
197+
# 生成请求头
198+
def generate_headers(device_id, mt_version, cookie, lat=None, lng=None):
199+
headers = {
200+
"MT-Device-ID": device_id,
201+
"MT-APP-Version": mt_version,
202+
"User-Agent": "iOS;16.3;Apple;?unrecognized?",
203+
"Cookie": f"MT-Token-Wap={cookie};MT-Device-ID-Wap={device_id};"
204+
}
205+
if lat and lng:
206+
headers["MT-Lat"] = lat
207+
headers["MT-Lng"] = lng
208+
return headers
209+
210+
211+
# 加密
212+
def aes_cbc_encrypt(data, key, iv):
213+
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv.encode('utf-8'))
214+
padded_data = pad(data.encode('utf-8'), AES.block_size)
215+
encrypted_data = cipher.encrypt(padded_data)
216+
return base64.b64encode(encrypted_data).decode('utf-8')
217+
218+
219+
# 预约商品
220+
def reserve_product(itemId, shopId, sessionId, userId, token, deviceId,
221+
mtVersion, lat, lng):
222+
223+
mt_k = f'{int(time.time() * 1000)}'
224+
headers = {
225+
'User-Agent': 'iOS;16.3;Apple;?unrecognized?',
226+
'MT-Token': token,
227+
'MT-Network-Type': 'WIFI',
228+
'MT-User-Tag': '0',
229+
'MT-K': mt_k,
230+
'MT-Info': '028e7f96f6369cafe1d105579c5b9377',
231+
'MT-APP-Version': mtVersion,
232+
'Accept-Language': 'zh-Hans-CN;q=1',
233+
'MT-Device-ID': deviceId,
234+
'MT-Bundle-ID': 'com.moutai.mall',
235+
'MT-Lng': lng,
236+
'MT-Lat': lat,
237+
'Content-Type': 'application/json',
238+
'userId': str(userId)
239+
}
240+
requestBody = {
241+
"itemInfoList": [{
242+
"count": 1,
243+
"itemId": str(itemId)
244+
}],
245+
"sessionId": sessionId,
246+
"userId": str(userId),
247+
"shopId": str(shopId)
248+
}
249+
actParam = aes_cbc_encrypt(json.dumps(requestBody), ENCRYPT_KEY,
250+
ENCRYPT_IV)
251+
requestBody['actParam'] = actParam
252+
response = requests.post(
253+
'https://app.moutai519.com.cn/xhr/front/mall/reservation/add',
254+
headers=headers,
255+
json=requestBody)
256+
code = response.json().get('code', 0)
257+
if code == 2000:
258+
result = response.json().get('data', {}).get('successDesc', "未知")
259+
logging.info(f"🛒 商品ID {itemId} ✅ 预约成功: {result}")
260+
return result
261+
else:
262+
message = response.json().get("message", "未知原因")
263+
error_msg = f'🚫 预约失败: 错误码 {code}, 错误信息: {message}'
264+
logging.error(f"🛒 商品ID {itemId} {error_msg}")
265+
266+
267+
# 获取蛇年茅台商品信息
268+
def get_snake_year_production_info():
269+
global timestamp_today
270+
271+
# 发送请求
272+
api_url = f"https://h5.moutai519.com.cn/xhr/front/mall/index/special/session/getByType/5?__timestamp={timestamp_today}"
273+
response = requests.get(api_url)
274+
data = response.json()
275+
if data["code"] != 2000:
276+
raise Exception("🚫 获取蛇年茅台商品信息失败")
277+
278+
# 解析响应
279+
session_id = data["data"]["sessionId"]
280+
item_list = data["data"]["itemList"]
281+
# 商品 ID
282+
product_id = item_list[0]["itemCode"]
283+
# 店铺信息
284+
shop_info_list = item_list[0]["shopList"]
285+
return session_id, product_id, shop_info_list
286+
287+
288+
# i茅台~ 启动!
289+
def start(user, session_id, product_id, shop_info_list):
290+
291+
logging.info('--------------------------')
292+
logging.info(f"🧾 用户:{user['PHONE_NUMBER']},开始预约蛇茅。")
293+
294+
logging.info(f"🏁 如果查询到支持预约蛇茅的店铺数量大于1,会自动选择离你最近的店铺。不支持库存最多的模式。")
295+
try:
296+
shop_id = get_shop_id(user["LAT"], user["LNG"], shop_info_list)
297+
298+
reserve_product(itemId=product_id,
299+
shopId=shop_id,
300+
sessionId=session_id,
301+
userId=user["USER_ID"],
302+
token=user["TOKEN"],
303+
deviceId=user["DEVICE_ID"],
304+
mtVersion=user["MT_VERSION"],
305+
lat=user["LAT"],
306+
lng=user["LNG"])
307+
except Exception as e:
308+
logging.error(f"🚫 预约商品ID {product_id} 失败: {e}")
309+
310+
311+
# 获取两个地点之间的距离
312+
def haversine(lat1, lng1, lat2, lng2):
313+
# 将经纬度转换为弧度
314+
lat1, lng1, lat2, lng2 = map(math.radians, [lat1, lng1, lat2, lng2])
315+
316+
# Haversine 公式
317+
dlat = lat2 - lat1
318+
dlng = lng2 - lng1
319+
a = math.sin(
320+
dlat / 2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlng / 2)**2
321+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
322+
323+
# 地球半径(公里)
324+
R = 6371.0
325+
distance = R * c
326+
# 保留三位小数
327+
return round(distance, 3)
328+
329+
330+
# 根据 SHOP_MODE 获取店铺ID
331+
def get_shop_id(lat, lng, shop_info_list):
332+
# 判断入参是否为空
333+
if not lat or not lng or not shop_info_list:
334+
logging.warning("🚫 获取店铺ID失败,请检查入参")
335+
return ""
336+
337+
if len(shop_info_list) > 1:
338+
# 计算用户位置到店铺的距离,并且按照距离近到远排序,把距离添加到 shop_info_list 中
339+
for shop in shop_info_list:
340+
distance = haversine(float(lat), float(lng), float(shop["lat"]),
341+
float(shop["lng"]))
342+
shop["distance"] = distance
343+
shop_info_list.sort(key=lambda x: x["distance"])
344+
if DEBUG:
345+
debug_log(f"--- 🏁 用户位置到各个店铺的距离: ")
346+
for shop in shop_info_list:
347+
debug_log(
348+
f"--- 🏁 --- 店铺名称: {shop.get('name')}, 店铺ID:{shop.get('shopId')},距离: {shop.get('distance')} 公里"
349+
)
350+
debug_log(
351+
f"--- 🏁 找到最近的店铺:{shop_info_list[0].get('name')}, 店铺ID:{shop_info_list[0].get('shopId')},距离:{shop_info_list[0].get('distance')} 公里"
352+
)
353+
else:
354+
debug_log(f"--- 🏁 只有一个店铺,直接使用该店铺ID:{shop_info_list[0].get('shopId')}")
355+
356+
logging.info(
357+
f"--- 🏁 获取店铺ID成功,店铺ID:{shop_info_list[0].get('shopId')},店铺名:{shop_info_list[0].get('name')}"
358+
)
359+
return shop_info_list[0]["shopId"]
360+
361+
362+
if __name__ == "__main__":
363+
if not DEBUG:
364+
# 判断当前时间是否是 9:00 到 10:00 期间
365+
now = datetime.datetime.now()
366+
if now.hour < 9 or now.hour > 10:
367+
err_msg = "🚫 当前时间不在 9:00 到 10:00 期间,不执行预约"
368+
logger.warning(err_msg)
369+
send("i茅台预约日志:", err_msg)
370+
exit()
371+
372+
# 生成时间戳
373+
timestamp_today = str(
374+
int(time.mktime(datetime.date.today().timetuple())) * 1000)
375+
376+
session_id, product_id, shop_info_list = get_snake_year_production_info()
377+
for user in users:
378+
start(user, session_id, product_id, shop_info_list)
379+
380+
logging.info('--------------------------')
381+
logging.info(" ✅ 所有用户预约完成")
382+
383+
log_contents = log_stream.getvalue()
384+
send("i茅台预约日志:", log_contents)

0 commit comments

Comments
 (0)