diff --git a/.env.example b/.env.example index 740b2aa..5a0dd2e 100644 --- a/.env.example +++ b/.env.example @@ -1,48 +1,18 @@ -# NewAPI 账号配置示例 -# 复制此文件为 .env 并填入实际值(仅用于本地测试) +# NewAPI Runner 环境变量示例 +# 账号在 Worker 控制台统一管理,GitHub Actions 只需要以下两个必填值。 -# ========== 云端配置(推荐)========== -# 优先级高于 NEWAPI_ACCOUNTS,配置文件存放在云盘/NAS,改配置无需推代码 -# CONFIG_URL=https://dav.jianguoyun.com/dav/your@email.com/newapi-config.json -# CONFIG_AUTH=your@email.com:your_app_password +# 部署后的 Worker 根地址,不带末尾斜杠 +CHECKIN_WORKER_URL=https://newapi-checkin.your-subdomain.workers.dev -# ========== 认证格式说明 ========== -# 坚果云: CONFIG_AUTH=邮箱:应用专用密码(账户设置 → 安全选项 → 第三方应用密码) -# 群晖 NAS: CONFIG_AUTH=用户名:密码(需启用 WebDAV 服务) -# NextCloud: CONFIG_AUTH=用户名:密码 或 CONFIG_AUTH=token:访问令牌 -# 直接链接: CONFIG_AUTH=(如果链接无需认证则留空) +# 自行生成,并与 Cloudflare Worker 的 RUNNER_TOKEN 完全一致 +# 生成命令:openssl rand -hex 32 +CHECKIN_RUNNER_TOKEN=replace-with-a-64-character-hex-token -# ========== 单账号格式 ========== -NEWAPI_ACCOUNTS=https://your-domain.com#your_session_cookie - -# ========== 多网站格式(逗号分隔)========== -# 支持多个不同的 NewAPI 站点 -# NEWAPI_ACCOUNTS=https://site1.com#session1,https://site2.com#session2,https://site3.com#session3 - -# ========== JSON 格式(推荐)========== -# 支持多网站、用户ID、备注名称、更好的可读性 -# NEWAPI_ACCOUNTS=[{"url":"https://api.example.com","session":"sess1","user_id":"123","name":"主力站"},{"url":"https://api2.example.com","session":"sess2","user_id":"456","name":"备用站"}] - -# ========== JSON 格式 + CF 绕过 ========== -# cf_clearance 可手动提供(可选,Playwright 自动绕过时不需要) -# NEWAPI_ACCOUNTS=[{"url":"https://cf-protected-site.com","session":"sess1","cf_clearance":"cf_clearance_value","name":"CF站点"}] - -# ========== 完整示例 ========== -# 替换下面的值为你自己的真实配置 -# NEWAPI_ACCOUNTS=[{"url":"https://api.example.com","session":"MTc2NzQxMzYzM3xEWDhFQVFMX2dBQUJFQUVRQUFE...","user_id":"123","name":"主力站"},{"url":"https://api2.example.com","session":"QVFMXzJhYWJFRUFRQUFEX3dfLUFBQVlHYzNSeWFXNW5EQTBBQzI...","user_id":"456","name":"备用站"}] - -# ========== 钉钉通知配置 ========== - -# 钉钉机器人 Webhook URL(可选,配置后启用通知) +# 可选:钉钉通知 # DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=xxxxx - -# 钉钉机器人签名密钥(可选,如果开启了加签安全设置) # DINGTALK_SECRET=SECxxxxx -# ========== Cloudflare 绕过说明 ========== -# 本项目支持自动绕过 Cloudflare 防护: -# - 默认使用 requests 直连(快速模式) -# - 检测到 CF 拦截时自动切换 Playwright 无头浏览器 -# - GitHub Actions 已配置 Playwright 安装步骤 -# - 本地运行需手动安装: pip install playwright && playwright install chromium -# - cf_clearance 字段仍可手动配置作为备用 +# 可选:兼容模式回退配置 +# NEWAPI_ACCOUNTS=[{"url":"https://api.example.com","session":"session-value","name":"主力站"}] +# CONFIG_URL=https://example.com/newapi-config.json +# CONFIG_AUTH=username:password diff --git a/.github/workflows/checkin.yml b/.github/workflows/checkin.yml index 71d58a4..92a04ff 100644 --- a/.github/workflows/checkin.yml +++ b/.github/workflows/checkin.yml @@ -1,15 +1,23 @@ name: NewAPI 自动签到 on: - # 定时执行:每天 UTC 0:10 (北京时间 8:10) + # 每天 UTC 0:10 执行一次(北京时间约 8:10)。GitHub schedule 可能延迟。 schedule: - cron: '10 0 * * *' # 手动触发 workflow_dispatch: +permissions: + contents: read + +concurrency: + group: newapi-checkin + cancel-in-progress: false + jobs: checkin: runs-on: ubuntu-latest + timeout-minutes: 90 steps: - name: 检出代码 @@ -28,9 +36,8 @@ jobs: - name: 执行签到 env: - CONFIG_URL: ${{ secrets.CONFIG_URL }} - CONFIG_AUTH: ${{ secrets.CONFIG_AUTH }} - NEWAPI_ACCOUNTS: ${{ secrets.NEWAPI_ACCOUNTS }} + CHECKIN_WORKER_URL: ${{ secrets.CHECKIN_WORKER_URL }} + CHECKIN_RUNNER_TOKEN: ${{ secrets.CHECKIN_RUNNER_TOKEN }} DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} DINGTALK_SECRET: ${{ secrets.DINGTALK_SECRET }} run: python checkin.py diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml index 9ca80f5..296b4dc 100644 --- a/.github/workflows/keepalive.yml +++ b/.github/workflows/keepalive.yml @@ -1,24 +1,28 @@ -name: Keepalive Workflow +name: Scheduled Workflow Check -# 防止 GitHub Actions 在 60 天不活动后自动禁用工作流 -# 每 50 天运行一次,保持工作流活跃 +# 定期输出工作流状态。此运行不计为仓库活动,无法阻止 GitHub +# 对长期无仓库活动的公开仓库停用 scheduled workflows。 on: schedule: - # 每 50 天运行一次 (每月 1 日和 21 日 UTC 1:00) + # 每月 1 日和 21 日 UTC 1:00 运行 - cron: '0 1 1,21 * *' # 手动触发 workflow_dispatch: +permissions: + contents: read + jobs: keepalive: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: 检出代码 uses: actions/checkout@v4 - - name: 保持工作流活跃 + - name: 输出计划任务状态 run: | - echo "Keepalive ping at $(date -u '+%Y-%m-%d %H:%M:%S UTC')" - echo "This workflow prevents GitHub from automatically disabling scheduled workflows." + echo "Scheduled workflow check at $(date -u '+%Y-%m-%d %H:%M:%S UTC')" + echo "Repository activity is still required to keep scheduled workflows enabled." diff --git a/.gitignore b/.gitignore index 3513379..80f624f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ env/ .spec-workflow .claude/ .serena/ +.monkeycode/ # OS .DS_Store @@ -41,6 +42,8 @@ Thumbs.db # 配置文件(包含敏感信息) .env +worker/.dev.vars +worker/.wrangler/ config.json accounts.json newapi_accounts.json diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..f3dd269 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,12 @@ +{ + "config": { + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD033": { + "allowed_elements": ["div", "strong", "br"] + }, + "MD060": false + } +} diff --git a/FIRST_RUN.md b/FIRST_RUN.md new file mode 100644 index 0000000..3801bd5 --- /dev/null +++ b/FIRST_RUN.md @@ -0,0 +1,272 @@ +# 首次使用:从部署完成到第一次自动签到 + +这份指南从 Worker 部署成功开始,带你完成账号录入、GitHub Actions 连接和第一次签到验收。Cloudflare 和 GitHub 的完整部署步骤见 [WORKER_DEPLOYMENT.md](WORKER_DEPLOYMENT.md)。 + +所有网页操作都在 Worker 根地址完成。GitHub Pages 和根目录静态页面不参与签到链路。 + +## 完成标准 + +完成本指南后应满足以下条件: + +- `/api/health` 返回 `database: connected`。 +- Worker 控制台中至少有一个启用账号。 +- GitHub Actions 可以获取账号并完成签到。 +- Worker 控制台显示本次运行和账号级结果。 +- 定时工作流保持启用状态。 + +## 完成后的数据链路 + +```mermaid +sequenceDiagram + participant U as 用户浏览器 + participant W as Cloudflare Worker + participant D as Cloudflare D1 + participant G as GitHub Actions + participant N as NewAPI 站点 + U->>W: 登录并提交账号 + W->>D: 加密保存 Session + G->>W: 使用 Runner Token 获取启用账号 + W->>D: 读取并解密账号 + W-->>G: 返回运行配置 + G->>N: 执行签到 + N-->>G: 返回签到结果 + G->>W: 上报脱敏结果 + W->>D: 保存运行历史 + U->>W: 查看结果看板 +``` + +## 开始前检查 + +访问: + +```text +https://你的-worker地址/api/health +``` + +配置完整时返回: + +```json +{ + "ok": true, + "service": "newapi-checkin-worker", + "database": "connected", + "time": "..." +} +``` + +返回 HTTP 503 时,根据 `missing` 数组补齐 Cloudflare Worker Binding 或 Secret。 + +## 第一步:登录 Worker 控制台 + +1. 浏览器打开 Worker 根地址,例如 `https://newapi-checkin.example.workers.dev`。 +2. 输入 Cloudflare Worker 中设置的 `DASHBOARD_PASSWORD`。 +3. 点击“验证并进入”。 + +这里使用的是浏览器控制台口令。该口令与 GitHub Actions 使用的 `RUNNER_TOKEN` 是两套独立凭据。 + +## 第二步:获取 NewAPI 账号信息 + +### 1. 站点地址 + +填写登录 NewAPI 时使用的根地址: + +```text +https://api.example.com +``` + +以下地址应整理成根地址再填写: + +```text +浏览器当前页面:https://api.example.com/console/personal +应填写:https://api.example.com +``` + +### 2. Session Cookie + +以 Chrome 或 Edge 为例: + +1. 在目标 NewAPI 站点完成登录。 +2. 按 `F12` 打开开发者工具。 +3. 打开 `Application`,中文界面通常显示为“应用”。 +4. 在左侧打开 `Storage` -> `Cookies`。 +5. 点击当前 NewAPI 站点域名。 +6. 在表格中找到名称为 `session` 的 Cookie。 +7. 复制 `Value` 列的完整内容。 + +表单只需要 Value: + +```text +浏览器 Cookie:session=abc123xyz; Path=/; HttpOnly +表单填写:abc123xyz +``` + +表单只填写 Value,省略 `session=` 前缀和末尾分号。 + +### 3. 用户 ID + +必须填写。Session Cookie 本身不能可靠推导用户 ID,签到接口通常要求请求头: + +```text +new-api-user: 12345 +``` + +获取方式:登录目标站点,打开浏览器开发者工具的 Network,筛选 Fetch/XHR,选择任一已登录 API 请求,在 Request Headers 中复制 `new-api-user` 的值。 + +### 4. cf_clearance + +通常留空。项目检测到 Cloudflare 拦截后会尝试 Playwright 回退。 + +需要手动填写时,在浏览器 Cookies 列表找到 `cf_clearance`,复制 Value 列内容。该 Cookie 可能绑定浏览器环境并会过期,因此只作为辅助配置。 + +## 第三步:在 Worker 控制台添加账号 + +表单字段对应关系: + +| 字段 | 必填 | 示例 | 说明 | +|------|------|------|------| +| 备注名称 | 是 | `主力站` | 只用于识别账号 | +| 用户 ID | 是 | `12345` | 浏览器请求头 `new-api-user` 的值 | +| 站点地址 | 是 | `https://api.example.com` | 填根地址 | +| Session Cookie | 是 | `abc123xyz` | 只填 session 的 Value | +| cf_clearance | 否 | `clearance-value` | Cloudflare 站点辅助 Cookie | + +点击“加密保存账号”后: + +1. 浏览器通过 HTTPS 将表单提交到 Worker。 +2. Worker 使用 `DATA_ENCRYPTION_KEY` 派生 AES 密钥。 +3. Worker 将 URL、Session、用户 ID 和 cf_clearance 加密。 +4. 密文写入 `Check` Binding 对应的 D1 数据库。 +5. 控制台只显示站点 Origin 和状态,不会重新返回 Session。 + +账号出现在“账号健康状态”且状态为“等待首跑”,表示保存成功。 + +Session 过期时,在账号行点击“更新凭据”,重新复制并填写新的 Session。Worker 会覆盖该账号的加密运行配置,并保留账号本身和历史运行记录。 + +## 第四步:连接 Worker 与 GitHub Actions + +需要建立两个对应关系。 + +### Worker 地址 + +复制浏览器地址栏中的 Worker 根地址: + +```text +https://newapi-checkin.example.workers.dev +``` + +在 GitHub 仓库打开: + +```text +Settings -> Secrets and variables -> Actions -> New repository secret +``` + +创建: + +```text +Name: CHECKIN_WORKER_URL +Secret: https://newapi-checkin.example.workers.dev +``` + +该 Secret 使用 Worker 根地址,省略 `/api` 路径和末尾 `/`。 + +### Runner Token + +Cloudflare Worker 中已经有一个由你生成的 Secret: + +```text +RUNNER_TOKEN=<随机值> +``` + +在 GitHub Actions Secrets 创建: + +```text +Name: CHECKIN_RUNNER_TOKEN +Secret: <与 Cloudflare RUNNER_TOKEN 完全相同的随机值> +``` + +名称不同,值相同: + +```text +Cloudflare Worker RUNNER_TOKEN + = +GitHub Actions CHECKIN_RUNNER_TOKEN +``` + +GitHub 无法读取已经保存的 Secret 原值。如果忘记了 `RUNNER_TOKEN`,生成一个新值,并同时更新 Cloudflare 与 GitHub。 + +## 第五步:手动执行第一次签到 + +1. 打开 GitHub 仓库的 `Actions` 页面。 +2. 在左侧选择 `NewAPI 自动签到`。 +3. 点击 `Run workflow`。 +4. 分支选择 `main`。 +5. 再次点击绿色的 `Run workflow` 按钮。 +6. 等待新运行记录出现并打开日志。 + +正常日志顺序: + +```text +[Worker] 正在获取启用账号配置... +[Worker] 成功获取 1 个账号配置 +共 1 个账号待签到 +签到完成: 成功 1, 失败 0 +[Worker] 签到结果上报成功 +``` + +## 第六步:确认完整链路成功 + +回到 Worker 控制台并刷新页面,检查: + +- “最近成功”大于 0。 +- “成功率”有数值。 +- 账号状态从“等待首跑”变成“运行正常”或“签到失败”。 +- “运行历史”出现刚才的执行时间。 +- 点击运行记录可以查看账号级结果。 + +以上五项出现后,GitHub Actions 会每天北京时间约 08:10 尝试执行一次。GitHub schedule 可能出现平台级延迟。 + +## 常见首次配置错误 + +### Runner 未授权 + +原因:GitHub `CHECKIN_RUNNER_TOKEN` 与 Cloudflare `RUNNER_TOKEN` 值不一致。 + +处理:生成一个新 Token,同时更新两边。 + +### 成功获取 0 个账号 + +原因:控制台中没有账号,或账号已停用。 + +处理:添加账号并将账号状态设置为“已启用”。 + +Actions 日志也会直接显示: + +```text +[Worker] 没有启用的签到账号,请先在 Worker 控制台添加或启用账号 +``` + +### Session 可能已过期 + +原因:复制错误、包含了 `session=`、Session 已失效。 + +处理:重新登录 NewAPI,重新复制 `session` Cookie 的 Value,在控制台账号行点击“更新凭据”并提交。 + +### 获取用户信息失败 + +处理顺序: + +1. 检查 Session。 +2. 在 `/api/user/self` 响应中找到 `data.id`。 +3. 重新添加账号并填写用户 ID。 + +### Worker 提示 Check 未定义 / reading 'prepare' + +原因:Cloudflare 自动资源配置尚未完成,或当前部署没有名称为 `Check` 的 D1 Binding。 + +处理: + +1. 打开 Worker → Settings → Bindings,确认存在自动创建的 `Check` D1 Binding。 +2. 在 Deployments 中重新运行最新的 Git 部署,让 Cloudflare 完成自动资源配置。 +3. 打开 `/api/health`,确认返回 `database: connected` 且 `missing` 不含 `Check`。 + +已有 Worker 在部署前已经绑定 `Check` 时,Wrangler 会继续使用该 Binding 指向的原数据库,不会替换已有账号和历史数据。 diff --git a/LICENSE b/LICENSE index fb970ac..c760bc6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2026 NewAPI-Checkin Contributors +Copyright (c) 2026 zhikanyeye Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 0f35b97..a5dc1df 100644 --- a/README.md +++ b/README.md @@ -1,1129 +1,257 @@ -# NewAPI 自动签到 +# Check Console -基于 HTTP 直连的 NewAPI 自动签到脚本,支持多账号、GitHub Actions 定时执行。 - -## ✨ 功能特性 - -- ✅ 支持单账号/多账号签到 -- ✅ **支持多个不同网站**(只要是基于 NewAPI 搭建的站点) -- ✅ HTTP 直连,无需浏览器 -- ✅ GitHub Actions 自动化执行 -- ✅ 详细的签到日志输出 -- ✅ 错误处理和超时控制 -- ✅ 支持手动触发和定时任务 -- ✅ **钉钉通知**(签到完成后自动推送结果) -- ✅ **工作流保活**(防止 GitHub Actions 自动禁用) - -## 🛠️ 配置工具(推荐) - -为了让配置更简单,我们提供了两个便捷工具: - -### 🌐 方式 1:网页配置生成器(最简单) - -**在线使用(推荐):** - -如果你的仓库启用了 GitHub Pages,可以直接访问: -``` -https://你的用户名.github.io/Newapi-checkin/config_generator.html -``` - -**启用 GitHub Pages 的方法:** -1. 进入仓库 `Settings` → `Pages` -2. 在 `Source` 下选择 `main` 分支 -3. 点击 `Save` -4. 等待几分钟后,访问上面的链接 - -**本地使用:** -1. 下载 `config_generator.html` 文件到本地 -2. 双击文件,用浏览器打开 -3. 填写站点 URL、Session Cookie 和备注名称 -4. 点击"生成配置"按钮 -5. 一键复制生成的配置 - -**特点:** -- 📱 支持移动端和桌面端 -- ✨ 可视化界面,无需编写代码 -- 🎯 自动格式化,避免语法错误 -- 📋 一键复制到剪贴板 -- 🌐 可在线访问,无需安装任何软件 -- 💾 **支持本地存储**,下次打开自动加载配置 - -### 💻 方式 2:命令行配置助手 - -```bash -python config_helper.py -``` - -**功能:** -- ✅ 交互式问答,逐步引导配置 -- ✅ 自动测试账号有效性 -- ✅ 支持生成 JSON 和简单格式 -- ✅ 自动保存到文件 - -**使用示例:** -``` ---- 配置第 1 个账号 --- -站点 URL(如 https://xxx.xxx): https://xxx.xxx -Session Cookie: MTc2NzQxMzYzM3xEWDhF... -备注名称(可选,便于识别) [站点1]: 主站 -是否测试此账号配置 (Y/n): y -正在测试... - ✅ 测试成功!用户名: xxx_xxx -✅ 第 1 个账号添加成功 - -是否继续添加账号 (y/N): n -``` - ---- - -## 🚀 快速开始 - -### 📝 配置流程图 - -``` -1. 使用配置工具生成配置 - ↓ - [网页工具] → 填写表单 → 生成配置 → 复制 - 或 - [命令行工具] → 交互式问答 → 自动测试 → 保存文件 - ↓ -2. 添加到 GitHub Secrets - ↓ -3. 启用 GitHub Actions - ↓ -4. 每天自动签到 ✅ -``` - -### 方式一:GitHub Actions(推荐) - -#### 1. Fork 本仓库 - -点击右上角 `Fork` 按钮,将本仓库 Fork 到你的账号下。 - -#### 1.5. 启用 GitHub Pages(使用网页工具必需) - -**重要:** 如果你想使用在线网页配置工具,需要先启用 GitHub Pages: - -1. 进入你 Fork 的仓库 -2. 点击 `Settings` → `Pages` -3. 在 `Source` 下选择 `Deploy from a branch` -4. 选择 `main` 分支,文件夹选择 `/ (root)` -5. 点击 `Save` -6. 等待 1-2 分钟,页面会显示访问链接 - -**访问你的配置工具:** -``` -https://你的用户名.github.io/Newapi-checkin/ -``` - -或直接访问配置生成器: -``` -https://你的用户名.github.io/Newapi-checkin/config_generator.html -``` - -> 💡 **提示:** 将上面的"你的用户名"替换为你的 GitHub 用户名 - -#### 2. 配置 Secrets - -进入你 Fork 的仓库,依次点击:`Settings` → `Secrets and variables` → `Actions` → `New repository secret` - -添加名为 `NEWAPI_ACCOUNTS` 的 Secret。 - -> 💡 **推荐使用配置工具生成配置**(见上方"配置工具"章节),也可以按以下格式手动配置: - -**单账号格式:** -``` -https://your-domain.com#your_session_cookie -``` - -**多账号/多网站格式(用逗号分隔):** -``` -https://site1.com#session1,https://site2.com#session2,https://site3.com#session3 -``` - -**实际示例(多个不同的 NewAPI 站点):** -``` -https://api.example1.com#MTc2NzQx...,https://api.example2.com#QVFMXzJh...,https://api.example3.com#RFhFN0FB... -``` - -**JSON 格式(推荐,支持备注和更好的可读性):** -```json -[ - { - "url": "https://api.example.com", - "session": "MTc2NzQxMzYzM3xEWDhFQVFMX2dBQUJFQUVRQUFE...", - "user_id": "123", - "name": "主力站" - }, - { - "url": "https://api2.example.com", - "session": "QVFMXzJhYWJFRUFRQUFEX3dfLUFBQVlHYzNS...", - "user_id": "456", - "name": "备用站" - }, - { - "url": "https://api3.example.com", - "session": "RFhFN0FBQkVBRVFBQUQzd19fQUFBWUdjM1J5...", - "user_id": "789", - "name": "测试站" - } -] -``` - -#### 3. 启用 GitHub Actions - -进入仓库的 `Actions` 页面,点击 `I understand my workflows, go ahead and enable them` 启用工作流。 - -#### 4. 测试运行 - -在 `Actions` 页面,选择 `NewAPI 自动签到` 工作流,点击 `Run workflow` 按钮手动触发一次测试。 - -#### 5. 定时执行 - -工作流默认每天 **北京时间 8:10** 自动执行签到,无需手动操作。 - ---- - -### 方式二:本地运行 - -#### 1. 克隆仓库 - -```bash -git clone https://github.com/Jasonliu-0/Newapi-checkin.git -cd Newapi-checkin -``` - -#### 2. 安装依赖 - -```bash -pip install -r requirements.txt -``` - -#### 3. 配置环境变量 - -**Linux/macOS:** -```bash -export NEWAPI_ACCOUNTS="https://your-domain.com#your_session_cookie" -``` - -**Windows PowerShell:** -```powershell -$env:NEWAPI_ACCOUNTS="https://your-domain.com#your_session_cookie" -``` - -**Windows CMD:** -```cmd -set NEWAPI_ACCOUNTS=https://your-domain.com#your_session_cookie -``` - -#### 4. 运行脚本 - -```bash -python checkin.py -``` - -#### 5. 测试单个站点(可选) - -在配置多个站点前,可以先用测试脚本验证单个站点: - -```bash -python test_checkin.py <站点URL> -``` - -**示例:** -```bash -python test_checkin.py https://xxx.xxx MTc2NzQxMzYzM3xEWDhFQVFMX2... -``` - -测试脚本会验证: -1. Session 是否有效(获取用户信息) -2. 签到功能是否正常 -3. 签到历史查询是否正常 - ---- - -## 🔑 获取 Session Cookie - -### 方法一:浏览器开发者工具(推荐) - -1. 打开 NewAPI 网站并登录 -2. 按 `F12` 打开开发者工具 -3. 切换到 `Network`(网络)标签 -4. 刷新页面(`F5`) -5. 在请求列表中找到任意 API 请求 -6. 点击该请求,查看 `Headers`(请求头) -7. 找到 `Cookie` 字段,复制其中 `session=` 后面的值 - -**示例:** -``` -Cookie: session=MTc2NzQxMzYzM3xEWDhFQVFMX2...; cf_clearance=... -``` - -只需要复制 `session=` 和 `;` 之间的部分: -``` -MTc2NzQxMzYzM3xEWDhFQVFMX2... -``` - -### 方法二:使用脚本提取 - -在浏览器控制台(Console)中运行: - -```javascript -document.cookie.split('; ').find(row => row.startsWith('session=')).split('=')[1] -``` - -直接复制输出的值即可。 - ---- - -## ⚙️ 配置说明 - -### 定时执行时间 - -默认每天 **北京时间 8:10** 执行,如需修改,编辑 `.github/workflows/checkin.yml`: - -```yaml -schedule: - - cron: '10 0 * * *' # UTC 0:10 = 北京时间 8:10 -``` - -**常用时间对照:** -- `0 0 * * *` - 每天 UTC 0:00(北京时间 8:00) -- `30 1 * * *` - 每天 UTC 1:30(北京时间 9:30) -- `0 16 * * *` - 每天 UTC 16:00(北京时间 0:00) - - -### 🔔 钉钉通知配置(可选) - -签到完成后自动发送通知到钉钉群,包含签到结果、获得额度、Session 失效提醒等信息。 - -#### 1. 创建钉钉机器人 - -1. 打开钉钉群 → **设置** → **智能群助手** -2. 点击 **添加机器人** → 选择 **自定义** -3. 设置机器人名称(如:签到通知) -4. 安全设置选择 **加签**(推荐)或 **自定义关键词** -5. 复制 **Webhook 地址** 和 **签名密钥** - -#### 2. 添加 GitHub Secrets - -在仓库 Settings → Secrets and variables → Actions 中添加: - -| Secret 名称 | 说明 | 是否必须 | -|-------------|------|----------| -| `DINGTALK_WEBHOOK` | 钉钉机器人 Webhook URL | 是 | -| `DINGTALK_SECRET` | 加签密钥(以 SEC 开头) | 否(如开启加签则必须) | - -#### 3. 通知内容示例 - -签到完成后,钉钉群会收到类似以下的消息: - -` -📋 NewAPI 签到报告 -执行时间: 2026-01-08 08:10:00 - -✅ 成功 (2个) -| 账号 | 奖励 | 详情 | -| 主力站 | +500K | 已签 15 天 | -| 备用站 | +100K | 已签 8 天 | - -汇总: 成功 2,失败 0 -` -### 💾 网页工具本地存储功能 - -网页配置生成器支持将配置保存到浏览器本地存储: - -**功能说明:** -- ✅ **保存到本地**:填写完配置后,点击"💾 保存到本地"按钮 -- ✅ **自动加载**:下次打开页面会提示恢复之前的配置 -- ✅ **从本地加载**:点击"📂 从本地加载"按钮恢复配置 -- ✅ **清除数据**:点击"🗑️ 清除本地数据"删除保存的配置 - -**安全提示:** -- ⚠️ 数据以**明文形式**存储在浏览器中 -- ⚠️ 仅在**私人电脑**上使用本地存储功能 -- ⚠️ 公共电脑请勿使用,用完记得清除 -- ✅ 换电脑或换浏览器需要重新配置 - -**使用流程:** -``` -1. 填写配置 → 2. 点击"保存到本地" → 3. 关闭页面 - ↓ -下次打开页面 - ↓ -4. 看到提示"发现本地保存的配置" → 5. 点击"从本地加载" → 6. 配置自动填充 ✅ -``` - -### 账号配置格式详解 - -| 格式 | 说明 | 示例 | -|------|------|------| -| `URL#SESSION` | 单账号 | `https://api.example.com#MTc2NzQx...` | -| `URL1#SESSION1,URL2#SESSION2` | 多网站/多账号(逗号分隔) | `https://a.com#sess1,https://b.com#sess2` | -| JSON 数组 | 支持备注名称和用户ID(推荐) | 见上文 JSON 格式示例 | - -### 多网站配置示例 - -脚本支持同时管理**多个不同的 NewAPI 站点**,每个站点独立签到。 - -**场景 1:管理 3 个不同的 NewAPI 站点** - -假设你有以下站点的账号: -- `https://api.site1.com` - 主力站 -- `https://api.site2.com` - 备用站 -- `https://api.site3.com` - 测试站 - -配置方式(JSON 格式,推荐): - -```json -[ - { - "url": "https://api.site1.com", - "session": "你的site1的session值", - "user_id": "123", - "name": "主力站" - }, - { - "url": "https://api.site2.com", - "session": "你的site2的session值", - "user_id": "456", - "name": "备用站" - }, - { - "url": "https://api.site3.com", - "session": "你的site3的session值", - "user_id": "789", - "name": "测试站" - } -] -``` - -**场景 2:同一个站点的多个账号** - -如果你在同一个站点有多个账号,也可以这样配置: - -```json -[ - { - "url": "https://api.example.com", - "session": "账号1的session", - "name": "账号A" - }, - { - "url": "https://api.example.com", - "session": "账号2的session", - "name": "账号B" - } -] -``` - -**场景 3:混合配置(多站点 + 多账号)** - -```json -[ - { - "url": "https://site1.com", - "session": "site1_account1_session", - "name": "站点1-账号A" - }, - { - "url": "https://site1.com", - "session": "site1_account2_session", - "name": "站点1-账号B" - }, - { - "url": "https://site2.com", - "session": "site2_session", - "name": "站点2" - } -] -``` - -> **提示:** 每个站点的 Session Cookie 需要单独获取,请在对应站点登录后提取。 +
+ NewAPI 多账号自动签到、加密配置与结果看板 +
+ Cloudflare Worker + D1 + GitHub Actions +
--- -## 📋 运行日志示例 - -**多网站签到日志:** - -``` -================================================== -NewAPI 自动签到 -执行时间: 2026-01-03 08:10:00 -================================================== -共 3 个账号待签到 - -[1/3] 主力站 - 站点: https://api.site1.com - 用户ID: 123 - 用户: user_123 - 结果: ✅ 签到成功 - 日期: 2026-01-03 - 奖励: +5.23M 额度 (5,230,000 tokens) - 统计: 本月已签 3 天,累计 15.67M 额度 - -[2/3] 备用站 - 站点: https://api.site2.com - 用户ID: 456 - 用户: user_456 - 结果: ✅ 签到成功 - 日期: 2026-01-03 - 奖励: +2.78M 额度 (2,780,000 tokens) - 统计: 本月已签 3 天,累计 8.45M 额度 - -[3/3] 测试站 - 站点: https://api.site3.com - 用户ID: 789 - 用户: user_789 - 结果: ✅ 签到成功 - 日期: 2026-01-03 - 奖励: +3.49M 额度 (3,487,044 tokens) - 统计: 本月已签 1 天,累计 3.49M 额度 - -================================================== -签到完成: 成功 3, 失败 0 -================================================== -``` - -**日志说明:** -- ✅ **结果**:显示 API 返回的签到消息 -- 📅 **日期**:本次签到的日期 -- 🎁 **奖励**:本次获得的额度(格式化显示 + 原始值) -- 📊 **统计**:本月累计签到天数和总额度 - ---- - -## ❓ 常见问题 - -### Q1: 签到失败提示 "session 已过期" - -**原因:** Session Cookie 过期或无效。 - -**解决:** -1. 重新登录网站 -2. 按照上述方法重新获取 Session Cookie -3. 更新 GitHub Secrets 中的 `NEWAPI_ACCOUNTS` 配置 - -### Q2: GitHub Actions 没有自动执行 +Check Console 将账号配置、定时签到和运行结果整合为一套轻量服务。Cloudflare Worker 提供管理控制台和 API,D1 保存加密账号与运行历史,GitHub Actions 每天执行签到任务。 -**可能原因:** -1. 工作流未启用 - 进入 Actions 页面启用 -2. 仓库长期无活动被暂停 - 手动触发一次即可恢复 -3. Fork 的仓库默认禁用 Actions - 需要手动启用 +生产控制台由 Worker Static Assets 托管,整个项目无需 GitHub Pages、独立服务器或仓库内数据库 ID。 -### Q3: 如何查看执行日志? +**文档导航:** [快速部署](#快速部署) · [首次使用](FIRST_RUN.md) · [完整部署](WORKER_DEPLOYMENT.md) · [安全说明](SECURITY.md) · [开发文档](docs/INDEX.md) -进入仓库的 `Actions` 页面,点击对应的工作流运行记录,即可查看详细日志。 +## 核心能力 -### Q4: 支持哪些 NewAPI 站点? +Check Console 把分散在 GitHub Secrets、Actions 日志和本地配置中的信息收拢到一个 Worker 控制台: -理论上支持所有基于 [New API](https://github.com/Calcium-Ion/new-api) 项目搭建的站点,只要 API 接口兼容即可。 - -### Q5: Session Cookie 多久会过期? - -根据站点配置不同,通常为 **7-30 天**。建议每月更新一次,或在签到失败时及时更新。 - -### Q6: 每次签到获得的额度不一样? - -**正常现象!** NewAPI 的签到奖励是随机的: -- 最小额度:通常 2.5M tokens -- 最大额度:通常 10M tokens -- 每次签到会在这个范围内随机分配 - -脚本会显示本次获得的具体额度和本月累计总额度。 - -### Q7: 脚本如何处理 new-api-user 请求头? - -**自动处理!** 脚本会: -1. 调用 `/api/user/self` 获取用户信息 -2. 自动提取用户ID -3. 将 `new-api-user: 用户ID` 添加到后续请求的请求头中 -4. 无需手动配置,完全自动化 - ---- - -## 🔧 详细故障排除指南 - -### 1. Session 已过期 - -**错误信息:** -``` -❌ 失败 - Session 可能已过期 -``` - -**原因:** -- Session Cookie 已过期(通常 7-30 天) -- Session Cookie 复制不完整 -- Session Cookie 格式错误 +| 能力 | 说明 | +|------|------| +| 多账号管理 | 在 Worker 控制台添加、更新、启用和停用账号 | +| 加密存储 | Session、用户 ID 和可选 Cookie 经 AES-256-GCM 加密后写入 D1 | +| 每日签到 | GitHub Actions 每天北京时间约 08:10 自动执行,也支持手动触发 | +| 运行看板 | 展示最近结果、成功率、连续失败次数和最近 30 次运行 | +| 双重鉴权 | Dashboard Token 保护管理接口,Runner Token 保护执行器接口 | +| 兼容接口 | 优先调用 `/api/user/sign_in`,在端点不可用时回退 `/api/user/checkin` | +| 可选通知 | 支持钉钉机器人推送签到摘要 | -**解决方法:** +## 工作原理 -#### 步骤 1:使用调试模式查看详细信息 -```bash -python test_checkin.py https://api.example.com "你的session" --verbose +```mermaid +graph LR + A["浏览器控制台"] -->|"Dashboard Token"| B["Cloudflare Worker"] + B -->|"Check Binding"| C["Cloudflare D1"] + D["GitHub Actions"] -->|"Runner Token"| B + D --> E["NewAPI 站点"] + D -->|"脱敏签到结果"| B ``` -#### 步骤 2:重新获取 Session Cookie - -**方法 A:浏览器开发者工具(推荐)** - -1. 打开 NewAPI 网站并登录 -2. 按 `F12` 打开开发者工具 -3. 切换到 `Application`(应用程序)或 `Storage`(存储)标签 -4. 左侧找到 `Cookies` → 选择你的网站 -5. 找到 `session` 这一行 -6. 复制 `Value`(值)列的**完整内容** +Worker 根地址同时提供控制台和 API。Cloudflare 首次部署时自动创建 D1,Binding 变量名固定为区分大小写的 `Check`,数据库 ID 仅保存在 Cloudflare 账号中。 -**重要:** 确保复制完整,不要遗漏开头或结尾! +已有部署升级时,Wrangler 会按 Binding 类型和名称继承远端现有的 `Check` D1,因此账号与运行历史继续保存在原数据库。远端缺少 `Check` 时才会自动创建新数据库。 -**方法 B:Network 标签法** +一次完整运行包含以下步骤: -1. 打开网站并登录 -2. 按 `F12` → `Network`(网络)标签 -3. 刷新页面(`F5`) -4. 找到任意 API 请求 -5. 查看 `Headers` → `Request Headers` → `Cookie` -6. 找到 `session=` 后面的值,复制到下一个分号之前 +1. GitHub Actions 使用 Runner Token 从 Worker 获取启用账号。 +2. Runner 访问 NewAPI 用户信息与签到接口。 +3. Runner 将脱敏结果上报 Worker。 +4. Worker 更新 D1 中的运行历史与账号状态。 +5. 浏览器通过 Dashboard Token 查询看板数据。 -**方法 C:控制台脚本法** +## 配置概览 -1. 打开网站并登录 -2. 按 `F12` → `Console`(控制台) -3. 输入以下代码并回车: - ```javascript - document.cookie.split('; ').find(row => row.startsWith('session=')).split('=')[1] - ``` -4. 复制输出的结果 +### Cloudflare Worker -#### 步骤 3:验证新的 Session +以下三个值均由部署者自行生成,并使用互不相同的随机值: -```bash -python test_checkin.py https://api.example.com "新的session" --verbose -``` - -如果成功,你会看到: -``` -[1/3] 测试获取用户信息... - [调试] HTTP 状态码: 200 - [调试] success 字段: True -✅ 成功 - 用户名: xxx - 用户ID: 123 -``` +| Worker 变量 | 建议 | 作用 | +|-------------|------|------| +| `DASHBOARD_PASSWORD` | 密码管理器生成 20 位以上口令 | 登录浏览器控制台 | +| `RUNNER_TOKEN` | 32 字节随机值 | GitHub Actions 调用 Worker | +| `DATA_ENCRYPTION_KEY` | 32 字节随机值 | 加密账号 Session | ---- - -### 2. Session 复制不完整 - -**症状:** -- Session 长度异常短(少于 100 字符) -- 测试立即失败 +可使用 OpenSSL 生成: -**检查方法:** ```bash -python test_checkin.py https://api.example.com "你的session" --verbose -``` +# 控制台登录口令 +openssl rand -base64 24 -查看输出的 `Session 长度`,通常应该是 **200-500 字符**。 +# Runner Token +openssl rand -hex 32 -**正常示例:** -``` -Session 长度: 384 字符 +# 数据加密密钥 +openssl rand -hex 32 ``` -**异常示例:** -``` -Session 长度: 50 字符 ← 太短,可能复制不完整 -``` +`RUNNER_TOKEN` 还需要以 `CHECKIN_RUNNER_TOKEN` 的名称保存到 GitHub Actions Secrets。 -**解决:** -重新复制完整的 Session Cookie,确保: -- 从开头开始 -- 到结尾结束 -- 中间没有换行或空格 +### GitHub Actions ---- +| Secret | 必填 | 用途 | +|--------|------|------| +| `CHECKIN_WORKER_URL` | 是 | Worker 根地址,不包含 `/api` 和末尾斜杠 | +| `CHECKIN_RUNNER_TOKEN` | 是 | 与 Cloudflare `RUNNER_TOKEN` 使用同一个值 | +| `DINGTALK_WEBHOOK` | 否 | 钉钉机器人 Webhook | +| `DINGTALK_SECRET` | 否 | 钉钉机器人加签密钥 | -### 3. 网络连接问题 +### 登录有效期 -**错误信息:** -``` -[错误] 网络请求失败: ... -``` +`SESSION_TTL_SECONDS=86400` 表示浏览器控制台登录状态有效 24 小时。 -**可能原因:** -1. 网站无法访问 -2. 防火墙/代理阻止 -3. DNS 解析失败 +该变量只控制 Dashboard Token: -**解决方法:** - -#### 检查网站是否可访问 -```bash -# Windows -ping api.example.com - -# 或使用浏览器直接访问 -``` +- 浏览器超过 24 小时后需要重新输入 `DASHBOARD_PASSWORD`。 +- GitHub Actions 使用独立的 `RUNNER_TOKEN`。 +- 每日自动签到不读取 Dashboard Token。 +- 控制台登录过期不会中断定时签到。 -#### 检查代理设置 -如果使用代理,需要设置环境变量: +个人部署推荐保留 `86400`。更严格的环境可使用 `3600`,受信任的私人环境可使用 `604800`。 -```bash -# Linux/macOS -export HTTP_PROXY=http://proxy.example.com:8080 -export HTTPS_PROXY=http://proxy.example.com:8080 +## 快速部署 -# Windows CMD -set HTTP_PROXY=http://proxy.example.com:8080 -set HTTPS_PROXY=http://proxy.example.com:8080 +完整界面路径、验证方法和排障步骤见 [Cloudflare Worker 完整部署指南](WORKER_DEPLOYMENT.md)。 -# Windows PowerShell -$env:HTTP_PROXY="http://proxy.example.com:8080" -$env:HTTPS_PROXY="http://proxy.example.com:8080" -``` +### 1. 连接 GitHub 仓库 ---- +在 Cloudflare Workers Builds 中使用以下设置: -### 4. JSON 解析失败 +| 设置 | 值 | +|------|----| +| Repository | `zhikanyeye/Newapi-checkin` | +| Production branch | `main` | +| Root directory | `worker` | +| Build command | 留空 | +| Deploy command | `npm run deploy` | -**错误信息:** -``` -[错误] 响应解析失败: ... -``` +Cloudflare 会根据仓库中的 `Check` Binding 自动创建并绑定 D1。Worker 首次收到请求时自动建表,仓库中无需数据库 ID。 -**可能原因:** -- 网站返回了 HTML 错误页面(如 404、503) -- 网站维护中 -- API 接口变更 +### 2. 配置 Worker Bindings -**调试:** -```bash -python test_checkin.py https://api.example.com "你的session" --verbose -``` +首次部署完成后,在 Worker 设置中确认 `Check` D1 Binding 已自动生成,再添加运行时变量: -查看 `[调试] 原始响应:` 的内容,可能看到: -- HTML 错误页面 -- 维护通知 -- 其他非 JSON 内容 +| 类型 | 名称 | 值 | +|------|------|----| +| Secret | `DASHBOARD_PASSWORD` | 自行生成 | +| Secret | `RUNNER_TOKEN` | 自行生成 | +| Secret | `DATA_ENCRYPTION_KEY` | 自行生成 | +| Variable | `SESSION_TTL_SECONDS` | `86400` | -**解决:** -1. 等待网站恢复 -2. 检查网站是否正常 -3. 联系网站管理员 +### 3. 配置 GitHub Actions ---- +在 GitHub `Settings` -> `Secrets and variables` -> `Actions` 添加: -### 5. GitHub Actions 签到失败 +| Secret | 值 | +|--------|----| +| `CHECKIN_WORKER_URL` | Worker 根地址 | +| `CHECKIN_RUNNER_TOKEN` | 与 Worker `RUNNER_TOKEN` 完全一致 | -**症状:** -- 本地测试成功 -- GitHub Actions 运行失败 +### 4. 完成首次联调 -**可能原因:** -1. Secrets 配置错误 -2. Session 已过期 -3. GitHub 服务器网络问题 +1. 打开 Worker 根地址,用 `DASHBOARD_PASSWORD` 登录。 +2. 从 NewAPI 站点浏览器 Cookies 中复制 `session` 的 Value。 +3. 在控制台填写备注名称、站点根地址、Session 和浏览器请求头中的 `new-api-user`;`cf_clearance` 为可选项。 +4. 将 Worker 地址保存为 GitHub Secret `CHECKIN_WORKER_URL`。 +5. 将 Cloudflare `RUNNER_TOKEN` 的同一个值保存为 GitHub Secret `CHECKIN_RUNNER_TOKEN`。 +6. 在 GitHub Actions 中手动运行 `NewAPI 自动签到`。 +7. 刷新控制台,确认账号状态和运行历史已经更新。 -**排查步骤:** +账号字段获取方式、Secrets 的填写位置和验收标准见 [首次使用指南](FIRST_RUN.md)。 -#### 检查 Secrets 配置 -1. 进入仓库 `Settings` → `Secrets and variables` → `Actions` -2. 确认 `NEWAPI_ACCOUNTS` 存在 -3. 点击 `Update` 检查配置格式 +## 账号字段 -#### 查看详细日志 -1. 进入 `Actions` 页面 -2. 点击失败的运行记录 -3. 展开 `执行签到` 步骤 -4. 查看详细错误信息 +| 字段 | 必填 | 填写内容 | +|------|------|----------| +| 备注名称 | 是 | 仅用于控制台识别账号 | +| 用户 ID | 是 | 浏览器 Network 请求头 `new-api-user` 的值 | +| 站点地址 | 是 | NewAPI 根地址,例如 `https://api.example.com` | +| Session Cookie | 是 | `session` Cookie 的 Value,不包含 `session=` 和分号 | +| `cf_clearance` | 否 | Cloudflare 挑战站点的辅助 Cookie,通常留空 | -#### 手动触发测试 -1. `Actions` → `NewAPI 自动签到` -2. `Run workflow` → `Run workflow` -3. 查看运行结果 +## 安全设计 ---- +- Session 与 `cf_clearance` 在写入 D1 前使用 AES-GCM 加密。 +- `DATA_ENCRYPTION_KEY` 经 SHA-256 派生为 AES-256 密钥。 +- 每条账号配置使用独立随机 IV。 +- Dashboard Token 在 D1 中只保存 SHA-256 哈希。 +- Dashboard API 只返回账号名称、站点 Origin 和状态信息。 +- Runner API 通过独立 `RUNNER_TOKEN` 保护。 +- `.env` 与 `worker/.dev.vars` 已加入 `.gitignore`。 -### 6. 配置格式错误 +Session、Token 和 Cookie 属于敏感凭据。请在私有环境录入,避免将真实值写入仓库、Issue、聊天记录或 Actions 日志。完整边界与轮换建议见 [SECURITY.md](SECURITY.md)。 -**错误信息:** -``` -❌ 账号配置解析失败 -``` +## 项目结构 -**常见错误:** - -#### 错误 1:JSON 格式错误 -```json -// ❌ 错误:缺少逗号 -[ - {"url": "https://site1.com", "session": "sess1"} - {"url": "https://site2.com", "session": "sess2"} -] - -// ✅ 正确 -[ - {"url": "https://site1.com", "session": "sess1"}, - {"url": "https://site2.com", "session": "sess2"} -] +```text +. +├── .github/workflows/checkin.yml # 每天执行一次签到 Runner +├── checkin.py # 签到、配置拉取和结果上报 +├── cf_bypass.py # Cloudflare 检测与回退 +├── dingtalk_notifier.py # 可选钉钉通知 +├── FIRST_RUN.md # 首次账号录入与 Actions 联调 +├── SECURITY.md # 凭据、存储和使用安全说明 +├── WORKER_DEPLOYMENT.md # 完整部署与排障指南 +└── worker/ + ├── public/index.html # 生产控制台 UI + ├── src/index.js # Worker API + ├── schema.sql # D1 数据结构 + ├── package.json # Workers Builds 命令 + ├── wrangler.toml # Worker 与 Static Assets 配置 + └── .dev.vars.example # 本地变量模板 ``` -#### 错误 2:引号问题 -```json -// ❌ 错误:使用了中文引号 -{"url": "https://site1.com", "session": "sess1"} +## API 概览 -// ✅ 正确:使用英文引号 -{"url": "https://site1.com", "session": "sess1"} -``` +| 方法 | 路径 | 鉴权 | 用途 | +|------|------|------|------| +| `GET` | `/api/health` | 无 | 检查 Worker 服务 | +| `POST` | `/api/auth/login` | 控制台口令 | 获取浏览器 Session Token | +| `GET` | `/api/dashboard/summary` | Dashboard Token | 摘要与账号状态 | +| `GET` | `/api/dashboard/runs` | Dashboard Token | 最近 30 次运行 | +| `GET` | `/api/dashboard/runs/:id` | Dashboard Token | 单次运行明细 | +| `POST` | `/api/dashboard/accounts` | Dashboard Token | 添加加密账号 | +| `PATCH` | `/api/dashboard/accounts/:id` | Dashboard Token | 更新凭据、启用或停用账号 | +| `GET` | `/api/runner/config` | Runner Token | 获取启用账号 | +| `POST` | `/api/runner/report` | Runner Token | 上报脱敏结果 | + +## 本地开发 -#### 错误 3:简单格式分隔符 ```bash -# ❌ 错误:使用分号 -https://site1.com;sess1,https://site2.com;sess2 - -# ✅ 正确:使用井号 -https://site1.com#sess1,https://site2.com#sess2 +cd worker +cp .dev.vars.example .dev.vars +npm install +npm run db:init:local +npm run dev ``` -**解决:** -使用配置工具生成配置,避免手动编写: -- 网页工具:`config_generator.html` -- 命令行工具:`python config_helper.py` - ---- +另开终端运行 Runner: -## 🔍 调试技巧 - -### 使用 --verbose 参数 - -启用详细调试信息: ```bash -python test_checkin.py https://api.example.com "你的session" --verbose +export CHECKIN_WORKER_URL=http://127.0.0.1:8787 +export CHECKIN_RUNNER_TOKEN=与_dev_vars_中的_RUNNER_TOKEN_一致 +python3 checkin.py ``` -你会看到: -- HTTP 状态码 -- API 响应内容 -- 请求头信息 -- 错误详情 - -### 检查 Session 格式 - -正确的 Session Cookie 特征: -- ✅ 长度:200-500 字符 -- ✅ 格式:Base64 编码(字母、数字、`+`、`/`、`=`) -- ✅ 开头:通常是 `MTc...` -- ✅ 结尾:通常是 `...=` 或 `...==` +本地 Worker 默认地址为 `http://127.0.0.1:8787`。`.dev.vars` 只用于本地开发,并已被 Git 忽略。 -### 本地测试流程 +## 兼容模式 -1. **第一步:测试连接** - ```bash - ping api.example.com - ``` - -2. **第二步:获取新 Session** - - 登录网站 - - 使用开发者工具获取 - -3. **第三步:测试签到** - ```bash - python test_checkin.py https://api.example.com "新session" --verbose - ``` - -4. **第四步:配置到生产** - - 本地测试成功后 - - 更新 GitHub Secrets - - 手动触发一次验证 - ---- - -## ⚠️ 注意事项 - -1. **保护隐私:** Session Cookie 相当于登录凭证,请妥善保管,不要泄露给他人 -2. **定期更新:** Cookie 会过期,需要定期更新 Secrets 配置 -3. **遵守规则:** 请遵守各站点的使用规则,合理使用自动签到功能 -4. **Fork 仓库私有化:** 建议将 Fork 的仓库设为私有(Private),避免配置泄露 -5. **测试后启用:** 首次配置完成后,先手动触发测试,确认无误后再依赖定时任务 -6. **本地存储安全:** 浏览器本地存储是明文的,仅在私人电脑使用,公共电脑请勿使用 - ---- - -## 🔐 安全使用指南 - -### 数据存储方式对比 - -#### 1. GitHub Secrets(最安全,推荐) - -**安全性:** ⭐⭐⭐⭐⭐ - -**优点:** -- ✅ 加密存储,GitHub 服务器加密保护 -- ✅ 仅授权用户可见 -- ✅ Actions 运行时自动注入,不会暴露 -- ✅ 支持版本控制和审计 -- ✅ 适合长期使用 - -**缺点:** -- ❌ 需要有 GitHub 账号 -- ❌ 需要 Fork 仓库 - -**适用场景:** -- ✅ 日常自动签到(推荐) -- ✅ 长期使用 -- ✅ 管理多个站点 - -**使用方法:** -1. 使用配置工具生成配置(网页或命令行) -2. 进入仓库 `Settings` → `Secrets and variables` → `Actions` -3. 新建 Secret:`NEWAPI_ACCOUNTS` -4. 粘贴配置并保存 - ---- +Runner 按以下优先级读取账号:Worker API、`CONFIG_URL`、`NEWAPI_ACCOUNTS`。新部署推荐使用 Worker 控制台,后两种方式用于兼容旧配置。 -#### 2. 浏览器本地存储(方便,需谨慎) +## 文档 -**安全性:** ⭐⭐⭐ +| 文档 | 适用场景 | +|------|----------| +| [FIRST_RUN.md](FIRST_RUN.md) | Worker 部署完成后的账号录入与首次签到 | +| [WORKER_DEPLOYMENT.md](WORKER_DEPLOYMENT.md) | Cloudflare、D1、GitHub Actions 的完整部署和排障 | +| [SECURITY.md](SECURITY.md) | 凭据保护、数据加密、轮换和调试边界 | +| [docs/INDEX.md](docs/INDEX.md) | 架构、API 与开发者文档索引 | -**优点:** -- ✅ 使用方便,一键保存和加载 -- ✅ 无需 GitHub 账号 -- ✅ 数据存储在本地浏览器 -- ✅ 下次访问自动提示恢复 +## 已知限制 -**缺点:** -- ❌ **明文存储**,无加密保护 -- ❌ 其他人使用你的电脑可能看到 -- ❌ 换电脑或换浏览器需要重新配置 -- ❌ 清除浏览器数据会丢失配置 +- GitHub Actions 的定时任务可能出现平台级延迟。 +- `cf_clearance` 可能绑定浏览器环境、IP 和有效期,自动流程无法保证通过所有挑战。 +- `DATA_ENCRYPTION_KEY` 丢失后,已有账号密文无法恢复,需要重新录入账号。 +- NewAPI 衍生站点的接口响应可能存在差异,Runner 已覆盖常见格式和旧签到端点。 -**适用场景:** -- ✅ 临时测试配置 -- ✅ 私人电脑 -- ✅ 快速生成配置 +## 致谢 -**不适用场景:** -- ❌ 公共电脑 -- ❌ 共享电脑 -- ❌ 公司电脑 +项目基于 Jasonliu-0 发布的 MIT 开源项目改造,感谢原作者提供签到逻辑、配置工具和 GitHub Actions 基础实现。原始版权声明和 MIT License 保留在仓库中。 -**使用方法:** -1. 打开 `config_generator.html` -2. 填写账号信息 -3. 点击"💾 保存到本地"按钮 -4. 下次访问点击"📂 从本地加载" - ---- - -#### 3. 本地文件存储(适合开发) - -**安全性:** ⭐⭐⭐⭐ - -**优点:** -- ✅ 文件级权限控制 -- ✅ 可以加密文件 -- ✅ 便于备份 -- ✅ 适合本地开发测试 - -**缺点:** -- ❌ 需要手动管理文件 -- ❌ 可能误提交到 Git - -**适用场景:** -- ✅ 本地开发和测试 -- ✅ 命令行工具使用 -- ✅ 批量管理配置 - -**使用方法:** -```bash -# 使用命令行配置助手 -python config_helper.py - -# 会生成: -# - newapi_accounts.json -# - newapi_accounts.txt - -# 这些文件已在 .gitignore 中,不会被提交 -``` - ---- - -### 安全最佳实践 - -#### ✅ 推荐做法 - -1. **使用 GitHub Secrets 存储生产配置** - - 所有自动签到都通过 GitHub Actions 执行 - - 配置安全加密存储 - -2. **本地存储仅用于临时测试** - - 使用网页工具快速生成配置 - - 测试无误后复制到 GitHub Secrets - - 测试完成后清除本地存储 - -3. **定期更新 Session Cookie** - - 每月主动更新一次 - - Session 过期后及时更新 - -4. **仓库设为私有** - - Fork 后将仓库改为 Private - - 保护你的代码和配置 - -5. **不要分享配置** - - Session Cookie 相当于登录凭证 - - 不要发送给任何人 - - 不要截图包含 Session 的页面 - -#### ❌ 避免做法 - -1. **不要提交敏感信息到代码仓库** - - ❌ 不要在代码中硬编码 Session - - ❌ 不要将配置文件提交到 Git - - ❌ 不要在 Issue/PR 中暴露配置 - -2. **公共电脑不要使用本地存储** - - ❌ 网吧、图书馆等公共场所 - - ❌ 公司电脑(可能被监控) - - ❌ 共享电脑(其他人可能看到) - -3. **不要使用不安全的分享方式** - - ❌ 通过聊天软件明文发送 - - ❌ 存储在云笔记(如未加密) - - ❌ 截图包含完整 Session - ---- - -### Session Cookie 安全 - -#### Session Cookie 的风险 - -Session Cookie 相当于**临时密码**,拥有它的人可以: -- ✅ 以你的身份访问 API -- ✅ 查看你的账号信息 -- ✅ 执行签到等操作 -- ⚠️ 但**不能修改密码**或敏感设置(通常需要额外验证) - -#### 保护 Session Cookie - -1. **定期更新** - - Session 会自动过期(7-30 天) - - 建议每月主动更新一次 - - 重新登录会生成新的 Session - -2. **使用 HTTPS** - - 所有 NewAPI 站点都应使用 HTTPS - - 避免中间人攻击 - -3. **不分享** - - 不要发送给任何人 - - 不要截图包含 Session - - 不要在公开场合展示 - ---- - -### GitHub Secrets 安全 - -#### GitHub Secrets 的安全性 - -- ✅ 加密存储在 GitHub 服务器 -- ✅ 只有仓库所有者和协作者可管理 -- ✅ Actions 运行日志中自动脱敏(显示为 `***`) -- ✅ 无法通过 GitHub API 读取原始值 -- ✅ 支持审计日志 - -#### 最佳实践 - -1. **仓库设为私有** - ``` - Settings → General → Danger Zone → Change visibility → Make private - ``` - -2. **限制协作者** - - 不要随意添加协作者 - - 协作者可以访问 Secrets - -3. **定期审计** - - 定期检查 Actions 运行日志 - - 确保 Session 未泄露 - ---- - -### 应急响应 - -#### 如果 Session 泄露了怎么办? - -1. **立即重新登录** - - 访问网站并重新登录 - - 这会使旧 Session 失效 - -2. **更新配置** - - 获取新的 Session Cookie - - 更新 GitHub Secrets - - 清除本地存储(如果使用) - -3. **检查账号安全** - - 查看登录日志 - - 检查是否有异常操作 - - 必要时修改密码 - -4. **清理痕迹** - - 删除包含 Session 的截图 - - 删除聊天记录中的 Session - - 清除浏览器历史记录 - ---- - -### 安全级别总结 - -| 存储方式 | 安全性 | 便捷性 | 推荐场景 | -|---------|-------|-------|---------| -| GitHub Secrets | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 生产环境、长期使用 | -| 浏览器本地存储 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 私人电脑、临时测试 | -| 本地文件 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 开发测试 | -| 环境变量 | ⭐⭐⭐⭐ | ⭐⭐⭐ | 本地运行 | - -**记住:安全性永远优先于便捷性!** 🔐 - ---- - -## 🛠️ 技术栈 - -- **语言:** Python 3.11+ -- **HTTP 库:** requests -- **CI/CD:** GitHub Actions -- **配置工具:** HTML + JavaScript (网页版), Python (命令行版) - ---- - -## 📦 项目文件说明 - -| 文件 | 说明 | -|------|------| -| `checkin.py` | 主签到脚本 | -| `test_checkin.py` | 单站点测试脚本 | -| `config_helper.py` | 命令行配置助手(交互式)| -| `config_generator.html` | 网页配置生成器(可视化)| -| `.github/workflows/checkin.yml` | GitHub Actions 工作流 | -| `requirements.txt` | Python 依赖 | -| `README.md` | 项目文档 | - ---- - -## 📄 许可证 - -MIT License - ---- - -## 🤝 贡献 - -欢迎提交 Issue 和 Pull Request! - ---- +NewAPI 兼容接口来源于 [New API](https://github.com/Calcium-Ion/new-api)。 -## 📮 反馈 +## License -如有问题或建议,请提交 [Issue](https://github.com/Jasonliu-0/Newapi-checkin/issues)。 +[MIT License](LICENSE) · Maintained by [zhikanyeye](https://github.com/zhikanyeye) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3b37b5b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,88 @@ +# 安全说明 + +本文说明 Check Console 的凭据边界、数据保护方式和安全操作要求。项目会处理 NewAPI Session 与自动化执行 Token,部署者应将 Cloudflare Dashboard 和 GitHub Secrets 作为唯一的生产凭据入口。 + +## 敏感信息分类 + +| 信息 | 敏感级别 | 存储位置 | 泄露影响 | +|------|----------|----------|----------| +| NewAPI `session` | 高 | D1 加密字段 | 可能导致账号会话被冒用 | +| `cf_clearance` | 高 | D1 加密字段 | 可能暴露浏览器验证状态 | +| `DATA_ENCRYPTION_KEY` | 高 | Cloudflare Secret | 可用于解密 D1 中的账号配置 | +| `RUNNER_TOKEN` | 高 | Cloudflare Secret、GitHub Secret | 可读取 Runner 配置并上报结果 | +| `DASHBOARD_PASSWORD` | 高 | Cloudflare Secret | 可登录管理控制台 | +| Dashboard Token | 高 | 浏览器 `localStorage`、D1 哈希 | 可在有效期内调用管理 API | +| D1 Database ID | 低 | Cloudflare Dashboard | 资源标识,不提供独立访问权限 | + +## 数据保护 + +- Worker 使用 Web Crypto API 的 AES-GCM 加密账号运行配置。 +- `DATA_ENCRYPTION_KEY` 经 SHA-256 派生为 256 位 AES 密钥。 +- 每次加密使用独立的 12 字节随机 IV。 +- D1 的 `accounts.secret` 保存 URL、Session、用户 ID和可选 `cf_clearance` 的密文。 +- Dashboard Token 仅以 SHA-256 哈希形式写入 D1。 +- Dashboard API 只返回账号名称、站点 Origin、状态和运行结果。 +- Runner API 使用独立 Bearer Token,并仅向执行器返回启用账号。 + +## 凭据配置 + +生产凭据应配置在以下位置: + +| 平台 | 配置项 | +|------|--------| +| Cloudflare Worker Secrets | `DASHBOARD_PASSWORD`、`RUNNER_TOKEN`、`DATA_ENCRYPTION_KEY` | +| GitHub Actions Secrets | `CHECKIN_WORKER_URL`、`CHECKIN_RUNNER_TOKEN`、可选钉钉变量 | +| 本地开发 | `worker/.dev.vars` | + +仓库示例、Issue、Actions 日志、截图和聊天记录统一使用 ``。`.env`、`worker/.dev.vars` 和常见账号配置文件已加入 `.gitignore`。 + +## 轮换策略 + +### Dashboard Password + +在 Cloudflare Dashboard 更新 `DASHBOARD_PASSWORD` 后重新部署。已有 Dashboard Token 会持续到自身过期,可通过清理 D1 `sessions` 表立即结束全部控制台会话。 + +### Runner Token + +生成新值后同步更新: + +1. Cloudflare Secret `RUNNER_TOKEN`。 +2. GitHub Secret `CHECKIN_RUNNER_TOKEN`。 +3. 手动运行一次签到工作流验证连接。 + +### Data Encryption Key + +该密钥与 D1 中已有密文强绑定。轮换流程为:记录现有账号列表、更新密钥、重新录入每个账号凭据。旧密钥应保留到迁移验收完成。 + +### NewAPI Session + +账号出现认证失败时,在目标站点重新登录,再通过控制台“更新凭据”提交新的 Session 和用户 ID。 + +## 浏览器安全 + +- 在受信任设备访问 Worker 控制台。 +- 共享设备使用结束后点击退出,并清理站点数据。 +- Worker 自定义域可结合 Cloudflare Access 增加身份认证层。 +- Dashboard Token 存储在浏览器 `localStorage`,同源脚本具有读取权限。 + +## 日志与调试 + +`checkin.py` 默认对站点 URL、用户 ID 和用户名做脱敏处理。排障时仍应检查第三方响应内容,避免服务端错误消息携带账号信息。 + +以下兼容工具仅适合本地临时排障: + +| 工具 | 风险 | +|------|------| +| `debug_session.py` | 会解析并打印 Session 内容 | +| `config_helper.py` | 会在终端输出完整旧版账号配置 | +| `test_checkin.py` | 会直接使用传入的账号凭据访问站点 | + +这些工具应在受控终端运行,输出文件和终端历史应按敏感数据处理。 + +## Cloudflare 挑战 + +Playwright 回退流程用于账号所有者访问其已授权使用的站点。目标站点策略、浏览器指纹、IP 和 Cookie 有效期会影响结果。部署者应遵守目标站点服务条款,并接受挑战失败作为正常运行结果。 + +## 安全问题反馈 + +发现可能泄露 Session、Token 或密钥的问题时,请使用 GitHub Security Advisory 私下报告,并在报告中使用脱敏日志和最小复现信息。 diff --git a/WORKER_DEPLOYMENT.md b/WORKER_DEPLOYMENT.md new file mode 100644 index 0000000..570b49c --- /dev/null +++ b/WORKER_DEPLOYMENT.md @@ -0,0 +1,469 @@ +# Cloudflare Worker 完整部署指南 + +本文档对应当前推荐架构:Cloudflare Worker 同时托管账号配置页面、签到结果看板和 API,D1 保存加密账号与运行历史,GitHub Actions 只执行签到。 + +本项目无需 GitHub Pages。若仓库之前启用过 Pages,请进入 GitHub `Settings` -> `Pages`,将发布 Source 设置为 `None`。 + +## 导航 + +| 阶段 | 目标 | +|------|------| +| [准备环境](#2-准备条件) | 准备 Cloudflare 和 GitHub | +| [自动配置数据库](#3-自动配置-d1-数据库) | 由 Cloudflare 创建并绑定 D1 | +| [生成凭据](#4-配置-worker-环境变量绑定) | 生成三个独立安全值 | +| [连接仓库](#5-通过-github-仓库自动部署-worker推荐) | 开启 Workers Builds | +| [连接签到](#7-连接-github-actions) | 设置 Actions Secrets | +| [首次联调](#8-首次联调) | 验证完整数据链路 | +| [故障排查](#11-故障排查) | 按现象定位问题 | + +部署结束后的账号录入、Cookie 获取和第一次签到操作,可直接阅读 [`FIRST_RUN.md`](FIRST_RUN.md)。 + +> 控制台登录有效期和自动签到相互独立。`SESSION_TTL_SECONDS` 只影响浏览器 Dashboard Token,GitHub Actions 始终使用 `RUNNER_TOKEN`。 + +## 1. 架构 + +```mermaid +graph LR + A["浏览器控制台"] -->|"同源 API"| B["Cloudflare Worker"] + B --> C["Cloudflare D1"] + D["GitHub Actions"] -->|"Runner Token"| B + D --> E["NewAPI 站点"] + D -->|"脱敏结果"| B +``` + +数据流: + +1. 浏览器打开 Worker 根地址,使用 `DASHBOARD_PASSWORD` 登录。 +2. 控制台将 NewAPI 账号配置提交给 Worker。 +3. Worker 使用 `DATA_ENCRYPTION_KEY` 派生 AES-256-GCM 密钥,将 Session 加密后写入 D1。 +4. GitHub Actions 使用 `RUNNER_TOKEN` 从 Worker 获取启用账号。 +5. GitHub Actions 执行签到,将脱敏结果上报 Worker。 +6. 控制台从 Worker 查询最新状态和历史结果。 + +## 2. 准备条件 + +- Cloudflare 账号 +- GitHub 仓库 `https://github.com/zhikanyeye/Newapi-checkin` +- Node.js 22 或更高版本(仅本地开发需要) +- Python 3.11(仅本地 Runner 测试需要) + +本地开发可选安装 Wrangler: + +```bash +npm install -g wrangler +``` + +登录 Cloudflare: + +```bash +wrangler login +``` + +## 3. 自动配置 D1 数据库 + +仓库中的 `worker/wrangler.toml` 只声明 D1 Binding 名称: + +```toml +[[d1_databases]] +binding = "Check" +``` + +通过 Cloudflare Dashboard 连接 GitHub 并首次部署时,Wrangler 自动完成以下操作: + +1. 在当前 Cloudflare 账号中创建 D1 数据库。 +2. 将数据库绑定到 Worker 的 `Check` 变量。 +3. 将数据库资源 ID 保留在 Cloudflare Dashboard 中。 + +Worker 第一次收到请求时会通过幂等 SQL 自动创建 `accounts`、`runs`、`run_results` 和 `sessions` 表。部署者无需执行 D1 创建命令、无需手工初始化 schema,也无需在 GitHub 仓库中填写 Database ID。 + +`Check` 区分大小写,Worker 代码通过 `env.Check` 访问 D1。首次部署后可进入 Worker `Settings` -> `Bindings` 确认自动生成的绑定。 + +### 3.1 已有数据库的升级行为 + +Wrangler 部署前会读取当前 Worker 的远端 Bindings,并按类型与变量名进行匹配: + +- 远端已有 D1 Binding `Check`:继承该 Binding 指向的原数据库,现有账号和运行历史保持原位。 +- 远端缺少 D1 Binding `Check`:创建新的 D1 数据库并绑定为 `Check`。 +- 远端使用其他变量名:该 Binding 与仓库声明不匹配,Cloudflare 会为 `Check` 配置新的资源。 + +已有部署推送本次配置前,先在 Worker `Settings` -> `Bindings` 确认原数据库的 Variable name 为 `Check`。数据库自身的名称和 UUID 均不参与继承匹配。 + +## 4. 配置 Worker 环境变量绑定 + +Worker 需要三个敏感环境变量和一个普通变量: + +| 变量 | 类型 | 用途 | +|------|------|------| +| `DASHBOARD_PASSWORD` | Secret | 登录控制台的访问口令 | +| `RUNNER_TOKEN` | Secret | GitHub Actions 调用 Runner API 的令牌 | +| `DATA_ENCRYPTION_KEY` | Secret | 加密账号 Session 的主密钥 | +| `SESSION_TTL_SECONDS` | Variable | 控制台登录有效期,默认 86400 秒 | + +### 4.1 生成三个独立凭据 + +`DASHBOARD_PASSWORD`、`RUNNER_TOKEN` 与 `DATA_ENCRYPTION_KEY` 均由部署者自行生成。三个变量使用不同的随机值。 + +```bash +# 登录控制台时输入,建议保存到密码管理器 +openssl rand -base64 24 + +# Worker 和 GitHub Actions 之间的长期认证令牌 +openssl rand -hex 32 + +# 加密 D1 中账号认证信息的主密钥 +openssl rand -hex 32 +``` + +变量生命周期: + +| 变量 | 有效期 | 轮换影响 | +|------|--------|----------| +| `DASHBOARD_PASSWORD` | 手动更换前有效 | 更换后使用新口令登录 | +| `RUNNER_TOKEN` | 手动更换前有效 | Worker 与 GitHub 必须同步更新 | +| `DATA_ENCRYPTION_KEY` | 长期保留 | 更换后已有账号密文无法解密,需要重新录入 | +| `SESSION_TTL_SECONDS` | 每次登录时应用 | 只影响浏览器登录,不影响 Actions | + +推荐将 `DATA_ENCRYPTION_KEY` 安全备份。丢失该值后,D1 中保存的账号密文无法恢复。 + +### 4.2 仓库默认变量 + +`SESSION_TTL_SECONDS` 已在 `wrangler.toml` 的 `[vars]` 中声明: + +```toml +[vars] +SESSION_TTL_SECONDS = "86400" +``` + +### 4.3 Cloudflare 控制台环境绑定 + +1. 打开 Cloudflare Dashboard。 +2. 进入 `Workers & Pages`。 +3. 选择 `newapi-checkin` Worker。首次部署后才会出现该 Worker。 +4. 打开 `Settings` -> `Variables and Secrets`。 +5. 分别添加 `DASHBOARD_PASSWORD`、`RUNNER_TOKEN`、`DATA_ENCRYPTION_KEY`。 +6. 三个敏感变量选择 `Secret` 类型。 +7. 添加普通变量 `SESSION_TTL_SECONDS=86400`。 +8. 保存并重新部署 Worker。 + +### 4.4 本地开发绑定 + +复制示例文件: + +```bash +cp .dev.vars.example .dev.vars +``` + +填写本地测试值。`worker/.dev.vars` 已加入 `.gitignore`,真实值不会进入仓库。 + +## 5. 通过 GitHub 仓库自动部署 Worker(推荐) + +Cloudflare Workers Builds 可以直接连接本仓库。连接完成后,每次推送到 `main` 分支都会自动部署 Worker,GitHub 仓库无需保存 Cloudflare API Token。 + +### 5.1 首次部署前准备 + +Git 集成会读取仓库中的 `worker/wrangler.toml`。请先完成以下准备: + +1. 将代码推送到 GitHub 的 `main` 分支。 +2. 确认 `worker/package.json` 和 `worker/wrangler.toml` 已存在。 +3. 确认 `wrangler.toml` 中的 `Check` Binding 未包含 `database_id`。 + +### 5.2 在 Cloudflare 连接 GitHub + +1. 打开 Cloudflare Dashboard。 +2. 进入 `Workers & Pages`。 +3. 选择 `Create application`。 +4. 选择 `Import a repository` 或 `Connect to Git`。 +5. 授权 Cloudflare GitHub App 访问 `zhikanyeye/Newapi-checkin`。 +6. 选择仓库 `zhikanyeye/Newapi-checkin`。 +7. 填写构建设置: + +| 设置 | 值 | +|------|----| +| Project name | `newapi-checkin` | +| Production branch | `main` | +| Root directory | `worker` | +| Build command | 留空 | +| Deploy command | `npm run deploy` | + +`worker/package.json` 中已经声明部署命令: + +```json +{ + "scripts": { + "deploy": "wrangler deploy" + } +} +``` + +Cloudflare 会在 `worker` 目录安装依赖,并执行 `npm run deploy`。`wrangler.toml` 会自动加载 Worker 入口和 Static Assets。 + +### 5.3 首次构建 + +点击 `Save and Deploy`。构建日志应包含以下阶段: + +```text +Installing dependencies +Running deploy command: npm run deploy +Uploading static assets +Deploying newapi-checkin +``` + +构建完成后,Cloudflare 会生成 Worker 地址: + +```text +https://newapi-checkin.<你的-workers-subdomain>.workers.dev +``` + +已有 Worker 的首次升级部署会继承远端 `Check` Binding。部署完成后,在 `Settings` -> `Bindings` 对照 D1 数据库名称,并访问 `/api/health` 确认连接状态。 + +### 5.4 配置运行时环境变量 + +首次构建完成后,进入 Worker 的 `Settings` -> `Variables and Secrets`,添加: + +- Secret `DASHBOARD_PASSWORD` +- Secret `RUNNER_TOKEN` +- Secret `DATA_ENCRYPTION_KEY` +- Variable `SESSION_TTL_SECONDS=86400` + +保存后,在 `Deployments` 页面重新部署最新版本,或者向 `main` 分支推送一次提交。 + +然后进入 `Settings` -> `Bindings`,确认 Cloudflare 已自动创建 `Check` D1 Binding。 + +这些变量属于 Worker Runtime Bindings。Cloudflare 构建过程无需读取三个敏感值,GitHub 仓库也无需保存这些值。 + +### 5.5 自动部署行为 + +- 推送到 `main`:Cloudflare 自动构建并部署生产 Worker。 +- Pull Request 或其他分支:可在 Workers Builds 设置中开启非生产分支构建。 +- 构建失败:Cloudflare 保留上一份成功部署的 Worker。 +- D1 数据:代码重新部署不会清除已有账号和签到历史。 +- D1 资源 ID:仅在 Cloudflare Dashboard 可见,不写回 GitHub 仓库。 +- D1 继承条件:远端 Binding 类型为 D1,变量名为区分大小写的 `Check`。 + +### 5.6 验证 Git 部署 + +访问健康检查: + +```bash +curl https://newapi-checkin.<你的-workers-subdomain>.workers.dev/api/health +``` + +预期响应: + +```json +{"ok":true,"service":"newapi-checkin-worker","database":"connected","time":"..."} +``` + +若 Binding 或 Secret 尚未配置完整,接口返回 HTTP 503,并在 `missing` 数组中列出缺失项: + +```json +{"ok":false,"service":"newapi-checkin-worker","missing":["Check","RUNNER_TOKEN"],"time":"..."} +``` + +浏览器打开 Worker 根地址,应显示签到控制台。 + +## 6. 使用 Wrangler 手工部署(备用) + +在 `worker` 目录执行: + +```bash +wrangler deploy +``` + +部署完成后会得到类似地址: + +```text +https://newapi-checkin.<你的-workers-subdomain>.workers.dev +``` + +健康检查: + +```bash +curl https://newapi-checkin.<你的-workers-subdomain>.workers.dev/api/health +``` + +预期响应: + +```json +{"ok":true,"service":"newapi-checkin-worker","database":"connected","time":"..."} +``` + +浏览器直接打开 Worker 根地址。配置页面、结果看板和 API 均由同一个 Worker 域名提供,无需 GitHub Pages,也无需配置 CORS。 + +## 7. 连接 GitHub Actions + +账号字段获取和控制台录入步骤见 [FIRST_RUN.md](FIRST_RUN.md)。完成至少一个账号录入后,再连接 GitHub Actions。 + +GitHub Actions 只需要两个必填 Secrets: + +| GitHub Secret | 值 | +|---------------|----| +| `CHECKIN_WORKER_URL` | Worker 根地址,不带末尾 `/` | +| `CHECKIN_RUNNER_TOKEN` | 与 Worker 的 `RUNNER_TOKEN` 完全一致 | + +配置步骤: + +1. 打开 GitHub 仓库。 +2. 进入 `Settings` -> `Secrets and variables` -> `Actions`。 +3. 打开 `Secrets` 标签。 +4. 添加 `CHECKIN_WORKER_URL`。 +5. 添加 `CHECKIN_RUNNER_TOKEN`。 +6. 可选添加 `DINGTALK_WEBHOOK` 和 `DINGTALK_SECRET`。 + +工作流会将 GitHub Secrets 映射为 Runner 环境变量: + +```yaml +env: + CHECKIN_WORKER_URL: ${{ secrets.CHECKIN_WORKER_URL }} + CHECKIN_RUNNER_TOKEN: ${{ secrets.CHECKIN_RUNNER_TOKEN }} +``` + +两个系统的绑定关系: + +```text +Cloudflare RUNNER_TOKEN + = +GitHub CHECKIN_RUNNER_TOKEN +``` + +`CHECKIN_WORKER_URL` 指向部署后的 Worker,形成完整连接。 + +Worker 地址使用根地址,不添加 `/api` 和末尾斜杠。 + +### Worker 与 GitHub 的实际调用过程 + +1. Actions 将两个 GitHub Secrets 注入 `checkin.py`。 +2. `checkin.py` 携带 Bearer Token 请求 Worker `/api/runner/config`。 +3. Worker 校验 Token,读取并解密 D1 中的启用账号。 +4. Runner 使用账号 Session 请求各 NewAPI 站点。 +5. Runner 将脱敏结果提交到 Worker `/api/runner/report`。 +6. Worker 保存结果,控制台从 D1 查询并展示。 + +## 8. 首次联调 + +1. 在 GitHub 仓库进入 `Actions`。 +2. 左侧选择 `NewAPI 自动签到`。 +3. 点击 `Run workflow`。 +4. 分支选择 `main`。 +5. 再次点击 `Run workflow` 并打开新运行日志。 + +工作流日志应依次出现: + +```text +[Worker] 正在获取启用账号配置... +[Worker] 成功获取 N 个账号配置 +签到完成: 成功 N, 失败 N +[Worker] 签到结果上报成功 +``` + +执行结束后刷新 Worker 控制台,应看到: + +- 最近运行时间 +- 成功和失败账号数 +- 成功率 +- 每个账号最近状态 +- 最近 30 次运行记录 + +验收标准:账号状态不再是“等待首跑”,运行历史出现本次执行时间,并且日志包含“签到结果上报成功”。 + +## 9. 本地联调 + +初始化本地 D1: + +```bash +wrangler d1 execute Check --local --file=./schema.sql +``` + +启动 Worker: + +```bash +wrangler dev +``` + +Runner 使用本地 Worker: + +```bash +export CHECKIN_WORKER_URL=http://127.0.0.1:8787 +export CHECKIN_RUNNER_TOKEN=与_dev_vars_中_RUNNER_TOKEN_一致 +python3 ../checkin.py +``` + +## 10. 更新部署 + +GitHub 自动部署模式下,将代码推送到 `main` 分支即可。手工部署模式执行: + +```bash +cd worker +wrangler deploy +``` + +`DATA_ENCRYPTION_KEY` 用于解密已有账号配置。保留该值可持续读取历史账号密文。密钥轮换需要先重新录入所有账号。 + +## 11. 故障排查 + +### 控制台提示访问口令错误 + +检查 Worker 的 `DASHBOARD_PASSWORD` 环境绑定,更新后重新部署。 + +### Actions 日志显示 Runner 未授权 + +检查 Cloudflare `RUNNER_TOKEN` 与 GitHub `CHECKIN_RUNNER_TOKEN` 是否完全一致。 + +### Actions 无法获取账号 + +检查以下项目: + +- `CHECKIN_WORKER_URL` 使用 Worker 根地址 +- Worker `/api/health` 可访问 +- 控制台中至少有一个启用账号 +- `/api/health` 返回 `database: connected`,Worker 已自动完成建表 + +### Worker 返回解密错误 + +当前 `DATA_ENCRYPTION_KEY` 与保存账号时使用的值不一致。恢复原密钥,或使用新密钥重新录入账号。 + +### 页面打开后显示静态资源绑定未配置 + +确认 `wrangler.toml` 包含: + +```toml +[assets] +directory = "./public" +binding = "ASSETS" +``` + +然后重新执行 `wrangler deploy`。 + +### Cloudflare Git 构建找不到 wrangler.toml + +检查 Workers Builds 的 `Root directory` 是否为 `worker`。构建命令执行目录必须包含 `package.json` 和 `wrangler.toml`。 + +### Worker 提示 Check 未定义 / `Cannot read properties of undefined (reading 'prepare')` + +原因:Cloudflare 自动资源配置尚未完成,或当前部署没有 `Check` D1 Binding。 + +处理步骤: + +1. 确认仓库的 `worker/wrangler.toml` 包含只有 `binding = "Check"` 的 `[[d1_databases]]`。 +2. 在 Worker `Deployments` 页面重新运行最新 Git 部署。 +3. 打开 `Settings` -> `Bindings`,确认存在名称区分大小写的 `Check` D1 Binding。 +4. 访问 `/api/health`,确认 `database` 为 `connected` 且 `missing` 不含 `Check`。 + +### GitHub 推送后没有触发 Worker 部署 + +检查以下设置: + +- Cloudflare GitHub App 是否仍有仓库访问权限 +- Workers Builds 的 Production branch 是否为 `main` +- Worker 的 Builds 设置是否启用自动部署 +- GitHub 提交是否已进入 `main` 分支 + +## 12. 安全建议 + +- 为三个敏感变量使用不同的长随机值。 +- 将 GitHub 仓库设为私有可进一步减少工作流信息暴露。 +- 定期轮换 `DASHBOARD_PASSWORD` 和 `RUNNER_TOKEN`。 +- 将 Worker 自定义域名接入 Cloudflare Access 可增加身份验证层。 +- 日志和截图中避免展示 Session、Runner Token 和加密密钥。 + +完整凭据分级、轮换流程和调试工具风险见 [SECURITY.md](SECURITY.md)。 diff --git a/cf_bypass.py b/cf_bypass.py index 268f589..baf4eab 100644 --- a/cf_bypass.py +++ b/cf_bypass.py @@ -27,21 +27,23 @@ def detect_cloudflare_block(status_code: int, response_text: str) -> Tuple[bool, - 403 + "Just a moment" / - 非 JSON 响应包含 bool: @@ -71,11 +75,13 @@ def _check_playwright(self) -> bool: def is_available(self) -> bool: return self._playwright_available - def _solve_cf_challenge(self, page, max_attempts: int = 5, wait_seconds: int = 8) -> bool: + def _solve_cf_challenge(self, page, max_attempts: int = 5, wait_seconds: int = 8, deadline: float = None) -> bool: """ 磻解 CF 验证挑战 """ for attempt in range(max_attempts): + if deadline is not None and time.monotonic() >= deadline: + break title = page.title() current_url = page.url print(f'[CF 绕过] 检查 CF 猡证状态 (尝试 {attempt + 1}/{max_attempts}): Title="{title[:50]}"') @@ -93,10 +99,12 @@ def _solve_cf_challenge(self, page, max_attempts: int = 5, wait_seconds: int = 8 print(f'[CF 绕过] CF 验证页面,等待自动解决 ({attempt + 1}/{max_attempts})...') try: - page.wait_for_load_state('networkidle', timeout=30000) + remaining_ms = 10000 if deadline is None else max(1, int((deadline - time.monotonic()) * 1000)) + page.wait_for_load_state('networkidle', timeout=min(10000, remaining_ms)) except Exception: pass - time.sleep(wait_seconds) + remaining = wait_seconds if deadline is None else max(0, deadline - time.monotonic()) + time.sleep(min(wait_seconds, remaining)) title = page.title() is_cf_challenge = ( @@ -112,7 +120,7 @@ def _solve_cf_challenge(self, page, max_attempts: int = 5, wait_seconds: int = 8 print('[CF 绕过] CF 验证未能自动解决') return False - def bypass_and_checkin(self, timeout: int = 90) -> Optional[dict]: + def bypass_and_checkin(self, checkin_path: str = '/api/user/sign_in', timeout: int = 90) -> Optional[dict]: """ 在同一个 Playwright 会话中完成 CF 绕过 + 笾到签到 @@ -129,6 +137,7 @@ def bypass_and_checkin(self, timeout: int = 90) -> Optional[dict]: return None print(f'[CF 绕过] 使用 Playwright 访问 {self._mask_url(self.base_url)}...') + deadline = time.monotonic() + timeout from playwright.sync_api import sync_playwright with sync_playwright() as p: @@ -155,18 +164,34 @@ def bypass_and_checkin(self, timeout: int = 90) -> Optional[dict]: Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh', 'en']}); """) - if self.session_cookie: - domain = self.base_url.replace('https://', '').replace('http://', '').split('/')[0] - context.add_cookies([ - {'name': 'session', 'value': self.session_cookie, 'domain': domain, 'path': '/'} - ]) + if self.session_cookie or self.cf_clearance: + from urllib.parse import urlparse + parsed_url = urlparse(self.base_url) + cookies = [] + if self.session_cookie: + cookies.append({ + 'name': 'session', + 'value': self.session_cookie, + 'domain': parsed_url.hostname, + 'path': '/', + 'secure': parsed_url.scheme == 'https', + }) + if self.cf_clearance: + cookies.append({ + 'name': 'cf_clearance', + 'value': self.cf_clearance, + 'domain': parsed_url.hostname, + 'path': '/', + 'secure': parsed_url.scheme == 'https', + }) + context.add_cookies(cookies) page = context.new_page() print('[CF 绕过] 正在加载页面并等待 CF 验证...') - page.goto(self.base_url, wait_until='domcontentloaded', timeout=timeout * 1000) + page.goto(self.base_url, wait_until='domcontentloaded', timeout=min(30000, max(1, int((deadline - time.monotonic()) * 1000)))) - cf_solved = self._solve_cf_challenge(page, max_attempts=6, wait_seconds=8) + cf_solved = self._solve_cf_challenge(page, max_attempts=6, wait_seconds=8, deadline=deadline) if not cf_solved: print('[CF 绕过] CF 验证无法自动通过,尝试直接签到...') @@ -174,47 +199,65 @@ def bypass_and_checkin(self, timeout: int = 90) -> Optional[dict]: print('[CF 绕过] CF 验证已通过,准备执行签到...') if self.user_id: - page.evaluate(f'() => localStorage.setItem("user", JSON.stringify({{"id": {self.user_id}}}))') + page.evaluate('(userId) => localStorage.setItem("user", JSON.stringify({id: userId}))', self.user_id) try: user_text = page.evaluate('() => localStorage.getItem("user")') - if not user_text and self.session_cookie: + if not user_text and self.session_cookie and deadline - time.monotonic() > 10: print('[CF 绕过] localStorage 无 user 数据, 尝试访问登录页...') - page.goto(f'{self.base_url}/login', wait_until='domcontentloaded', timeout=20000) - self._solve_cf_challenge(page, max_attempts=3, wait_seconds=5) + page.goto(f'{self.base_url}/login', wait_until='domcontentloaded', timeout=min(15000, max(1, int((deadline - time.monotonic()) * 1000)))) + self._solve_cf_challenge(page, max_attempts=3, wait_seconds=5, deadline=deadline) user_text = page.evaluate('() => localStorage.getItem("user")') except Exception: pass - checkin_result = page.evaluate('''async () => { + req_headers = {'Content-Type': 'application/json'} + if self.user_id: + req_headers['new-api-user'] = str(self.user_id) + remaining_ms = max(1, int((deadline - time.monotonic()) * 1000)) + checkin_result = page.evaluate('''async ({reqHeaders, checkinPath, requestDeadline}) => { try { - const resp = await fetch('/api/user/checkin', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - credentials: 'include' - }); - const text = await resp.text(); - try { - const data = JSON.parse(text); - const success = data.success === true || data.status === 'success' || data.ret === 1 || data.code === 0; - const message = data.message || data.msg || data.data || '签到完成'; - const msgStr = typeof message === 'string' ? message : JSON.stringify(message); - const alreadyKeywords = ['已签到', '已经签到', 'already', '重复签到']; - const alreadyCheckedIn = !success && alreadyKeywords.some(k => msgStr.includes(k)); - return { - success: success || alreadyCheckedIn, - alreadyCheckedIn, - message: msgStr, - httpStatus: resp.status, - data: data - }; - } catch(e) { - return { error: 'Response is not JSON: ' + text.substring(0, 200), httpStatus: resp.status, success: false }; + const paths = checkinPath === '/api/user/sign_in' + ? ['/api/user/sign_in', '/api/user/checkin'] + : [checkinPath]; + for (const path of paths) { + const requestTimeout = Math.max(1, requestDeadline - Date.now()); + const resp = await fetch(path, { + method: 'POST', + headers: reqHeaders, + credentials: 'include', + redirect: 'error', + signal: AbortSignal.timeout(requestTimeout) + }); + if (path === '/api/user/sign_in' && (resp.status === 404 || resp.status === 405)) continue; + const text = await resp.text(); + try { + const data = JSON.parse(text); + const success = data.success === true || data.status === 'success' || data.ret === 1 || data.code === 0; + const message = data.message || data.msg || data.data || '签到完成'; + const msgStr = typeof message === 'string' ? message : JSON.stringify(message); + const alreadyKeywords = ['已签到', '已经签到', '今日已签', '重复签到', 'already checked', 'already check-in', 'already checkin']; + const alreadyCheckedIn = !success && alreadyKeywords.some(k => msgStr.includes(k)); + return { + success: success || alreadyCheckedIn, + alreadyCheckedIn, + message: msgStr, + httpStatus: resp.status, + data: data + }; + } catch(e) { + return { error: 'Response is not JSON: ' + text.substring(0, 200), httpStatus: resp.status, success: false }; + } } + return { error: 'No supported check-in endpoint', success: false, httpStatus: 404 }; } catch(e) { return { error: e.message, success: false, httpStatus: 0 }; } - }''') + }''', { + 'reqHeaders': req_headers, + 'checkinPath': checkin_path, + 'requestDeadline': int(time.time() * 1000) + remaining_ms, + }) print(f'[CF 绕过] 磾到结果: {checkin_result.get("message", checkin_result.get("error", "unknown"))}') @@ -234,11 +277,12 @@ def _mask_url(url: str) -> str: try: from urllib.parse import urlparse parsed = urlparse(url) - domain_parts = parsed.netloc.split('.') + domain_parts = (parsed.hostname or '').split('.') if len(domain_parts) >= 2: masked_domain = f"{domain_parts[0]}.***." + '.'.join(domain_parts[-1:]) else: masked_domain = '***' - return f"{parsed.scheme}://{masked_domain}" + port = f':{parsed.port}' if parsed.port else '' + return f"{parsed.scheme}://{masked_domain}{port}" except Exception: - return 'https://***' \ No newline at end of file + return 'https://***' diff --git a/checkin.py b/checkin.py index 24b214f..6e09cc5 100644 --- a/checkin.py +++ b/checkin.py @@ -9,9 +9,11 @@ import sys import json import base64 +import math import requests from datetime import datetime from typing import Optional +from urllib.parse import urlparse try: from cf_bypass import detect_cloudflare_block, CloudflareBypasser @@ -39,13 +41,14 @@ def _mask_url(url: str) -> str: try: from urllib.parse import urlparse parsed = urlparse(url) - domain_parts = parsed.netloc.split('.') + domain_parts = (parsed.hostname or '').split('.') if len(domain_parts) >= 2: # 保留第一部分和最后一部分,中间用 *** 代替 masked_domain = f"{domain_parts[0]}.***." + '.'.join(domain_parts[-1:]) else: masked_domain = '***' - return f"{parsed.scheme}://{masked_domain}" + port = f':{parsed.port}' if parsed.port else '' + return f"{parsed.scheme}://{masked_domain}{port}" except Exception: return 'https://***' @@ -58,15 +61,31 @@ def _mask_user_id(user_id: str) -> str: return '****' def __init__(self, base_url: str, session_cookie: str, user_id: str = None, cf_clearance: str = None): - self.base_url = base_url.rstrip('/') + if not isinstance(base_url, str): + raise ValueError('站点 URL 必须是字符串') + parsed_url = urlparse(base_url.strip()) + if parsed_url.scheme not in ('http', 'https') or not parsed_url.hostname: + raise ValueError('站点 URL 必须是有效的 HTTP(S) 地址') + if not isinstance(session_cookie, str) or not session_cookie.strip(): + raise ValueError('Session Cookie 不能为空') + + self.base_url = f'{parsed_url.scheme}://{parsed_url.netloc}' self.session_cookie = session_cookie + self.user_id = str(user_id).strip() if user_id is not None and str(user_id).strip() else None self.original_cf_clearance = cf_clearance self.cf_bypassed = False + self.last_user_info_error = None self.session = requests.Session() - self.session.cookies.set('session', session_cookie) + cookie_domain = parsed_url.hostname if ('.' in parsed_url.hostname or ':' in parsed_url.hostname) else f'{parsed_url.hostname}.local' + cookie_options = { + 'domain': cookie_domain, + 'path': '/', + 'secure': parsed_url.scheme == 'https', + } + self.session.cookies.set('session', session_cookie, **cookie_options) if cf_clearance: - self.session.cookies.set('cf_clearance', cf_clearance) + self.session.cookies.set('cf_clearance', cf_clearance, **cookie_options) self.session.headers.update({ 'Accept': 'application/json, text/plain, */*', @@ -74,15 +93,13 @@ def __init__(self, base_url: str, session_cookie: str, user_id: str = None, cf_c 'Cache-Control': 'no-store', 'Pragma': 'no-cache', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Origin': self.base_url, + 'Referer': f'{self.base_url}/', + 'X-Requested-With': 'XMLHttpRequest', }) - if user_id: - self.user_id = user_id - self.session.headers.update({'new-api-user': str(user_id)}) - else: - self.user_id = self._extract_user_id_from_session(session_cookie) - if self.user_id: - self.session.headers.update({'new-api-user': str(self.user_id)}) + if self.user_id: + self.session.headers.update({'new-api-user': self.user_id}) def _extract_user_id_from_session(self, session_cookie: str) -> Optional[str]: """ @@ -118,6 +135,84 @@ def _extract_user_id_from_session(self, session_cookie: str) -> Optional[str]: return None + @staticmethod + def _already_checked_in(message) -> bool: + if message is None: + return False + msg = message if isinstance(message, str) else str(message) + keywords = ( + '已签到', '已经签到', '今日已签', '重复签到', + 'already checked', 'already check-in', 'already checkin', + ) + lower = msg.lower() + return any((k.lower() in lower) if all(ord(c) < 128 for c in k) else (k in msg) for k in keywords) + + @staticmethod + def _normalize_checkin_payload(data) -> dict: + """统一解析签到接口,兼容 data=null、嵌套 data 与「已签到」伪失败。""" + result = { + 'success': False, + 'message': '', + 'checkin_date': None, + 'quota_awarded': None, + 'already_checked_in': False, + } + if not isinstance(data, dict): + result['message'] = f'响应格式错误: {type(data).__name__}' + return result + + message = data.get('message') + if message is None: + message = data.get('msg') + if message is None and not isinstance(data.get('data'), (dict, list, type(None))): + message = data.get('data') + if message is None: + message = '' + if not isinstance(message, str): + try: + message = json.dumps(message, ensure_ascii=False) + except Exception: + message = str(message) + + success_flag = ( + data.get('success') is True + or data.get('status') == 'success' + or data.get('ret') == 1 + or data.get('code') == 0 + ) + already = NewAPICheckin._already_checked_in(message) + result['already_checked_in'] = already + result['success'] = bool(success_flag or already) + result['message'] = message or ('签到成功' if result['success'] else '签到失败') + + candidates = [] + payload = data.get('data') + if isinstance(payload, dict): + candidates.append(payload) + if isinstance(payload.get('data'), dict): + candidates.append(payload['data']) + candidates.append(data) + for item in candidates: + if not isinstance(item, dict): + continue + if result['checkin_date'] is None and item.get('checkin_date') is not None: + result['checkin_date'] = item.get('checkin_date') + if result['quota_awarded'] is None and item.get('quota_awarded') is not None: + result['quota_awarded'] = NewAPICheckin._coerce_number(item.get('quota_awarded')) + return result + + @staticmethod + def _coerce_number(value, default=None): + if value is None or isinstance(value, bool): + return default + try: + number = float(value) + except (TypeError, ValueError): + return default + if not math.isfinite(number): + return default + return int(number) if number.is_integer() else number + def get_user_info(self, verbose: bool = False) -> Optional[dict]: """ 获取用户信息 @@ -127,8 +222,9 @@ def get_user_info(self, verbose: bool = False) -> Optional[dict]: Args: verbose: 是否显示详细调试信息 """ + self.last_user_info_error = None try: - resp = self.session.get(f'{self.base_url}/api/user/self', timeout=30) + resp = self.session.get(f'{self.base_url}/api/user/self', timeout=30, allow_redirects=False) if verbose: print(f' [调试] HTTP 状态码: {resp.status_code}') @@ -136,6 +232,7 @@ def get_user_info(self, verbose: bool = False) -> Optional[dict]: # 检查认证失败 if resp.status_code == 401: + self.last_user_info_error = 'session' print(f'[错误] 认证失败 (401): Session 可能已过期') if verbose: print(f' [调试] 完整响应: {resp.text[:500]}') @@ -149,10 +246,12 @@ def get_user_info(self, verbose: bool = False) -> Optional[dict]: if detect_cloudflare_block: is_blocked, reason = detect_cloudflare_block(resp.status_code, resp.text) if is_blocked: + self.last_user_info_error = 'cloudflare' print(f'[CF] 获取用户信息时检测到 Cloudflare 拦截: {reason}') print(f'[CF] 该站点需要 CF 绕过才能访问') return None print(f'[错误] 响应格式错误 (HTTP {resp.status_code}): 无法解析 JSON') + self.last_user_info_error = 'response' if verbose: print(f' [调试] 原始响应: {resp.text[:500]}') return None @@ -172,20 +271,25 @@ def get_user_info(self, verbose: bool = False) -> Optional[dict]: }) return user_data else: + self.last_user_info_error = 'api' if verbose: print(f' [调试] API 返回失败: {data.get("message", "未知错误")}') else: + self.last_user_info_error = 'http' print(f'[错误] HTTP {resp.status_code}: {data.get("message", "未知错误")}') return None except requests.exceptions.Timeout: + self.last_user_info_error = 'network' print(f'[错误] 请求超时') return None except requests.exceptions.RequestException as e: + self.last_user_info_error = 'network' print(f'[错误] 网络请求失败: {e}') return None except Exception as e: + self.last_user_info_error = 'unknown' print(f'[错误] 未知错误: {e}') if verbose: import traceback @@ -211,43 +315,48 @@ def checkin(self) -> dict: 'quota_awarded': None } - try: - resp = self.session.post(f'{self.base_url}/api/user/checkin', timeout=30) - - if resp.status_code == 401: - result['message'] = '认证失败: Session 可能已过期,请重新获取' - return result - - try: - data = resp.json() - except json.JSONDecodeError: - if detect_cloudflare_block: - is_blocked, reason = detect_cloudflare_block(resp.status_code, resp.text) - if is_blocked: - print(f'[CF] 检测到 Cloudflare 拦截: {reason}') - return self._cf_bypass_checkin() - content_preview = resp.text[:200] if resp.text else '(空响应)' - result['message'] = f'响应格式错误 (HTTP {resp.status_code}): {content_preview}' - return result - - if detect_cloudflare_block and resp.status_code in (403, 503): - is_blocked, reason = detect_cloudflare_block(resp.status_code, json.dumps(data)) - if is_blocked: - print(f'[CF] 检测到 Cloudflare 拦截: {reason}') - return self._cf_bypass_checkin() + if not self.user_id: + result['message'] = '缺少用户 ID:请在控制台填写浏览器请求头 new-api-user 的值后重试' + return result - if resp.status_code == 200: - if data.get('success'): + try: + # NewAPI/OneAPI 的标准签到端点是 sign_in;checkin 仅保留给旧实现。 + for path in ('/api/user/sign_in', '/api/user/checkin'): + resp = self.session.post(f'{self.base_url}{path}', timeout=30, allow_redirects=False) + + if resp.status_code in (404, 405) and path == '/api/user/sign_in': + continue + if resp.status_code == 401: + result['message'] = '认证失败: Session 可能已过期或用户 ID 不匹配,请重新获取' + return result + + try: + data = resp.json() + except json.JSONDecodeError: + if detect_cloudflare_block: + is_blocked, reason = detect_cloudflare_block(resp.status_code, resp.text) + if is_blocked: + print(f'[CF] 签到接口被 Cloudflare 拦截: {reason}') + return self._cf_bypass_checkin(path) + content_preview = resp.text[:200] if resp.text else '(空响应)' + result['message'] = f'响应格式错误 (HTTP {resp.status_code}): {content_preview}' + return result + + if resp.status_code == 200: + normalized = self._normalize_checkin_payload(data) + result['success'] = normalized['success'] + result['message'] = normalized['message'] + result['checkin_date'] = normalized['checkin_date'] + result['quota_awarded'] = normalized['quota_awarded'] + return result + + message = data.get('message') or data.get('msg') or '未知错误' + if self._already_checked_in(message): result['success'] = True - result['message'] = data.get('message', '签到成功') - - checkin_data = data.get('data', {}) - result['checkin_date'] = checkin_data.get('checkin_date') - result['quota_awarded'] = checkin_data.get('quota_awarded') + result['message'] = message else: - result['message'] = data.get('message', '签到失败') - else: - result['message'] = f'HTTP {resp.status_code}: {data.get("message", "未知错误")}' + result['message'] = f'HTTP {resp.status_code}: {message}' + return result except requests.exceptions.Timeout: result['message'] = '请求超时' @@ -258,7 +367,7 @@ def checkin(self) -> dict: return result - def _cf_bypass_checkin(self) -> dict: + def _cf_bypass_checkin(self, path: str = '/api/user/sign_in') -> dict: """ CF 绕过签到流程 @@ -276,14 +385,16 @@ def _cf_bypass_checkin(self) -> dict: result['message'] = 'Cloudflare 拦截: 需安装 Playwright 才能自动绕过 (pip install playwright && playwright install chromium)' return result - bypasser = CloudflareBypasser(self.base_url, self.session_cookie, self.user_id) + bypasser = CloudflareBypasser( + self.base_url, self.session_cookie, self.user_id, self.original_cf_clearance + ) if not bypasser.is_available(): result['message'] = 'Cloudflare 拦截: Playwright 未正确安装' return result print('[CF] 开始 Playwright 绕过流程...') - browser_result = bypasser.bypass_and_checkin() + browser_result = bypasser.bypass_and_checkin(path) if not browser_result: result['message'] = 'Cloudflare 绕过失败: 无法通过 CF 验证' @@ -292,22 +403,33 @@ def _cf_bypass_checkin(self) -> dict: self.cf_bypassed = True if browser_result.get('error'): - result['message'] = f'CF 绕过后签到失败: {browser_result["error"]}' + error = browser_result['error'] + if 'Just a moment' in error or 'challenge-platform' in error or 'cf-challenge' in error: + result['message'] = 'Cloudflare 验证仍在进行:签到接口返回挑战页 HTML' + else: + result['message'] = f'CF 回退后签到失败: {error}' return result - if browser_result.get('alreadyCheckedIn'): - result['success'] = True - result['message'] = browser_result.get('message', '今日已签到 (CF绕过)') - elif browser_result.get('success'): + # Playwright 返回: {success, alreadyCheckedIn, message, data: } + api_data = browser_result.get('data') + if not isinstance(api_data, dict): + api_data = { + 'success': browser_result.get('success'), + 'message': browser_result.get('message'), + } + + normalized = self._normalize_checkin_payload(api_data) + if browser_result.get('alreadyCheckedIn') or browser_result.get('success') or normalized['success']: result['success'] = True - result['message'] = browser_result.get('message', '签到成功 (CF绕过)') - data = browser_result.get('data', {}) - if isinstance(data, dict): - checkin_data = data.get('data', data) - result['checkin_date'] = checkin_data.get('checkin_date') - result['quota_awarded'] = checkin_data.get('quota_awarded') + result['message'] = ( + browser_result.get('message') + or normalized['message'] + or ('今日已签到 (CF绕过)' if browser_result.get('alreadyCheckedIn') else '签到成功 (CF绕过)') + ) + result['checkin_date'] = normalized['checkin_date'] + result['quota_awarded'] = normalized['quota_awarded'] else: - result['message'] = browser_result.get('message', 'CF 绕过后签到失败') + result['message'] = browser_result.get('message') or normalized['message'] or 'CF 绕过后签到失败' return result @@ -325,7 +447,8 @@ def get_checkin_history(self, month: str = None) -> Optional[dict]: resp = self.session.get( f'{self.base_url}/api/user/checkin', params={'month': month}, - timeout=30 + timeout=30, + allow_redirects=False, ) if resp.status_code == 200: data = resp.json() @@ -348,6 +471,15 @@ def parse_accounts(accounts_str: str) -> list: """ accounts = [] + def valid_account(url, session) -> bool: + if not isinstance(url, str) or not url.strip() or not isinstance(session, str) or not session.strip(): + return False + try: + parsed = urlparse(url.strip()) + return parsed.scheme in ('http', 'https') and bool(parsed.hostname) + except ValueError: + return False + if not accounts_str: return accounts @@ -356,18 +488,24 @@ def parse_accounts(accounts_str: str) -> list: data = json.loads(accounts_str) if isinstance(data, list): for item in data: - if isinstance(item, dict) and 'url' in item and 'session' in item: + if ( + isinstance(item, dict) + and valid_account(item.get('url'), item.get('session')) + ): account = { - 'url': item['url'], - 'session': item['session'], - 'name': item.get('name', '') + 'url': item['url'].strip(), + 'session': item['session'].strip(), + 'name': str(item.get('name') or '') } + # Worker 下发的 ID 用于将签到结果写回对应的面板账号。 + if item.get('account_id') is not None: + account['account_id'] = item['account_id'] # 如果提供了 user_id,添加到账号信息中 - if 'user_id' in item: - account['user_id'] = item['user_id'] + if item.get('user_id') is not None: + account['user_id'] = str(item['user_id']).strip() # 如果提供了 cf_clearance,添加到账号信息中 - if 'cf_clearance' in item: - account['cf_clearance'] = item['cf_clearance'] + if isinstance(item.get('cf_clearance'), str) and item['cf_clearance'].strip(): + account['cf_clearance'] = item['cf_clearance'].strip() accounts.append(account) return accounts except json.JSONDecodeError: @@ -378,11 +516,12 @@ def parse_accounts(accounts_str: str) -> list: part = part.strip() if '#' in part: url, session = part.split('#', 1) - accounts.append({ - 'url': url.strip(), - 'session': session.strip(), - 'name': '' - }) + if valid_account(url, session): + accounts.append({ + 'url': url.strip(), + 'session': session.strip(), + 'name': '' + }) return accounts @@ -467,6 +606,77 @@ def load_config_from_cloud(config_url: str, config_auth: str = None) -> Optional return None +def load_config_from_worker(worker_url: str, runner_token: str) -> Optional[str]: + """从 Cloudflare Worker 获取加密保存的账号配置。""" + if not worker_url or not runner_token: + return None + + try: + print('[Worker] 正在获取启用账号配置...') + resp = requests.get( + f'{worker_url.rstrip("/")}/api/runner/config', + headers={'Authorization': f'Bearer {runner_token}'}, + timeout=30 + ) + if resp.status_code != 200: + try: + error = resp.json().get('error', resp.text[:200]) + except json.JSONDecodeError: + error = resp.text[:200] + print(f'[Worker] 获取配置失败: HTTP {resp.status_code} - {error}') + return None + data = resp.json() + accounts = data.get('accounts') if isinstance(data, dict) else None + if not isinstance(accounts, list): + print('[Worker] 配置响应格式错误') + return None + if not accounts: + print('[Worker] 没有启用的签到账号,请先在 Worker 控制台添加或启用账号') + return None + print(f'[Worker] 成功获取 {len(accounts)} 个账号配置') + return json.dumps(accounts) + except (requests.exceptions.RequestException, json.JSONDecodeError) as exc: + print(f'[Worker] 获取配置失败: {exc}') + return None + + +def report_results_to_worker(worker_url: str, runner_token: str, execution_time: str, + results: list, total: int, success_count: int, + fail_count: int) -> bool: + """将本次执行结果上报到 Cloudflare Worker。""" + if not worker_url or not runner_token: + return False + + payload = { + 'execution_time': execution_time, + 'total': total, + 'success_count': success_count, + 'fail_count': fail_count, + 'results': results, + } + try: + resp = requests.post( + f'{worker_url.rstrip("/")}/api/runner/report', + headers={ + 'Authorization': f'Bearer {runner_token}', + 'Content-Type': 'application/json', + }, + json=payload, + timeout=30 + ) + if resp.status_code in (200, 201): + print('[Worker] 签到结果上报成功') + return True + try: + error = resp.json().get('error', resp.text[:200]) + except json.JSONDecodeError: + error = resp.text[:200] + print(f'[Worker] 签到结果上报失败: HTTP {resp.status_code} - {error}') + except requests.exceptions.RequestException as exc: + print(f'[Worker] 签到结果上报失败: {exc}') + return False + + def main(): """主函数""" import pytz @@ -479,10 +689,15 @@ def main(): config_url = os.environ.get('CONFIG_URL', '') config_auth = os.environ.get('CONFIG_AUTH', '') + worker_url = os.environ.get('CHECKIN_WORKER_URL', '') + runner_token = os.environ.get('CHECKIN_RUNNER_TOKEN', '') accounts_str = '' - if config_url: + if worker_url and runner_token: + accounts_str = load_config_from_worker(worker_url, runner_token) or '' + + if not accounts_str and config_url: accounts_str = load_config_from_cloud(config_url, config_auth) or '' if not accounts_str: @@ -490,7 +705,7 @@ def main(): if not accounts_str: print('[错误] 未配置账号信息') - print('请设置 CONFIG_URL(云端配置)或 NEWAPI_ACCOUNTS(本地配置)环境变量') + print('请设置 CHECKIN_WORKER_URL 和 CHECKIN_RUNNER_TOKEN 环境变量') sys.exit(1) accounts = parse_accounts(accounts_str) @@ -527,7 +742,12 @@ def main(): masked_username = username[:3] + '***' if len(username) > 3 else '***' print(f' 用户: {masked_username}') else: - print(' 用户: 获取失败(可能 session 已过期)') + if client.last_user_info_error == 'cloudflare': + print(' 用户: 获取失败(Cloudflare 拦截)') + elif client.last_user_info_error == 'session': + print(' 用户: 获取失败(Session 已失效或用户 ID 不匹配)') + else: + print(' 用户: 获取失败(用户信息接口异常)') # 执行签到 result = client.checkin() @@ -542,8 +762,8 @@ def main(): print(f' 日期: {result["checkin_date"]}') # 显示获得的额度(格式化显示) - if result['quota_awarded']: - quota = result['quota_awarded'] + quota = NewAPICheckin._coerce_number(result.get('quota_awarded')) + if quota: # 格式化额度显示 if quota >= 1000000: quota_str = f'{quota / 1000000:.2f}M' @@ -557,8 +777,8 @@ def main(): history = client.get_checkin_history() if history and history.get('stats'): stats = history['stats'] - checkin_count = stats.get('checkin_count', 0) - total_quota = stats.get('total_quota', 0) + checkin_count = NewAPICheckin._coerce_number(stats.get('checkin_count'), 0) + total_quota = NewAPICheckin._coerce_number(stats.get('total_quota'), 0) if total_quota >= 1000000: total_str = f'{total_quota / 1000000:.2f}M' elif total_quota >= 1000: @@ -569,10 +789,11 @@ def main(): # 收集结果用于钉钉通知 account_result = { + 'account_id': account.get('account_id'), 'name': name, 'success': True, - 'message': result['message'], - 'quota_awarded': result.get('quota_awarded'), + 'message': str(result.get('message') or ''), + 'quota_awarded': quota, 'checkin_count': checkin_count } checkin_results.append(account_result) @@ -581,8 +802,9 @@ def main(): print(f' 结果: ❌ {result["message"]}') # 收集结果用于钉钉通知 - message = result.get('message', '') + message = str(result.get('message') or '') account_result = { + 'account_id': account.get('account_id'), 'name': name, 'success': False, 'message': message, @@ -604,6 +826,11 @@ def main(): elif os.environ.get('DINGTALK_WEBHOOK'): print('[警告] 已配置 DINGTALK_WEBHOOK 但无法导入通知模块') + report_results_to_worker( + worker_url, runner_token, execution_time, checkin_results, + len(accounts), success_count, fail_count + ) + # 如果全部失败则返回错误码 if fail_count == len(accounts): sys.exit(1) @@ -611,6 +838,3 @@ def main(): if __name__ == '__main__': main() - -# === DINGTALK NOTIFICATION PATCH === -# This section was added to send DingTalk notifications diff --git a/config_generator.html b/config_generator.html deleted file mode 100644 index f8de699..0000000 --- a/config_generator.html +++ /dev/null @@ -1,2569 +0,0 @@ - - - - - - NewAPI 配置生成器 - - - -
-
- ← 返回首页 -

🚀 NewAPI 配置生成器

-

轻松生成多账号签到配置,同站点多账号只需填写一次 URL

-
- -
- -
- -
- - -
- -
- - - - - -
- - -
- - - -
-

💡 使用提示:

-
    -
  • 站点分组:同一站点的 URL 和 CF Clearance 只需填写一次,站点下可添加多个账号
  • -
  • 账号信息:每个账号只需填写 Session、用户ID 和备注名称
  • -
  • 保存到本地:将配置保存到浏览器本地存储,下次访问自动加载
  • -
  • 明文存储:数据以明文形式存储在浏览器中,请注意安全
  • -
-
- - -
- - - - - - - - - - \ No newline at end of file diff --git a/config_helper.py b/config_helper.py index 74e688c..d135305 100644 --- a/config_helper.py +++ b/config_helper.py @@ -35,11 +35,11 @@ def get_yes_no(prompt: str, default: bool = True) -> bool: return value in ['y', 'yes', '是'] -def test_account(url: str, session: str) -> bool: +def test_account(url: str, session: str, user_id: str) -> bool: """测试账号配置是否有效""" try: from checkin import NewAPICheckin - client = NewAPICheckin(url, session) + client = NewAPICheckin(url, session, user_id) user_info = client.get_user_info() if user_info: print(f' ✅ 测试成功!用户名: {user_info.get("username")}') @@ -93,7 +93,7 @@ def collect_accounts(): # 是否测试 if get_yes_no('是否测试此账号配置', True): print('正在测试...') - test_account(url, session) + test_account(url, session, user_id) # 添加到列表 account_data = { @@ -116,18 +116,9 @@ def collect_accounts(): return accounts -def generate_config(accounts: list) -> dict: - """生成配置字符串""" - # JSON 格式(推荐) - json_config = json.dumps(accounts, ensure_ascii=False, indent=2) - - # 简单格式 - simple_config = ','.join([f"{acc['url']}#{acc['session']}" for acc in accounts]) - - return { - 'json': json_config, - 'simple': simple_config - } +def generate_config(accounts: list) -> str: + """生成包含用户 ID 的 JSON 配置。""" + return json.dumps(accounts, ensure_ascii=False, indent=2) def save_to_file(content: str, filename: str): @@ -158,18 +149,11 @@ def main(): print('配置生成成功!') print('=' * 60) - configs = generate_config(accounts) - - # 显示 JSON 格式(推荐) - print('\n【方式 1】JSON 格式(推荐,支持备注):') - print('-' * 60) - print(configs['json']) - print('-' * 60) + config = generate_config(accounts) - # 显示简单格式 - print('\n【方式 2】简单格式(不支持备注):') + print('\n【JSON 配置】:') print('-' * 60) - print(configs['simple']) + print(config) print('-' * 60) # 使用说明 @@ -185,24 +169,13 @@ def main(): # 保存选项 if get_yes_no('\n是否保存配置到文件', True): - print('\n选择保存格式:') - print('1. JSON 格式(推荐)') - print('2. 简单格式') - print('3. 两种都保存') - - choice = get_input('请选择 (1/2/3)', '1') - - if choice in ['1', '3']: - save_to_file(configs['json'], 'newapi_accounts.json') - - if choice in ['2', '3']: - save_to_file(configs['simple'], 'newapi_accounts.txt') + save_to_file(config, 'newapi_accounts.json') print('\n' + '=' * 60) print('配置完成!') print('=' * 60) print('\n💡 提示:') - print('- JSON 文件和 TXT 文件已添加到 .gitignore,不会被提交') + print('- JSON 文件已添加到 .gitignore,不会被提交') print('- 请妥善保管 Session Cookie,不要泄露给他人') print('- Session 通常 7-30 天过期,请定期更新') diff --git a/dingtalk_notifier.py b/dingtalk_notifier.py index 3a46838..69a8f53 100644 --- a/dingtalk_notifier.py +++ b/dingtalk_notifier.py @@ -10,6 +10,7 @@ import time import urllib.parse import json +import math import os from datetime import datetime from typing import Optional, List, Dict, Any @@ -146,6 +147,27 @@ def _send(self, data: dict) -> bool: return False +def _number(value, default=0): + if value is None or isinstance(value, bool): + return default + try: + number = float(value) + except (TypeError, ValueError): + return default + if not math.isfinite(number): + return default + return int(number) if number.is_integer() else number + + +def _markdown_cell(value, default='') -> str: + return str(value if value is not None else default).replace('|', '\\|').replace('\r', ' ').replace('\n', ' ') + + +def _session_expired(result: Dict[str, Any]) -> bool: + message = str(result.get('message') or '') + return bool(result.get('session_expired') is True or 'session' in message.lower() or '认证' in message or '过期' in message) + + def format_quota(quota: int) -> str: """ 格式化额度显示 @@ -156,6 +178,7 @@ def format_quota(quota: int) -> str: Returns: 格式化后的字符串 """ + quota = _number(quota) if quota >= 1000000: return f'{quota / 1000000:.2f}M' elif quota >= 1000: @@ -181,8 +204,8 @@ def build_checkin_report(results: List[Dict[str, Any]], execution_time: str) -> Returns: Markdown 格式的报告内容 """ - success_list = [r for r in results if r.get('success')] - fail_list = [r for r in results if not r.get('success')] + success_list = [r for r in results if r.get('success') is True] + fail_list = [r for r in results if r.get('success') is not True] # 标题 lines = [ @@ -201,11 +224,11 @@ def build_checkin_report(results: List[Dict[str, Any]], execution_time: str) -> lines.append('| 账号 | 奖励 | 详情 |') lines.append('|------|------|------|') for r in success_list: - name = r.get('name', '未知账号') - quota = r.get('quota_awarded', 0) + name = _markdown_cell(r.get('name'), '未知账号') + quota = _number(r.get('quota_awarded')) quota_str = f'+{format_quota(quota)}' if quota else '-' - checkin_count = r.get('checkin_count') - detail = f'已签 {checkin_count} 天' if checkin_count else r.get('message', '成功') + checkin_count = _number(r.get('checkin_count')) + detail = _markdown_cell(f'已签 {checkin_count} 天' if checkin_count else r.get('message'), '成功') lines.append(f'| {name} | {quota_str} | {detail} |') lines.append('') @@ -216,12 +239,12 @@ def build_checkin_report(results: List[Dict[str, Any]], execution_time: str) -> lines.append('| 账号 | 原因 |') lines.append('|------|------|') for r in fail_list: - name = r.get('name', '未知账号') - message = r.get('message', '未知错误') + name = _markdown_cell(r.get('name'), '未知账号') + message = str(r.get('message') or '未知错误') # 标注 session 失效 - if r.get('session_expired') or 'session' in message.lower() or '认证' in message or '过期' in message: + if _session_expired(r): message = f'⚠️ {message}' - lines.append(f'| {name} | {message} |') + lines.append(f'| {name} | {_markdown_cell(message)} |') lines.append('') # 汇总 @@ -240,10 +263,7 @@ def build_checkin_report(results: List[Dict[str, Any]], execution_time: str) -> lines.append(f'**汇总**: 成功 {success_count},失败 {fail_count}') # 如果有 session 失效的账号,添加提醒 - expired_accounts = [r for r in fail_list if r.get('session_expired') or - 'session' in r.get('message', '').lower() or - '认证' in r.get('message', '') or - '过期' in r.get('message', '')] + expired_accounts = [r for r in fail_list if _session_expired(r)] if expired_accounts: lines.append('') lines.append('> ⚠️ **注意**: 部分账号 Session 已失效,请及时更新 Cookie!') @@ -278,8 +298,8 @@ def send_checkin_notification(results: List[Dict[str, Any]], execution_time: Opt report = build_checkin_report(results, execution_time) # 生成标题(用于消息列表预览) - success_count = len([r for r in results if r.get('success')]) - fail_count = len([r for r in results if not r.get('success')]) + success_count = len([r for r in results if r.get('success') is True]) + fail_count = len([r for r in results if r.get('success') is not True]) if fail_count == 0: title = f'✅ 签到成功 ({success_count}个账号)' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ed1c260 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,106 @@ +# 系统架构 + +## 概览 + +Check Console 采用 Worker-only Web 架构。Cloudflare Worker 同时提供 Dashboard 静态资源和 API,Cloudflare D1 保存账号与运行数据,GitHub Actions 负责定时执行 Python Runner。 + +```mermaid + U["用户浏览器"] -->|"Dashboard Token"| W["Cloudflare Worker"] + W -->|"Check Binding"| D["Cloudflare D1"] + G["GitHub Actions Runner"] -->|"Runner Token"| W + G -->|"Session 与用户 ID"| N["NewAPI 站点"] + G -->|"脱敏运行结果"| W +``` + +## 组件职责 + +### Cloudflare Worker + +`worker/src/index.js` 负责: + +- 托管 `worker/public/index.html`。 +- 提供健康检查、登录、Dashboard 和 Runner API。 +- 创建并维护 D1 表结构。 +- 加密和解密账号运行配置。 +- 校验 Dashboard Token 与 Runner Token。 +- 保存运行摘要并更新账号健康状态。 + +### Dashboard + +`worker/public/index.html` 是内嵌 CSS 和 JavaScript 的单文件界面,负责: + +- 使用访问口令登录。 +- 展示首次使用进度、运行摘要和最近历史。 +- 添加账号、更新凭据、启用或停用账号。 +- 在浏览器 `localStorage` 保存短期 Dashboard Token。 + +### Runner + +`checkin.py` 负责: + +- 按优先级加载 Worker、远程配置或环境变量账号。 +- 获取 NewAPI 用户信息。 +- 执行签到并兼容常见响应格式。 +- 查询本月签到统计。 +- 上报脱敏结果。 +- 调用可选钉钉通知模块。 + +### Cloudflare 回退 + +`cf_bypass.py` 在普通 HTTP 请求遇到 Cloudflare 挑战时提供 Playwright 浏览器回退。该流程受目标站点策略、浏览器环境和网络条件影响。 + +## 运行时数据流 + +```mermaid +sequenceDiagram + participant A as GitHub Actions + participant W as Worker + participant D as D1 + participant N as NewAPI + A->>W: GET /api/runner/config + W->>D: 查询并解密启用账号 + D-->>W: 账号密文 + W-->>A: 账号运行配置 + A->>N: 获取用户信息并签到 + N-->>A: 签到响应 + A->>W: POST /api/runner/report + W->>D: 保存运行与账号状态 +``` + +## 认证模型 + +| 访问方 | 凭据 | 校验方式 | 权限 | +|--------|------|----------|------| +| 浏览器登录 | `DASHBOARD_PASSWORD` | 与 Worker Secret 比较 | 签发 Dashboard Token | +| Dashboard | Dashboard Token | D1 中 SHA-256 哈希与过期时间 | 管理账号、查看结果 | +| GitHub Actions | `RUNNER_TOKEN` | 与 Worker Secret 比较 | 获取启用账号、上报结果 | + +## 数据模型 + +### accounts + +保存账号名称、站点 Origin、加密运行配置、启用状态、连续失败次数和最近结果。 + +### runs + +保存一次 Runner 执行的时间、账号总数、成功数和失败数。 + +### run_results + +保存一次运行中的账号级结果,包括消息、奖励额度、当月签到次数和 Session 失效标记。 + +### sessions + +保存 Dashboard Token 哈希、创建时间和过期时间。 + +## D1 生命周期 + +`worker/wrangler.toml` 仅声明 `Check` Binding。Wrangler 部署时按远端 D1 Binding 类型与名称继承已有数据库;远端缺少 `Check` 时自动配置新资源。Worker 每个实例首次处理请求时执行幂等建表语句。 + +## 关键约束 + +1. D1 Binding 变量名固定为 `Check`。 +2. Dashboard API 响应不包含账号密文或明文凭据。 +3. Runner API 是唯一返回解密账号配置的接口。 +4. `DATA_ENCRYPTION_KEY` 必须持续可用才能解密已有账号。 +5. 运行上报失败不改变已完成签到的实际结果。 diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md new file mode 100644 index 0000000..6317115 --- /dev/null +++ b/docs/DEVELOPER_GUIDE.md @@ -0,0 +1,126 @@ +# 开发者指南 + +## 环境要求 + +- Node.js 22+ +- Wrangler 4+ +- Python 3.11+ +- Chromium,由 Playwright 管理 + +## Worker 本地开发 + +准备本地变量: + +```bash +cd worker +cp .dev.vars.example .dev.vars +``` + +编辑 `.dev.vars`,使用专门的本地测试值。随后安装依赖并启动: + +```bash +npm install +npm run db:init:local +npm run dev +``` + +本地地址默认为 `http://127.0.0.1:8787`。 + +## Runner 本地开发 + +安装 Python 依赖: + +```bash +pip install -r requirements.txt +playwright install chromium +``` + +连接本地 Worker: + +```bash +export CHECKIN_WORKER_URL=http://127.0.0.1:8787 +export CHECKIN_RUNNER_TOKEN= +python3 checkin.py +``` + +## 配置优先级 + +Runner 按以下顺序加载账号: + +1. `CHECKIN_WORKER_URL` 与 `CHECKIN_RUNNER_TOKEN`。 +2. `CONFIG_URL` 与可选 `CONFIG_AUTH`。 +3. `NEWAPI_ACCOUNTS`。 + +Worker 模式是当前推荐路径,其余配置用于兼容已有部署。 + +## 常用验证 + +Python 语法检查: + +```bash +python3 -m py_compile checkin.py cf_bypass.py dingtalk_notifier.py +``` + +Python 回归测试: + +```bash +python3 -B -m unittest discover -v +``` + +Worker 语法检查: + +```bash +node --check worker/src/index.js +``` + +Wrangler 配置和打包检查: + +```bash +cd worker +wrangler deploy --dry-run +``` + +Git 补丁格式检查: + +```bash +git diff --check +``` + +`tests/` 当前覆盖 Cookie 域限制、账号输入校验、签到端点回退和 Cloudflare 页面识别。涉及鉴权或 D1 写入的改动还应补充对应测试后再扩展行为。 + +## 修改检查清单 + +### Worker API + +1. 保持 Dashboard 与 Runner 鉴权边界。 +2. 检查所有 Dashboard 响应中的敏感字段。 +3. 对 D1 修改使用参数绑定。 +4. 保持 `ensureTables()` 与 `worker/schema.sql` 一致。 +5. 同步更新 `INTERFACES.md`。 + +### Runner + +1. 保持日志中的 URL、用户 ID 和用户名脱敏。 +2. 为网络请求设置超时。 +3. 保持 `/api/user/sign_in` 与旧端点回退顺序。 +4. 将认证失败映射为 `session_expired`。 +5. 保持结果上报失败与签到结果解耦。 + +### Dashboard + +1. 使用同源相对 `/api` 路径。 +2. 保持 401 自动返回登录页。 +3. 避免在 DOM、日志或本地存储中保存账号 Session。 +4. 检查桌面和移动端布局。 + +## 兼容工具 + +| 文件 | 用途 | 注意事项 | +|------|------|----------| +| `config_helper.py` | 生成旧版 `NEWAPI_ACCOUNTS` | 终端输出包含完整账号配置 | +| `test_checkin.py` | 测试单个账号 | 使用真实凭据时保护终端记录 | +| `debug_session.py` | 调试 Session 编码 | 输出可能包含敏感会话内容 | + +## 文档同步 + +用户流程变化时更新 `README.md`、`FIRST_RUN.md` 或 `WORKER_DEPLOYMENT.md`。接口和架构变化时同步更新 `docs/`。安全边界变化时更新 `SECURITY.md`。 diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..0c54076 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,41 @@ +# Check Console 开发文档 + +本目录记录 Check Console 的实现架构、接口契约和开发流程。用户部署操作以仓库根目录的指南为准。 + +## 文档索引 + +| 文档 | 内容 | +|------|------| +| [ARCHITECTURE.md](ARCHITECTURE.md) | 系统边界、组件职责、数据流与数据模型 | +| [INTERFACES.md](INTERFACES.md) | Worker HTTP API、鉴权和请求响应字段 | +| [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md) | 本地开发、验证命令和修改约定 | +| [OPERATIONS.md](OPERATIONS.md) | 运行机制、可观测性、升级和故障定位 | + +## 用户文档 + +| 文档 | 内容 | +|------|------| +| [README.md](../README.md) | 项目概览与快速部署 | +| [FIRST_RUN.md](../FIRST_RUN.md) | 账号录入与首次签到 | +| [WORKER_DEPLOYMENT.md](../WORKER_DEPLOYMENT.md) | 完整部署与排障 | +| [SECURITY.md](../SECURITY.md) | 凭据、加密和安全边界 | + +## 源码入口 + +| 模块 | 文件 | +|------|------| +| GitHub Actions Runner | `checkin.py` | +| Cloudflare 检测与浏览器回退 | `cf_bypass.py` | +| 钉钉通知 | `dingtalk_notifier.py` | +| Worker API | `worker/src/index.js` | +| Dashboard | `worker/public/index.html` | +| D1 Schema | `worker/schema.sql` | +| Worker 配置 | `worker/wrangler.toml` | + +## 文档维护原则 + +1. API 行为以 `worker/src/index.js` 为准。 +2. Runner 行为以 `checkin.py` 为准。 +3. 部署配置以 `worker/wrangler.toml` 和 GitHub Workflows 为准。 +4. 示例统一使用虚构域名和占位凭据。 +5. 功能变更应同步更新 README、相关指南和本目录对应页面。 diff --git a/docs/INTERFACES.md b/docs/INTERFACES.md new file mode 100644 index 0000000..cc821f7 --- /dev/null +++ b/docs/INTERFACES.md @@ -0,0 +1,171 @@ +# HTTP 接口 + +Worker 根地址同时提供静态 Dashboard 和 `/api/*` JSON API。所有 JSON 响应使用 UTF-8,并设置 `Cache-Control: no-store`。 + +## 鉴权 + +受保护接口使用 Bearer Token: + +```http +Authorization: Bearer +``` + +Dashboard API 使用登录接口签发的短期 Token。Runner API 使用 Cloudflare Secret `RUNNER_TOKEN`。 + +## 健康检查 + +### GET /api/health + +鉴权:无。 + +成功响应: + +```json +{ + "ok": true, + "service": "newapi-checkin-worker", + "database": "connected", + "time": "2026-01-01T00:00:00.000Z" +} +``` + +配置缺失时返回 HTTP 503,`missing` 可能包含 `Check`、`DASHBOARD_PASSWORD`、`RUNNER_TOKEN` 或 `DATA_ENCRYPTION_KEY`。 + +## 登录接口 + +### POST /api/auth/login + +请求: + +```json +{ + "password": "" +} +``` + +成功响应: + +```json +{ + "token": "", + "expires_at": "2026-01-02T00:00:00.000Z" +} +``` + +### POST /api/auth/logout + +使用 Dashboard Bearer Token 调用。Worker 删除对应的服务端会话,响应为 `{"ok": true}`。 + +## Dashboard API + +以下接口均要求 Dashboard Bearer Token。 + +| 方法 | 路径 | 用途 | +|------|------|------| +| `GET` | `/api/dashboard/summary` | 最近运行与账号状态 | +| `GET` | `/api/dashboard/runs` | 最近 30 次运行 | +| `GET` | `/api/dashboard/runs/:id` | 指定运行和账号级结果 | +| `GET` | `/api/dashboard/accounts` | 脱敏账号列表 | +| `POST` | `/api/dashboard/accounts` | 添加账号 | +| `PATCH` | `/api/dashboard/accounts/:id` | 更新凭据或启用状态 | + +### 添加账号 + +```json +{ + "name": "主力站", + "url": "https://api.example.com", + "session": "", + "user_id": "12345", + "cf_clearance": "" +} +``` + +`name`、`url`、`session` 和 `user_id` 为必填字段。`url` 必须为不含嵌入式凭据的 HTTPS 地址,Worker 将其规范化为 Origin,并加密完整运行配置。 + +### 更新启用状态 + +```json +{ + "enabled": false +} +``` + +### 更新凭据 + +```json +{ + "name": "主力站", + "url": "https://api.example.com", + "session": "", + "user_id": "12345", + "cf_clearance": "" +} +``` + +更新凭据后,Worker 会清零连续失败次数和最近状态。 + +## Runner API + +以下接口均要求 Runner Bearer Token。 + +### GET /api/runner/config + +返回所有启用账号的运行配置: + +```json +{ + "accounts": [ + { + "name": "主力站", + "account_id": 1, + "url": "https://api.example.com", + "session": "", + "user_id": "12345", + "cf_clearance": "" + } + ] +} +``` + +### POST /api/runner/report + +```json +{ + "execution_time": "2026-01-01 08:10:00", + "total": 1, + "success_count": 1, + "fail_count": 0, + "results": [ + { + "account_id": 1, + "name": "主力站", + "success": true, + "message": "签到成功", + "quota_awarded": 1000, + "checkin_count": 12, + "session_expired": false + } + ] +} +``` + +单次最多上报 40 个账号结果,控制台也限制最多启用 40 个账号。Worker 根据 `results` 重新计算总数、成功数和失败数,并通过 D1 batch 提交运行记录、账号明细和状态更新。 + +## 错误响应 + +错误统一返回 JSON: + +```json +{ + "error": "错误说明" +} +``` + +| 状态码 | 含义 | +|--------|------| +| `400` | 请求字段或 JSON 格式错误 | +| `401` | Token 无效、访问口令错误或登录过期 | +| `404` | 路由或账号不存在 | +| `500` | Worker、加密或数据库内部错误 | +| `503` | 必要 Binding 或 Secret 缺失 | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..581ba2f --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,79 @@ +# 运行与维护 + +## 定时任务 + +`.github/workflows/checkin.yml` 提供两种触发方式: + +| 触发方式 | 配置 | +|----------|------| +| 定时运行 | UTC 00:10,即北京时间约 08:10 | +| 手动运行 | GitHub Actions `workflow_dispatch` | + +GitHub schedule 可能延迟。公开仓库长期没有仓库活动时,GitHub 可能停用 scheduled workflows;计划任务自身的运行不计为仓库活动。`.github/workflows/keepalive.yml` 仅用于定期检查计划任务状态,维护者仍需关注 Actions 是否保持启用。 + +## 一次运行的阶段 + +1. 安装 Python 依赖和 Playwright Chromium。 +2. 从 Worker 拉取启用账号。 +3. 逐个获取用户信息并执行签到。 +4. 查询成功账号的本月签到统计。 +5. 发送可选钉钉通知。 +6. 将运行结果上报 Worker。 + +## 健康检查 + +生产部署后访问: + +```text +https:///api/health +``` + +`database: connected` 表示 D1 可查询。HTTP 503 响应中的 `missing` 数组用于定位缺失的 Binding 或 Secret。 + +## 账号健康状态 + +Worker 根据每次 Runner 上报更新: + +- 成功后将 `failure_count` 归零,并设置 `last_status=success`。 +- 失败后增加 `failure_count`,并设置 `last_status=failed`。 +- 更新凭据后清空连续失败次数和最近状态。 +- 停用账号后 Runner 配置接口不再返回该账号。 + +## 升级部署 + +Cloudflare Workers Builds 连接 `main` 分支后,每次推送都会执行 `worker/package.json` 中的 `wrangler deploy`。 + +`worker/wrangler.toml` 仅声明 `Check` Binding。远端已存在同名 D1 Binding 时,Wrangler 继承原数据库;远端缺少该 Binding 时自动配置新数据库。 + +升级后执行以下检查: + +1. Cloudflare Deployment 状态为成功。 +2. Worker Bindings 中存在 D1 `Check`。 +3. `/api/health` 返回成功。 +4. Dashboard 可显示原账号和运行历史。 +5. 手动 Actions 运行可以获取账号并上报结果。 + +## 常见故障定位 + +| 现象 | 优先检查 | +|------|----------| +| `/api/health` 返回 503 | `missing` 数组和 Worker Bindings | +| Dashboard 口令错误 | `DASHBOARD_PASSWORD` | +| Dashboard 登录后立即过期 | `SESSION_TTL_SECONDS` 和 D1 `sessions` | +| Runner 未授权 | 两端 Runner Token 是否一致 | +| Runner 获取 0 个账号 | Dashboard 中是否存在启用账号 | +| 账号认证失败 | Session 与 `new-api-user` 是否匹配 | +| Cloudflare 挑战失败 | Playwright 安装、目标站点策略、Cookie 有效期 | +| D1 解密错误 | `DATA_ENCRYPTION_KEY` 是否发生变化 | +| 定时任务未触发 | Workflow 启用状态和最近仓库活动 | + +## 数据保留 + +项目当前保留全部账号、运行摘要和账号级结果。Dashboard 查询最近 30 次运行,历史数据仍保存在 D1。需要长期运行时,应根据 D1 配额制定归档或清理策略。 + +## 恢复原则 + +- Cloudflare 构建失败时继续使用上一份成功部署。 +- D1 与 Worker 代码部署相互独立,代码更新不会清空表数据。 +- `DATA_ENCRYPTION_KEY` 是账号密文恢复的必要条件。 +- Runner Token 轮换需要同步更新 Cloudflare 与 GitHub。 diff --git a/index.html b/index.html deleted file mode 100644 index 370a1d3..0000000 --- a/index.html +++ /dev/null @@ -1,403 +0,0 @@ - - - - - - NewAPI 自动签到 - - - -
-
-
🚀
-

NewAPI 自动签到

-

支持多账号、多站点的自动签到解决方案,每日定时执行

-
- -
-
-

🎯 项目简介

-

基于 HTTP 直连的 NewAPI 自动签到脚本,支持多账号、多站点配置,通过 GitHub Actions 实现每日自动签到。

-
- -
-

✨ 核心功能

-
    -
  • 支持多个不同的 NewAPI 站点
  • -
  • 可视化配置工具,无需编写代码
  • -
  • GitHub Actions 自动化执行
  • -
  • 详细的签到日志和统计
  • -
  • 错误处理和超时控制
  • -
  • 钉钉通知推送结果
  • -
-
- - -
- - -
- - - \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..290a217 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "newapi-checkin", + "version": "1.0.0", + "private": true, + "scripts": { + "deploy": "cd worker && npm install && npm run deploy" + } +} diff --git a/requirements.txt b/requirements.txt index 9ecc7fd..41e07ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -requests>=2.28.0 +requests==2.34.2 -pytz>=2023.3 +pytz==2026.3 -playwright>=1.40.0 +playwright==1.61.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_checkin_unit.py b/tests/test_checkin_unit.py new file mode 100644 index 0000000..dd717e2 --- /dev/null +++ b/tests/test_checkin_unit.py @@ -0,0 +1,131 @@ +import json +import unittest +from unittest.mock import Mock, patch + +from requests import Request + +from cf_bypass import detect_cloudflare_block +from checkin import NewAPICheckin, parse_accounts + + +class NewAPICheckinTests(unittest.TestCase): + def test_cookies_are_scoped_to_configured_host(self): + client = NewAPICheckin( + 'https://api.example.com/console', + 'SESSION_SECRET', + '123', + 'CLEARANCE_SECRET', + ) + + same_host = client.session.prepare_request(Request('GET', 'https://api.example.com/api/user/self')) + other_host = client.session.prepare_request(Request('GET', 'https://example.org/api/user/self')) + + self.assertEqual(client.base_url, 'https://api.example.com') + self.assertIn('session=SESSION_SECRET', same_host.headers.get('Cookie', '')) + self.assertIn('cf_clearance=CLEARANCE_SECRET', same_host.headers.get('Cookie', '')) + self.assertNotIn('Cookie', other_host.headers) + + def test_rejects_invalid_account_values(self): + with self.assertRaises(ValueError): + NewAPICheckin(None, 'session') + with self.assertRaises(ValueError): + NewAPICheckin('invalid-url', 'session') + with self.assertRaises(ValueError): + NewAPICheckin('https://api.example.com', '') + + def test_localhost_cookie_is_sent_only_to_localhost(self): + client = NewAPICheckin('http://localhost:8000', 'LOCAL_SESSION', '123') + + local = client.session.prepare_request(Request('GET', 'http://localhost:8000/api/user/self')) + remote = client.session.prepare_request(Request('GET', 'http://example.org/api/user/self')) + + self.assertIn('session=LOCAL_SESSION', local.headers.get('Cookie', '')) + self.assertNotIn('Cookie', remote.headers) + + def test_parse_accounts_skips_malformed_items(self): + accounts = parse_accounts( + '[{"url": null, "session": "bad"}, ' + '{"url": "https://api.example.com", "session": "good", "user_id": 12}]' + ) + + self.assertEqual(len(accounts), 1) + self.assertEqual(accounts[0]['user_id'], '12') + self.assertEqual(parse_accounts('invalid#session,https://api.example.com#,http://[::1#session'), []) + + def test_parse_accounts_preserves_worker_account_id_for_result_reporting(self): + accounts = parse_accounts( + '[{"account_id": 42, "name": "主账号", "url": "https://api.example.com", "session": "good"}]' + ) + + self.assertEqual(accounts[0]['account_id'], 42) + + def test_standard_endpoint_falls_back_only_for_not_found(self): + client = NewAPICheckin('https://api.example.com', 'session', '123') + missing = Mock(status_code=404) + missing.json.return_value = {'message': 'not found'} + success = Mock(status_code=200) + success.json.return_value = {'success': True, 'message': '签到成功'} + client.session.post = Mock(side_effect=[missing, success]) + + result = client.checkin() + + self.assertTrue(result['success']) + self.assertEqual( + [call.args[0] for call in client.session.post.call_args_list], + [ + 'https://api.example.com/api/user/sign_in', + 'https://api.example.com/api/user/checkin', + ], + ) + for call in client.session.post.call_args_list: + self.assertFalse(call.kwargs['allow_redirects']) + + def test_normalizes_string_quota(self): + result = NewAPICheckin._normalize_checkin_payload({ + 'success': True, + 'message': None, + 'data': {'quota_awarded': '1000'}, + }) + + self.assertTrue(result['success']) + self.assertEqual(result['quota_awarded'], 1000) + self.assertEqual(result['message'], '签到成功') + + @patch('checkin.detect_cloudflare_block', return_value=(True, 'challenge')) + def test_cloudflare_fallback_receives_blocked_endpoint(self, _detect): + client = NewAPICheckin('https://api.example.com', 'session', '123') + blocked = Mock(status_code=403, text='challenge') + blocked.json.side_effect = json.JSONDecodeError('not json', '', 0) + client.session.post = Mock(return_value=blocked) + client._cf_bypass_checkin = Mock(return_value={'success': False, 'message': 'blocked'}) + + client.checkin() + + client._cf_bypass_checkin.assert_called_once_with('/api/user/sign_in') + + @patch('checkin.CloudflareBypasser') + @patch('checkin.CF_BYPASS_AVAILABLE', True) + def test_cloudflare_fallback_passes_clearance_cookie_to_browser(self, bypasser_class): + bypasser = bypasser_class.return_value + bypasser.is_available.return_value = True + bypasser.bypass_and_checkin.return_value = {'success': True, 'message': '签到成功'} + client = NewAPICheckin('https://api.example.com', 'session', '123', 'clearance') + + result = client._cf_bypass_checkin() + + self.assertTrue(result['success']) + bypasser_class.assert_called_once_with( + 'https://api.example.com', 'session', '123', 'clearance' + ) + + +class CloudflareDetectionTests(unittest.TestCase): + def test_detects_doctype_case_variants(self): + for doctype in ('', ''): + with self.subTest(doctype=doctype): + blocked, _ = detect_cloudflare_block(403, f'{doctype}

Cloudflare challenge

') + self.assertTrue(blocked) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..2efd80a --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,54 @@ +import json +import unittest +from unittest.mock import patch + +from config_helper import generate_config, test_account as validate_account +from dingtalk_notifier import build_checkin_report + + +class NotificationTests(unittest.TestCase): + def test_report_handles_type_drift_and_escapes_table_cells(self): + report = build_checkin_report([ + { + 'name': 'main|account', + 'success': True, + 'message': None, + 'quota_awarded': '1000', + 'checkin_count': '2', + }, + { + 'name': 'backup', + 'success': 'false', + 'message': None, + 'session_expired': 'false', + }, + ], '2026-07-25 08:10:00') + + self.assertIn('main\\|account', report) + self.assertIn('+1.00K', report) + self.assertIn('已签 2 天', report) + self.assertIn('未知错误', report) + self.assertNotIn('部分账号 Session 已失效', report) + + +class ConfigHelperTests(unittest.TestCase): + @patch('checkin.NewAPICheckin') + def test_account_validation_passes_user_id(self, client_class): + client_class.return_value.get_user_info.return_value = {'username': 'tester'} + + self.assertTrue(validate_account('https://api.example.com', 'session', '123')) + client_class.assert_called_once_with('https://api.example.com', 'session', '123') + + def test_generated_config_preserves_user_id(self): + config = generate_config([{ + 'url': 'https://api.example.com', + 'session': 'session', + 'user_id': '123', + 'name': 'main', + }]) + + self.assertEqual(json.loads(config)[0]['user_id'], '123') + + +if __name__ == '__main__': + unittest.main() diff --git a/worker/.dev.vars.example b/worker/.dev.vars.example new file mode 100644 index 0000000..0ef848c --- /dev/null +++ b/worker/.dev.vars.example @@ -0,0 +1,11 @@ +# 复制为 .dev.vars 后用于 wrangler dev。 +# 三个值均由部署者自行生成,并使用互不相同的值。 + +# 建议:openssl rand -base64 24 +DASHBOARD_PASSWORD=replace-with-dashboard-password + +# 建议:openssl rand -hex 32 +RUNNER_TOKEN=replace-with-runner-token + +# 建议:openssl rand -hex 32;请长期安全备份 +DATA_ENCRYPTION_KEY=replace-with-data-encryption-key diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..c10acbe --- /dev/null +++ b/worker/package.json @@ -0,0 +1,13 @@ +{ + "name": "newapi-checkin-worker", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "db:init:local": "wrangler d1 execute Check --local --file=./schema.sql" + }, + "devDependencies": { + "wrangler": "4.113.0" + } +} diff --git a/worker/public/index.html b/worker/public/index.html new file mode 100644 index 0000000..e77e21d --- /dev/null +++ b/worker/public/index.html @@ -0,0 +1,614 @@ + + + + + + + Check Console · NewAPI 签到控制台 + + + +
+
+ + + diff --git a/worker/schema.sql b/worker/schema.sql new file mode 100644 index 0000000..fa2266f --- /dev/null +++ b/worker/schema.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + failure_count INTEGER NOT NULL DEFAULT 0, + last_status TEXT, + last_message TEXT, + last_checkin_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_time TEXT NOT NULL, + total INTEGER NOT NULL, + success_count INTEGER NOT NULL, + fail_count INTEGER NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS run_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + account_id INTEGER, + name TEXT NOT NULL, + success INTEGER NOT NULL, + message TEXT, + quota_awarded INTEGER, + checkin_count INTEGER, + session_expired INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (run_id) REFERENCES runs(id), + FOREIGN KEY (account_id) REFERENCES accounts(id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_runs_created_at ON runs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_results_run_id ON run_results(run_id); diff --git a/worker/src/index.js b/worker/src/index.js new file mode 100644 index 0000000..cfce596 --- /dev/null +++ b/worker/src/index.js @@ -0,0 +1,366 @@ +const json = (data, status = 200, env) => new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, +}); + +const now = () => new Date().toISOString(); +const text = async (request) => request.json().catch(() => null); + +function httpsOrigin(value) { + if (typeof value !== 'string') return null; + try { + const url = new URL(value.trim()); + if (url.protocol !== 'https:' || url.username || url.password) return null; + return url.origin; + } catch { + return null; + } +} + +function optionalInteger(value) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isSafeInteger(number) ? number : null; +} + +function getDb(env) { + return env?.Check || env?.DB || env?.D1 || env?.CHECK || null; +} + +function missingDbError() { + return json({ + error: 'D1 未绑定:请确认 Cloudflare 部署已完成自动资源配置,且 Worker Settings → Bindings 中存在名为 Check 的 D1 绑定。', + code: 'D1_BINDING_MISSING', + }, 503); +} + +async function sha256(value) { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function tokenFrom(request) { + const value = request.headers.get('authorization') || ''; + return value.startsWith('Bearer ') ? value.slice(7) : ''; +} + +async function requireRunner(request, env) { + return tokenFrom(request) && tokenFrom(request) === env.RUNNER_TOKEN; +} + +async function requireSession(request, env) { + const token = tokenFrom(request); + if (!token) return false; + const db = getDb(env); + if (!db) return false; + const hash = await sha256(token); + const row = await db.prepare('SELECT token_hash FROM sessions WHERE token_hash = ? AND expires_at > ?') + .bind(hash, now()).first(); + return Boolean(row); +} + +async function encrypt(value, env) { + if (!env.DATA_ENCRYPTION_KEY) throw new Error('缺少 DATA_ENCRYPTION_KEY Secret'); + const keyBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(env.DATA_ENCRYPTION_KEY))); + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(value)); + return `${btoa(String.fromCharCode(...iv))}.${btoa(String.fromCharCode(...new Uint8Array(encrypted)))}`; +} + +async function decrypt(value, env) { + if (!env.DATA_ENCRYPTION_KEY) throw new Error('缺少 DATA_ENCRYPTION_KEY Secret'); + const [ivText, dataText] = value.split('.'); + const keyBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(env.DATA_ENCRYPTION_KEY))); + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']); + const iv = Uint8Array.from(atob(ivText), (char) => char.charCodeAt(0)); + const data = Uint8Array.from(atob(dataText), (char) => char.charCodeAt(0)); + const result = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); + return new TextDecoder().decode(result); +} + +function accountView(row) { + return { + id: row.id, name: row.name, url: row.url, enabled: Boolean(row.enabled), + failure_count: row.failure_count, last_status: row.last_status, + last_message: row.last_message, last_checkin_at: row.last_checkin_at, + }; +} + +let tablesReady = false; + +async function ensureTables(env) { + const db = getDb(env); + if (tablesReady || !db) return; + await db.batch([ + db.prepare(`CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + failure_count INTEGER NOT NULL DEFAULT 0, + last_status TEXT, + last_message TEXT, + last_checkin_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`), + db.prepare(`CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_time TEXT NOT NULL, + total INTEGER NOT NULL, + success_count INTEGER NOT NULL, + fail_count INTEGER NOT NULL, + created_at TEXT NOT NULL + )`), + db.prepare(`CREATE TABLE IF NOT EXISTS run_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + account_id INTEGER, + name TEXT NOT NULL, + success INTEGER NOT NULL, + message TEXT, + quota_awarded INTEGER, + checkin_count INTEGER, + session_expired INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (run_id) REFERENCES runs(id), + FOREIGN KEY (account_id) REFERENCES accounts(id) + )`), + db.prepare(`CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + )`), + db.prepare(`CREATE INDEX IF NOT EXISTS idx_runs_created_at ON runs(created_at DESC)`), + db.prepare(`CREATE INDEX IF NOT EXISTS idx_results_run_id ON run_results(run_id)`), + ]); + tablesReady = true; +} + +async function handler(request, env) { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + + if (method === 'GET' && path === '/api/health') { + const missing = []; + if (!getDb(env)) missing.push('Check'); + if (!env.DASHBOARD_PASSWORD) missing.push('DASHBOARD_PASSWORD'); + if (!env.RUNNER_TOKEN) missing.push('RUNNER_TOKEN'); + if (!env.DATA_ENCRYPTION_KEY) missing.push('DATA_ENCRYPTION_KEY'); + if (missing.length) { + return json({ ok: false, service: 'newapi-checkin-worker', missing, time: now() }, 503, env); + } + await getDb(env).prepare('SELECT 1').first(); + return json({ ok: true, service: 'newapi-checkin-worker', database: 'connected', time: now() }, 200, env); + } + + if (method === 'POST' && path === '/api/auth/login') { + const body = await text(request); + if (!getDb(env)) return missingDbError(); + if (!env.DASHBOARD_PASSWORD) return json({ error: 'Worker 尚未配置 DASHBOARD_PASSWORD' }, 503, env); + if (!body?.password || body.password !== env.DASHBOARD_PASSWORD) return json({ error: '访问口令错误' }, 401, env); + const token = crypto.randomUUID(); + const expires = new Date(Date.now() + Number(env.SESSION_TTL_SECONDS || 86400) * 1000).toISOString(); + const createdAt = now(); + await getDb(env).batch([ + getDb(env).prepare('DELETE FROM sessions WHERE expires_at <= ?').bind(createdAt), + getDb(env).prepare('INSERT INTO sessions (token_hash, expires_at, created_at) VALUES (?, ?, ?)') + .bind(await sha256(token), expires, createdAt), + ]); + return json({ token, expires_at: expires }, 200, env); + } + + if (method === 'POST' && path === '/api/auth/logout') { + if (!getDb(env)) return missingDbError(); + const token = tokenFrom(request); + if (!token) return json({ error: '缺少登录 Token' }, 401, env); + await getDb(env).prepare('DELETE FROM sessions WHERE token_hash = ?').bind(await sha256(token)).run(); + return json({ ok: true }, 200, env); + } + + if (path.startsWith('/api/runner/')) { + if (!(await requireRunner(request, env))) return json({ error: 'Runner 未授权' }, 401, env); + if (!getDb(env)) return missingDbError(); + if (method === 'GET' && path === '/api/runner/config') { + const rows = await getDb(env).prepare('SELECT id, name, url, secret FROM accounts WHERE enabled = 1 ORDER BY id').all(); + if (rows.results.length > 40) return json({ error: '启用账号数量超过 40,请先停用部分账号' }, 409, env); + const accounts = []; + for (const row of rows.results) { + const config = JSON.parse(await decrypt(row.secret, env)); + const origin = httpsOrigin(config.url); + if (!origin) throw new Error(`账号 ${row.id} 的站点 URL 必须更新为 HTTPS 地址`); + accounts.push({ ...config, url: origin, name: row.name, account_id: row.id }); + } + return json({ accounts }, 200, env); + } + if (method === 'POST' && path === '/api/runner/report') { + const body = await text(request); + if (!body || !Array.isArray(body.results) || body.results.length > 40) { + return json({ error: '结果格式错误或账号数量超过 40' }, 400); + } + + const accountRows = await getDb(env).prepare('SELECT id FROM accounts').all(); + const accountMap = new Map(accountRows.results.map((account) => [account.id, account])); + const seenAccounts = new Set(); + const results = []; + for (const result of body.results) { + const accountId = optionalInteger(result?.account_id); + const account = accountMap.get(accountId); + if (!account || accountId <= 0 || seenAccounts.has(accountId) || typeof result?.success !== 'boolean') { + return json({ error: '结果包含无效或重复的账号 ID' }, 400); + } + seenAccounts.add(accountId); + results.push({ + account, + accountId, + name: String(result.name || '未知账号').slice(0, 100), + success: result.success, + message: String(result.message || '').slice(0, 1000), + quotaAwarded: optionalInteger(result.quota_awarded), + checkinCount: optionalInteger(result.checkin_count), + sessionExpired: result.session_expired === true, + }); + } + + const createdAt = now(); + const successCount = results.filter((result) => result.success).length; + const statements = [ + getDb(env).prepare('INSERT INTO runs (execution_time, total, success_count, fail_count, created_at) VALUES (?, ?, ?, ?, ?)') + .bind(String(body.execution_time || createdAt).slice(0, 64), results.length, successCount, results.length - successCount, createdAt), + ]; + if (results.length) { + for (let offset = 0; offset < results.length; offset += 12) { + const chunk = results.slice(offset, offset + 12); + const values = chunk.map(() => '((SELECT MAX(id) FROM runs), ?, ?, ?, ?, ?, ?, ?, ?)').join(', '); + const bindings = chunk.flatMap((result) => [ + result.accountId, result.name, result.success ? 1 : 0, result.message, + result.quotaAwarded, result.checkinCount, result.sessionExpired ? 1 : 0, createdAt, + ]); + statements.push(getDb(env).prepare(`INSERT INTO run_results + (run_id, account_id, name, success, message, quota_awarded, checkin_count, session_expired, created_at) + VALUES ${values}`).bind(...bindings)); + } + const accountUpdates = JSON.stringify(results.map((result) => ({ + id: result.accountId, + success: result.success ? 1 : 0, + message: result.message, + }))); + statements.push(getDb(env).prepare(`WITH updates AS ( + SELECT CAST(json_extract(value, '$.id') AS INTEGER) AS id, + CAST(json_extract(value, '$.success') AS INTEGER) AS success, + json_extract(value, '$.message') AS message + FROM json_each(?) + ) + UPDATE accounts SET + failure_count = CASE WHEN (SELECT success FROM updates WHERE updates.id = accounts.id) = 1 THEN 0 ELSE failure_count + 1 END, + last_status = CASE WHEN (SELECT success FROM updates WHERE updates.id = accounts.id) = 1 THEN 'success' ELSE 'failed' END, + last_message = COALESCE((SELECT message FROM updates WHERE updates.id = accounts.id), ''), + last_checkin_at = ?, updated_at = ? + WHERE id IN (SELECT id FROM updates)`).bind(accountUpdates, createdAt, createdAt)); + } + const batch = await getDb(env).batch(statements); + return json({ ok: true, run_id: batch[0].meta.last_row_id }, 201, env); + } + } + + if (!getDb(env)) return missingDbError(); + if (!(await requireSession(request, env))) return json({ error: '登录已过期' }, 401, env); + if (method === 'GET' && path === '/api/dashboard/summary') { + const latest = await getDb(env).prepare('SELECT * FROM runs ORDER BY id DESC LIMIT 1').first(); + const accounts = await getDb(env).prepare('SELECT * FROM accounts ORDER BY id').all(); + return json({ latest, accounts: accounts.results.map(accountView) }, 200, env); + } + if (method === 'GET' && path === '/api/dashboard/runs') { + const rows = await getDb(env).prepare('SELECT * FROM runs ORDER BY id DESC LIMIT 30').all(); + return json({ runs: rows.results }, 200, env); + } + if (method === 'GET' && path.startsWith('/api/dashboard/runs/')) { + const id = path.split('/').pop(); + const run = await getDb(env).prepare('SELECT * FROM runs WHERE id = ?').bind(id).first(); + const results = await getDb(env).prepare('SELECT * FROM run_results WHERE run_id = ? ORDER BY id').bind(id).all(); + return json({ run, results: results.results }, 200, env); + } + if (method === 'GET' && path === '/api/dashboard/accounts') { + const rows = await getDb(env).prepare('SELECT * FROM accounts ORDER BY id').all(); + return json({ accounts: rows.results.map(accountView) }, 200, env); + } + if (method === 'POST' && path === '/api/dashboard/accounts') { + const body = await text(request); + const origin = httpsOrigin(body?.url); + if (!body?.name || !origin || !body?.session || !body?.user_id) return json({ error: '请填写有效的名称、HTTPS URL、Session 和用户 ID' }, 400, env); + const createdAt = now(); + const secret = await encrypt(JSON.stringify({ url: origin, session: body.session, user_id: body.user_id, cf_clearance: body.cf_clearance || undefined }), env); + const inserted = await getDb(env).prepare(`INSERT INTO accounts (name, url, secret, created_at, updated_at) + SELECT ?, ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM accounts WHERE enabled = 1) < 40`) + .bind(body.name, origin, secret, createdAt, createdAt).run(); + if (!inserted.meta.changes) return json({ error: '启用账号数量已达到 40 个上限' }, 409, env); + return json({ ok: true }, 201, env); + } + if (method === 'PATCH' && path.startsWith('/api/dashboard/accounts/')) { + const id = path.split('/').pop(); + const body = await text(request); + if (!body) return json({ error: '请求格式错误' }, 400, env); + if (body.session && typeof body.enabled === 'boolean') return json({ error: '凭据更新和启停操作请分别提交' }, 400, env); + const account = await getDb(env).prepare('SELECT * FROM accounts WHERE id = ?').bind(id).first(); + if (!account) return json({ error: '账号不存在' }, 404, env); + + const updates = []; + if (body.session) { + const current = JSON.parse(await decrypt(account.secret, env)); + const nextUrl = httpsOrigin(body.url || current.url); + if (!nextUrl) return json({ error: 'URL 必须是有效的 HTTPS Origin' }, 400, env); + if (!body.user_id) return json({ error: '更新凭据时必须填写用户 ID' }, 400, env); + const next = { + ...current, + url: nextUrl, + session: body.session, + }; + next.user_id = body.user_id; + if ('cf_clearance' in body) next.cf_clearance = body.cf_clearance || undefined; + const secret = await encrypt(JSON.stringify(next), env); + updates.push(getDb(env).prepare('UPDATE accounts SET name = ?, url = ?, secret = ?, failure_count = 0, last_status = NULL, last_message = NULL, updated_at = ? WHERE id = ?') + .bind(body.name || account.name, nextUrl, secret, now(), id)); + } + if (typeof body.enabled === 'boolean') { + if (body.enabled && !account.enabled) { + updates.push(getDb(env).prepare(`UPDATE accounts SET enabled = 1, updated_at = ? + WHERE id = ? AND (SELECT COUNT(*) FROM accounts WHERE enabled = 1) < 40`).bind(now(), id)); + } else { + updates.push(getDb(env).prepare('UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?') + .bind(body.enabled ? 1 : 0, now(), id)); + } + } + + if (typeof body.enabled !== 'boolean' && !body.session) { + return json({ error: '请提供 enabled 或新的 Session' }, 400, env); + } + const updated = await getDb(env).batch(updates); + if (body.enabled === true && !account.enabled && !updated[0].meta.changes) { + return json({ error: '启用账号数量已达到 40 个上限' }, 409, env); + } + return json({ ok: true }, 200, env); + } + return json({ error: 'Not found' }, 404, env); +} + +export default { + async fetch(request, env) { + try { + await ensureTables(env); + const url = new URL(request.url); + if (url.pathname.startsWith('/api/')) return await handler(request, env); + if (env.ASSETS) { + const assetUrl = new URL(request.url); + if (assetUrl.pathname === '/') assetUrl.pathname = '/index.html'; + return env.ASSETS.fetch(new Request(assetUrl, request)); + } + return json({ error: '静态资源绑定未配置' }, 500, env); + } catch (error) { + return json({ error: error.message || 'Internal error' }, 500, env); + } + }, +}; diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..068c965 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,14 @@ +name = "newapi-checkin" +main = "src/index.js" +compatibility_date = "2026-07-22" +keep_vars = true + +[assets] +directory = "./public" +binding = "ASSETS" + +[vars] +SESSION_TTL_SECONDS = "86400" + +[[d1_databases]] +binding = "Check"