mirror of
https://github.com/XiaoGe-LiBai/yangmao.git
synced 2025-12-17 03:58:13 +08:00
820 lines
30 KiB
Python
820 lines
30 KiB
Python
"""
|
||
小程序自动打卡脚本 - 多账号版本(优化版)
|
||
|
||
主要改动:
|
||
- 支持自定义配置文件路径,全程使用同一配置文件(含自动登录后 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())
|