Files
XiaoGe-LiBai-yangmao/小程序打卡/多账号打卡/auto_checkin_multi_opt.py
XiaoGe-LiBai 0f6593789f 新增
2025-12-10 17:49:11 +08:00

820 lines
30 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
小程序自动打卡脚本 - 多账号版本(优化版)
主要改动:
- 支持自定义配置文件路径,全程使用同一配置文件(含自动登录后 token 回写)
- 避免重复请求用户详情接口,只请求一次同时做 token 校验和用户信息获取
- 照片文件只读取一次并缓存,减少磁盘 IO
- 精简并统一网络重试逻辑,重试次数和间隔可配置
- 日志信息更清晰,关键异常使用 logger.exception 方便排查
"""
import argparse
import json
import logging
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import requests
# ========= 常量与全局配置 =========
BASE_URL = "https://erp.baian.tech/baian-admin"
APP_ID = "wxa81d5d83880ea6b6"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 "
"MicroMessenger/8.0.65(0x1800412e) NetType/WIFI"
)
ENV_VAR_NAME = "hxza_dk"
DEFAULT_CONFIG_FILE = "accounts.json"
DEFAULT_PHOTO_FILE = ""
REQUEST_TIMEOUT = 15
MAX_RETRIES = 3
ACCOUNT_DELAY_SECONDS = 2
# 为了兼容青龙,尝试导入通知模块
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from notify import send as notify_send # type: ignore
NOTIFY_ENABLED = True
except ImportError:
try:
from sendNotify import send as notify_send # type: ignore
NOTIFY_ENABLED = True
except ImportError:
NOTIFY_ENABLED = False
def notify_send(title: str, content: str) -> None:
pass
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
# ========= 配置相关 =========
@dataclass
class AccountConfig:
"""账号配置"""
name: str
token: str
photo: Optional[str] = None
enabled: bool = True
@classmethod
def from_dict(cls, data: Dict) -> "AccountConfig":
return cls(
name=data.get("name", "") or "",
token=data.get("token", "") or "",
photo=data.get("photo"),
enabled=bool(data.get("enabled", True)),
)
@classmethod
def from_env_string(cls, env_str: str) -> "AccountConfig":
"""
从环境变量字符串创建配置
支持格式:
1) 备注名#token#photo文件名
示例:张三##photo.txt token 为空时自动登录)
2) token#备注名(兼容旧版)
示例eyJhbGc...xyz#张三
3) 只有 token无备注名
"""
parts = [p.strip() for p in env_str.split("#")]
if len(parts) >= 3:
# 新格式: 备注名#token#photo文件名
name, token, photo = parts[0], parts[1], parts[2]
photo = photo or None
return cls(name=name, token=token, photo=photo)
if len(parts) == 2:
# 旧格式: token#备注名
token, name = parts[0], parts[1]
return cls(name=name, token=token, photo=None)
# 只有 token
token = parts[0] if parts else ""
return cls(name="", token=token, photo=None)
def load_accounts_from_file(config_path: str) -> List[AccountConfig]:
"""从配置文件加载账号"""
if not os.path.exists(config_path):
return []
try:
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
accounts: List[AccountConfig] = []
for account_data in data.get("accounts", []):
account = AccountConfig.from_dict(account_data)
if account.enabled:
accounts.append(account)
return accounts
except Exception as e:
logger.exception(f"❌ 加载配置文件失败: {config_path} - {e}")
return []
def load_accounts_from_env() -> List[AccountConfig]:
"""从环境变量加载账号"""
env_value = os.getenv(ENV_VAR_NAME, "") or ""
if not env_value.strip():
return []
accounts: List[AccountConfig] = []
for account_str in env_value.split("&"):
account_str = account_str.strip()
if not account_str:
continue
accounts.append(AccountConfig.from_env_string(account_str))
return accounts
def load_all_accounts(config_path: str) -> List[AccountConfig]:
"""加载所有账号配置(配置文件优先)"""
accounts = load_accounts_from_file(config_path)
if accounts:
logger.info(f"✅ 从配置文件加载账号: {config_path}")
return accounts
accounts = load_accounts_from_env()
if accounts:
logger.info(f"✅ 从环境变量 {ENV_VAR_NAME} 加载账号")
return accounts
# ========= 打卡逻辑 =========
class AutoCheckIn:
"""单账号自动打卡"""
def __init__(self, account: AccountConfig, config_path: str):
self.account = account
self.config_path = config_path
self.base_url = BASE_URL
self.app_id = APP_ID
self.token: str = account.token or ""
self.remark: str = account.name
self.photo_file: Optional[str] = account.photo
self.session = requests.Session()
# 用户信息
self.user_name: Optional[str] = None
self.user_mobile: Optional[str] = None
# 班次信息
self.start_time: Optional[str] = None
self.end_time: Optional[str] = None
self.is_cross_day: bool = False
# 打卡状态
self.is_card_time: bool = False
self.checkin_status: Optional[int] = None
self.checkin_time: Optional[str] = None
self.checkout_status: Optional[int] = None
self.checkout_time: Optional[str] = None
# 其他参数(接口返回)
self.item_id: Optional[str] = None
self.job_date_id: Optional[str] = None
self.job_id: Optional[str] = None
self.address_name: Optional[str] = None
self.longitude: Optional[float] = None
self.latitude: Optional[float] = None
# 照片数据缓存
self._image_data: Optional[str] = None
# ---- HTTP & 工具方法 ----
@property
def headers(self) -> Dict[str, str]:
return {
"Host": "erp.baian.tech",
"Connection": "keep-alive",
"appId": self.app_id,
"content-type": "application/json",
"wxAppKeyCompanyId": self.app_id,
"token": self.token,
"Accept-Encoding": "gzip,compress,br,deflate",
"User-Agent": DEFAULT_USER_AGENT,
"Referer": f"https://servicewechat.com/{self.app_id}/102/page-frame.html",
}
def _post_with_retry(self, url: str, data: Dict, max_retries: int = MAX_RETRIES) -> Optional[Dict]:
"""带重试的 POST 请求(自己控制重试,不依赖 HTTPAdapter"""
for attempt in range(1, max_retries + 1):
try:
resp = self.session.post(url, json=data, headers=self.headers, timeout=REQUEST_TIMEOUT)
if resp.status_code != 200:
if attempt < max_retries:
logger.warning(f"⚠️ HTTP {resp.status_code}, 重试 {attempt}/{max_retries}")
time.sleep(2**attempt)
continue
logger.error(f"❌ 请求失败: HTTP {resp.status_code}")
return None
result = resp.json()
if result.get("code") != 200:
logger.error(f"❌ 接口返回错误: {result.get('msg')}")
return None
return result
except requests.exceptions.Timeout:
if attempt < max_retries:
logger.warning(f"⚠️ 请求超时, 重试 {attempt}/{max_retries}")
time.sleep(2**attempt)
continue
logger.error("❌ 请求超时")
return None
except requests.exceptions.RequestException as e:
if attempt < max_retries:
logger.warning(f"⚠️ 请求异常 {e}, 重试 {attempt}/{max_retries}")
time.sleep(2**attempt)
continue
logger.exception(f"❌ 请求异常: {e}")
return None
except Exception as e:
logger.exception(f"❌ 未知异常: {e}")
return None
return None
def _load_image_data(self) -> bool:
"""读取并缓存照片文件内容"""
if self._image_data is not None:
return True
try:
if not self.photo_file:
logger.error("❌ 未配置照片文件,请在配置中指定 photo")
return False
if not os.path.exists(self.photo_file):
logger.error(f"❌ 照片文件不存在: {self.photo_file}")
return False
with open(self.photo_file, "r", encoding="utf-8") as f:
image_data = f.read().strip()
if not image_data:
logger.error("❌ 照片文件内容为空")
return False
self._image_data = image_data
logger.info("✅ 照片文件读取成功")
return True
except Exception as e:
logger.exception(f"❌ 读取照片文件异常: {e}")
return False
# ---- 账号初始化相关 ----
def _update_token_in_config(self, new_token: str) -> None:
"""自动登录后回写 token 到配置文件"""
if not self.config_path or not os.path.exists(self.config_path):
# 环境变量模式或无配置文件时忽略
return
try:
logger.info(f"\n🔄 更新配置文件中的 token: {self.config_path}")
with open(self.config_path, "r", encoding="utf-8") as f:
config = json.load(f)
updated = False
for account in config.get("accounts", []):
if account.get("name", "") == self.account.name:
account["token"] = new_token
updated = True
break
if not updated:
logger.warning(f"⚠️ 配置文件中未找到账号 [{self.account.name}],未更新 token")
return
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
logger.info("✅ 配置文件 token 已更新")
except PermissionError:
logger.warning("⚠️ 没有权限写入配置文件,跳过 token 回写")
except Exception as e:
logger.exception(f"❌ 更新配置文件失败: {e}")
def _auto_login(self) -> bool:
"""使用人脸识别接口自动登录获取新 token"""
logger.info("🔐 Token 无效,尝试自动登录...")
if not self._load_image_data():
return False
url = f"{self.base_url}/recognition/faceSearch"
data = {"image": self._image_data}
# 登录时不携带 token
headers = self.headers.copy()
headers["token"] = ""
try:
resp = self.session.post(url, json=data, headers=headers, timeout=REQUEST_TIMEOUT)
if resp.status_code != 200:
logger.error(f"❌ 自动登录请求失败: HTTP {resp.status_code}")
return False
result = resp.json()
if result.get("code") != 200:
logger.error(f"❌ 自动登录失败: {result.get('msg')}")
return False
data = result.get("data", {}) or {}
token = data.get("token")
expire = data.get("expire", 0)
if not token:
logger.error("❌ 自动登录未返回 token")
return False
self.token = token
self.account.token = token
expire_days = expire // 86400 if expire else 0
expire_dt = datetime.now() + timedelta(seconds=expire or 0)
logger.info("✅ 自动登录成功")
logger.info(f" 🔑 Token: {token[:20]}...{token[-10:]}")
logger.info(f" ⏰ 有效期: {expire_days} 天, 过期时间: {expire_dt:%Y-%m-%d %H:%M:%S}")
self._update_token_in_config(token)
return True
except Exception as e:
logger.exception(f"❌ 自动登录异常: {e}")
return False
def _fetch_user_info(self, allow_auto_login: bool = True) -> bool:
"""获取用户信息;若 token 失效可选择自动登录一次"""
url = f"{self.base_url}/userH5/getMyDetails"
try:
resp = self.session.get(url, headers=self.headers, timeout=REQUEST_TIMEOUT)
if resp.status_code != 200:
logger.warning(f"⚠️ 获取用户信息失败 HTTP {resp.status_code}")
if allow_auto_login and self._auto_login():
return self._fetch_user_info(allow_auto_login=False)
return False
result = resp.json()
if result.get("code") != 200:
logger.warning(f"⚠️ 获取用户信息失败: {result.get('msg')}")
if allow_auto_login and self._auto_login():
return self._fetch_user_info(allow_auto_login=False)
return False
data = result.get("data", {}) or {}
self.user_name = data.get("name", "")
self.user_mobile = data.get("mobile", "")
return True
except Exception as e:
logger.exception(f"❌ 获取用户信息异常: {e}")
if allow_auto_login and self._auto_login():
return self._fetch_user_info(allow_auto_login=False)
return False
def _fetch_job_details(self) -> bool:
"""获取班次信息itemId/jobDateId/经纬度/班次时间/打卡状态等)"""
url = f"{self.base_url}/Management/getJobUserDetails"
try:
resp = self.session.get(url, params={"userId": ""}, headers=self.headers, timeout=REQUEST_TIMEOUT)
if resp.status_code != 200:
logger.error(f"❌ 获取班次信息失败: HTTP {resp.status_code}")
return False
result = resp.json()
if result.get("code") != 200:
logger.error(f"❌ 获取班次信息失败: {result.get('msg')}")
return False
data = result.get("data", {}) or {}
self.item_id = data.get("itemId")
self.job_id = data.get("jobId")
self.is_card_time = bool(data.get("isCardTime", False))
job_date_dtos = data.get("jobDateDTOS", []) or []
if job_date_dtos:
job_date = job_date_dtos[0] or {}
self.job_date_id = job_date.get("jobDateId")
self.start_time = job_date.get("startDate")
self.end_time = job_date.get("endDate")
self.is_cross_day = job_date.get("ismorrow") == 1
self.checkin_status = job_date.get("status")
self.checkin_time = job_date.get("date")
self.checkout_status = job_date.get("statusOff")
self.checkout_time = job_date.get("dateOff")
address_dtos = data.get("addressDTOS", []) or []
if address_dtos:
address = address_dtos[0] or {}
self.address_name = address.get("addressName")
try:
self.longitude = float(address.get("longitude"))
self.latitude = float(address.get("latitude"))
except (TypeError, ValueError):
self.longitude = None
self.latitude = None
required = [
self.item_id,
self.job_date_id,
self.address_name,
self.longitude,
self.latitude,
self.start_time,
self.end_time,
]
if not all(required):
logger.error("❌ 班次信息不完整,无法打卡")
return False
return True
except Exception as e:
logger.exception(f"❌ 获取班次信息异常: {e}")
return False
# ---- 时间窗口与判断 ----
@staticmethod
def _time_to_minutes(time_str: str) -> int:
h, m = map(int, time_str.split(":"))
return h * 60 + m
@staticmethod
def _minutes_to_time(minutes: int) -> str:
h, m = divmod(minutes % 1440, 60)
return f"{h:02d}:{m:02d}"
def _get_checkin_windows(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
"""
根据班次时间计算打卡时间窗口单位分钟0-1439
- 上班窗口:上班时间前 120 分钟内
- 下班窗口:下班后到下一次上班前 121 分钟以内
"""
if not self.start_time or not self.end_time:
raise ValueError("尚未加载班次时间")
start_minutes = self._time_to_minutes(self.start_time)
end_minutes = self._time_to_minutes(self.end_time)
first_start = (start_minutes - 120) % 1440
first_end = start_minutes
second_start = (end_minutes + 1) % 1440
second_end = (start_minutes - 121) % 1440
return (first_start, first_end), (second_start, second_end)
@staticmethod
def _is_in_window(current_minutes: int, start: int, end: int) -> bool:
"""检查当前时间是否在窗口内(考虑跨午夜情况)"""
if start <= end:
return start <= current_minutes <= end
return current_minutes >= start or current_minutes <= end
def _get_current_checkin_type(self) -> Optional[str]:
"""
智能判断应该执行哪种打卡
Returns:
'first' - 需要上班打卡
'second' - 需要下班打卡
'already_done' - 已完成所有打卡
None - 不在打卡时间内或不在对应窗口内
"""
if not self.is_card_time:
return None
now = datetime.now()
current_minutes = now.hour * 60 + now.minute
(first_start, first_end), (second_start, second_end) = self._get_checkin_windows()
need_checkin = (
self.checkin_status == 0
or self.checkin_status is None
or self.checkin_time is None
)
need_checkout = (
self.checkout_status == 0
or self.checkout_status is None
or self.checkout_time is None
)
in_first_window = self._is_in_window(current_minutes, first_start, first_end)
in_second_window = self._is_in_window(current_minutes, second_start, second_end)
if need_checkin and in_first_window:
return "first"
if (not need_checkin) and need_checkout and in_second_window:
return "second"
if (not need_checkin) and (not need_checkout):
return "already_done"
return None
def _show_checkin_windows(self) -> None:
"""显示打卡时间窗口和状态"""
(first_start, first_end), (second_start, second_end) = self._get_checkin_windows()
first_start_str = self._minutes_to_time(first_start)
first_end_str = self._minutes_to_time(first_end)
second_start_str = self._minutes_to_time(second_start)
second_end_str = self._minutes_to_time(second_end)
logger.info("\n🕒 打卡时间窗口:")
logger.info(f" ⏰ 上班: {first_start_str} - {first_end_str}")
logger.info(f" ⏰ 下班: {second_start_str} - {second_end_str}")
logger.info("\n📌 打卡状态:")
if self.checkin_status == 1 and self.checkin_time:
logger.info(f" ✅ 上班打卡: 已完成 ({self.checkin_time})")
else:
logger.info(" ❗ 上班打卡: 未完成")
if self.checkout_status == 1 and self.checkout_time:
logger.info(f" ✅ 下班打卡: 已完成 ({self.checkout_time})")
else:
logger.info(" ❗ 下班打卡: 未完成")
now = datetime.now().strftime("%H:%M:%S")
logger.info(f"\n⏱ 当前时间: {now}")
if self.is_card_time:
logger.info(" ✅ 当前在打卡时间内")
else:
logger.info(" ⛔ 当前不在打卡时间内")
# ---- 核心动作 ----
def initialize(self) -> bool:
"""初始化账号信息token 检查/自动登录 + 用户信息 + 班次信息)"""
display_name = self.remark or "未命名"
logger.info("\n" + "=" * 50)
logger.info(f"👤 正在初始化账号: {display_name}")
logger.info("=" * 50)
# token 为空或无效,将在 _fetch_user_info 内自动尝试登录一次
if not self.token:
logger.warning("⚠️ Token 为空,将尝试自动登录")
if not self._fetch_user_info(allow_auto_login=True):
logger.error("❌ 初始化失败:获取用户信息失败")
return False
if not self._fetch_job_details():
logger.error("❌ 初始化失败:获取班次信息失败")
return False
display_name = self.remark or self.user_name or "未命名"
logger.info("\n✅ 账号初始化成功")
logger.info(f" 👤 姓名: {display_name}")
logger.info(f" 📱 手机: {self.user_mobile}")
logger.info(f" 📍 地点: {self.address_name}")
logger.info(
f" 🗓 班次: {self.start_time} - {self.end_time}{' (跨天)' if self.is_cross_day else ''}"
)
self._show_checkin_windows()
return True
def face_detect(self, checkin_type: str) -> Optional[Dict]:
"""人脸识别验证"""
if not self._load_image_data():
return None
url = f"{self.base_url}/recognition/faceDetect"
data = {"image": self._image_data}
return self._post_with_retry(url, data)
def save_attendance(self, checkin_type: str) -> bool:
"""保存打卡记录"""
if not self._load_image_data():
return False
url = f"{self.base_url}/Management/saveAttendanceManagement"
is_off = 0 if checkin_type == "first" else 1
update_card = 0 if checkin_type == "first" else 1
data = {
"itemId": self.item_id,
"jobDateId": self.job_date_id,
"addressName": self.address_name,
"isLegwork": 0,
"legworkState": "",
"isOff": is_off,
"imageId": "",
"updateCard": update_card,
"image": self._image_data,
"longitude": self.longitude,
"latitude": self.latitude,
"userId": "",
}
result = self._post_with_retry(url, data)
return bool(result)
def do_checkin(self, checkin_type: str) -> Tuple[bool, str]:
"""执行打卡流程,返回 (成功与否, 结果消息)"""
checkin_name = "上班" if checkin_type == "first" else "下班"
emoji = "🌞" if checkin_type == "first" else "🌙"
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
display_name = self.remark or self.user_name or "未命名"
(first_start, first_end), (second_start, second_end) = self._get_checkin_windows()
first_window = f"{self._minutes_to_time(first_start)} - {self._minutes_to_time(first_end)}"
second_window = f"{self._minutes_to_time(second_start)} - {self._minutes_to_time(second_end)}"
msg_header = (
f"👤 {display_name}\n"
f"📱 {self.user_mobile}\n"
f"📍 {self.address_name}\n"
f"🗓 {self.start_time}-{self.end_time}{' (跨天)' if self.is_cross_day else ''}\n\n"
f"⏰ 上班: {first_window}\n"
f"⏰ 下班: {second_window}\n\n"
)
logger.info(f"\n{emoji} [{display_name}] {checkin_name}打卡中...")
face_result = self.face_detect(checkin_type)
if not face_result:
logger.error(f"❌ [{display_name}] {checkin_name}打卡失败(人脸识别失败)")
return False, f"{msg_header}🕒 {current_time}\n{checkin_name}失败"
time.sleep(1)
success = self.save_attendance(checkin_type)
if success:
logger.info(f"✅ [{display_name}] {checkin_name}打卡成功")
return True, f"{msg_header}🕒 {current_time}\n{checkin_name}成功"
logger.error(f"❌ [{display_name}] {checkin_name}打卡失败(保存记录失败)")
return False, f"{msg_header}🕒 {current_time}\n{checkin_name}失败"
# ========= 主流程 =========
def run() -> int:
parser = argparse.ArgumentParser(description="小程序自动打卡脚本 - 多账号版本(优化版)")
parser.add_argument("--type", choices=["first", "second"], help="立即执行指定类型的打卡")
parser.add_argument(
"--config",
default=DEFAULT_CONFIG_FILE,
help="配置文件路径(默认 accounts.json",
)
args = parser.parse_args()
try:
accounts = load_all_accounts(args.config)
if not accounts:
msg = f"❌ 未找到任何账号配置,请检查 {args.config} 或环境变量 {ENV_VAR_NAME}"
logger.error(msg)
if NOTIFY_ENABLED:
notify_send("❌ 小程序打卡配置错误", msg)
return 1
total = len(accounts)
logger.info(f"\n✅ 共加载 {total} 个账号")
success_count = 0
fail_count = 0
skip_count = 0
all_messages: List[str] = []
for i, account in enumerate(accounts, 1):
display_name = account.name or "未命名"
logger.info("\n" + "=" * 60)
logger.info(f"👤 处理账号 {i}/{total}: {display_name}")
logger.info("=" * 60)
try:
checker = AutoCheckIn(account, config_path=args.config)
if not checker.initialize():
fail_count += 1
all_messages.append(f"❌ [{display_name}] 初始化失败")
continue
success = False
msg = ""
if args.type:
logger.info(
f"\n🎯 强制执行 {'上班' if args.type == 'first' else '下班'} 打卡"
)
success, msg = checker.do_checkin(args.type)
else:
ctype = checker._get_current_checkin_type()
if ctype == "first":
logger.info("\n🤖 智能判断: 需要执行上班打卡")
success, msg = checker.do_checkin("first")
elif ctype == "second":
logger.info("\n🤖 智能判断: 需要执行下班打卡")
success, msg = checker.do_checkin("second")
elif ctype == "already_done":
skip_count += 1
logger.info(f"\n✅ [{display_name}] 今日打卡已全部完成")
logger.info(f" 上班打卡: {checker.checkin_time}")
logger.info(f" 下班打卡: {checker.checkout_time}")
else:
skip_count += 1
logger.info(f"\n [{display_name}] 当前不在打卡时间内")
if success:
success_count += 1
if msg:
all_messages.append(msg)
elif msg:
fail_count += 1
all_messages.append(msg)
if i < total:
time.sleep(ACCOUNT_DELAY_SECONDS)
except Exception as e:
fail_count += 1
logger.exception(f"❌ 处理账号 [{display_name}] 异常: {e}")
all_messages.append(f"❌ [{display_name}] 处理异常: {e}")
logger.info("\n" + "=" * 60)
logger.info("📊 执行结果汇总")
logger.info("=" * 60)
logger.info(f" 总账号数: {total}")
logger.info(f" ✅ 成功: {success_count}")
logger.info(f" ❌ 失败: {fail_count}")
logger.info(f" ⏭ 跳过: {skip_count}")
if NOTIFY_ENABLED:
# 全部账号都被跳过时不发送任何通知
if success_count == 0 and fail_count == 0 and skip_count == total:
logger.info("\n 所有账号均被跳过,不发送通知")
else:
summary = (
f"📊 总计: {total} | ✅ 成功: {success_count} | "
f"❌ 失败: {fail_count} | ⏭ 跳过: {skip_count}"
)
if all_messages:
summary += "\n\n" + "\n\n".join(all_messages)
if fail_count > 0:
title = "⚠️ 小程序打卡完成(有失败)"
elif success_count > 0:
title = "✅ 小程序打卡完成"
else:
title = "📊 小程序打卡执行完成"
notify_send(title, summary)
logger.info(f"\n📣 通知已发送: {title}")
else:
logger.warning("\n⚠️ 通知模块未启用,跳过通知推送")
return 0 if fail_count == 0 else 1
except KeyboardInterrupt:
logger.info("\n⛔ 用户中断程序")
return 1
except Exception as e:
logger.exception(f"❌ 程序异常: {e}")
if NOTIFY_ENABLED:
notify_send("❌ 小程序打卡异常", str(e))
return 1
if __name__ == "__main__":
raise SystemExit(run())