Initial commit: Fengxiang order monitor with WeChat & Xiaomi speaker push

This commit is contained in:
houhuan
2026-04-30 14:25:34 +08:00
commit 1bb7bba970
10 changed files with 970 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
"""
丰享订单监控 v4
- 优先使用 Cookie 文件请求 API,无需每次都打开浏览器
- Cookie 过期时自动打开浏览器提示重新登录
- 晚上 21:00 到早上 7:40 自动暂停
- 高峰期缩短间隔,闲时拉长间隔
"""
import json
import random
import time
import datetime
import asyncio
import threading
import requests
from pathlib import Path
from aiohttp import ClientSession
from miservice import MiAccount, MiNAService
from playwright.sync_api import sync_playwright
# ============ 配置 ============
LOGIN_URL = "https://fspass.szfx.com/ucenter/userlogin?platform=saas&redirect_url=https%3A%2F%2Ffs.szfx.com%2FMMS%23%2F&return_url=https%3A%2F%2Ffs.szfx.com%2Fsaasmerchant%2Fsetstoken"
DATA_API = "https://fs.szfx.com/saasmerchant/pcweb/order/quickpayorder/list"
SHOP_ID = "20434543575189"
WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=644ab6d9-3b66-4166-88e9-5a8a89e3731d"
COOKIES_FILE = "cookies.json"
# 小爱音箱配置
XIAOMI_USER_ID = "1136458602"
XIAOMI_TOKEN_PATH = str(Path.home() / ".mi.token")
XIAOMI_SPEAKER_DID = "3ba2c1e8-d8cb-45c5-b88a-15624e7a02f3"
# 高峰时段配置
PEAK_HOURS = [(11, 13), (17, 19)]
PEAK_INTERVAL_MIN = 10
PEAK_INTERVAL_MAX = 20
PEAK_PAGE_SIZE = 20
IDLE_INTERVAL_MIN = 30
IDLE_INTERVAL_MAX = 60
IDLE_PAGE_SIZE = 5
# 夜间暂停:21:00 ~ 07:40 不轮询
NIGHT_START_H = 21
NIGHT_END_H = 7
NIGHT_END_M = 40
KEEPALIVE_INTERVAL = 300 # 每5分钟保活一次
# ==============================
def fmt_money(cents):
return f"{cents / 100:.2f}" if cents else "0.00元"
def log(msg):
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{now}] {msg}")
def is_peak_hour():
hour = datetime.datetime.now().hour
return any(start <= hour < end for start, end in PEAK_HOURS)
def is_night_time():
"""是否在夜间暂停时段 (21:00 ~ 07:40)"""
now = datetime.datetime.now()
minutes = now.hour * 60 + now.minute
return minutes >= NIGHT_START_H * 60 or minutes < NIGHT_END_H * 60 + NIGHT_END_M
def seconds_until_morning():
"""距离明天早上 07:40 还有多少秒"""
now = datetime.datetime.now()
target = now.replace(hour=NIGHT_END_H, minute=NIGHT_END_M, second=0, microsecond=0)
if now.hour >= NIGHT_START_H:
target += datetime.timedelta(days=1)
return max(1, int((target - now).total_seconds()))
def get_poll_config():
if is_peak_hour():
return PEAK_INTERVAL_MIN, PEAK_INTERVAL_MAX, PEAK_PAGE_SIZE
return IDLE_INTERVAL_MIN, IDLE_INTERVAL_MAX, IDLE_PAGE_SIZE
def format_msg(order):
return f"【丰享丰食】订单收款成功,收款{fmt_money(order['amountPayable'])}"
def send_to_wecom(text):
payload = {"msgtype": "text", "text": {"content": text}}
try:
resp = requests.post(WEBHOOK_URL, json=payload, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
log("企业微信推送成功")
else:
log(f"推送失败: {result}")
except Exception as e:
log(f"推送异常: {e}")
def _run_async_in_thread(coro):
"""在独立线程中运行协程,避免与 Playwright 事件循环冲突"""
result = None
error = None
def _target():
nonlocal result, error
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(coro)
except Exception as e:
error = e
finally:
loop.close()
t = threading.Thread(target=_target)
t.start()
t.join(timeout=15)
if error:
raise error
return result
def speak(text):
"""调用小爱音箱语音播报"""
try:
async def _tts():
async with ClientSession() as session:
account = MiAccount(session, XIAOMI_USER_ID, None, XIAOMI_TOKEN_PATH)
mina = MiNAService(account)
return await mina.text_to_speech(XIAOMI_SPEAKER_DID, text)
result = _run_async_in_thread(_tts())
if result and result.get("code") == 0:
log("语音播报成功")
else:
log(f"语音播报失败: {result}")
except Exception as e:
log(f"语音播报异常: {e}")
def load_cookies():
"""加载本地 Cookie 文件"""
cookie_path = Path(__file__).parent / COOKIES_FILE
if cookie_path.exists():
with open(cookie_path, "r", encoding="utf-8") as f:
return json.load(f)
return None
def save_cookies(cookiejar):
"""把浏览器 Cookie 列表保存为 dict 到文件"""
cookie_dict = {c["name"]: c["value"] for c in cookiejar}
cookie_path = Path(__file__).parent / COOKIES_FILE
with open(cookie_path, "w", encoding="utf-8") as f:
json.dump(cookie_dict, f, ensure_ascii=False, indent=2)
log("Cookie 已保存到 cookies.json")
def fetch_orders_via_requests(cookies, page_size=5):
"""通过 requests + Cookie 拉取订单列表,返回 (订单列表 / SESSION_EXPIRED / None)"""
headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://fs.szfx.com/MMS",
"Origin": "https://fs.szfx.com",
}
payload = {"shopId": SHOP_ID, "page": 1, "pageSize": page_size}
try:
resp = requests.post(DATA_API, json=payload, headers=headers, cookies=cookies, timeout=30)
data = resp.json()
if data.get("errno") == 0:
return data["data"]["list"]
elif data.get("errno") in (110003, 10009):
return "SESSION_EXPIRED"
else:
log(f"API 异常: errno={data.get('errno')}, errmsg={data.get('errmsg')}")
return None
except Exception as e:
log(f"请求异常: {e}")
return None
def browser_login():
"""打开浏览器让用户手动登录,返回 Cookie dict"""
log("启动浏览器,请手动登录...")
pw = sync_playwright().start()
browser = pw.chromium.launch(headless=False, args=["--start-maximized"])
context = browser.new_context(no_viewport=True)
page = context.new_page()
page.goto(LOGIN_URL)
log("等待登录完成(10分钟超时)...")
try:
page.wait_for_url("**/MMS**", timeout=600_000)
log("登录成功!提取 Cookie...")
except Exception:
log("登录超时")
browser.close()
pw.stop()
return None
time.sleep(2)
cookiejar = context.cookies()
save_cookies(cookiejar)
browser.close()
pw.stop()
log("浏览器已关闭")
return {c["name"]: c["value"] for c in cookiejar}
def main():
# 1. 尝试加载已有 Cookie
cookies = load_cookies()
if cookies:
log("检测到已有 Cookie,验证有效性...")
test_result = fetch_orders_via_requests(cookies, page_size=1)
if test_result == "SESSION_EXPIRED" or test_result is None:
log("Cookie 已过期,需要重新登录")
cookies = None
else:
log("Cookie 有效,无需打开浏览器")
# 2. 没有有效 Cookie → 打开浏览器登录
if not cookies:
send_to_wecom("【监控通知】需要登录丰享平台,请查看电脑浏览器完成登录")
cookies = browser_login()
if not cookies:
log("登录失败,退出")
return
# 3. 获取初始最新订单号
log("获取初始订单...")
_, _, page_size = get_poll_config()
last_order_id = None
orders = fetch_orders_via_requests(cookies, page_size)
if orders and orders != "SESSION_EXPIRED" and len(orders) > 0:
last_order_id = orders[0]["orderId"]
log(f"当前最新订单号: {last_order_id}")
last_keepalive = time.time()
mode = "高峰期" if is_peak_hour() else ("夜间暂停" if is_night_time() else "闲时")
log(f"开始监控(当前模式: {mode}")
try:
while True:
# 夜间暂停:到点就睡,天亮再起
if is_night_time():
sleep_secs = seconds_until_morning()
until = datetime.datetime.now() + datetime.timedelta(seconds=sleep_secs)
log(f"夜间模式,暂停监控至 {until.strftime('%H:%M:%S')}(约 {sleep_secs // 60} 分钟)")
time.sleep(sleep_secs)
log("恢复监控...")
# 验证 Cookie 是否还活着
test = fetch_orders_via_requests(cookies, page_size=1)
if test == "SESSION_EXPIRED":
log("Cookie 在夜间过期,需要重新登录")
send_to_wecom("【监控通知】Cookie 过期,请查看电脑浏览器完成登录")
cookies = browser_login()
if not cookies:
log("登录失败,退出")
return
last_keepalive = time.time()
continue
# 轮询
interval_min, interval_max, page_size = get_poll_config()
wait = random.randint(interval_min, interval_max)
mode = "高峰期" if is_peak_hour() else "闲时"
log(f"[{mode}] 等待 {wait} 秒,拉取 {page_size} 条...")
time.sleep(wait)
# 定期保活(一次 API 请求即可维持 Cookie)
if time.time() - last_keepalive > KEEPALIVE_INTERVAL:
fetch_orders_via_requests(cookies, page_size=1)
last_keepalive = time.time()
# 拉取订单
orders = fetch_orders_via_requests(cookies, page_size)
# Cookie 过期 → 浏览器重新登录
if orders == "SESSION_EXPIRED":
log("Cookie 已过期!")
send_to_wecom("【监控异常】Cookie 已过期,请查看电脑浏览器完成登录")
cookies = browser_login()
if not cookies:
send_to_wecom("登录超时,监控已退出。请手动重新运行。")
return
send_to_wecom("重新登录成功,监控已恢复")
last_keepalive = time.time()
orders = fetch_orders_via_requests(cookies, page_size)
if orders and orders != "SESSION_EXPIRED" and len(orders) > 0:
last_order_id = orders[0]["orderId"]
continue
if not orders or orders == "SESSION_EXPIRED":
continue
# 找出所有新订单(orderId > last_order_id
new_orders = []
for o in orders:
if o["orderId"] == last_order_id:
break
new_orders.append(o)
if new_orders:
log(f"发现 {len(new_orders)} 条新订单!")
# 从旧到新依次推送
for o in reversed(new_orders):
msg = format_msg(o)
send_to_wecom(msg)
speak(msg)
time.sleep(0.5)
last_order_id = new_orders[0]["orderId"]
else:
log(f"暂无新订单(最新: {last_order_id}")
except KeyboardInterrupt:
log("监控已停止")
if __name__ == "__main__":
main()