331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""
|
||
丰享订单监控 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()
|