mirror of
https://github.com/XiaoGe-LiBai/yangmao.git
synced 2025-12-17 03:58:13 +08:00
933 lines
34 KiB
Python
933 lines
34 KiB
Python
"""
|
||
小程序自动打卡脚本 - 多账号版本
|
||
|
||
=== 配置说明 ===
|
||
|
||
方式一:配置文件(推荐)
|
||
在 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()
|