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

933 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""
小程序自动打卡脚本 - 多账号版本
=== 配置说明 ===
方式一:配置文件(推荐)
在 accounts.json 中配置多个账号:
{
"accounts": [
{
"name": "张三",
"token": "eyJhbGc...xyz",
"photo": "photo.txt",
"enabled": true
},
{
"name": "李四",
"token": "eyJhbGc...abc",
"photo": "photo_lisi.txt",
"enabled": true
}
]
}
方式二:环境变量
变量名: hxza_dk
格式1推荐: 备注名#token#photo文件名
示例: 张三##photo.txt&侯利##photo_houli.txt
说明: token留空时会自动使用photo文件登录获取token
格式2完整: 备注名#token#photo文件名
示例: 张三#eyJhbGc...xyz#photo.txt&侯利#eyJhbGc...abc#photo_houli.txt
格式3兼容旧版: token#备注名
示例: eyJhbGc...xyz#张三&eyJhbGc...abc#侯利
说明: 使用默认photo.txt文件
说明:
- name: 账号备注名称(用于日志标识)
- token: 登录凭证(可留空,留空时自动登录)
- photo: 打卡照片文件路径相对于脚本目录默认photo.txt
- enabled: 是否启用该账号(环境变量模式下默认全部启用)
- 多个账号用 & 分隔
- 配置文件优先级高于环境变量
"""
import requests
import time
import os
import sys
import logging
import json
from datetime import datetime
from typing import Dict, Optional, Tuple, List
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# 添加父目录到路径,以便导入通知模块
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 导入通知模块
try:
from notify import send as notify_send
NOTIFY_ENABLED = True
except ImportError:
try:
from sendNotify import send as notify_send
NOTIFY_ENABLED = True
except ImportError:
NOTIFY_ENABLED = False
def notify_send(title, content):
pass
# 配置日志(青龙面板只输出到控制台)
logging.basicConfig(
level=logging.INFO,
format='%(message)s',
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
class AccountConfig:
"""账号配置类"""
def __init__(self, name: str, token: str, photo: str = "photo.txt", enabled: bool = True):
self.name = name
self.token = token
self.photo = photo
self.enabled = enabled
@classmethod
def from_dict(cls, data: Dict) -> 'AccountConfig':
"""从字典创建配置"""
return cls(
name=data.get('name', ''),
token=data.get('token', ''),
photo=data.get('photo', 'photo.txt'),
enabled=data.get('enabled', True)
)
@classmethod
def from_env_string(cls, env_str: str) -> 'AccountConfig':
"""从环境变量字符串创建配置
支持两种格式:
1. 旧格式: token#备注名
2. 新格式: 备注名#token#photo文件名
"""
parts = env_str.split('#')
if len(parts) >= 3:
# 新格式: 备注名#token#photo文件名
name = parts[0].strip()
token = parts[1].strip()
photo = parts[2].strip()
return cls(name=name, token=token, photo=photo)
elif len(parts) == 2:
# 旧格式: token#备注名
token = parts[0].strip()
name = parts[1].strip()
return cls(name=name, token=token)
else:
# 只有token
token = parts[0].strip()
return cls(name="", token=token)
class ConfigLoader:
"""配置加载器"""
@staticmethod
def load_from_file(file_path: str = "accounts.json") -> List[AccountConfig]:
"""从配置文件加载账号"""
try:
if not os.path.exists(file_path):
return []
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
accounts = []
for account_data in data.get('accounts', []):
account = AccountConfig.from_dict(account_data)
# 只要账号启用就加载token 可以为空(会自动登录)
if account.enabled:
accounts.append(account)
return accounts
except Exception as e:
logger.error(f"❌ 加载配置文件失败: {str(e)}")
return []
@staticmethod
def load_from_env() -> List[AccountConfig]:
"""从环境变量加载账号"""
env_value = os.getenv('hxza_dk', '')
if not env_value:
return []
accounts = []
# 支持 & 分隔多个账号
for account_str in env_value.split('&'):
account_str = account_str.strip()
if account_str:
accounts.append(AccountConfig.from_env_string(account_str))
return accounts
@staticmethod
def load_accounts() -> List[AccountConfig]:
"""加载所有账号配置(配置文件优先)"""
# 优先从配置文件加载
accounts = ConfigLoader.load_from_file()
# 如果配置文件为空,尝试从环境变量加载
if not accounts:
accounts = ConfigLoader.load_from_env()
return accounts
class AutoCheckIn:
"""自动打卡类"""
def __init__(self, account: AccountConfig):
self.account = account
self.base_url = "https://erp.baian.tech/baian-admin"
self.token = account.token
self.remark = account.name
self.photo_file = account.photo
# 配置带重试的 session
self.session = self._create_session()
# 动态获取的用户信息
self.user_name = None
self.user_mobile = None
# 动态获取的班次时间
self.start_time = None
self.end_time = None
self.is_cross_day = False
# 打卡状态信息(从接口获取)
self.is_card_time = False # 是否在打卡时间内
self.checkin_status = None # 上班打卡状态 (0=未打卡, 1=已打卡)
self.checkin_time = None # 上班实际打卡时间
self.checkout_status = None # 下班打卡状态 (0=未打卡, 1=已打卡)
self.checkout_time = None # 下班实际打卡时间
# 固定参数
self.app_id = "wxa81d5d83880ea6b6"
# 动态参数(从班次信息接口获取)
self.item_id = None
self.job_date_id = None
self.job_id = None
self.address_name = None
self.longitude = None
self.latitude = None
self.headers = {
'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': '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',
'Referer': f"https://servicewechat.com/{self.app_id}/102/page-frame.html"
}
def _create_session(self) -> requests.Session:
"""创建带重试机制的 session"""
session = requests.Session()
retry = Retry(
total=3,
backoff_factor=1,
status_forcelist=[500, 502, 503, 504],
allowed_methods=["POST", "GET"]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount('https://', adapter)
session.mount('http://', adapter)
return session
def _check_token_valid(self) -> bool:
"""检查 token 是否有效"""
try:
# 尝试获取用户信息来验证 token
url = f"{self.base_url}/userH5/getMyDetails"
response = self.session.get(url, headers=self.headers, timeout=15)
if response.status_code != 200:
return False
result = response.json()
return result.get('code') == 200
except Exception:
return False
def _update_accounts_json(self, new_token: str, config_file: str = "accounts.json") -> bool:
"""
更新 accounts.json 配置文件中的 token
Args:
new_token: 新的 token
config_file: 配置文件路径
Returns:
成功返回 True失败返回 False
"""
try:
if not os.path.exists(config_file):
logger.warning(f"⚠️ 配置文件不存在: {config_file}")
return False
logger.info(f"\n📝 正在更新配置文件...")
logger.info(f" 📄 文件: {config_file}")
# 读取配置文件
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
# 查找并更新当前账号的 token
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}] 的配置")
return False
# 写回配置文件
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
logger.info(f"✅ 配置文件已更新")
logger.info(f" 账号: {self.account.name}")
logger.info(f" Token: {new_token[:20]}...{new_token[-10:]}")
return True
except PermissionError:
logger.warning("⚠️ 没有权限写入配置文件")
return False
except Exception as e:
logger.warning(f"⚠️ 更新配置文件失败: {str(e)}")
return False
def _auto_login(self) -> bool:
"""
使用照片自动登录获取新 token
Returns:
成功返回 True失败返回 False
"""
try:
logger.info("🔍 正在读取照片文件...")
# 读取照片文件
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(f"❌ 照片文件为空")
return False
logger.info(f"✅ 照片文件读取成功")
# 发送登录请求
logger.info("🔐 正在发送登录请求...")
url = f"{self.base_url}/recognition/faceSearch"
data = {"image": image_data}
headers = {
'Host': 'erp.baian.tech',
'Connection': 'keep-alive',
'appId': self.app_id,
'content-type': 'application/json',
'wxAppKeyCompanyId': self.app_id,
'token': '', # 登录时不需要 token
'Accept-Encoding': 'gzip,compress,br,deflate',
'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 Language/zh_CN',
'Referer': f"https://servicewechat.com/{self.app_id}/102/page-frame.html"
}
response = self.session.post(
url,
json=data,
headers=headers,
timeout=30
)
if response.status_code != 200:
logger.error(f"❌ 登录请求失败: HTTP {response.status_code}")
return False
result = response.json()
if result.get('code') != 200:
logger.error(f"❌ 登录失败: {result.get('msg')}")
return False
# 解析返回数据
data = result.get('data', {})
token = data.get('token')
expire = data.get('expire') # 秒数1296000 = 15天
if not token:
logger.error(f"❌ 未获取到 token")
return False
# 更新 token
self.token = token
self.account.token = token
self.headers['token'] = token
# 计算过期时间
from datetime import timedelta
expire_date = datetime.now() + timedelta(seconds=expire)
expire_str = expire_date.strftime('%Y-%m-%d %H:%M:%S')
logger.info(f"✅ 自动登录成功!")
logger.info(f" 🎫 Token: {token[:20]}...{token[-10:]}")
logger.info(f" ⏰ 有效期: {expire // 86400}")
logger.info(f" 📅 过期时间: {expire_str}")
# 尝试更新配置文件
self._update_accounts_json(token)
return True
except Exception as e:
logger.error(f"❌ 自动登录异常: {str(e)}")
return False
def _fetch_user_info(self) -> bool:
"""获取用户信息(姓名、手机号等)"""
try:
url = f"{self.base_url}/userH5/getMyDetails"
response = self.session.get(url, headers=self.headers, timeout=15)
if response.status_code != 200:
logger.error(f"❌ 获取用户信息失败: HTTP {response.status_code}")
return False
result = response.json()
if result.get('code') != 200:
logger.error(f"❌ 获取用户信息失败: {result.get('msg')}")
return False
data = result.get('data', {})
self.user_name = data.get('name', '')
self.user_mobile = data.get('mobile', '')
return True
except Exception as e:
logger.error(f"❌ 获取用户信息异常: {str(e)}")
return False
def _fetch_job_details(self) -> bool:
"""获取班次信息itemId、jobDateId、经纬度、班次时间等"""
try:
url = f"{self.base_url}/Management/getJobUserDetails"
params = {"userId": ""}
response = self.session.get(url, params=params, headers=self.headers, timeout=15)
if response.status_code != 200:
logger.error(f"❌ 获取班次信息失败: HTTP {response.status_code}")
return False
result = response.json()
if result.get('code') != 200:
logger.error(f"❌ 获取班次信息失败: {result.get('msg')}")
return False
data = result.get('data', {})
# 提取 itemId 和 jobId
self.item_id = data.get('itemId')
self.job_id = data.get('jobId')
# 提取是否在打卡时间内
self.is_card_time = data.get('isCardTime', False)
# 提取班次时段信息
job_date_dtos = data.get('jobDateDTOS', [])
if job_date_dtos:
job_date = job_date_dtos[0]
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', [])
if address_dtos:
address = address_dtos[0]
self.address_name = address.get('addressName')
self.longitude = float(address.get('longitude'))
self.latitude = float(address.get('latitude'))
# 验证必需参数
if not all([self.item_id, self.job_date_id, self.address_name,
self.longitude, self.latitude, self.start_time, self.end_time]):
logger.error(f"❌ 班次信息不完整")
return False
return True
except Exception as e:
logger.error(f"❌ 获取班次信息异常: {str(e)}")
return False
def initialize(self) -> bool:
"""初始化账号信息"""
display_name = self.remark if self.remark else "未命名"
logger.info(f"\n{'='*50}")
logger.info(f"🔄 正在初始化账号: {display_name}")
logger.info(f"{'='*50}")
# 检查 Token 有效性
if not self.token or not self._check_token_valid():
if not self.token:
logger.warning("⚠️ Token 为空")
else:
logger.warning("⚠️ Token 无效或已过期")
logger.info("🔐 尝试自动登录...")
# 尝试自动登录
if not self._auto_login():
logger.error("❌ 自动登录失败")
return False
if not self._fetch_user_info():
return False
if not self._fetch_job_details():
return False
# 显示账号信息
display_name = self.remark if self.remark else self.user_name
logger.info(f"\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 _request_with_retry(self, url: str, data: Dict, checkin_type: str, max_retries: int = 3) -> Optional[Dict]:
"""带重试的网络请求"""
for attempt in range(1, max_retries + 1):
try:
response = self.session.post(url, json=data, headers=self.headers, timeout=15)
if response.status_code == 200:
result = response.json()
if result.get('code') == 200:
return result
else:
logger.error(f"{result.get('msg')}")
return None
else:
if attempt < max_retries:
logger.warning(f"⚠️ HTTP {response.status_code}, 重试 {attempt}/{max_retries}")
time.sleep(2 ** attempt)
continue
return None
except requests.exceptions.Timeout:
if attempt < max_retries:
logger.warning(f"⏱️ 超时, 重试 {attempt}/{max_retries}")
time.sleep(2 ** attempt)
continue
return None
except requests.exceptions.RequestException as e:
if attempt < max_retries:
logger.warning(f"⚠️ {str(e)}, 重试 {attempt}/{max_retries}")
time.sleep(2 ** attempt)
continue
return None
logger.error(f"❌ 请求失败")
return None
def _time_to_minutes(self, time_str: str) -> int:
"""时间字符串转为分钟数"""
h, m = map(int, time_str.split(':'))
return h * 60 + m
def _minutes_to_time(self, 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]]:
"""根据班次时间,自动计算打卡时间窗口"""
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)
def _is_in_window(self, current_minutes: int, start: int, end: int) -> bool:
"""检查当前时间是否在窗口内"""
if start <= end:
return start <= current_minutes <= end
else:
return current_minutes >= start or current_minutes <= end
def _get_current_checkin_type(self) -> Optional[str]:
"""
智能判断应该执行哪种打卡
判断逻辑:
1. 必须在打卡时间内 (isCardTime == true)
2. 检查当前时间是否在对应的打卡窗口内
3. 如果 status == 0 或 date == null 且在上班窗口 → 执行上班打卡
4. 如果 status == 1 且 (statusOff == 0 或 dateOff == null) 且在下班窗口 → 执行下班打卡
5. 如果两次都已打卡 → 返回 'already_done'
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):
"""显示打卡时间窗口和打卡状态信息"""
(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(f"\n📅 打卡时间窗口:")
logger.info(f" 🌅 上班: {first_start_str} - {first_end_str}")
logger.info(f" 🌆 下班: {second_start_str} - {second_end_str}")
# 显示打卡状态
logger.info(f"\n📊 打卡状态:")
# 上班打卡状态
if self.checkin_status == 1 and self.checkin_time:
logger.info(f" ✅ 上班打卡: 已完成 ({self.checkin_time})")
else:
logger.info(f" ⏰ 上班打卡: 未完成")
# 下班打卡状态
if self.checkout_status == 1 and self.checkout_time:
logger.info(f" ✅ 下班打卡: 已完成 ({self.checkout_time})")
else:
logger.info(f" ⏰ 下班打卡: 未完成")
# 显示当前时间和状态
now = datetime.now()
current_time_str = now.strftime('%H:%M:%S')
logger.info(f"\n🕐 当前时间: {current_time_str}")
if self.is_card_time:
logger.info(f" ✅ 当前在打卡时间内")
else:
logger.info(f" ⏰ 当前不在打卡时间内")
def face_detect(self, checkin_type: str) -> Optional[Dict]:
"""人脸识别验证"""
try:
url = f"{self.base_url}/recognition/faceDetect"
if not os.path.exists(self.photo_file):
logger.error(f"❌ 照片文件不存在: {self.photo_file}")
return None
with open(self.photo_file, 'r', encoding='utf-8') as f:
image_data = f.read().strip()
data = {"image": image_data}
return self._request_with_retry(url, data, checkin_type)
except Exception as e:
logger.error(f"❌ 人脸识别异常: {str(e)}")
return None
def save_attendance(self, checkin_type: str) -> bool:
"""保存打卡记录"""
try:
url = f"{self.base_url}/Management/saveAttendanceManagement"
with open(self.photo_file, 'r', encoding='utf-8') as f:
image_data = f.read().strip()
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": image_data,
"longitude": self.longitude,
"latitude": self.latitude,
"userId": ""
}
result = self._request_with_retry(url, data, checkin_type)
return bool(result)
except Exception as e:
logger.error(f"❌ 保存记录异常: {str(e)}")
return False
def do_checkin(self, checkin_type: str) -> Tuple[bool, str]:
"""执行打卡流程,返回(成功与否, 结果消息)"""
checkin_name = '上班' if checkin_type == 'first' else '下班'
checkin_emoji = '🌅' if checkin_type == 'first' else '🌆'
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
display_name = self.remark if self.remark else self.user_name
(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📱 {self.user_mobile}\n📍 {self.address_name}\n{self.start_time}-{self.end_time}{' (跨天)' if self.is_cross_day else ''}\n\n🌅 上班: {first_window}\n🌆 下班: {second_window}\n\n"
logger.info(f"\n{checkin_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}成功"
else:
logger.error(f"❌ [{display_name}] {checkin_name}打卡失败")
return False, f"{msg_header}{current_time}\n{checkin_name}失败"
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(description='小程序自动打卡脚本 - 多账号版本')
parser.add_argument('--type', choices=['first', 'second'], help='立即执行指定类型的打卡')
parser.add_argument('--config', default='accounts.json', help='配置文件路径')
args = parser.parse_args()
try:
# 加载所有账号配置
accounts = ConfigLoader.load_accounts()
if not accounts:
error_msg = "❌ 未找到任何账号配置,请检查 accounts.json 或环境变量 hxza_dk"
logger.error(error_msg)
if NOTIFY_ENABLED:
notify_send("❌ 小程序打卡配置错误", error_msg)
exit(1)
logger.info(f"\n📋 共加载 {len(accounts)} 个账号")
# 统计结果
total_count = len(accounts)
success_count = 0
fail_count = 0
skip_count = 0
all_messages = []
# 循环处理每个账号
for i, account in enumerate(accounts, 1):
try:
logger.info(f"\n{'='*60}")
logger.info(f"📌 处理账号 {i}/{total_count}")
logger.info(f"{'='*60}")
checkin = AutoCheckIn(account)
if not checkin.initialize():
fail_count += 1
display_name = account.name if account.name else "未命名"
all_messages.append(f"❌ [{display_name}] 初始化失败")
continue
success = False
msg = ""
if args.type:
# 强制执行指定类型的打卡(忽略智能判断)
logger.info(f"\n⚠️ 强制执行 {'上班' if args.type == 'first' else '下班'} 打卡")
success, msg = checkin.do_checkin(args.type)
else:
# 智能判断打卡类型
checkin_type = checkin._get_current_checkin_type()
if checkin_type == 'first':
# 需要上班打卡
logger.info(f"\n🎯 智能判断: 需要执行上班打卡")
success, msg = checkin.do_checkin('first')
elif checkin_type == 'second':
# 需要下班打卡
logger.info(f"\n🎯 智能判断: 需要执行下班打卡")
success, msg = checkin.do_checkin('second')
elif checkin_type == 'already_done':
# 已完成所有打卡 - 只记录日志,不生成通知消息
skip_count += 1
display_name = account.name if account.name else checkin.user_name
logger.info(f"\n✅ [{display_name}] 今日打卡已全部完成")
logger.info(f" 上班打卡: {checkin.checkin_time}")
logger.info(f" 下班打卡: {checkin.checkout_time}")
# 不设置 msg跳过的账号不加入通知消息
else:
# 不在打卡时间内 - 只记录日志,不生成通知消息
skip_count += 1
display_name = account.name if account.name else checkin.user_name
logger.info(f"\n⏰ [{display_name}] 当前不在打卡时间内")
# 不设置 msg跳过的账号不加入通知消息
if success:
success_count += 1
# 只有成功和失败的消息才加入通知列表
if msg:
all_messages.append(msg)
elif msg:
# 打卡失败
fail_count += 1
all_messages.append(msg)
# 账号之间间隔,避免请求过快
if i < total_count:
time.sleep(2)
except Exception as e:
fail_count += 1
display_name = account.name if account.name else "未命名"
error_msg = f"❌ [{display_name}] 处理异常: {str(e)}"
logger.error(error_msg)
all_messages.append(error_msg)
# 汇总结果
logger.info(f"\n{'='*60}")
logger.info(f"📊 执行结果汇总")
logger.info(f"{'='*60}")
logger.info(f" 总账号数: {total_count}")
logger.info(f" ✅ 成功: {success_count}")
logger.info(f" ❌ 失败: {fail_count}")
logger.info(f" ⏰ 跳过: {skip_count}")
# 发送汇总通知
if NOTIFY_ENABLED:
# 构建统计摘要
summary = f"📊 总计: {total_count} | ✅ 成功: {success_count} | ❌ 失败: {fail_count} | ⏰ 跳过: {skip_count}"
# 如果有详细消息,追加到摘要后面
if all_messages:
summary += "\n\n" + "\n\n".join(all_messages)
# 根据结果确定通知标题
if fail_count > 0:
title = f"⚠️ 小程序打卡完成 (有失败)"
elif success_count > 0:
title = f"✅ 小程序打卡完成"
elif skip_count == total_count:
title = f"⏰ 小程序打卡提醒 (全部跳过)"
else:
title = f"📊 小程序打卡执行完成"
notify_send(title, summary)
logger.info(f"\n📢 通知已发送: {title}")
else:
logger.warning("\n⚠️ 通知模块未启用,跳过通知推送")
# 如果有失败的账号,返回失败状态
exit(0 if fail_count == 0 else 1)
except KeyboardInterrupt:
logger.info("\n⚠️ 用户中断程序")
exit(1)
except Exception as e:
error_msg = f"❌ 程序异常: {str(e)}"
logger.error(error_msg)
if NOTIFY_ENABLED:
notify_send("❌ 小程序打卡异常", error_msg)
exit(1)
if __name__ == '__main__':
main()