mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
init
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
__pycache__
|
||||
chromedriver
|
||||
chrome-linux64
|
||||
debug_page_source.html
|
||||
debug_screenshot.png
|
||||
sessions
|
||||
*.lock
|
||||
*.log
|
||||
*.pid
|
||||
@@ -0,0 +1,33 @@
|
||||
# Jielong
|
||||
|
||||
Run: `sh start.sh` and get usage(Linux only).
|
||||
|
||||
## Environment
|
||||
|
||||
Use `python3 -m venv venv` to create a virtual environment.
|
||||
|
||||
Then activate the venv, use `pip install -r .\requirements.txt` to install dependencies.
|
||||
|
||||
Note that you have to ensure the CHROME_BINARY_PATH and CHROMEDRIVER_PATH in `shared_config.py` correctly.
|
||||
|
||||
Also, if you want the email notification(to notify the expiration of token) works, you need to check `config.ini`.
|
||||
|
||||
## Details
|
||||
|
||||
Please edit the config and payload by yourself to meet your actual need.
|
||||
|
||||
### JieLong Payload
|
||||
|
||||
Jielong Payload: `payload` in the `try-catch` block of `perform_check_in()` function in `check_in_worker.py`
|
||||
|
||||
You may use [Reqable](https://reqable.com/) or other tools to get the payload of JieLong. (Tips: Catch the request of "EditRecord" and you will find what you want.)
|
||||
|
||||
### Config
|
||||
|
||||
APScheduler config: `app.py`
|
||||
|
||||
Other config: `shared_config.py`
|
||||
|
||||
JieLong Token data: `config.csv`
|
||||
|
||||
Email notification config: `config.ini`
|
||||
@@ -0,0 +1,361 @@
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import configparser
|
||||
import csv
|
||||
from datetime import datetime, timezone
|
||||
from filelock import FileLock, Timeout
|
||||
from flask import Flask, render_template, request, jsonify
|
||||
import json
|
||||
import jwt
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
import atexit
|
||||
|
||||
# 导入其他模块
|
||||
from shared_config import (
|
||||
CONFIG_PATH, LOG_PATH, SCHEDULER_LOCK, SESSIONS_DIR, CONFIG_INI_PATH,
|
||||
CONFIG_FILE_LOCK, CSV_FIELDNAMES, CHECKIN_HOUR, CHECKIN_MIN
|
||||
)
|
||||
from token_refresher import get_token_headless
|
||||
from email_notifier import notification_worker_loop
|
||||
from check_in_worker import perform_check_in
|
||||
|
||||
# --- Flask App 设置 ---
|
||||
app = Flask(__name__)
|
||||
|
||||
# 1. 定义日志格式和处理器
|
||||
log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(name)s] - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||
log_handler = RotatingFileHandler(LOG_PATH, mode='a', maxBytes=5*1024*1024, backupCount=5, encoding='utf-8')
|
||||
log_handler.setFormatter(log_formatter)
|
||||
|
||||
# 2. 获取并配置根记录器 (root logger)
|
||||
# 这是关键:所有模块通过 getLogger(__name__) 创建的子记录器,都会将日志传递给根记录器
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(log_handler)
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
# 3. 移除Flask默认的handler,防止日志重复
|
||||
app.logger.handlers.clear()
|
||||
app.logger.propagate = True # 确保app.logger也将日志传递给根记录器
|
||||
|
||||
# 4. 将werkzeug的日志也重定向到我们的文件
|
||||
werkzeug_logger = logging.getLogger('werkzeug')
|
||||
werkzeug_logger.propagate = True # 确保werkzeug日志也传递给根记录器
|
||||
|
||||
# --- 辅助函数 ---
|
||||
def read_configs():
|
||||
"""加固后的读取函数,增加日志,处理空文件和不存在的情况"""
|
||||
with CONFIG_FILE_LOCK:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
app.logger.warning(f"配置文件 {CONFIG_PATH} 不存在,将返回空列表。")
|
||||
return []
|
||||
try:
|
||||
with open(CONFIG_PATH, mode='r', encoding='utf-8-sig') as file:
|
||||
content = file.read().strip()
|
||||
if not content:
|
||||
app.logger.warning(f"配置文件 {CONFIG_PATH} 为空,返回空列表。")
|
||||
return []
|
||||
file.seek(0)
|
||||
reader = csv.DictReader(file)
|
||||
rows = list(reader)
|
||||
app.logger.info(f"成功从 {CONFIG_PATH} 读取 {len(rows)} 条配置。")
|
||||
return rows
|
||||
except Exception as e:
|
||||
app.logger.error(f"读取config.csv时出错: {e}")
|
||||
return []
|
||||
|
||||
def write_configs(rows):
|
||||
"""加固后的写入函数,始终使用全局列名"""
|
||||
with CONFIG_FILE_LOCK:
|
||||
try:
|
||||
with open(CONFIG_PATH, 'w', encoding='utf-8-sig', newline='') as file:
|
||||
writer = csv.DictWriter(file, fieldnames=CSV_FIELDNAMES)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
app.logger.info(f"成功将 {len(rows)} 条配置写入到 {CONFIG_PATH}。")
|
||||
except Exception as e:
|
||||
app.logger.error(f"写入config.csv时出错: {e}")
|
||||
|
||||
def append_new_config(new_row_dict):
|
||||
"""只在文件末尾追加一行新配置,更安全"""
|
||||
with CONFIG_FILE_LOCK:
|
||||
# 检查文件是否存在或为空,如果为空,则先写入标题行
|
||||
file_exists = os.path.exists(CONFIG_PATH)
|
||||
is_empty = not file_exists or os.path.getsize(CONFIG_PATH) == 0
|
||||
|
||||
try:
|
||||
with open(CONFIG_PATH, 'a', encoding='utf-8-sig', newline='') as file:
|
||||
writer = csv.DictWriter(file, fieldnames=CSV_FIELDNAMES)
|
||||
if is_empty:
|
||||
writer.writeheader()
|
||||
writer.writerow(new_row_dict)
|
||||
app.logger.info(f"成功追加新配置到 {CONFIG_PATH}。")
|
||||
except Exception as e:
|
||||
app.logger.error(f"追加新配置到 {CONFIG_PATH} 时失败: {e}")
|
||||
|
||||
def is_token_expired(exp_timestamp):
|
||||
if not exp_timestamp or not exp_timestamp.isdigit(): return True
|
||||
return datetime.now(timezone.utc).timestamp() > int(exp_timestamp)
|
||||
|
||||
def cleanup_stale_sessions():
|
||||
"""后台任务:清理超过10分钟未完成的刷新会话,运行一次后退出。"""
|
||||
# 使用 app.logger 来记录日志
|
||||
app.logger.info("Scheduler: 正在执行过期会话清理任务...")
|
||||
try:
|
||||
now = time.time()
|
||||
cleared_count = 0
|
||||
for filename in os.listdir(SESSIONS_DIR):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(SESSIONS_DIR, filename)
|
||||
file_time = os.path.getmtime(filepath)
|
||||
if now - file_time > 600: # 超过10分钟
|
||||
os.remove(filepath)
|
||||
cleared_count += 1
|
||||
if cleared_count > 0:
|
||||
app.logger.info(f"Scheduler: 成功清理了 {cleared_count} 个过期的会话文件。")
|
||||
except Exception as e:
|
||||
app.logger.error(f"Scheduler: 清理会话文件时出错: {e}")
|
||||
|
||||
def run_all_checkins(triggered_by="Scheduler"):
|
||||
try:
|
||||
app.logger.info(f"开始执行一轮打卡任务 (触发源: {triggered_by})...")
|
||||
configs = read_configs()
|
||||
if not configs:
|
||||
app.logger.warning("配置文件为空,跳过本轮打卡。")
|
||||
return
|
||||
|
||||
email_settings = None
|
||||
if os.path.exists(CONFIG_INI_PATH):
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(CONFIG_INI_PATH)
|
||||
if 'Email' in config_parser:
|
||||
email_settings = config_parser['Email']
|
||||
|
||||
for config in configs:
|
||||
auth_token = config.get('Authorization')
|
||||
if auth_token:
|
||||
perform_check_in(config)
|
||||
else:
|
||||
signature = config.get('Signature', '未知用户')
|
||||
app.logger.warning(f"用户 {signature} 的 'Authorization' Token 为空,已跳过打卡。")
|
||||
app.logger.debug(f" 该用户的完整配置为: {config}")
|
||||
|
||||
app.logger.info(f"本轮打卡任务已全部提交。")
|
||||
except Exception as e:
|
||||
# --- 这是关键的顶层异常捕获 ---
|
||||
app.logger.critical(f"执行 'run_all_checkins' 时发生未捕获的严重错误: {e}", exc_info=True)
|
||||
|
||||
# --- 路由 / API ---
|
||||
@app.route('/')
|
||||
def index():
|
||||
configs = read_configs()
|
||||
for config in configs:
|
||||
config['show_qrcode'] = is_token_expired(config.get('jwt_exp'))
|
||||
return render_template('index.html', configs=configs)
|
||||
|
||||
@app.route('/request_qrcode', methods=['POST'])
|
||||
def request_qrcode():
|
||||
# 1. 从POST请求的JSON body中获取signature
|
||||
data = request.json
|
||||
signature = data.get('signature')
|
||||
if not signature:
|
||||
return jsonify({'status': 'error', 'message': 'Signature is required'}), 400
|
||||
|
||||
# 2. 使用 signature 构建 session_id,以确保唯一性
|
||||
session_id = f"{signature}_{int(time.time())}"
|
||||
|
||||
# 启动线程时,只传递 session_id,这与你的 token_refresher.py 匹配
|
||||
threading.Thread(target=get_token_headless, args=(session_id,)).start()
|
||||
return jsonify({'status': 'success', 'session_id': session_id})
|
||||
|
||||
@app.route('/get_qrcode_image/<session_id>')
|
||||
def get_qrcode_image(session_id):
|
||||
session_filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json")
|
||||
for _ in range(30):
|
||||
if os.path.exists(session_filepath):
|
||||
try:
|
||||
with open(session_filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('qr_image_data'):
|
||||
return jsonify({'status': 'success', 'image_data': data['qr_image_data']})
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
app.logger.error(f"读取会话文件 {session_filepath} 失败: {e}")
|
||||
time.sleep(1)
|
||||
return jsonify({'status': 'error', 'message': '获取二维码超时'}), 408
|
||||
|
||||
@app.route('/check_refresh_status/<session_id>')
|
||||
def check_refresh_status(session_id):
|
||||
session_filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json")
|
||||
if not os.path.exists(session_filepath):
|
||||
return jsonify({'status': 'waiting'})
|
||||
|
||||
try:
|
||||
with open(session_filepath, 'r', encoding='utf-8') as f:
|
||||
session = json.load(f)
|
||||
status = session.get('status')
|
||||
|
||||
if status == 'success':
|
||||
signature_to_update = session_id.split('_')[0]
|
||||
|
||||
raw_token_value = session['token']
|
||||
pure_jwt = unquote(raw_token_value)
|
||||
if pure_jwt.lower().startswith('bearer '):
|
||||
pure_jwt = pure_jwt[7:]
|
||||
|
||||
new_exp, new_sub = '0', ''
|
||||
try:
|
||||
decoded = jwt.decode(pure_jwt, options={"verify_signature": False})
|
||||
new_exp, new_sub = decoded.get('exp', '0'), decoded.get('sub', '')
|
||||
app.logger.info(f"成功解码JWT for sub {new_sub}, exp: {new_exp}")
|
||||
except Exception as e:
|
||||
app.logger.error(f"解码新的JWT时失败: {e}")
|
||||
|
||||
rows = read_configs()
|
||||
is_updated = False
|
||||
for row in rows:
|
||||
# 使用 signature 来查找要更新的行
|
||||
if row['Signature'] == signature_to_update:
|
||||
row['Authorization'], row['jwt_exp'], row['jwt_sub'] = pure_jwt, new_exp, new_sub
|
||||
is_updated = True
|
||||
# 找到并更新后,可以跳出循环,提高效率
|
||||
break
|
||||
|
||||
if not is_updated:
|
||||
app.logger.error(f"严重错误:在更新Token时,未在config.csv中找到Signature {signature_to_update}")
|
||||
|
||||
write_configs(rows)
|
||||
|
||||
os.remove(session_filepath) # 成功后删除临时文件
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
elif status == 'error':
|
||||
message = session.get('message', '未知错误')
|
||||
os.remove(session_filepath) # 失败后也删除
|
||||
return jsonify({'status': 'error', 'message': message})
|
||||
|
||||
return jsonify({'status': status}) # e.g., 'waiting_scan'
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"检查状态时出错 {session_filepath}: {e}")
|
||||
return jsonify({'status': 'error', 'message': '读取状态文件失败'})
|
||||
|
||||
@app.route('/create_user', methods=['POST'])
|
||||
def create_user():
|
||||
data = request.json
|
||||
rows = read_configs() # 读取所有现有用户
|
||||
|
||||
# 1. 使用 Signature 检查用户是否已存在
|
||||
for row in rows:
|
||||
if row['Signature'] == data['Signature']:
|
||||
app.logger.warning(f"尝试添加已存在的用户: Signature {data['Signature']}")
|
||||
# 如果用户已存在,直接为他请求二维码,而不是重复添加
|
||||
# 这需要模拟 request_qrcode 的逻辑
|
||||
signature = data['Signature']
|
||||
session_id = f"{signature}_{int(time.time())}"
|
||||
threading.Thread(target=get_token_headless, args=(session_id,)).start()
|
||||
return jsonify({'status': 'success', 'session_id': session_id})
|
||||
|
||||
# 创建一个完整的新行
|
||||
new_row = {field: '' for field in CSV_FIELDNAMES}
|
||||
new_row.update({
|
||||
'ThreadId': data['ThreadId'],
|
||||
'Signature': data['Signature'],
|
||||
'Texts': data['Texts'],
|
||||
'Values': data['Values'],
|
||||
'email': data['Email'],
|
||||
'jwt_exp': '0'
|
||||
})
|
||||
append_new_config(new_row)
|
||||
|
||||
signature = data['Signature']
|
||||
session_id = f"{signature}_{int(time.time())}"
|
||||
threading.Thread(target=get_token_headless, args=(session_id,)).start()
|
||||
return jsonify({'status': 'success', 'session_id': session_id})
|
||||
|
||||
@app.route('/api/checkin_all', methods=['POST'])
|
||||
def trigger_checkin_all():
|
||||
"""API端点,用于手动触发所有用户的打卡"""
|
||||
try:
|
||||
# 在后台线程中运行,以防用户数量多时导致请求超时
|
||||
# 我们传递 "Manual Trigger" 来区分日志来源
|
||||
threading.Thread(target=run_all_checkins, args=("Manual Trigger",)).start()
|
||||
return jsonify({'status': 'success', 'message': '已成功触发全部重新打卡,请稍后在日志中查看结果。'})
|
||||
except Exception as e:
|
||||
app.logger.error(f"手动触发全部打卡时失败: {e}")
|
||||
return jsonify({'status': 'error', 'message': '触发失败,请查看服务器日志。'}), 500
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# APScheduler 后台任务调度
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 创建一个锁文件路径
|
||||
# 确保这个文件位于一个所有 worker 都能访问到的地方
|
||||
lock = FileLock(SCHEDULER_LOCK, timeout=5) # 设置5秒超时
|
||||
|
||||
try:
|
||||
# 尝试非阻塞地获取锁
|
||||
lock.acquire()
|
||||
|
||||
# --- 只有成功获取锁的进程才能执行以下代码 ---
|
||||
app.logger.info("Scheduler lock acquired by this process. Initializing scheduler...")
|
||||
|
||||
# 1. 初始化调度器
|
||||
scheduler = BackgroundScheduler(daemon=True, timezone='Asia/Shanghai')
|
||||
|
||||
# 2. 添加你的后台任务
|
||||
scheduler.add_job(
|
||||
func=run_all_checkins,
|
||||
trigger='cron',
|
||||
hour=CHECKIN_HOUR,
|
||||
minute=CHECKIN_MIN,
|
||||
id='daily_check_in_job',
|
||||
name='执行每日打卡任务',
|
||||
replace_existing=True
|
||||
)
|
||||
app.logger.info(f"已添加每日打卡任务,将在每天 {CHECKIN_HOUR}:{CHECKIN_MIN:02d} 执行。")
|
||||
|
||||
scheduler.add_job(
|
||||
func=cleanup_stale_sessions,
|
||||
trigger='interval',
|
||||
hours=24,
|
||||
id='cleanup_sessions_job',
|
||||
name='清理过期的会话文件',
|
||||
replace_existing=True
|
||||
)
|
||||
app.logger.info("已添加定期清理会话任务,每 24 小时执行一次。")
|
||||
|
||||
scheduler.add_job(
|
||||
func=notification_worker_loop,
|
||||
trigger='interval',
|
||||
minutes=30,
|
||||
id='token_expiry_notification_job',
|
||||
name='检查Token过期并发送邮件',
|
||||
replace_existing=True
|
||||
)
|
||||
app.logger.info("已添加Token过期检查任务,每 30 分钟执行一次。")
|
||||
|
||||
# 3. 启动调度器
|
||||
scheduler.start()
|
||||
app.logger.info("APScheduler 已成功启动。")
|
||||
|
||||
# 4. 注册一个应用退出时的回调函数,确保调度器被安全关闭
|
||||
# 这个也只在持有锁的进程中注册
|
||||
atexit.register(lambda: scheduler.shutdown())
|
||||
|
||||
except Timeout:
|
||||
# 如果获取锁超时,说明另一个进程已经启动了调度器
|
||||
app.logger.info("Could not acquire scheduler lock, another process is handling scheduling.")
|
||||
finally:
|
||||
# 确保在进程退出时释放锁 (尽管 with 语句通常能处理好)
|
||||
# lock.release() # 在这个场景下,锁应该由持有它的进程一直持有,所以不需要手动释放
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 确保sessions目录存在
|
||||
if not os.path.exists(SESSIONS_DIR):
|
||||
os.makedirs(SESSIONS_DIR)
|
||||
|
||||
app.run(debug=False, host='0.0.0.0', port=5000)
|
||||
@@ -0,0 +1,192 @@
|
||||
import requests
|
||||
import configparser
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
from shared_config import (
|
||||
CONFIG_INI_PATH, CONFIG_PATH, CONFIG_FILE_LOCK, get_logger,
|
||||
CHROME_BINARY_PATH, CHROMEDRIVER_PATH
|
||||
)
|
||||
from email_notifier import send_success_notification, send_failure_notification
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def read_configs():
|
||||
"""线程安全地读取配置文件"""
|
||||
with CONFIG_FILE_LOCK:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
return []
|
||||
with open(CONFIG_PATH, mode='r', encoding='utf-8-sig') as file:
|
||||
return list(csv.DictReader(file))
|
||||
|
||||
def get_live_x_api_payload(auth_token):
|
||||
"""
|
||||
启动一个临时的无头浏览器会话,只为了获取新鲜的 x-api-request-payload。
|
||||
"""
|
||||
logger.info("正在启动临时浏览器会话以监听网络日志...")
|
||||
|
||||
service = Service(executable_path=CHROMEDRIVER_PATH) if CHROMEDRIVER_PATH else Service()
|
||||
chrome_options = Options()
|
||||
chrome_options.binary_location = CHROME_BINARY_PATH
|
||||
|
||||
# --- 1. (最关键) 开启性能日志记录功能 ---
|
||||
# 这会让浏览器记录下所有的网络事件
|
||||
logging_prefs = {'performance': 'ALL'}
|
||||
chrome_options.set_capability('goog:loggingPrefs', logging_prefs)
|
||||
|
||||
# --- Headless模式配置 ---
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
chrome_options.add_argument(f'user-agent={user_agent}')
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
chrome_options.add_argument("--window-size=1920,1080")
|
||||
chrome_options.add_argument('--ignore-certificate-errors')
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
|
||||
payload_signature = None
|
||||
try:
|
||||
# 1. 导航到一个同源空白页,用于设置Cookie
|
||||
driver.get("https://i.jielong.com/my-class")
|
||||
|
||||
# 3. 注入我们的长期Token
|
||||
driver.add_cookie({
|
||||
'name': 'token',
|
||||
'value': auth_token,
|
||||
'domain': '.jielong.com'
|
||||
})
|
||||
|
||||
# 4. 导航到触发API的页面,这将产生网络日志
|
||||
driver.get("https://i.jielong.com/my-form")
|
||||
|
||||
# 5. 等待几秒,确保页面有足够的时间加载并发起API请求
|
||||
max_wait_time = 20 # 最多等待20秒
|
||||
start_time = time.time()
|
||||
found = False
|
||||
while time.time() - start_time < max_wait_time:
|
||||
logs = driver.get_log('performance')
|
||||
for entry in logs:
|
||||
log = json.loads(entry['message'])['message']
|
||||
if log['method'] == 'Network.requestWillBeSent':
|
||||
headers = log.get('params', {}).get('request', {}).get('headers', {})
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
if 'x-api-request-payload' in headers_lower:
|
||||
payload_signature = headers_lower['x-api-request-payload']
|
||||
logger.info("成功通过网络日志捕获到现场的 x-api-request-payload!")
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
time.sleep(1) # 每次轮询间隔1秒
|
||||
|
||||
if not payload_signature:
|
||||
raise Exception(f"在 {max_wait_time} 秒内未能通过网络日志捕获到 x-api-request-payload。")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取现场 x-api-request-payload 时失败: {e}")
|
||||
driver.save_screenshot(os.path.join(os.path.dirname(__file__), 'payload_debug.png'))
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
return payload_signature
|
||||
|
||||
def perform_check_in(config):
|
||||
logger.info(f"Selenium打卡: 正在为 Signature: {config['Signature']} 执行打卡...")
|
||||
|
||||
auth_token = config.get('Authorization')
|
||||
if not auth_token:
|
||||
logger.error(f"Signature: {config['Signature']} 的长期Token为空,跳过。")
|
||||
return
|
||||
|
||||
payload_signature = get_live_x_api_payload(auth_token)
|
||||
if not payload_signature:
|
||||
logger.error(f"Signature: {config['Signature']} 未能获取到现场签名,打卡中止。")
|
||||
return
|
||||
|
||||
email_settings = None
|
||||
if config.get('email'):
|
||||
if os.path.exists(CONFIG_INI_PATH):
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(CONFIG_INI_PATH)
|
||||
# 使用 .get() 安全访问
|
||||
if 'Email' in config_parser:
|
||||
email_settings = config_parser['Email']
|
||||
else:
|
||||
logger.warning("在 config.ini 中找不到 [Email] 配置段,无法发送邮件。")
|
||||
else:
|
||||
logger.warning("找不到 config.ini,无法发送邮件通知。")
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"Id": 0,
|
||||
"ThreadId": config['ThreadId'],
|
||||
"Number": "",
|
||||
"Signature": config['Signature'],
|
||||
"RecordValues": [{
|
||||
"FieldId": 1,
|
||||
"Values": [config['Values']],
|
||||
"Texts": [config['Texts']],
|
||||
"HasValue": True,
|
||||
"Scores": [],
|
||||
"Files": [],
|
||||
"MatrixValues": [],
|
||||
"CustomTableValues": [],
|
||||
"FillInMatrixFieldValues": [],
|
||||
"MatrixFormValues": []
|
||||
}],
|
||||
"DateTarget": "",
|
||||
"IsNeedManualAudit": False,
|
||||
"MinuteTarget": -1,
|
||||
"IsNameNumberComfirm": False
|
||||
}
|
||||
|
||||
headers = {
|
||||
'User-Agent': "Mozilla%2f5.0+(Linux%3b+Android+16%3b+wv)+AppleWebKit%2f537.36+(KHTML%2c+like+Gecko)+Chrome%2f142.0.0.0+Safari%2f537.36+QQ%2f9.2.30.31620+QQ%2fMiniApp",
|
||||
'Accept-Encoding': "gzip",
|
||||
'Content-Type': "application/json",
|
||||
'authorization': f"Bearer {auth_token}",
|
||||
'x-api-request-referer': "https://appservice.qq.com/1110276759",
|
||||
'x-api-request-payload': payload_signature,
|
||||
'referer': "https://appservice.qq.com/1110276759/8.10.1.7/page-frame.html",
|
||||
'platform': "qq",
|
||||
'x-api-request-mode': "cors",
|
||||
}
|
||||
|
||||
url = "https://api.jielong.com/api/CheckIn/EditRecord"
|
||||
response = requests.post(url, data=json.dumps(payload), headers=headers)
|
||||
response.raise_for_status()
|
||||
response_text = response.text
|
||||
logger.info(f"Signature: {config['Signature']} 打卡请求完成!响应: {response_text}")
|
||||
# logger.info(f"payload = {payload}")
|
||||
# logger.info(f"headers = {headers}")
|
||||
# logger.info(f"url = {url}")
|
||||
|
||||
# 判断响应内容
|
||||
if email_settings:
|
||||
if "打卡成功" in response_text:
|
||||
logger.info(f"检测到成功关键字,为 {config['Signature']} 发送成功邮件...")
|
||||
send_success_notification(config, email_settings)
|
||||
elif "QSfqFrHF0jbMZcd3DVuvf6k5HceMjOlDwzX1b/SJ4agLnRkO" in response_text: # 打卡失败附带的Data
|
||||
logger.warning(f"检测到登录失败关键字,为 {config['Signature']} 发送失败提醒邮件...")
|
||||
send_failure_notification(config, email_settings)
|
||||
|
||||
# 检查HTTP状态码,如果需要的话
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"为 Signature: {config['Signature']} 打卡时请求失败: {e}")
|
||||
if e.response is not None:
|
||||
logger.error(f" 响应状态码: {e.response.status_code}, 响应内容: {e.response.text}")
|
||||
return e.response.text # 同样返回响应文本
|
||||
return None # 请求彻底失败
|
||||
except Exception as e:
|
||||
logger.error(f"为 Signature: {config['Signature']} 打卡时发生未知错误: {e}")
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
ThreadId,Signature,Texts,Values,jwt_sub,Authorization,jwt_exp,email
|
||||
|
@@ -0,0 +1,6 @@
|
||||
[Email]
|
||||
SmtpServer = __YOUR_EMAIL_PROVIDER__
|
||||
SmtpPort = 465
|
||||
SenderEmail = __YOUR_EMAIL__
|
||||
# 重要提示:这里通常不是你的邮箱登录密码,而是邮箱服务商提供的“应用专用密码”或“授权码”
|
||||
SenderPassword = __YOUR_EMAIL_AUTH_CODE__
|
||||
@@ -0,0 +1,178 @@
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import time
|
||||
import csv
|
||||
import os
|
||||
import configparser
|
||||
|
||||
from shared_config import CONFIG_PATH, CONFIG_FILE_LOCK, get_logger, CONFIG_INI_PATH
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# --- 邮件模板 ---
|
||||
|
||||
EXPIRATION_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Token 到期通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #d9534f; }}
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #d9534f; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>注意!</h1>
|
||||
<div class="message">
|
||||
<p>{name},请注意!</p>
|
||||
<p>您的 <span class="important">token</span> 已经到期,请前往 <span class="important"><a href="http://localhost:5000">http://localhost:5000</a></span> 重新刷新您的 token,否则您的自动打卡功能将会失效。</p>
|
||||
<p><strong>到期时间:</strong> {exp_time}</p>
|
||||
</div>
|
||||
<p class="footer">邮件发送时间: {send_time}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
SUCCESS_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>打卡成功通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #5cb85c; }}
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #5cb85c; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>打卡成功!</h1>
|
||||
<div class="message">
|
||||
<p>{name},您好!</p>
|
||||
<p>系统已于 <span class="important">{send_time}</span> 成功为您完成自动打卡。</p>
|
||||
<p>您无需进行任何操作,此邮件仅作通知。</p>
|
||||
</div>
|
||||
<p class="footer">感谢您的使用!</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
FAILURE_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>打卡失败通知</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
|
||||
h1 {{ color: #d9534f; }} /* 红色标题 */
|
||||
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.important {{ font-weight: bold; color: #d9534f; }}
|
||||
.footer {{ font-size: 0.9em; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>通知:自动打卡失败!</h1>
|
||||
<div class="message">
|
||||
<p>{name},您好!</p>
|
||||
<p>系统于 <span class="important">{send_time}</span> 尝试为您自动打卡时失败。</p>
|
||||
<p><strong>失败原因:</strong> 服务器返回 "需要登录",这通常意味着您的 <span class="important">Token 已失效</span>。</p>
|
||||
<p><strong>请您立即前往 <span class="important"><a href="http://localhost:5000">http://localhost:5000</a></span> 刷新您的 Token,以确保后续打卡能够成功。</strong></p>
|
||||
</div>
|
||||
<p class="footer">感谢您的使用!</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def _send_email(to_email, subject, html_content, email_settings):
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = email_settings['senderemail']
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html_content, 'html', 'utf-8'))
|
||||
|
||||
with smtplib.SMTP_SSL(email_settings['smtpserver'], int(email_settings['smtpport'])) as server:
|
||||
server.login(email_settings['senderemail'], email_settings['senderpassword'])
|
||||
server.sendmail(msg["From"], msg["To"], msg.as_string())
|
||||
|
||||
logger.info(f"已成功向 {to_email} 发送邮件,主题: {subject}")
|
||||
except Exception as e:
|
||||
logger.error(f"向 {to_email} 发送邮件时失败: {e}")
|
||||
|
||||
def send_notification_email(user_config, email_settings):
|
||||
"""发送Token到期提醒邮件"""
|
||||
html = EXPIRATION_HTML_TEMPLATE.format(
|
||||
name=user_config["email"],
|
||||
exp_time=time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime(float(user_config["jwt_exp"]))),
|
||||
send_time=time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
)
|
||||
_send_email(user_config["email"], "接龙管家Token到期通知", html, email_settings)
|
||||
|
||||
def send_success_notification(user_config, email_settings):
|
||||
"""发送打卡成功通知邮件"""
|
||||
html = SUCCESS_HTML_TEMPLATE.format(
|
||||
name=user_config["email"],
|
||||
send_time=time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
)
|
||||
_send_email(user_config["email"], "自动打卡成功通知", html, email_settings)
|
||||
|
||||
def send_failure_notification(user_config, email_settings):
|
||||
"""发送打卡失败通知邮件"""
|
||||
html = FAILURE_HTML_TEMPLATE.format(
|
||||
name=user_config["email"],
|
||||
send_time=time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
||||
)
|
||||
_send_email(user_config["email"], "【紧急】自动打卡失败 - 需要刷新Token", html, email_settings)
|
||||
|
||||
def notification_worker_loop():
|
||||
"""后台任务:检查Token是否即将过期,并发送邮件提醒。单次运行。"""
|
||||
logger.info("Scheduler: 正在执行邮件过期通知检查...")
|
||||
config_ini_path = os.path.join(os.path.dirname(__file__), 'config.ini')
|
||||
|
||||
try:
|
||||
# 1. 读取邮件配置
|
||||
if not os.path.exists(config_ini_path):
|
||||
logger.warning("Scheduler: 找不到 config.ini,邮件通知功能将跳过。")
|
||||
return # 直接退出
|
||||
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(config_ini_path)
|
||||
if 'Email' not in config_parser:
|
||||
logger.warning("Scheduler: config.ini 中缺少 [Email] 部分,跳过。")
|
||||
return
|
||||
email_settings = config_parser['Email']
|
||||
|
||||
# 2. 线程安全地读取用户配置
|
||||
with CONFIG_FILE_LOCK:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
configs = []
|
||||
else:
|
||||
with open(CONFIG_PATH, mode='r', encoding='utf-8-sig') as file:
|
||||
configs = list(csv.DictReader(file))
|
||||
|
||||
# 3. 检查每个用户的Token是否即将过期
|
||||
now = time.time()
|
||||
for user in configs:
|
||||
if not user.get('jwt_exp') or not user.get('email'):
|
||||
continue
|
||||
|
||||
try:
|
||||
exp_time = float(user['jwt_exp'])
|
||||
# 检查是否在 30 分钟内过期,并且尚未发送过提醒(可选,防止重复发送)
|
||||
if 0 < (now - exp_time) < 1800:
|
||||
logger.info(f"{user['Signature']} 的Token过期,准备发送邮件...")
|
||||
send_notification_email(user, email_settings)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Scheduler: 跳过用户 {user['Signature']},因为jwt_exp格式不正确: {user['jwt_exp']}")
|
||||
continue
|
||||
logger.info("Scheduler: 邮件到期检查完成。")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: 邮件通知任务发生严重错误: {e}")
|
||||
@@ -0,0 +1,7 @@
|
||||
APScheduler==3.11.0
|
||||
filelock==3.20.0
|
||||
Flask==3.1.2
|
||||
PyJWT==2.10.1
|
||||
Requests==2.32.5
|
||||
selenium==4.38.0
|
||||
gunicorn==23.0.0
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
from filelock import FileLock
|
||||
|
||||
# 1. 存放所有共享的路径常量
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_PATH = os.path.join(BASE_DIR, 'config.csv')
|
||||
LOG_PATH = os.path.join(BASE_DIR, 'CheckIn.log')
|
||||
SESSIONS_DIR = os.path.join(BASE_DIR, 'sessions')
|
||||
CONFIG_INI_PATH = os.path.join(BASE_DIR, 'config.ini')
|
||||
|
||||
DEBUG_SCREENSHOT_PATH = os.path.join(BASE_DIR, 'debug_screenshot.png')
|
||||
DEBUG_PAGE_SOURCE_PATH = os.path.join(BASE_DIR, 'debug_page_source.html')
|
||||
|
||||
CHROME_BINARY_PATH = os.path.join(BASE_DIR, "chrome-linux64/chrome")
|
||||
CHROMEDRIVER_PATH = os.path.join(BASE_DIR, "chromedriver")
|
||||
|
||||
# 2. 存放所有共享的锁
|
||||
CONFIG_LOCK_PATH = os.path.join(BASE_DIR, 'config.csv.lock')
|
||||
CONFIG_FILE_LOCK = FileLock(CONFIG_LOCK_PATH, timeout=10) # 10秒超时
|
||||
SCHEDULER_LOCK = os.path.join(BASE_DIR, "scheduler.lock")
|
||||
|
||||
# 3. 存放共享的CSV列名
|
||||
CSV_FIELDNAMES = [
|
||||
'ThreadId', 'Signature', 'Texts', 'Values',
|
||||
'jwt_sub', 'Authorization', 'jwt_exp', 'email'
|
||||
]
|
||||
|
||||
CHECKIN_HOUR = 20 # 设置每天打卡的小时数 (24小时制, 8代表早上8点)
|
||||
CHECKIN_MIN = 0 # 分钟
|
||||
|
||||
def get_logger(name):
|
||||
"""
|
||||
只获取指定名称的logger实例。
|
||||
具体的配置(如Handler, Formatter)将由主应用 app.py 统一完成。
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==============================================================================
|
||||
# CheckInApp 启动脚本
|
||||
# ==============================================================================
|
||||
|
||||
# --- 配置 ---
|
||||
|
||||
# 项目的绝对路径
|
||||
# $(dirname "$0") 会获取脚本文件所在的目录
|
||||
# cd ... && pwd 会确保我们得到的是绝对路径
|
||||
APP_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
# 虚拟环境的路径
|
||||
VENV_DIR="$APP_DIR/venv"
|
||||
|
||||
# Gunicorn的配置
|
||||
WORKERS=4 # 工作进程数,可以根据你的CPU核心数调整 (2 * cores + 1)
|
||||
BIND_ADDR="0.0.0.0:5000" # 绑定的地址和端口
|
||||
|
||||
# Gunicorn进程ID文件的存放位置
|
||||
PID_FILE="$APP_DIR/gunicorn.pid"
|
||||
|
||||
# Flask应用的主文件和实例
|
||||
# 格式为: <文件名>:<Flask实例名>
|
||||
APP_MODULE="app:app"
|
||||
|
||||
# 日志文件(可选,Gunicorn会把输出重定向到这里)
|
||||
LOG_FILE="$APP_DIR/gunicorn.log"
|
||||
|
||||
# --- 检查虚拟环境 ---
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "错误: 虚拟环境目录 '$VENV_DIR' 不存在。"
|
||||
echo "请先运行 'python3 -m venv venv' 来创建它。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- 激活虚拟环境 ---
|
||||
# source命令必须在当前shell下执行,所以我们把所有操作放在这个脚本里
|
||||
source "$VENV_DIR/bin/activate"
|
||||
echo "虚拟环境已激活。"
|
||||
|
||||
# --- 脚本操作 (start|stop|restart) ---
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "正在启动 CheckInApp..."
|
||||
# --daemon 参数让gunicorn在后台运行
|
||||
# --pid 参数指定了PID文件的位置,方便我们停止它
|
||||
# --access-logfile 和 --error-logfile 将日志输出到文件
|
||||
gunicorn --workers $WORKERS \
|
||||
--bind $BIND_ADDR \
|
||||
--pid $PID_FILE \
|
||||
--access-logfile $LOG_FILE \
|
||||
--error-logfile $LOG_FILE \
|
||||
--daemon \
|
||||
$APP_MODULE
|
||||
echo "CheckInApp 已启动。PID保存在 $PID_FILE, 日志输出到 $LOG_FILE"
|
||||
;;
|
||||
stop)
|
||||
echo "正在停止 CheckInApp..."
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
# 从PID文件中读取进程ID并杀死它
|
||||
kill $(cat "$PID_FILE")
|
||||
rm "$PID_FILE"
|
||||
echo "服务已停止。"
|
||||
else
|
||||
echo "错误: 找不到PID文件。服务可能没有在运行。"
|
||||
fi
|
||||
;;
|
||||
restart)
|
||||
echo "正在重启 CheckInApp..."
|
||||
# 先执行stop,再执行start
|
||||
"$0" stop
|
||||
sleep 2 # 等待一下,确保进程已完全关闭
|
||||
"$0" start
|
||||
;;
|
||||
status)
|
||||
echo "检查 CheckInApp 状态..."
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null; then
|
||||
echo "服务正在运行,PID: $PID"
|
||||
else
|
||||
echo "服务不在运行,但PID文件存在。可能已意外崩溃。"
|
||||
fi
|
||||
else
|
||||
echo "服务不在运行。"
|
||||
fi
|
||||
;;
|
||||
log)
|
||||
echo "查看实时日志 (按 Ctrl+C 退出)..."
|
||||
tail -f "$LOG_FILE"
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {start|stop|restart|status|log}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,216 @@
|
||||
<!-- /CheckInApp/templates/index.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>自动打卡管理</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 20px; }
|
||||
h1 { text-align: center; color: #333; }
|
||||
table { width: 100%; margin-top: 20px; border-collapse: collapse; background: white; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); }
|
||||
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #ddd; }
|
||||
th { background-color: #4CAF50; color: white; }
|
||||
tr:nth-child(even) { background-color: #f2f2f2; }
|
||||
button { background-color: #4CAF50; color: white; border: none; padding: 10px 20px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; margin: 4px 2px; cursor: pointer; border-radius: 5px; transition: background-color 0.3s; }
|
||||
button:hover { background-color: #45a049; }
|
||||
button:disabled { background-color: #cccccc; cursor: not-allowed; }
|
||||
form { margin-top: 20px; width: 500px; margin-left: auto; margin-right: auto; background: white; padding: 20px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); }
|
||||
input, textarea { width: 100%; padding: 10px; margin: 8px 0; display: inline-block; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
|
||||
input[type=submit] { background-color: #4CAF50; color: white; }
|
||||
.qrcode-popup { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); z-index: 1000; text-align: center; }
|
||||
.qrcode-popup img { max-width: 300px; height: auto; display: block; }
|
||||
.overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 999; }
|
||||
.jwt-exp { cursor: help; position: relative; display: inline-block; }
|
||||
.jwt-exp::after { content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background-color: #555; color: #fff; padding: 5px 10px; border-radius: 5px; white-space: nowrap; visibility: hidden; opacity: 0; transition: opacity 0.3s; z-index: 1000; }
|
||||
.jwt-exp:hover::after { visibility: visible; opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>自动打卡管理</h1>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<button id="checkin-all-btn" onclick="triggerAllCheckIns(event)" style="background-color: #f0ad4e;">
|
||||
立即全部重新打卡 (调试)
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<tr>
|
||||
<th>ThreadId</th><th>Signature</th><th>Texts</th><th>Values</th><th>Token Expire (UTC+8)</th><th>Action</th>
|
||||
</tr>
|
||||
{% for config in configs %}
|
||||
<tr>
|
||||
<td>{{ config.ThreadId }}</td><td>{{ config.Signature }}</td><td>{{ config.Texts }}</td><td>{{ config.Values }}</td>
|
||||
<td><span class="jwt-exp" data-unix-time="{{ config.jwt_exp }}"></span></td>
|
||||
<td>
|
||||
{% if config.show_qrcode %}
|
||||
<button onclick="requestQRCode('{{ config.Signature }}', event)">Request QR Code</button>
|
||||
{% else %}
|
||||
Token is valid
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<form id="newUserForm">
|
||||
<h3>添加新用户</h3>
|
||||
<label for="ThreadId">ThreadId:</label><input type="text" id="ThreadId" name="ThreadId" value="" placeholder="ThreadId">
|
||||
<label for="Signature">Signature (required):</label><input type="text" id="Signature" name="Signature" required placeholder="Signature">
|
||||
<label for="Texts">Texts:</label><input type="text" id="Texts" name="Texts" value="Your location" placeholder="Texts">
|
||||
<label for="Values">Values:</label><input type="text" id="Values" name="Values" value='{"latitude":00.000000,"longitude":00.000000}' placeholder="Values">
|
||||
<label for="Email">Email (required):</label><input type="text" id="Email" name="Email" required placeholder="Email">
|
||||
<input type="submit" value="添加并获取二维码">
|
||||
</form>
|
||||
|
||||
<div id="qrcodePopup" class="qrcode-popup">
|
||||
<img id="qrcodeImage" src="" alt="QR Code" style="display: none;">
|
||||
<p id="qrcodeStatus">正在生成二维码...</p>
|
||||
<button onclick="closePopup()">关闭</button>
|
||||
</div>
|
||||
<div id="overlay" class="overlay"></div>
|
||||
<script>
|
||||
let pollingInterval;
|
||||
let activeSessionId = null;
|
||||
|
||||
const qrcodePopup = document.getElementById('qrcodePopup');
|
||||
const qrcodeImage = document.getElementById('qrcodeImage');
|
||||
const qrcodeStatus = document.getElementById('qrcodeStatus');
|
||||
const overlay = document.getElementById('overlay');
|
||||
|
||||
function showPopup() {
|
||||
qrcodePopup.style.display = 'block';
|
||||
overlay.style.display = 'block';
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
qrcodePopup.style.display = 'none';
|
||||
overlay.style.display = 'none';
|
||||
qrcodeImage.src = '';
|
||||
qrcodeImage.style.display = 'none';
|
||||
qrcodeStatus.textContent = '正在生成二维码...';
|
||||
activeSessionId = null;
|
||||
}
|
||||
|
||||
function setButtonLoading(button, isLoading) {
|
||||
if (!button) return;
|
||||
button.disabled = isLoading;
|
||||
button.textContent = isLoading ? '加载中...' : button.dataset.originalText;
|
||||
}
|
||||
|
||||
async function startRefreshFlow(url, options, button) {
|
||||
if (button) {
|
||||
button.dataset.originalText = button.textContent;
|
||||
setButtonLoading(button, true);
|
||||
}
|
||||
showPopup();
|
||||
|
||||
try {
|
||||
const startResponse = await fetch(url, options);
|
||||
const startData = await startResponse.json();
|
||||
if (startData.status !== 'success') throw new Error('启动刷新流程失败!');
|
||||
|
||||
activeSessionId = startData.session_id;
|
||||
|
||||
const imageResponse = await fetch(`/get_qrcode_image/${activeSessionId}`);
|
||||
const imageData = await imageResponse.json();
|
||||
if (imageData.status !== 'success') throw new Error('获取二维码失败: ' + imageData.message);
|
||||
|
||||
qrcodeImage.src = `data:image/png;base64,${imageData.image_data}`;
|
||||
qrcodeImage.style.display = 'block';
|
||||
qrcodeStatus.textContent = '请用QQ扫描二维码';
|
||||
|
||||
pollingInterval = setInterval(async () => {
|
||||
if (!activeSessionId) { clearInterval(pollingInterval); return; }
|
||||
const statusResponse = await fetch(`/check_refresh_status/${activeSessionId}`);
|
||||
const statusData = await statusResponse.json();
|
||||
if (statusData.status === 'success') {
|
||||
clearInterval(pollingInterval);
|
||||
alert('Token刷新成功!');
|
||||
window.location.reload();
|
||||
} else if (statusData.status === 'error') {
|
||||
throw new Error('刷新失败: ' + statusData.message);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
qrcodeImage.style.display = 'none';
|
||||
qrcodeStatus.textContent = `错误: ${error.message}`;
|
||||
if (button) setButtonLoading(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
function requestQRCode(signature, event) {
|
||||
startRefreshFlow('/request_qrcode', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ signature: signature }) // 在POST body中发送signature
|
||||
}, event.target);
|
||||
}
|
||||
|
||||
document.getElementById('newUserForm').onsubmit = function (event) {
|
||||
event.preventDefault();
|
||||
const submitButton = this.querySelector('input[type="submit"]');
|
||||
const formData = {
|
||||
ThreadId: document.getElementById('ThreadId').value,
|
||||
Signature: document.getElementById('Signature').value,
|
||||
Texts: document.getElementById('Texts').value,
|
||||
Values: document.getElementById('Values').value,
|
||||
Email: document.getElementById('Email').value
|
||||
};
|
||||
startRefreshFlow('/create_user', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(formData)
|
||||
}, submitButton);
|
||||
};
|
||||
|
||||
overlay.onclick = closePopup;
|
||||
|
||||
function convertUnixToUTC8(unixTime) {
|
||||
if (!unixTime || !/^\d+$/.test(unixTime) || parseInt(unixTime) === 0) return 'N/A';
|
||||
const date = new Date(parseInt(unixTime) * 1000);
|
||||
return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.jwt-exp').forEach(function (element) {
|
||||
const unixTime = element.getAttribute('data-unix-time');
|
||||
const utc8Time = convertUnixToUTC8(unixTime);
|
||||
element.textContent = utc8Time;
|
||||
element.setAttribute('data-tooltip', `Unix: ${unixTime}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
async function triggerAllCheckIns(event) {
|
||||
if (!confirm('确定要为所有用户立即重新打卡吗?这是一个调试功能。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = event.target;
|
||||
const originalText = button.textContent;
|
||||
setButtonLoading(button, true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/checkin_all', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status === 'success') {
|
||||
alert('成功!已发送全部重新打卡指令,请在服务器日志中查看详细过程。');
|
||||
} else {
|
||||
throw new Error(data.message || '请求失败,请查看服务器日志。');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('错误: ' + error.message);
|
||||
} finally {
|
||||
// 确保按钮状态被恢复
|
||||
setButtonLoading(button, false);
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,118 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
|
||||
from shared_config import CHROME_BINARY_PATH, CHROMEDRIVER_PATH, DEBUG_PAGE_SOURCE_PATH, DEBUG_SCREENSHOT_PATH, SESSIONS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def update_session_file(session_id, data):
|
||||
"""线程安全地写入会话文件"""
|
||||
filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json")
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f)
|
||||
except Exception as e:
|
||||
logger.error(f"写入会话文件 {filepath} 失败: {e}")
|
||||
|
||||
def get_session_status(session_id):
|
||||
"""安全地读取会话文件的状态"""
|
||||
filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json")
|
||||
if not os.path.exists(filepath):
|
||||
return None
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
# 增加对空文件的判断
|
||||
content = f.read()
|
||||
if not content:
|
||||
return None
|
||||
data = json.loads(content)
|
||||
return data.get('status')
|
||||
except (IOError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def get_token_headless(session_id):
|
||||
service = Service(executable_path=CHROMEDRIVER_PATH)
|
||||
chrome_options = Options()
|
||||
chrome_options.binary_location = CHROME_BINARY_PATH
|
||||
|
||||
# --- Headless模式配置 ---
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
chrome_options.add_argument(f'user-agent={user_agent}')
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
chrome_options.add_argument("--window-size=1920,1080")
|
||||
chrome_options.add_argument('--ignore-certificate-errors')
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
|
||||
try:
|
||||
current_step = "导航到登录页面"
|
||||
logger.info(f"Selenium: {current_step}...")
|
||||
driver.get("https://i.jielong.com/login?redirectTo=https%3A%2F%2Fi.jielong.com%2F")
|
||||
|
||||
wait = WebDriverWait(driver, 60)
|
||||
|
||||
# --- 智能等待流程 ---
|
||||
current_step = "查找并点击切换按钮"
|
||||
toggle_button_selector = "div.login-wrap .toggle"
|
||||
logger.info(f"Selenium: {current_step} ({toggle_button_selector})...")
|
||||
toggle_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, toggle_button_selector)))
|
||||
toggle_button.click()
|
||||
|
||||
current_step = "等待QQ登录容器出现"
|
||||
qq_container_selector = "#login_container"
|
||||
logger.info(f"Selenium: {current_step} ({qq_container_selector})...")
|
||||
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, qq_container_selector)))
|
||||
|
||||
current_step = "等待QQ二维码图片加载"
|
||||
qq_qr_image_selector = "#login_container img"
|
||||
logger.info(f"Selenium: {current_step} ({qq_qr_image_selector})...")
|
||||
qr_element = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, qq_qr_image_selector)))
|
||||
|
||||
logger.info("Selenium: 成功找到QQ二维码元素,正在截图...")
|
||||
qr_base64 = qr_element.screenshot_as_base64
|
||||
update_session_file(session_id, {'status': 'waiting_scan', 'qr_image_data': qr_base64})
|
||||
|
||||
current_step = "等待用户扫描登录 (Cookie 'token' 出现)"
|
||||
cookie_name_to_find = "token"
|
||||
logger.info(f"Selenium: {current_step}...")
|
||||
WebDriverWait(driver, 300, 1).until(lambda d: d.get_cookie(cookie_name_to_find) is not None)
|
||||
|
||||
cookie = driver.get_cookie(cookie_name_to_find)
|
||||
if cookie:
|
||||
logger.info("Selenium: 成功在Cookie中捕获到Token!")
|
||||
update_session_file(session_id, {'status': 'success', 'token': cookie['value']})
|
||||
else:
|
||||
raise Exception("等待Cookie成功但获取失败")
|
||||
|
||||
except TimeoutException:
|
||||
if get_session_status(session_id) == 'success':
|
||||
logger.warning(f"Selenium ({session_id}): 一个并发线程超时,但会话已成功,将忽略此超时。")
|
||||
else:
|
||||
error_message = f"操作超时!卡在了步骤: '{current_step}'。请检查CSS选择器或网络。"
|
||||
logger.error(f"Selenium ({session_id}): {error_message}")
|
||||
driver.save_screenshot(DEBUG_SCREENSHOT_PATH)
|
||||
with open(DEBUG_PAGE_SOURCE_PATH, 'w', encoding='utf-8') as f: f.write(driver.page_source)
|
||||
logger.error(f"Selenium ({session_id}): 调试截图和源码已保存。当前URL: {driver.current_url}")
|
||||
update_session_file(session_id, {'status': 'error', 'message': error_message})
|
||||
|
||||
except Exception as e:
|
||||
# --- 同样地,在其他异常中也加入检查 ---
|
||||
if get_session_status(session_id) == 'success':
|
||||
logger.warning(f"Selenium ({session_id}): 一个并发线程出错 ({e}),但会话已成功,将忽略此错误。")
|
||||
else:
|
||||
logger.error(f"Selenium ({session_id}): 发生未知错误: {e}", exc_info=True)
|
||||
update_session_file(session_id, {'status': 'error', 'message': str(e)})
|
||||
|
||||
finally:
|
||||
driver.quit()
|
||||
Reference in New Issue
Block a user