From 0bf6353839ca8a3aba71d4072b645c1667325e6d Mon Sep 17 00:00:00 2001 From: Cccc_ Date: Thu, 6 Nov 2025 23:10:20 +0800 Subject: [PATCH] init --- .gitignore | 9 ++ README.md | 33 ++++ app.py | 361 +++++++++++++++++++++++++++++++++++++++++++ check_in_worker.py | 192 +++++++++++++++++++++++ config.csv | 1 + config.ini | 6 + email_notifier.py | 178 +++++++++++++++++++++ requirements.txt | 7 + shared_config.py | 38 +++++ start.sh | 101 ++++++++++++ templates/index.html | 216 ++++++++++++++++++++++++++ token_refresher.py | 118 ++++++++++++++ 12 files changed, 1260 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 check_in_worker.py create mode 100644 config.csv create mode 100644 config.ini create mode 100644 email_notifier.py create mode 100644 requirements.txt create mode 100644 shared_config.py create mode 100644 start.sh create mode 100644 templates/index.html create mode 100644 token_refresher.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c7565c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__ +chromedriver +chrome-linux64 +debug_page_source.html +debug_screenshot.png +sessions +*.lock +*.log +*.pid \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0a8854 --- /dev/null +++ b/README.md @@ -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` diff --git a/app.py b/app.py new file mode 100644 index 0000000..e7e3259 --- /dev/null +++ b/app.py @@ -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/') +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/') +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) \ No newline at end of file diff --git a/check_in_worker.py b/check_in_worker.py new file mode 100644 index 0000000..cb40eb5 --- /dev/null +++ b/check_in_worker.py @@ -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 diff --git a/config.csv b/config.csv new file mode 100644 index 0000000..a566a07 --- /dev/null +++ b/config.csv @@ -0,0 +1 @@ +ThreadId,Signature,Texts,Values,jwt_sub,Authorization,jwt_exp,email diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..77c9c8f --- /dev/null +++ b/config.ini @@ -0,0 +1,6 @@ +[Email] +SmtpServer = __YOUR_EMAIL_PROVIDER__ +SmtpPort = 465 +SenderEmail = __YOUR_EMAIL__ +# 重要提示:这里通常不是你的邮箱登录密码,而是邮箱服务商提供的“应用专用密码”或“授权码” +SenderPassword = __YOUR_EMAIL_AUTH_CODE__ diff --git a/email_notifier.py b/email_notifier.py new file mode 100644 index 0000000..d783cd0 --- /dev/null +++ b/email_notifier.py @@ -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 = """ + + + + + Token 到期通知 + + + +

注意!

+
+

{name},请注意!

+

您的 token 已经到期,请前往 http://localhost:5000 重新刷新您的 token,否则您的自动打卡功能将会失效。

+

到期时间: {exp_time}

+
+ + + +""" + +SUCCESS_HTML_TEMPLATE = """ + + + + + 打卡成功通知 + + + +

打卡成功!

+
+

{name},您好!

+

系统已于 {send_time} 成功为您完成自动打卡。

+

您无需进行任何操作,此邮件仅作通知。

+
+ + + +""" + +FAILURE_HTML_TEMPLATE = """ + + + + + 打卡失败通知 + + + +

通知:自动打卡失败!

+
+

{name},您好!

+

系统于 {send_time} 尝试为您自动打卡时失败。

+

失败原因: 服务器返回 "需要登录",这通常意味着您的 Token 已失效

+

请您立即前往 http://localhost:5000 刷新您的 Token,以确保后续打卡能够成功。

+
+ + + +""" + +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}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dd85a05 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/shared_config.py b/shared_config.py new file mode 100644 index 0000000..73f8410 --- /dev/null +++ b/shared_config.py @@ -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) \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..b305fce --- /dev/null +++ b/start.sh @@ -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应用的主文件和实例 +# 格式为: <文件名>: +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 \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..27401d7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,216 @@ + + + + + + 自动打卡管理 + + + +

自动打卡管理

+
+ +
+ + + + + {% for config in configs %} + + + + + + {% endfor %} +
ThreadIdSignatureTextsValuesToken Expire (UTC+8)Action
{{ config.ThreadId }}{{ config.Signature }}{{ config.Texts }}{{ config.Values }} + {% if config.show_qrcode %} + + {% else %} + Token is valid + {% endif %} +
+ +
+

添加新用户

+ + + + + + +
+ +
+ +

正在生成二维码...

+ +
+
+ + + \ No newline at end of file diff --git a/token_refresher.py b/token_refresher.py new file mode 100644 index 0000000..fbc26da --- /dev/null +++ b/token_refresher.py @@ -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() \ No newline at end of file