This commit is contained in:
2025-11-06 23:10:20 +08:00
commit 0bf6353839
12 changed files with 1260 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__
chromedriver
chrome-linux64
debug_page_source.html
debug_screenshot.png
sessions
*.lock
*.log
*.pid
+33
View File
@@ -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`
+361
View File
@@ -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)
+192
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
ThreadId,Signature,Texts,Values,jwt_sub,Authorization,jwt_exp,email
1 ThreadId Signature Texts Values jwt_sub Authorization jwt_exp email
+6
View File
@@ -0,0 +1,6 @@
[Email]
SmtpServer = __YOUR_EMAIL_PROVIDER__
SmtpPort = 465
SenderEmail = __YOUR_EMAIL__
# 重要提示:这里通常不是你的邮箱登录密码,而是邮箱服务商提供的“应用专用密码”或“授权码”
SenderPassword = __YOUR_EMAIL_AUTH_CODE__
+178
View File
@@ -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}")
+7
View File
@@ -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
+38
View File
@@ -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)
+101
View File
@@ -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
+216
View File
@@ -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>
+118
View File
@@ -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()