diff --git a/.github/KEEPALIVE.md b/.github/KEEPALIVE.md index 217fd63..88832ee 100644 --- a/.github/KEEPALIVE.md +++ b/.github/KEEPALIVE.md @@ -2,9 +2,9 @@ This file is automatically updated to keep the repository active. -**Last Update:** 2026-07-01 02:49:14 UTC -**Workflow Run:** #3 -**Trigger:** schedule +**Last Update:** 2026-07-11 06:29:05 UTC +**Workflow Run:** #1 +**Trigger:** workflow_dispatch --- -*Auto-generated by [keepalive workflow](.github/workflows/keepalive.yml)* +*Auto-generated by [仓库保活 workflow](.github/workflows/仓库保活.yml)* diff --git a/.github/workflows/qrcodeLogin.yml b/.github/workflows/qrcodeLogin.yml deleted file mode 100644 index 5490d05..0000000 --- a/.github/workflows/qrcodeLogin.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: qrcodeLogin -on: - workflow_dispatch: - inputs: - number: - description: "需要登录几个账号?" - required: true - default: 1 - append_user: - description: "是否追加新账号?(不删除已存在账号信息)" - required: true - default: "否" - type: choice - options: - - 是 - - 否 - print_userinfo: - description: "自动写入失败时是否在日志输出 USERINFO?含登录token,默认否" - required: true - default: "否" - type: choice - options: - - 否 - - 是 -permissions: - contents: read -jobs: - run: - runs-on: ubuntu-latest - steps: - - name: 检出仓库 - uses: actions/checkout@v6 - - name: 创建node - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: 'npm' - cache-dependency-path: ./api/package-lock.json - - name: 🔧 检查GitHub CLI - run: | - if ! command -v gh >/dev/null 2>&1; then - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt update - sudo apt install gh -y - fi - gh --version - - name: Cache node_modules in api directory - id: cache-npm - uses: actions/cache@v5 - with: - path: ./api/node_modules - key: ${{ runner.os }}-node-${{ hashFiles('./api/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - name: 安装依赖 - run: npm run install - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} - - name: 二维码登录 - run: npm run qrcodeLogin - env: - NUMBER: ${{ github.event.inputs.number }} - PAT: ${{ secrets.PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - USERINFO: ${{ secrets.USERINFO }} - APPEND_USER: ${{ github.event.inputs.append_user }} - ALLOW_PRINT_USERINFO: ${{ github.event.inputs.print_userinfo }} diff --git a/.github/workflows/sent.yml b/.github/workflows/sent.yml deleted file mode 100644 index 811b8a4..0000000 --- a/.github/workflows/sent.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: sent -on: - workflow_dispatch: - # push: - # schedule: - # - cron: 1 16 * * * -permissions: - contents: read -jobs: - run: - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: 检出仓库 - uses: actions/checkout@v6 - - name: 创建node - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: 'npm' - cache-dependency-path: ./api/package-lock.json - - name: Cache node_modules in api directory - id: cache-npm - uses: actions/cache@v5 - with: - path: ./api/node_modules - key: ${{ runner.os }}-node-${{ hashFiles('./api/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - name: 安装依赖 - run: npm run install - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} - - name: 发送验证码 - run: npm run sent - env: - PHONE: ${{ secrets.PHONE }} diff --git "a/.github/workflows/\344\272\214\347\273\264\347\240\201\347\231\273\345\275\225.yml" "b/.github/workflows/\344\272\214\347\273\264\347\240\201\347\231\273\345\275\225.yml" new file mode 100644 index 0000000..bed8fdb --- /dev/null +++ "b/.github/workflows/\344\272\214\347\273\264\347\240\201\347\231\273\345\275\225.yml" @@ -0,0 +1,175 @@ +name: 二维码登录 +on: + workflow_dispatch: + inputs: + number: + description: "需要登录几个账号?" + required: true + default: "1" + append_user: + description: "是否追加新账号?(不删除已存在账号信息)" + required: true + type: choice + default: "否" + options: + - 是 + - 否 + print_userinfo: + description: "自动写入失败时是否在日志输出 USERINFO?(含登录token,默认否)" + required: true + type: choice + default: "否" + options: + - 否 + - 是 +# 需要 contents: write 才能创建 Release 上传二维码直链 +permissions: + contents: write +jobs: + run: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: 检出仓库 + uses: actions/checkout@v4 + + - name: 创建 Node 环境 + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: ./api/package-lock.json + + - name: 检查 GitHub CLI + run: | + if ! command -v gh >/dev/null 2>&1; then + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt update + sudo apt install gh -y + fi + gh --version + + - name: 缓存依赖 + id: cache-npm + uses: actions/cache@v4 + with: + path: ./api/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('./api/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: 安装依赖 + run: npm run install + if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} + + # ════════════════════════════════════════════ + # 步骤一:生成二维码 → PNG + Summary + HTML 登录页 + # ✅ 完成后立即结束 step(process.exit(0) 保证干净退出) + # ════════════════════════════════════════════ + - name: 生成二维码图片与HTML登录页 + id: gen + run: node qrcodeLogin.js gen + env: + NUMBER: ${{ github.event.inputs.number }} + + # ════════════════════════════════════════════ + # 步骤二(核心):发布二维码为 GitHub Release 直链 + # 浏览器点击链接即显示图片/网页,无需下载解压 + # ════════════════════════════════════════════ + - name: 发布二维码图片直链(👈 点击即可查看,无需下载解压) + id: publish + if: ${{ always() && steps.gen.outcome == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="qr-${{ github.run_id }}" + gh release create "$TAG" qr/*.png qr/login.html \ + --repo "$GITHUB_REPOSITORY" \ + --title "酷狗扫码登录二维码" \ + --notes "用「酷狗音乐 APP」扫描下方二维码完成登录(约 2 分钟内有效)" + + echo "" + echo "═══════════════════════════════════════════════════════" + echo " 📱 点击下方链接,浏览器直接显示二维码图片(无需解压):" + echo "═══════════════════════════════════════════════════════" + for f in qr/*.png; do + echo " 🔗 https://github.com/${{ github.repository }}/releases/download/$TAG/$(basename "$f")" + done + echo "═══════════════════════════════════════════════════════" + + # 写入 Summary 页面,用户直接在 Summary 即可看到扫码链接 + { + echo "## 🎵 酷狗音乐扫码登录" + echo "" + echo "请点击下方链接,浏览器打开即可扫码(约 2 分钟内有效):" + echo "" + for f in qr/*.png; do + echo "- 🔗 [账号 $(basename "$f" .png | sed 's/qr-//')] https://github.com/${{ github.repository }}/releases/download/$TAG/$(basename "$f")" + done + echo "" + echo "🌐 [HTML 登录页(含所有二维码)](https://github.com/${{ github.repository }}/releases/download/$TAG/login.html)" + echo "" + echo "⏳ 二维码有效期约 2 分钟,请尽快扫描" + } >> "$GITHUB_STEP_SUMMARY" + + # ═══ 备用:HTML 仍上传为 artifact(万一 Release 失败兜底)═══ + - name: 上传二维码 HTML 页面(兜底备份) + if: ${{ always() && steps.gen.outcome == 'success' && steps.publish.outcome != 'success' }} + uses: actions/upload-artifact@v4 + with: + name: 二维码HTML + path: | + qr/login.html + qr/qr-*.html + retention-days: 1 + if-no-files-found: warn + + # ═══ 错误处理 ═══ + - name: 二维码生成失败提示 + if: ${{ failure() }} + run: | + echo "::error::❌ 二维码生成失败" + echo "" + echo "排查方向:" + echo " 1️⃣ 运行器网络是否能访问酷狗接口(查看上方步骤日志)" + echo " 2️⃣ npm 依赖安装是否成功" + + # ════════════════════════════════════════════ + # 步骤三:轮询等待用户扫码确认 + # ════════════════════════════════════════════ + - name: 等待扫码登录 + if: ${{ always() && steps.gen.outcome == 'success' }} + run: node qrcodeLogin.js wait + env: + USERINFO: ${{ secrets.USERINFO }} + APPEND_USER: ${{ github.event.inputs.append_user }} + ALLOW_PRINT_USERINFO: ${{ github.event.inputs.print_userinfo }} + PAT: ${{ secrets.PAT }} + GITHUB_REPOSITORY: ${{ github.repository }} + + # ═══ 清理:登录完成后立即删除本次 Release(不堆积)═══ + - name: 清理本次二维码 Release + if: ${{ always() && steps.publish.outcome == 'success' }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="qr-${{ github.run_id }}" + gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" -y 2>/dev/null \ + && echo "✅ 已删除本次 Release: $TAG" || echo "⚠️ Release $TAG 不存在或已删除" + + # ═══ 兜底:清理残留的旧二维码 Release(保留最近 3 个)═══ + - name: 清理旧的二维码 Release + if: ${{ always() }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ + | awk '$1 ~ /^qr-/ {print $1}' | head -n -3 \ + | while read t; do + gh release delete "$t" --repo "$GITHUB_REPOSITORY" -y 2>/dev/null + echo "已删除旧 Release: $t" + done || true diff --git a/.github/workflows/keepalive.yml "b/.github/workflows/\344\273\223\345\272\223\344\277\235\346\264\273.yml" similarity index 64% rename from .github/workflows/keepalive.yml rename to ".github/workflows/\344\273\223\345\272\223\344\277\235\346\264\273.yml" index 1eb55d4..f41d901 100644 --- a/.github/workflows/keepalive.yml +++ "b/.github/workflows/\344\273\223\345\272\223\344\277\235\346\264\273.yml" @@ -1,10 +1,10 @@ -name: Repository Keepalive +name: 仓库保活 on: schedule: - # 每30天运行一次(可自定义),避免仓库未更新导致Actions被禁用 + # 每30天运行一次,避免仓库未更新导致 Actions 被禁用 - cron: '0 0 */30 * *' - workflow_dispatch: # 允许手动触发 + workflow_dispatch: jobs: keepalive: @@ -13,14 +13,13 @@ jobs: contents: write steps: - - name: Checkout repository + - name: 检出仓库 uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Update keepalive timestamp + - name: 更新保活时间戳 run: | - # 创建或更新保活文件 mkdir -p .github cat > .github/KEEPALIVE.md << EOF # Repository Keepalive @@ -32,21 +31,20 @@ jobs: **Trigger:** ${{ github.event_name }} --- - *Auto-generated by [keepalive workflow](.github/workflows/keepalive.yml)* + *Auto-generated by [仓库保活 workflow](.github/workflows/仓库保活.yml)* EOF - - name: Commit and push changes + - name: 提交并推送 run: | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" git add .github/KEEPALIVE.md - # 只有文件变化时才提交 if git diff --staged --quiet; then - echo "No changes to commit" + echo "无变更" else git commit -m "chore: keepalive at $(date -u +'%Y-%m-%d')" git push - echo "✅ Keepalive commit pushed successfully" + echo "保活提交成功" fi diff --git a/.github/workflows/phoneLogin.yml "b/.github/workflows/\346\211\213\346\234\272\345\217\267\347\231\273\345\275\225.yml" similarity index 74% rename from .github/workflows/phoneLogin.yml rename to ".github/workflows/\346\211\213\346\234\272\345\217\267\347\231\273\345\275\225.yml" index f993c35..216dce8 100644 --- a/.github/workflows/phoneLogin.yml +++ "b/.github/workflows/\346\211\213\346\234\272\345\217\267\347\231\273\345\275\225.yml" @@ -1,20 +1,28 @@ -name: phoneLogin +name: 手机号登录 on: workflow_dispatch: inputs: + step: + description: "操作步骤" + required: true + type: choice + default: "发送验证码" + options: + - 发送验证码 + - 登录 append_user: description: "是否追加新账号?(不删除已存在账号信息)" required: true - default: "否" type: choice + default: "否" options: - 是 - 否 print_userinfo: - description: "自动写入失败时是否在日志输出 USERINFO?含登录token,默认否" + description: "自动写入失败时是否在日志输出 USERINFO?(含登录token,默认否)" required: true - default: "否" type: choice + default: "否" options: - 否 - 是 @@ -26,14 +34,15 @@ jobs: timeout-minutes: 2 steps: - name: 检出仓库 - uses: actions/checkout@v6 - - name: 创建node - uses: actions/setup-node@v6 + uses: actions/checkout@v4 + - name: 创建 Node 环境 + uses: actions/setup-node@v4 with: node-version: 24 cache: 'npm' cache-dependency-path: ./api/package-lock.json - - name: 🔧 检查GitHub CLI + - name: 检查 GitHub CLI + if: github.event.inputs.step == '登录' run: | if ! command -v gh >/dev/null 2>&1; then curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg @@ -43,9 +52,9 @@ jobs: sudo apt install gh -y fi gh --version - - name: Cache node_modules in api directory + - name: 缓存依赖 id: cache-npm - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: ./api/node_modules key: ${{ runner.os }}-node-${{ hashFiles('./api/package-lock.json') }} @@ -54,7 +63,13 @@ jobs: - name: 安装依赖 run: npm run install if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} + - name: 发送验证码 + if: github.event.inputs.step == '发送验证码' + run: npm run sent + env: + PHONE: ${{ secrets.PHONE }} - name: 手机号登录 + if: github.event.inputs.step == '登录' run: npm run phoneLogin env: PHONE: ${{ secrets.PHONE }} diff --git a/.github/workflows/main.yml "b/.github/workflows/\347\255\276\345\210\260.yml" similarity index 53% rename from .github/workflows/main.yml rename to ".github/workflows/\347\255\276\345\210\260.yml" index eb7edb3..8f3b6d9 100644 --- a/.github/workflows/main.yml +++ "b/.github/workflows/\347\255\276\345\210\260.yml" @@ -1,7 +1,6 @@ -name: main +name: 签到 on: workflow_dispatch: - # push: schedule: - cron: '10 17 * * *' permissions: @@ -9,16 +8,17 @@ permissions: jobs: run: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: 检出仓库 - uses: actions/checkout@v6 - - name: 创建node - uses: actions/setup-node@v6 + uses: actions/checkout@v4 + - name: 创建 Node 环境 + uses: actions/setup-node@v4 with: node-version: 24 cache: 'npm' cache-dependency-path: ./api/package-lock.json - - name: 🔧 检查GitHub CLI + - name: 检查 GitHub CLI run: | if ! command -v gh >/dev/null 2>&1; then curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg @@ -28,9 +28,9 @@ jobs: sudo apt install gh -y fi gh --version - - name: Cache node_modules in api directory + - name: 缓存依赖 id: cache-npm - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: ./api/node_modules key: ${{ runner.os }}-node-${{ hashFiles('./api/package-lock.json') }} @@ -39,9 +39,28 @@ jobs: - name: 安装依赖 run: npm run install if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} - - name: 签到 + - name: 执行签到 run: npm run main env: USERINFO: ${{ secrets.USERINFO }} PAT: ${{ secrets.PAT }} GITHUB_REPOSITORY: ${{ github.repository }} + # 通知渠道(按需配置,全部可选) + WECOM_BOT_KEY: ${{ secrets.WECOM_BOT_KEY }} + DINGTALK_BOT_KEY: ${{ secrets.DINGTALK_BOT_KEY }} + DINGTALK_SECRET: ${{ secrets.DINGTALK_SECRET }} + FEISHU_BOT_KEY: ${{ secrets.FEISHU_BOT_KEY }} + YUNHU_BOT_KEY: ${{ secrets.YUNHU_BOT_KEY }} + SERVERCHAN_SENDKEY: ${{ secrets.SERVERCHAN_SENDKEY }} + PUSHPLUS_TOKEN: ${{ secrets.PUSHPLUS_TOKEN }} + PUSHPLUS_TOPIC: ${{ secrets.PUSHPLUS_TOPIC }} + TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} + TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} + BARK_KEY: ${{ secrets.BARK_KEY }} + BARK_GROUP: ${{ secrets.BARK_GROUP }} + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + MAIL_HOST: ${{ secrets.MAIL_HOST }} + MAIL_PORT: ${{ secrets.MAIL_PORT }} + MAIL_USER: ${{ secrets.MAIL_USER }} + MAIL_PASS: ${{ secrets.MAIL_PASS }} + MAIL_TO: ${{ secrets.MAIL_TO }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfb5282 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# 依赖 +node_modules/ + +# 运行时生成的临时文件 +qr/ +qrkeys.json + +# 环境变量 +.env +.env.* + +# 系统文件 +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ diff --git a/README.md b/README.md index 5e74b99..8d8d594 100644 --- a/README.md +++ b/README.md @@ -44,20 +44,55 @@ GitHub Actions 实现 `酷狗概念VIP` 自动签到,每天领取总计 `两 - 滑动到底部,点击绿色的 Generate token 保存按钮 - 复制生成的字符串,回到本仓库添加到`Secret`,变量名 `PAT`,value 为复制的令牌 -1. 登录(两种登录方式任选其一) +1. 登录(两种独立的登录方式,任选其一) - 3.1 二维码(推荐) + 3.1 二维码登录(推荐) - 运行 Actions `qrcodeLogin` 并进入(若不显示,可以刷新页面),点击run -> 展开二维码登录, 根据提示操作即可。 + 运行 Actions `二维码登录`,点击 Run → 在运行摘要页面(Summary)查看二维码图片,使用酷狗音乐 APP 扫码并确认登录即可。 - 3.2 手机号 + 3.2 手机号登录 - 添加手机号到 Secret `PHONE`,运行 Actions `sent` 获取验证码,把验证码添加到 Secret `CODE`;运行Actions `phoneLogin` ,成功即可 + 添加手机号到 Secret `PHONE`,运行 Actions `手机号登录`,操作步骤选择「发送验证码」获取验证码,把验证码添加到 Secret `CODE`;再次运行 Actions `手机号登录`,操作步骤选择「登录」即可。 -1. 启用 Actions `main` , 每天北京时间 01:15 自动签到(可在`main.yml`中设置cron)。启用 Actions `Repository Keepalive` 以保证签到可以长期执行。 +1. 启用 Actions `签到`,每天北京时间 01:15 自动签到(可在 `签到.yml` 中设置 cron)。启用 Actions `仓库保活` 以保证签到可以长期执行。 + +1. (可选)配置运行结果通知 + + 在仓库 Settings → Secrets and variables → Actions 中添加对应渠道的 Secret,签到完成后将自动推送结果通知。支持以下渠道(全部可选,配置多个将同时发送): + + | 通知渠道 | Secret 变量名 | 说明 | + |---------|-------------|------| + | 企业微信机器人 | `WECOM_BOT_KEY` | 企业微信群机器人 webhook 的 key | + | 钉钉机器人 | `DINGTALK_BOT_KEY` | 钉钉机器人 access_token | + | 钉钉加签 | `DINGTALK_SECRET` | 钉钉机器人加签密钥(可选) | + | 飞书机器人 | `FEISHU_BOT_KEY` | 飞书自定义机器人 webhook 的 key | + | 云湖机器人 | `YUNHU_BOT_KEY` | 云湖机器人 webhook 的 key | + | Server酱 | `SERVERCHAN_SENDKEY` | Server酱 SendKey | + | PushPlus | `PUSHPLUS_TOKEN` | PushPlus token | + | PushPlus群组 | `PUSHPLUS_TOPIC` | PushPlus 群组编码(可选) | + | Telegram | `TG_BOT_TOKEN` | Telegram Bot Token | + | Telegram | `TG_CHAT_ID` | Telegram 接收消息的 Chat ID | + | Bark (iOS) | `BARK_KEY` | Bark key 或完整 URL | + | Bark分组 | `BARK_GROUP` | Bark 消息分组(可选) | + | Discord | `DISCORD_WEBHOOK` | Discord Webhook 完整 URL | + | 邮箱 SMTP | `MAIL_HOST` | SMTP 服务器地址(如 `smtp.qq.com`) | + | 邮箱 SMTP | `MAIL_PORT` | SMTP 端口(默认 465) | + | 邮箱 SMTP | `MAIL_USER` | 发件邮箱账号 | + | 邮箱 SMTP | `MAIL_PASS` | 发件邮箱授权码(非登录密码) | + | 邮箱 SMTP | `MAIL_TO` | 收件邮箱地址 | + + 通知内容包含:运行日期、账号数量、成功/失败统计、各账号听歌领取状态、VIP 领取次数、VIP 到期时间、错误信息等。 API源代码来自 [MakcRe/KuGouMusicApi](https://github.com/MakcRe/KuGouMusicApi) ~~图省事直接搬来~~ +## 令牌(Token)机制说明 + +项目中包含两类令牌: + +1. **GitHub Personal Access Token (PAT)**:用于自动将酷狗登录信息写入仓库 Secret `USERINFO`,以及每周日自动刷新酷狗登录 Token。 + +2. **酷狗登录 Token**:存储在 `USERINFO` Secret 中,用于酷狗 API 身份认证。通过登录获取,每周日自动刷新。 + ## Secret 位置 1. 步骤一 diff --git a/api/module/youth_listen_song.js b/api/module/youth_listen_song.js index 62f7123..3b0fe1c 100644 --- a/api/module/youth_listen_song.js +++ b/api/module/youth_listen_song.js @@ -8,7 +8,7 @@ module.exports = (params, useAxios) => { url: '/youth/v2/report/listen_song', data: dataMap, method: 'POST', - encryptTyPe: 'android', + encryptType: 'android', params: { clientver: 10566 }, cookie: params?.cookie, headers: { diff --git a/main.js b/main.js index 47a8967..bcae6b9 100644 --- a/main.js +++ b/main.js @@ -1,13 +1,13 @@ import { printBlue, printGreen, printMagenta, printRed, printYellow } from "./utils/colorOut.js"; import { hasSecretWriteToken, setRepoSecret } from "./utils/githubSecrets.js"; import { maskDisplayName, maskIdentifier, sanitizeForLog, summarizeResponse } from "./utils/safeLog.js"; +import { sendNotify } from "./utils/notify.js"; import { close_api, delay, send, startService } from "./utils/utils.js"; async function main() { const USERINFO = process.env.USERINFO // 刷新token - const refreshUserinfo = [] let needRefresh = false if (!USERINFO) { throw new Error("未配置") @@ -28,11 +28,14 @@ async function main() { const date = yyyy + '-' + MM + '-' + DD const errorMsg = {} + // 通知结果收集 + const notifyResults = [] + let hasError = false + try { // 开始签到 for (const user of userinfo) { const headers = { 'cookie': 'token=' + user.token + '; userid=' + user.userid } - // console.log(headers) const userDetail = await send(`/user/detail?timestrap=${Date.now()}`, "GET", headers) if (userDetail?.data?.nickname == null) { const safeUserId = maskIdentifier(user.userid) @@ -41,13 +44,22 @@ async function main() { msg: `token过期或账号不存在, userid: ${safeUserId}`, data: summarizeResponse(userDetail) } + notifyResults.push({ + nickname: safeUserId, + status: '失败', + listen: '账号不存在', + vipClaim: '0/8', + vipExpiry: '未知', + error: 'token过期或账号不存在' + }) + hasError = true continue } const safeNickname = maskDisplayName(userDetail.data.nickname) printMagenta(`账号 ${safeNickname} 开始领取VIP...`) // 周日刷新token - if (today.getDay() == 0) { + if (today.getDay() === 0) { const refreshToken = await send(`/login/token?timestrap=${Date.now()}`, "POST", headers) if (refreshToken?.status == 1) { if (refreshToken?.data?.token !== user.token) { @@ -56,7 +68,6 @@ async function main() { user.token = refreshToken.data.token } } - refreshUserinfo.push(user) } // 开始听歌 @@ -64,23 +75,30 @@ async function main() { // 听歌获取vip const listen = await send(`/youth/listen/song?timestrap=${Date.now()}`, "GET", headers) + let listenStatus = '未知' if (listen.status === 1) { printGreen("听歌领取成功") + listenStatus = '成功' } else if (listen.error_code === 130012) { printGreen("今日已领取") + listenStatus = '今日已领取' } else { errorMsg[`${safeNickname} listen`] = summarizeResponse(listen) printRed("听歌领取失败") + listenStatus = '失败' + hasError = true } printYellow("开始领取VIP...") + let claimCount = 0 + let claimTotal = 0 for (let i = 1; i <= 8; i++) { // ad获取vip const ad = await send(`/youth/vip?timestrap=${Date.now()}`, "GET", headers) - // 签到出现问题 - // errorMsg[`${safeNickname} ad${i}`] = summarizeResponse(ad) + claimTotal = i if (ad.status === 1) { printGreen(`第${i}次领取成功`) + claimCount++ if (i != 8) { await delay(30 * 1000) } @@ -89,43 +107,79 @@ async function main() { break } else { printRed(`第${i}次领取失败`) - // console.dir(ad, { depth: null }) errorMsg[`${safeNickname} ad`] = summarizeResponse(ad) + hasError = true break } } + let vipExpiry = '未知' const vip_details = await send(`/user/vip/detail?timestrap=${Date.now()}`, "GET", headers) - if (vip_details.status === 1) { + if (vip_details.status === 1 && Array.isArray(vip_details.data?.busi_vip) && vip_details.data.busi_vip.length > 0) { + vipExpiry = vip_details.data.busi_vip[0].vip_end_time printBlue(`今天是:${date}`) - printBlue(`VIP到期时间:${vip_details.data.busi_vip[0].vip_end_time}\n`) + printBlue(`VIP到期时间:${vipExpiry}\n`) } else { printRed("获取失败\n") errorMsg[`${safeNickname} vip_details`] = summarizeResponse(vip_details) + hasError = true } + + notifyResults.push({ + nickname: safeNickname, + status: listenStatus === '失败' || claimCount === 0 ? '部分失败' : '成功', + listen: listenStatus, + vipClaim: `${claimCount}/${claimTotal}`, + vipExpiry, + error: '' + }) } } finally { close_api(api) } - // 更新secret - if (refreshUserinfo.length > 0 && needRefresh) { - + // 更新secret (使用完整 userinfo 数组,保留所有用户包括过期账号) + let secretError = null + if (needRefresh) { if (hasSecretWriteToken()) { - const userinfoJSON = JSON.stringify(refreshUserinfo) + const userinfoJSON = JSON.stringify(userinfo) try { setRepoSecret("USERINFO", userinfoJSON) printGreen("secret token刷新成功") } catch (error) { printRed("token刷新失败") console.dir(sanitizeForLog({ message: error.message }), { depth: null }) - throw new Error("secret token刷新失败") + secretError = new Error("secret token刷新失败") } } else { printYellow("存在账号需要刷新token,但是未配置PAT,未刷新token最多两个月后过期") } + } + + // 构建通知内容(放在 secret 更新之后、错误抛出之前,确保始终执行) + const title = `酷狗签到${hasError ? '异常' : '成功'} ${date}` + let content = `📅 日期: ${date}\n` + content += `📊 账号数: ${notifyResults.length}\n` + const successCount = notifyResults.filter(r => r.status === '成功').length + const failCount = notifyResults.length - successCount + content += `✅ 成功: ${successCount} ❌ 失败: ${failCount}\n` + + for (const r of notifyResults) { + content += `\n【${r.nickname}】\n` + content += ` 🎵 听歌领取: ${r.listen}\n` + content += ` 🎁 VIP领取: ${r.vipClaim} 次\n` + content += ` ⏰ VIP到期: ${r.vipExpiry}\n` + if (r.error) { + content += ` ⚠️ 错误: ${r.error}\n` + } + } + // 发送通知(确保即使 secret 更新失败也能发出) + try { + await sendNotify(title, content) + } catch (e) { + printYellow(`通知发送异常: ${e.message}`) } if (Object.keys(errorMsg).length > 0) { @@ -134,10 +188,10 @@ async function main() { throw new Error("领取异常") } - if (api.killed) { - process.exit(0) + if (secretError) { + throw secretError } -} -main() +} +main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) }) diff --git a/package.json b/package.json index da8c6cd..26bbd10 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,8 @@ "phoneLogin": "node phoneLogin.js", "sent": "node sent.js", "qrcodeLogin": "node qrcodeLogin.js" + }, + "dependencies": { + "qrcode": "^1.5.3" } } diff --git a/phoneLogin.js b/phoneLogin.js index db790c0..6895671 100644 --- a/phoneLogin.js +++ b/phoneLogin.js @@ -1,6 +1,6 @@ -import { printBlue, printGreen, printRed, printYellow } from "./utils/colorOut.js"; -import { hasSecretWriteToken, setRepoSecret } from "./utils/githubSecrets.js"; -import { maskIdentifier, sanitizeForLog, shouldPrintSensitiveValue, summarizeResponse } from "./utils/safeLog.js"; +import { printGreen, printRed, printYellow } from "./utils/colorOut.js"; +import { sanitizeForLog, summarizeResponse } from "./utils/safeLog.js"; +import { upsertUser, saveUserinfo } from "./utils/userinfo.js"; import { close_api, delay, send, startService } from "./utils/utils.js"; async function login() { @@ -23,54 +23,9 @@ async function login() { // 手机号登录请求 const result = await send(`/login/cellphone?mobile=${phone}&code=${code}`, "GET", {}) if (result.status === 1) { - - let userAlreadyExist = false printGreen("登录成功!") - if (APPEND_USER == "是") { - for (let i = 0; i < userinfo.length; i++) { - - if (userinfo[i].userid == result.data.userid) { - userAlreadyExist = true - printYellow(`userid: ${maskIdentifier(userinfo[i].userid)} 此账号已存在, 仅更新登录信息`) - userinfo[i].token = result.data.token - } - } - } - if (!userAlreadyExist) { - userinfo.push({ - userid: result.data.userid, - token: result.data.token - }) - } - if (userinfo.length) { - const userinfoJSON = JSON.stringify(userinfo) - if (hasSecretWriteToken()) { - try { - setRepoSecret("USERINFO", userinfoJSON) - printGreen("secret 更改成功") - } catch (error) { - printRed("自动写入 secret 出错") - console.dir(sanitizeForLog({ message: error.message }), { depth: null }) - if (shouldPrintSensitiveValue()) { - printYellow("已按显式配置输出 USERINFO;请用完后删除 Actions 日志") - printBlue(userinfoJSON) - } else { - printYellow("为避免泄露 token,默认不在日志输出 USERINFO") - printYellow("如必须手动复制,请重新运行并将 print_userinfo 选择为 是") - } - } - } else { - if (shouldPrintSensitiveValue()) { - printGreen("登录信息如下,把它添加到secret USERINFO 即可") - printYellow("注意:日志包含登录 token,请用完后删除 Actions 日志") - printBlue(userinfoJSON) - } else { - printYellow("PAT/GH_TOKEN 未配置,无法自动写入 secret ") - printYellow("为避免泄露 token,默认不在日志输出 USERINFO") - printYellow("如必须手动复制,请重新运行并将 print_userinfo 选择为 是") - } - } - } + upsertUser(userinfo, { userid: result.data.userid, token: result.data.token }, APPEND_USER == "是") + saveUserinfo(userinfo) } else if (result.error_code === 34175) { throw new Error("暂不支持多账号绑定手机登录") } else { @@ -81,10 +36,6 @@ async function login() { } finally { close_api(api) } - - if (api.killed) { - process.exit(0) - } } -login() +login().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) }) diff --git a/qrcodeLogin.js b/qrcodeLogin.js index 785ab92..39b4e8c 100644 --- a/qrcodeLogin.js +++ b/qrcodeLogin.js @@ -1,124 +1,286 @@ +import { createRequire } from 'module' +import fs from 'node:fs' import { close_api, delay, send, startService } from "./utils/utils.js"; -import { printBlue, printGreen, printMagenta, printRed, printYellow } from "./utils/colorOut.js"; -import { hasSecretWriteToken, setRepoSecret } from "./utils/githubSecrets.js"; -import { maskIdentifier, sanitizeForLog, shouldPrintSensitiveValue, summarizeResponse } from "./utils/safeLog.js"; +import { printGreen, printMagenta, printRed, printYellow } from "./utils/colorOut.js"; +import { summarizeResponse } from "./utils/safeLog.js"; +import { upsertUser, saveUserinfo } from "./utils/userinfo.js"; -async function qrcode() { +const require = createRequire(import.meta.url) +const QRCode = require('./api/node_modules/qrcode') - // 启动服务 +// GitHub Actions 运行环境下自动注入的 Step Summary 文件路径 +const SUMMARY_FILE = process.env.GITHUB_STEP_SUMMARY || '' +const QR_DIR = './qr' +const KEYS_FILE = './qrkeys.json' + +/** + * 向 GitHub Step Summary 追加 Markdown 内容。 + * 非 Actions 环境(本地运行)时 SUMMARY_FILE 为空,自动跳过。 + * @param {string} markdown + */ +function appendSummary(markdown) { + if (!SUMMARY_FILE) return + try { + fs.appendFileSync(SUMMARY_FILE, markdown + '\n') + const size = fs.statSync(SUMMARY_FILE).size + if (size > 0) { + console.log(`[Summary] 已追加 ${Buffer.byteLength(markdown)} 字节,总计 ${size} 字节`) + } + } catch (err) { + console.warn(`[Summary] 写入失败:${err.message}`) + } +} + +/** + * 生成并展示单个二维码 — 展示渠道: + * + * ① PNG 文件(qr/qr-N.png):Release 直链 + HTML 内嵌双用途 + * ② 自包含 HTML 页面(qr/login.html):浏览器打开即见大图,手机直接扫 + * ③ base64 data URI:HTML 共用 + * + * @param {string} url 酷狗扫码登录完整 URL + * @param {number} index 账号序号(从 1 开始) + * @param {number} total 总账号数 + * @returns {{ dataUrl: string, url: string, header: string, index: number }} 供 HTML 聚合用 + */ +async function buildQr(url, index, total) { + const header = total > 1 ? `(第 ${index}/${total} 个账号)` : '' + + // ── 1) PNG 文件(Release 直链 + HTML 内嵌双用途)── + await QRCode.toFile(`${QR_DIR}/qr-${index}.png`, url, { width: 320, margin: 2 }) + + // ── 2) base64 data URI(HTML 共用)── + const dataUrl = await QRCode.toDataURL(url, { width: 320, margin: 2 }) + + // ── 3) 日志输出:指引去直链步骤 ── + printMagenta(`\n═══ 第 ${index}/${total} 个二维码已生成 ═══`) + console.log('') + console.log(' 🔗 请查看下一步「发布二维码图片直链」输出的链接,浏览器打开即可直接扫码') + console.log('') + + return { dataUrl, url, header, index } +} + +/** + * 生成自包含 HTML 登录页(所有二维码的大图集中展示) + * 用户从 artifact 下载后双击/手机打开即可直接扫码,无需任何依赖。 + */ +function generateHtmlPage(qrItems) { + const cards = qrItems.map(item => ` +
+

账号 ${item.index}/${qrItems.length} ${item.header}

+
+ 账号${item.index} 二维码 +
+

${item.url}

+

⏳ 有效期约 2 分钟

+
+ `).join('\n') + + return ` + + + + +酷狗音乐扫码登录 + + + +
+

🎵 酷狗音乐扫码登录

+

使用「酷狗音乐 APP」扫描下方二维码完成登录

+
+
${cards}
+ + +` +} + +/** 解析账号数量,无效输入回退为 1 */ +function resolveNumber() { + const args = process.argv.slice(3) + const n = parseInt(process.env.NUMBER || args[0] || "1") + return (Number.isNaN(n) || n < 1) ? 1 : n +} + +/** + * 模式一:生成二维码(PNG + HTML),随后立即结束 step。 + * step 结束后 Release 直链即可使用,用户浏览器打开直接扫码。 + */ +async function genMode() { const api = startService() await delay(2000) - let qrcode = "" - const USERINFO = process.env.USERINFO - const APPEND_USER = process.env.APPEND_USER - const userinfo = (USERINFO && APPEND_USER == "是") ? JSON.parse(USERINFO) : [] - const args = process.argv.slice(2); - const number = parseInt(process.env.NUMBER || args[0] || "1") + const number = resolveNumber() + const keys = [] + + // 清理上次运行残留的 QR 文件,避免旧二维码混入本次 Release + fs.rmSync(QR_DIR, { recursive: true, force: true }) + fs.mkdirSync(QR_DIR, { recursive: true }) + + if (!SUMMARY_FILE) { + console.log('[INFO] 非 Actions 环境($GITHUB_STEP_SUMMARY 未设置),Summary 将跳过') + } + try { - for (let i = 0; i < number; i++) { - // 二维码 + const qrItems = [] // 收集所有二维码信息用于生成聚合 HTML + + for (let n = 0; n < number; n++) { const result = await send(`/login/qr/key?timestrap=${Date.now()}`, "GET", {}) if (result.status === 1) { - qrcode = result.data.qrcode - const img_base64 = result.data.qrcode_img; - const chunkSize = 1000; - printMagenta("二维码链接如下, 请在浏览器打开使用APP扫描并确认登录") - for (let i = 0; i < img_base64.length; i += chunkSize) { - console.log(img_base64.slice(i, i + chunkSize)); - } + const qrcode = result.data.qrcode + const qrUrl = `https://h5.kugou.com/apps/loginQRCode/html/index.html?qrcode=${qrcode}` + keys.push(qrcode) + const item = await buildQr(qrUrl, n + 1, number) + qrItems.push(item) } else { printRed("响应内容") console.dir(summarizeResponse(result), { depth: null }) - throw new Error("请求出错") + throw new Error(`获取二维码密钥失败:接口返回 status=${result.status}`) + } + } + + // ── 生成自包含 HTML 登录页(核心展示渠道!)── + if (qrItems.length > 0) { + const htmlContent = generateHtmlPage(qrItems) + fs.writeFileSync(`${QR_DIR}/login.html`, htmlContent, 'utf8') + + // 也把每个二维码单独做成一个 HTML 方便多账号时逐个处理 + for (const item of qrItems) { + fs.writeFileSync( + `${QR_DIR}/qr-${item.index}.html`, + `扫码登录 ${item.header}` + + `` + + `扫码登录${item.header}`, + 'utf8' + ) } - printMagenta("正在等待,请扫描二维码并确定登录") - // 登录 - for (let i = 0; i < 25; i++) { + } + + fs.writeFileSync(KEYS_FILE, JSON.stringify({ number, keys })) + printMagenta(`\n✅ 已生成 ${number} 个二维码。`) + printMagenta(`🔗 请查看下一步「发布二维码图片直链」输出的可点击链接,浏览器打开即可直接扫码!`) + + // 写入 Summary 提示 + appendSummary(`## 🎵 酷狗音乐扫码登录\n\n✅ 已生成 ${number} 个二维码,请查看下一步「发布二维码图片直链」输出的链接进行扫码。\n\n⏳ 二维码有效期约 2 分钟,请尽快扫描。`) + } catch (e) { + const msg = e && e.message ? e.message : String(e) + console.error(`::error::二维码生成失败:${msg}`) + appendSummary(`## ❌ 二维码生成失败\n\n错误信息:${msg}`) + throw e + } finally { + close_api(api) + } +} + +/** + * 模式二:读取已生成的二维码密钥,轮询等待用户扫码确认 + */ +async function waitMode() { + const api = startService() + await delay(2000) + let parsed + try { + parsed = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8')) + } catch { + throw new Error('未找到二维码密钥文件,请确认已先运行「生成登录二维码图片」步骤') + } + const { number, keys } = parsed + const USERINFO = process.env.USERINFO + const APPEND_USER = process.env.APPEND_USER + const userinfo = (USERINFO && APPEND_USER == "是") ? JSON.parse(USERINFO) : [] + + const results = [] // 收集每个账号的扫码结果用于 Summary + + try { + for (let n = 0; n < number; n++) { + const qrcode = keys[n] + if (!qrcode) { + printRed(`第 ${n + 1}/${number} 个账号的二维码密钥缺失,跳过`) + results.push({ index: n + 1, status: '密钥缺失' }) + continue + } + printMagenta(`\n正在等待第 ${n + 1}/${number} 个账号扫码登录...`) + let loggedIn = false + let expireFlag = false + for (let i = 0; i < 30; i++) { const timestrap = Date.now(); const res = await send(`/login/qr/check?key=${qrcode}×trap=${timestrap}`, "GET", {}) const status = res?.data?.status switch (status) { case 0: - printYellow("二维码已过期") + printYellow("二维码已过期,请重新运行工作流生成新二维码") + expireFlag = true break - case 1: - // console.log("未扫描二维码") + // 未扫描二维码 break - case 2: - // console.log("二维码未确认,请点击确认登录") + // 二维码未确认,请点击确认登录 break case 4: - let userAlreadyExist = false printGreen("登录成功!") - if (APPEND_USER == "是") { - for (let i = 0; i < userinfo.length; i++) { - - if (userinfo[i].userid == res.data.userid) { - userAlreadyExist = true - printYellow(`userid: ${maskIdentifier(userinfo[i].userid)} 此账号已存在, 仅更新登录信息`) - userinfo[i].token = res.data.token - } - } - } - if (!userAlreadyExist) { - userinfo.push({ - userid: res.data.userid, - token: res.data.token - }) - } - break; + upsertUser(userinfo, { userid: res.data.userid, token: res.data.token }, APPEND_USER == "是") + loggedIn = true + break default: printRed("请求出错") console.dir(summarizeResponse(res), { depth: null }) } - if (status == 4 || status == 0) { + if (loggedIn || expireFlag) { break } - if (i == 24) { + if (i === 29) { printRed("等待超时\n") - break - } - await delay(5000) - } - } - if (userinfo.length) { - const userinfoJSON = JSON.stringify(userinfo) - if (hasSecretWriteToken()) { - try { - setRepoSecret("USERINFO", userinfoJSON) - printGreen("secret 更改成功") - } catch (error) { - printRed("自动写入 secret 出错") - console.dir(sanitizeForLog({ message: error.message }), { depth: null }) - if (shouldPrintSensitiveValue()) { - printYellow("已按显式配置输出 USERINFO;请用完后删除 Actions 日志") - printBlue(userinfoJSON) - } else { - printYellow("为避免泄露 token,默认不在日志输出 USERINFO") - printYellow("如必须手动复制,请重新运行并将 print_userinfo 选择为 是") - } - } - } else { - if (shouldPrintSensitiveValue()) { - printGreen("登录信息如下,把它添加到secret USERINFO 即可") - printYellow("注意:日志包含登录 token,请用完后删除 Actions 日志") - printBlue(userinfoJSON) - } else { - printYellow("PAT/GH_TOKEN 未配置,无法自动写入 secret ") - printYellow("为避免泄露 token,默认不在日志输出 USERINFO") - printYellow("如必须手动复制,请重新运行并将 print_userinfo 选择为 是") } + // 前 10 次用 3 秒间隔(快速响应),之后用 5 秒间隔 + await delay(i < 10 ? 3000 : 5000) } + results.push({ + index: n + 1, + status: loggedIn ? '✅ 登录成功' : (expireFlag ? '❌ 二维码过期' : '❌ 等待超时') + }) } + saveUserinfo(userinfo) + + // 写入扫码结果到 Summary + const resultLines = results.map(r => `- 账号 ${r.index}/${number}:${r.status}`).join('\n') + appendSummary(`### 扫码结果\n\n${resultLines}`) } finally { close_api(api) } - - if (api.killed) { - process.exit(0) - } } -qrcode() +const mode = process.argv[2] || 'gen' +if (mode === 'wait') { + waitMode().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) }) +} else { + genMode().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) }) +} diff --git a/sent.js b/sent.js index 41226e5..e7852a2 100644 --- a/sent.js +++ b/sent.js @@ -26,10 +26,6 @@ async function login() { } finally { close_api(api) } - - if (api.killed) { - process.exit(0) - } } -login() +login().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) }) diff --git a/utils/notify.js b/utils/notify.js new file mode 100644 index 0000000..c65d705 --- /dev/null +++ b/utils/notify.js @@ -0,0 +1,387 @@ +/** + * 多渠道通知模块 + * 支持以下通知渠道(通过环境变量 / GitHub Secrets 配置): + * + * 1. 企业微信机器人 WECOM_BOT_KEY (webhook key) + * 2. 钉钉机器人 DINGTALK_BOT_KEY (+ 可选 DINGTALK_SECRET) + * 3. 飞书机器人 FEISHU_BOT_KEY (webhook key) + * 4. 云湖机器人 YUNHU_BOT_KEY (webhook key) + * 5. Server酱 SERVERCHAN_SENDKEY (sendkey) + * 6. PushPlus PUSHPLUS_TOKEN (+ 可选 PUSHPLUS_TOPIC) + * 7. Telegram Bot TG_BOT_TOKEN + TG_CHAT_ID + * 8. Bark (iOS) BARK_KEY (+ 可选 BARK_GROUP) + * 9. Discord Webhook DISCORD_WEBHOOK (完整 URL) + * 10. 邮箱 SMTP MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, MAIL_TO + * + * 所有渠道均使用 Node.js 内置模块,无外部依赖。 + */ + +import crypto from 'node:crypto' +import tls from 'node:tls' +import { printGreen, printRed, printYellow } from './colorOut.js' + +/* ------------------------------------------------------------------ */ +/* 各渠道发送实现 */ +/* ------------------------------------------------------------------ */ + +// 1. 企业微信机器人 +async function sendWeCom(title, content, key) { + const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${key}` + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msgtype: 'text', + text: { content: `${title}\n\n${content}` }, + }), + }) + return resp.ok +} + +// 2. 钉钉机器人 +async function sendDingTalk(title, content, key, secret) { + let url = `https://oapi.dingtalk.com/robot/send?access_token=${key}` + if (secret) { + const timestamp = Date.now() + const stringToSign = `${timestamp}\n${secret}` + const sign = crypto + .createHmac('sha256', secret) + .update(stringToSign) + .digest('base64') + url += `×tamp=${timestamp}&sign=${encodeURIComponent(sign)}` + } + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msgtype: 'markdown', + markdown: { title, text: `### ${title}\n\n${content}` }, + }), + }) + return resp.ok +} + +// 3. 飞书机器人 +async function sendFeishu(title, content, key) { + const url = `https://open.feishu.cn/open-apis/bot/v2/hook/${key}` + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msg_type: 'text', + content: { text: `${title}\n\n${content}` }, + }), + }) + return resp.ok +} + +// 4. 云湖机器人 +async function sendYunhu(title, content, key) { + const url = `https://www.yhchat.com/bot/send?key=${key}` + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + msg: { text: `${title}\n\n${content}` }, + }), + }) + return resp.ok +} + +// 5. Server酱 +async function sendServerChan(title, content, sendkey) { + const url = `https://sctapi.ftqq.com/${sendkey}.send` + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ title, desp: content }), + }) + return resp.ok +} + +// 6. PushPlus +async function sendPushPlus(title, content, token, topic) { + const url = 'https://www.pushplus.plus/send' + const body = { token, title, content, template: 'txt' } + if (topic) body.topic = topic + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return resp.ok +} + +// 7. Telegram Bot +async function sendTelegram(title, content, botToken, chatId) { + const url = `https://api.telegram.org/bot${botToken}/sendMessage` + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: `*${title}*\n\n${content}`, + parse_mode: 'Markdown', + }), + }) + return resp.ok +} + +// 8. Bark (iOS 推送) +async function sendBark(title, content, key, group) { + const base = key.startsWith('http') ? key : `https://api.day.app/${key}` + const url = `${base}/${encodeURIComponent(title)}/${encodeURIComponent(content)}` + const params = new URLSearchParams() + if (group) params.set('group', group) + const finalUrl = params.toString() ? `${url}?${params}` : url + const resp = await fetch(finalUrl) + return resp.ok +} + +// 9. Discord Webhook +async function sendDiscord(title, content, webhookUrl) { + const resp = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: `**${title}**\n\n${content}`.slice(0, 2000), + }), + }) + return resp.ok +} + +// 10. 邮箱 SMTP(使用 node:net/node:tls 实现最小 SMTP 客户端) +// 支持隐式 TLS(端口 465)和 STARTTLS(端口 587/25) +function sendMailSMTP(title, content, cfg) { + return new Promise((resolve, reject) => { + const { host, port, user, pass, to } = cfg + const from = user + const subject = `=?UTF-8?B?${Buffer.from(title).toString('base64')}?=` + const date = new Date().toUTCString() + const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@kgcheckin>` + + const mailBody = [ + `From: ${from}`, + `To: ${to}`, + `Subject: ${subject}`, + `Date: ${date}`, + `Message-ID: ${messageId}`, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: 8bit', + '', + content, + ].join('\r\n') + + // 隐式 TLS (465) 直接 TLS 连接;STARTTLS (587/25) 先明文再升级 + const smtpPort = Number(port) || 465 + const useSTARTTLS = smtpPort !== 465 + + // SMTP 会话状态机 + // 隐式 TLS: GREETING → EHLO_SENT → AUTH 流程 + // STARTTLS: GREETING → EHLO_SENT → STARTTLS_SENT → TLS_UPGRADED → EHLO2_SENT → AUTH 流程 + let state = 'GREETING' + let buffer = '' + let socket + let tlsSocket + let timer + + // AUTH 流程命令序列(EHLO 之后依次执行) + const authCommands = [ + 'AUTH LOGIN', + Buffer.from(user).toString('base64'), + Buffer.from(pass).toString('base64'), + `MAIL FROM:<${from}>`, + `RCPT TO:<${to}>`, + 'DATA', + mailBody.replace(/\r?\n/g, '\r\n') + '\r\n.', + 'QUIT', + ] + let authStep = 0 + + function sendCmd(cmd) { + socket.write(cmd + '\r\n') + } + + function processData(line) { + const code = parseInt(line.slice(0, 3), 10) + + if (code >= 400) { + socket.destroy() + reject(new Error(`SMTP 错误: ${line}`)) + return + } + + // 等待多行响应结束(中间行以 "code-" 开头,最后一行以 "code " 开头) + if (line[3] === '-') return + + switch (state) { + case 'GREETING': + // 收到 220 服务器就绪 → 发送 EHLO + sendCmd('EHLO kgcheckin') + state = 'EHLO_SENT' + break + + case 'EHLO_SENT': + if (useSTARTTLS) { + // STARTTLS 模式:EHLO 响应后发送 STARTTLS + sendCmd('STARTTLS') + state = 'STARTTLS_SENT' + } else { + // 隐式 TLS:直接进入 AUTH 流程 + state = 'AUTH' + authStep = 0 + sendCmd(authCommands[authStep++]) + } + break + + case 'STARTTLS_SENT': + // 收到 220 → 升级为 TLS + if (code !== 220) { + socket.destroy() + reject(new Error(`STARTTLS 失败: ${line}`)) + return + } + tlsSocket = require('node:tls').connect({ socket, host, rejectUnauthorized: true }) + tlsSocket.setEncoding('utf8') + tlsSocket.on('data', (chunk) => { handleChunk(chunk) }) + tlsSocket.on('end', () => { clearTimeout(timer); resolve(true) }) + tlsSocket.on('close', () => { clearTimeout(timer); resolve(true) }) + tlsSocket.on('error', (err) => { clearTimeout(timer); reject(err) }) + socket = tlsSocket + // 升级后重新发送 EHLO + sendCmd('EHLO kgcheckin') + state = 'EHLO2_SENT' + break + + case 'EHLO2_SENT': + // STARTTLS 升级后的第二次 EHLO 响应 → 进入 AUTH 流程 + state = 'AUTH' + authStep = 0 + sendCmd(authCommands[authStep++]) + break + + case 'AUTH': + if (authStep < authCommands.length) { + sendCmd(authCommands[authStep++]) + } + break + } + } + + function handleChunk(chunk) { + buffer += chunk + while (true) { + const idx = buffer.indexOf('\r\n') + if (idx === -1) break + const line = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + processData(line) + } + } + + // 超时保护 + timer = setTimeout(() => { + if (socket) socket.destroy() + reject(new Error('SMTP 超时')) + }, 15000) + + if (useSTARTTLS) { + // STARTTLS:先明文连接 + socket = require('node:net').connect(smtpPort, host, () => {}) + } else { + // 隐式 TLS:直接 TLS 连接 + socket = require('node:tls').connect( + { host, port: smtpPort, rejectUnauthorized: true }, + () => {} + ) + } + + socket.setEncoding('utf8') + socket.on('data', (chunk) => handleChunk(chunk)) + socket.on('end', () => { clearTimeout(timer); resolve(true) }) + socket.on('close', () => { clearTimeout(timer); resolve(true) }) + socket.on('error', (err) => { clearTimeout(timer); reject(err) }) + }) +} + +/* ------------------------------------------------------------------ */ +/* 统一发送入口 */ +/* ------------------------------------------------------------------ */ + +/** + * 发送通知到所有已配置的渠道 + * @param {string} title - 通知标题 + * @param {string} content - 通知正文(纯文本) + */ +async function sendNotify(title, content) { + const channels = [] + + // 收集所有已配置的渠道 + if (process.env.WECOM_BOT_KEY) { + channels.push({ name: '企业微信', fn: () => sendWeCom(title, content, process.env.WECOM_BOT_KEY) }) + } + if (process.env.DINGTALK_BOT_KEY) { + channels.push({ name: '钉钉', fn: () => sendDingTalk(title, content, process.env.DINGTALK_BOT_KEY, process.env.DINGTALK_SECRET) }) + } + if (process.env.FEISHU_BOT_KEY) { + channels.push({ name: '飞书', fn: () => sendFeishu(title, content, process.env.FEISHU_BOT_KEY) }) + } + if (process.env.YUNHU_BOT_KEY) { + channels.push({ name: '云湖', fn: () => sendYunhu(title, content, process.env.YUNHU_BOT_KEY) }) + } + if (process.env.SERVERCHAN_SENDKEY) { + channels.push({ name: 'Server酱', fn: () => sendServerChan(title, content, process.env.SERVERCHAN_SENDKEY) }) + } + if (process.env.PUSHPLUS_TOKEN) { + channels.push({ name: 'PushPlus', fn: () => sendPushPlus(title, content, process.env.PUSHPLUS_TOKEN, process.env.PUSHPLUS_TOPIC) }) + } + if (process.env.TG_BOT_TOKEN && process.env.TG_CHAT_ID) { + channels.push({ name: 'Telegram', fn: () => sendTelegram(title, content, process.env.TG_BOT_TOKEN, process.env.TG_CHAT_ID) }) + } + if (process.env.BARK_KEY) { + channels.push({ name: 'Bark', fn: () => sendBark(title, content, process.env.BARK_KEY, process.env.BARK_GROUP) }) + } + if (process.env.DISCORD_WEBHOOK) { + channels.push({ name: 'Discord', fn: () => sendDiscord(title, content, process.env.DISCORD_WEBHOOK) }) + } + if (process.env.MAIL_HOST && process.env.MAIL_USER && process.env.MAIL_PASS && process.env.MAIL_TO) { + channels.push({ + name: '邮箱', + fn: () => sendMailSMTP(title, content, { + host: process.env.MAIL_HOST, + port: process.env.MAIL_PORT || 465, + user: process.env.MAIL_USER, + pass: process.env.MAIL_PASS, + to: process.env.MAIL_TO, + }), + }) + } + + if (channels.length === 0) { + return + } + + printYellow(`正在发送通知到 ${channels.length} 个渠道...`) + const results = await Promise.allSettled(channels.map(async (ch) => { + const ok = await ch.fn() + return { name: ch.name, ok } + })) + + let success = 0 + let fail = 0 + for (const r of results) { + if (r.status === 'fulfilled' && r.value.ok) { + printGreen(` ✓ ${r.value.name} 发送成功`) + success++ + } else { + const name = r.status === 'fulfilled' ? r.value.name : '未知' + const reason = r.status === 'rejected' ? r.reason?.message : 'HTTP错误' + printRed(` ✗ ${name} 发送失败: ${reason}`) + fail++ + } + } + printYellow(`通知发送完成: ${success} 成功, ${fail} 失败`) +} + +export { sendNotify } diff --git a/utils/safeLog.js b/utils/safeLog.js index 8622a15..ba04057 100644 --- a/utils/safeLog.js +++ b/utils/safeLog.js @@ -126,7 +126,9 @@ function summarizeResponse(response) { } function shouldPrintSensitiveValue() { - return ["是", "true", "1", "yes"].includes(String(process.env.ALLOW_PRINT_USERINFO || "").toLowerCase()); + const raw = String(process.env.ALLOW_PRINT_USERINFO || "").trim().toLowerCase() + // 中文“是”直接匹配,英文值需要小写后匹配 + return raw === "是" || ["true", "1", "yes"].includes(raw) } export { maskDisplayName, maskIdentifier, sanitizeForLog, shouldPrintSensitiveValue, summarizeResponse }; diff --git a/utils/userinfo.js b/utils/userinfo.js new file mode 100644 index 0000000..8838155 --- /dev/null +++ b/utils/userinfo.js @@ -0,0 +1,67 @@ +/** + * 用户信息管理工具 + * 提供登录脚本共享的用户信息更新与保存逻辑 + */ + +import { printBlue, printGreen, printRed, printYellow } from './colorOut.js' +import { hasSecretWriteToken, setRepoSecret } from './githubSecrets.js' +import { maskIdentifier, sanitizeForLog, shouldPrintSensitiveValue } from './safeLog.js' + +/** + * 将登录用户信息更新或追加到 userinfo 数组中 + * @param {Array} userinfo - 用户信息数组 + * @param {{ userid: string, token: string }} loginUser - 新登录的用户 + * @param {boolean} append - 是否追加模式(已存在则更新,否则添加) + */ +function upsertUser(userinfo, loginUser, append) { + if (append) { + for (const user of userinfo) { + if (user.userid == loginUser.userid) { + printYellow(`userid: ${maskIdentifier(user.userid)} 此账号已存在, 仅更新登录信息`) + user.token = loginUser.token + return + } + } + } + userinfo.push({ userid: loginUser.userid, token: loginUser.token }) +} + +/** + * 保存 userinfo 到 GitHub Secret,失败时降级为日志输出 + * @param {Array} userinfo - 用户信息数组 + */ +function saveUserinfo(userinfo) { + if (!userinfo.length) return + + const userinfoJSON = JSON.stringify(userinfo) + + if (hasSecretWriteToken()) { + try { + setRepoSecret('USERINFO', userinfoJSON) + printGreen('secret 更改成功') + } catch (error) { + printRed('自动写入 secret 出错') + console.dir(sanitizeForLog({ message: error.message }), { depth: null }) + printUserinfoFallback(userinfoJSON) + } + } else { + printYellow('PAT/GH_TOKEN 未配置,无法自动写入 secret ') + printUserinfoFallback(userinfoJSON) + } +} + +/** + * 降级方案:按配置决定是否在日志中输出 USERINFO + */ +function printUserinfoFallback(userinfoJSON) { + if (shouldPrintSensitiveValue()) { + printGreen('登录信息如下,把它添加到secret USERINFO 即可') + printYellow('注意:日志包含登录 token,请用完后删除 Actions 日志') + printBlue(userinfoJSON) + } else { + printYellow('为避免泄露 token,默认不在日志输出 USERINFO') + printYellow('如必须手动复制,请重新运行并将 print_userinfo 选择为 是') + } +} + +export { upsertUser, saveUserinfo } diff --git a/utils/utils.js b/utils/utils.js index 9b380bd..13f388e 100644 --- a/utils/utils.js +++ b/utils/utils.js @@ -1,44 +1,72 @@ import { spawn } from 'child_process' +/** 延时 */ function delay(ms) { - return new Promise(resolve => { - setTimeout(resolve, ms) - }) + return new Promise(resolve => setTimeout(resolve, ms)) } -// 运行api服务 +/** 启动 api 服务(detached 使其成为独立进程组,便于整组强杀) */ function startService() { - - let api = spawn("npm", ["run", "apiService"]) - - api.stdout.on('data', data => { - // console.log(`${data}`) + const api = spawn('npm', ['run', 'apiService'], { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], }) - api.on('close', code => { - console.log(`子进程退出`) - }) + api.stdout.on('data', () => {}) api.stderr.on('data', data => { - console.log("服务启动失败") - throw new Error(`${data}`) + const msg = String(data).trim() + if (msg) console.log('[api stderr]', msg) }) - return api + api.on('close', code => console.log(`[api] 子进程退出,code=${code}`)) + return api } -// 关闭api服务 +/** + * 关闭 api 服务。 + * 关键修复:npm 不会把 SIGTERM 转发给它的子进程(真正的 Express 服务), + * 仅 api.kill() 会导致 3000 端口一直被占 → 下一阶段 startService 报 EADDRINUSE。 + * 因此用 detached 进程组 + process.kill(-pid) 强杀整组。 + */ function close_api(api) { - api.kill() + try { + process.kill(-api.pid, 'SIGKILL') // 杀掉整个进程组(npm + Express) + } catch (e) { + try { api.kill('SIGKILL') } catch (_) { /* 已退出 */ } + } } -// 发送请求 +/** + * 发送请求到本地 api 服务(带超时 + 重试) + * 超时 10 秒,失败后指数退避重试最多 3 次 + */ async function send(path, method, headers) { - const result = await fetch("http://127.0.0.1:3000" + path, { - method: method, - headers: headers - }).then(r => r.json()) - // console.log(result) - return result + const MAX_RETRIES = 3 + const TIMEOUT_MS = 10000 + let lastError + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + const resp = await fetch('http://127.0.0.1:3000' + path, { + method, + headers, + signal: controller.signal, + }) + clearTimeout(timer) + return await resp.json() + } catch (err) { + clearTimeout(timer) + lastError = err + if (attempt < MAX_RETRIES - 1) { + const waitMs = 1000 * Math.pow(2, attempt) // 1s, 2s, 4s + console.log(`[send] 第 ${attempt + 1} 次请求失败,${waitMs}ms 后重试: ${err.message}`) + await delay(waitMs) + } + } + } + throw lastError } export { delay, startService, close_api, send }