From a7463277e58e939bff5bc2814a1d36e2d2266492 Mon Sep 17 00:00:00 2001 From: Unicorn88b <118087634+Unicorn88b@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:05:41 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=89=B9=E9=87=8F=E8=AF=B7=E6=B1=82=E8=A2=AB?= =?UTF-8?q?=20goals-api=20=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goals-api 的 /progress/batch 只接受带 UTC 标识的 ISO-8601 时间戳, 原先传入无时区的本地时间导致固定返回 400。 - 新增 _goals_timestamp() 生成毫秒级 UTC 时间戳 - 新增 _goals_timezone() 统一时区推导,优先使用 TZ 中的 IANA 名 - 批量请求调用两次以触发奖章结算,失败时输出 HTTP 状态码 - 移除 _get_quest_schema 中无效的 duo_get 调用 同时加入无人值守脚本 src/daily.py、GitHub Actions 定时任务、 .env.example 与 .gitignore,并更新中英文 README。 --- .env.example | 14 ++ .github/workflows/daily.yml | 41 +++++ .gitignore | 19 ++ README.en.md | 353 ++++++++++++++++++++++++++++++++++++ README.md | 270 +++++++++++++++++---------- src/daily.py | 194 ++++++++++++++++++++ src/main.py | 68 +++---- 7 files changed, 831 insertions(+), 128 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/daily.yml create mode 100644 .gitignore create mode 100644 README.en.md create mode 100644 src/daily.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9fd51cc --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# 本地测试配置 — 不会被提交(见 .gitignore) +# 复制为 .env 后填入自己的值:cp .env.example .env + +# 一个或多个 JWT,多账号用逗号或分号分隔 +DUOLINGO_JWT= + +# 时区,需与 Duolingo 账号设置一致 +TZ=Asia/Shanghai + +# 请求间隔毫秒 +DELAY_MS=1500 + +# 每个步骤的最大重试次数 +MAX_RETRY=3 diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml new file mode 100644 index 0000000..edb43da --- /dev/null +++ b/.github/workflows/daily.yml @@ -0,0 +1,41 @@ +name: Duolingo Daily + +on: + schedule: + # UTC 01:00 = 北京时间 09:00 + - cron: "0 1 * * *" + # UTC 13:00 = 北京时间 21:00,作为当日补跑 + - cron: "0 13 * * *" + workflow_dispatch: + +concurrency: + group: duolingo-daily + cancel-in-progress: false + +permissions: + contents: read + +jobs: + daily: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + TZ: ${{ vars.TZ || 'Asia/Shanghai' }} + DELAY_MS: ${{ vars.DELAY_MS || '1500' }} + MAX_RETRY: ${{ vars.MAX_RETRY || '3' }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: src/requirements.txt + + - name: Install dependencies + run: pip install -r src/requirements.txt + + - name: Run daily quests & keep streak + env: + DUOLINGO_JWT: ${{ secrets.DUOLINGO_JWT }} + run: python src/daily.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b0ff58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# 本地配置与凭据 +.env +.env.local +!.env.example + +# 运行时生成的账号与配置 +accounts.json +config.json +src/accounts.json +src/config.json + +# Launcher 缓存 +.DuoHacker-Python_cache/ + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..9a095ca --- /dev/null +++ b/README.en.md @@ -0,0 +1,353 @@ +``` + _____ _ _ ____ _ _ _____ _ ________ _____ + | __ \| | | |/ __ \| | | | /\ / ____| |/ / ____| __ \ + | | | | | | | | | | |__| | / \ | | | ' /| |__ | |__) | + | | | | | | | | | | __ | / /\ \| | | < | __| | _ / + | |__| | |__| | |__| | | | |/ ____ \ |____| . \| |____| | \ \ + |_____/ \____/ \____/|_| |_/_/ \_\_____|_|\_\______|_| \_\ + + +``` + +# DuoHacker + +**English** · [简体中文](README.md) + +Duolingo automation tool — XP farming, gem farming, streak farming, practice auto-solving, and daily quest completion. Professional terminal UI powered by `rich`. + +Ships with a GitHub Action that completes your daily quests and keeps your streak alive automatically — see [GitHub Action](#github-action--unattended-daily-run). + +--- + +## Requirements + +- Python 3.8 or higher +- Internet connection + +All Python dependencies are installed automatically on first run via the launcher. + +| Package | Purpose | Auto-installed | +|---|---|---| +| `rich` | Terminal UI, progress bars, tables | Yes | + +--- + +## Installation + +```bash +git clone []() +cd DuoHacker +``` + +No manual `pip install` needed. The launcher handles everything. + +--- + +## Usage + +### Recommended — always use the Launcher + +```bash +python Launcher.py +``` + +The launcher fetches the latest `main.py` and `requirements.txt` from GitHub, installs any new dependencies, caches locally, then runs. You always get the most recent version automatically. + +### Run directly (skip update check) + +```bash +python main.py +``` + +--- + +## Launcher + +`Launcher.py` is a zero-dependency auto-updater (pure Python stdlib). Every launch fetches the latest script and requirements from GitHub. + +``` + DuoHacker-Python Launcher v1.0.0 + + ⠹ Fetching DuoHacker-Python.py + ⠼ Fetching requirements.txt + ✓ Up to date 1.0.0 + ✓ Dependencies up to date +``` + +### Launcher options + +``` +python Launcher.py [options] + + --offline Run from cache, skip update and dependency check + --help Show this help +``` + +### Cache structure + +``` +DuoHacker-Python/ +├── Launcher.py +├── main.py +├── accounts.json ← created on first account add +├── config.json ← created on first settings change +└── .DuoHacker-Python_cache/ + ├── DuoHacker-Python.py ← cached script from GitHub + ├── requirements.txt ← cached requirements from GitHub + └── meta.json ← version, hash, timestamps +``` + +--- + +## Getting your JWT token + +JWT is the authentication token from your active Duolingo browser session. + +**Desktop (Chrome / Firefox / Edge)** + +1. Go to [duolingo.com](https://www.duolingo.com) and log in +2. Open DevTools — `Ctrl+Shift+I` on Windows/Linux, `Cmd+Option+I` on Mac +3. Go to the **Console** tab +4. Paste and run: + ```js + document.cookie.match(/jwt_token=([^;]+)/)[1] + ``` +5. Copy the output and paste it into DuoHacker-Python when prompted + +**Mobile** + +- iOS: [Web Inspector]() +- Android: [Kiwi Browser]() with DevTools enabled + +> JWT tokens expire after roughly 30 days or when you log out. DuoHacker-Python warns you when a token has 3 days or less remaining. If you receive a 403 error, get a fresh token and re-add the account. + +--- + +## GitHub Action — unattended daily run + +The repository ships with [.github/workflows/daily.yml](.github/workflows/daily.yml), which runs [src/daily.py](src/daily.py) on a schedule to complete the current day's daily quests and submit one practice session so your streak stays intact. + +### Setup + +1. Fork or use this repository, then go to **Settings → Secrets and variables → Actions** +2. Create a repository secret: + + | Secret | Description | + |---|---| + | `DUOLINGO_JWT` | Your JWT. For multiple accounts, separate them with newlines, commas, or semicolons | + +3. Optional repository variables: + + | Variable | Default | Description | + |---|---|---| + | `TZ` | `Asia/Shanghai` | Timezone — affects how Duolingo decides what "today" is | + | `DELAY_MS` | `1500` | Delay between requests, in milliseconds | + | `MAX_RETRY` | `3` | Maximum retries per step | + +4. Open the **Actions** tab to enable workflows. You can also click **Run workflow** there to trigger a run immediately. + +You need admin access on the repository to add secrets. On a fork, configure it under your own fork — forks do not inherit upstream secrets, and scheduled workflows are disabled by default until you enable them manually. + +### Schedule + +Twice a day by default: UTC `01:00` and UTC `13:00`, the second run acting as a same-day retry. Edit the `cron` expressions to change this. GitHub's scheduled jobs can be delayed by tens of minutes during peak hours. + +### What it does + +For each token, in order: + +1. Validate that the JWT parses and has not expired — expired tokens are skipped with a log message telling you to refresh the secret +2. Fetch user info; skip the streak step if today's streak is already done +3. Otherwise submit one `GLOBAL_PRACTICE` session with the current timestamp to keep the streak +4. Call the Goals API to complete all outstanding daily quests +5. Re-fetch the streak count and write everything to the Actions run summary + +If any account fails, the job exits non-zero so GitHub sends you a failure notification. Tokens are always masked in logs. + +### Local testing + +The same script runs locally. Put your configuration in a `.env` file at the repository root — it is ignored by [.gitignore](.gitignore) and will not be committed: + +```bash +cp .env.example .env +# Edit .env and fill in DUOLINGO_JWT +python src/daily.py +``` + +[.env.example](.env.example) lists every available variable. You can also point at a different file with `ENV_FILE`: + +```bash +ENV_FILE=/path/to/my.env python src/daily.py +``` + +Real environment variables take precedence over `.env`, so overriding a single value is just a prefix: + +```bash +DELAY_MS=3000 python src/daily.py +``` + +There is no `.env` on GitHub Actions, so the script falls back to secrets and variables automatically — no code changes needed. + +> This is not a dry run. It really submits practice sessions and completes quests, so even your first local test affects the account. + +--- + +## Features + +### XP Farm + +Calls the Stories API endpoint for 499 XP per request. Falls back to UNIT_TEST sessions (~110 XP) when rate-limited. Displays live progress with a rich progress bar. + +``` + ● XP ████████████░░░░░░░░ 4,970 0:00:03 +``` + +### Gem Farm + +Calls the reward endpoint in configurable batches of 30 gems per call. Stops automatically after 5 consecutive errors. + +``` + ● gems ████████████░░░░░░░░ 720 0:00:03 +``` + +### Streak Farm — Safe mode + +Calculates your account age in days, then farms GLOBAL_PRACTICE sessions backwards from your streak start date. The streak is capped to the number of days your account has existed — cannot exceed a realistic value. + +``` + ╭──────────── Streak Farm — Safe Mode ────────────╮ + │ Created 2022-03-15 │ + │ Account age 1,098 days │ + │ Current streak 0 days │ + │ Safe target 1,098 days │ + ╰──────────────────────────────────────────────────╯ + + ● streak days ████████░░░░░░░░░░ 440/1098 0:01:22 +``` + +### Streak Farm — Normal mode + +No cap. Goes backwards from your current streak start date. Higher detection risk — confirm prompt required. + +### Mixed Farm + +Alternates one XP call and one gem call per iteration. Maximises both simultaneously with a single delay setting. + +### Auto Daily Quest + +Completes all pending daily quests instantly via the Goals API. No delay required — runs once and exits. + +### Auto League + +Farms XP in a loop until your score is 1000 XP ahead of rank 2 in the current league. Stops automatically when the gap is achieved. + +--- + +## Terminal UI + +Navigation uses number keys — type the number and press Enter. All menus display per-category color coding. + +``` + DuoHacker-Python 1.0.0 · 14:32 + + 3 accounts — 1 expiring soon + + 1. Farm XP / Gems / Streak / Mixed / Quest / League + 2. Account Manager Add, remove, and view saved accounts + 3. Shop Items Browse and buy Duolingo shop items + 4. Generate Account Auto-generate new Duolingo accounts + 5. Streak Status Check streak status across all accounts + 6. Settings Configure DuoHacker-Python options + + 0. Exit + + > _ +``` + +--- + +## Multi-account support + +Add as many accounts as needed. Each account stores its JWT token, user ID, and cached profile in `accounts.json`. The account selector shows username, streak, XP, and token expiry status. + +JWT expiry warnings appear automatically: +- **3 days or less remaining** — yellow warning in main menu subtitle and farm menu +- **Expired** — red label, blocked from farming + +--- + +## Config + +Settings are stored in `config.json` (created automatically on first change). + +| Key | Default | Description | +|---|---|---| +| `delay_ms` | `1500` | Default delay between farm requests (ms) | +| `debug` | `false` | Print raw API responses | + +Editable via **Settings** in the main menu or directly in the JSON file. + +--- + +## Settings menu + +- **Default delay** — change the farm request delay (minimum 200 ms) +- **Debug mode** — toggle raw API response logging +- **Clear all accounts** — wipe `accounts.json` +- **Show accounts file** — print paths to `accounts.json` and `config.json` + +--- + +## File structure + +``` +DuoHacker-Python/ +├── .github/workflows/ +│ └── daily.yml Scheduled daily quest + streak workflow +├── .env.example Template for local testing +├── .gitignore +├── Launcher/ +│ └── Launcher.py Auto-updater and entry point +├── src/ +│ ├── main.py Main tool (interactive TUI) +│ ├── daily.py Unattended entry point for CI / local cron +│ ├── requirements.txt +│ ├── accounts.json Saved accounts (auto-created, ignored) +│ └── config.json Settings (auto-created, ignored) +├── README.md Chinese (default) +├── README.en.md English +└── .DuoHacker-Python_cache/ + ├── DuoHacker-Python.py Cached version from GitHub + ├── requirements.txt + └── meta.json Update metadata +``` + +--- + +## Security + +- JWT tokens are stored in `accounts.json` in plain text. Keep this file private and do not commit it to version control. +- `.env`, `accounts.json`, `config.json`, and the launcher cache are already listed in [.gitignore](.gitignore). +- DuoHacker-Python makes HTTPS requests only to `www.duolingo.com` and `stories.duolingo.com`. +- The launcher fetches scripts only from `raw.githubusercontent.com/not2pixel/DuoHacker-Python`. +- No data is sent to any third-party service. + +--- + +## Credits + +- API endpoints and session payloads referenced from [DuoXPy]() +- Browser automation approach inspired by [DuoHacker]() +- UI Theme from [DuoKLI]() + +--- + +## Disclaimer + +This project is for educational and research purposes. Automating Duolingo activity may violate their [Terms of Service](https://www.duolingo.com/terms). Use at your own risk. The authors are not responsible for any account actions taken by Duolingo. + +--- + +## License + +MIT diff --git a/README.md b/README.md index 1af6f6c..3bbb738 100644 --- a/README.md +++ b/README.md @@ -11,45 +11,49 @@ # DuoHacker -Duolingo automation tool — XP farming, gem farming, streak farming, practice auto-solving, and daily quest completion. Professional terminal UI powered by `rich`. +**简体中文** · [English](README.en.md) + +Duolingo 自动化工具 —— 刷 XP、刷宝石、刷连胜、自动完成练习、自动完成每日任务。基于 `rich` 的终端界面。 + +内置 GitHub Action,可每天自动完成每日任务并保持连胜,见 [GitHub Action](#github-action--每日自动运行)。 --- -## Requirements +## 环境要求 -- Python 3.8 or higher -- Internet connection +- Python 3.8 或更高版本 +- 可用的网络连接 -All Python dependencies are installed automatically on first run via the launcher. +所有 Python 依赖会在首次通过启动器运行时自动安装。 -| Package | Purpose | Auto-installed | +| 依赖 | 用途 | 自动安装 | |---|---|---| -| `rich` | Terminal UI, progress bars, tables | Yes | +| `rich` | 终端界面、进度条、表格 | 是 | --- -## Installation +## 安装 ```bash git clone []() cd DuoHacker ``` -No manual `pip install` needed. The launcher handles everything. +无需手动 `pip install`,启动器会处理全部依赖。 --- -## Usage +## 使用 -### Recommended — always use the Launcher +### 推荐 —— 通过启动器运行 ```bash python Launcher.py ``` -The launcher fetches the latest `main.py` and `requirements.txt` from GitHub, installs any new dependencies, caches locally, then runs. You always get the most recent version automatically. +启动器会从 GitHub 拉取最新的 `main.py` 和 `requirements.txt`,安装新增依赖并写入本地缓存,然后运行。这样始终使用最新版本。 -### Run directly (skip update check) +### 直接运行(跳过更新检查) ```bash python main.py @@ -57,9 +61,9 @@ python main.py --- -## Launcher +## 启动器 -`Launcher.py` is a zero-dependency auto-updater (pure Python stdlib). Every launch fetches the latest script and requirements from GitHub. +`Launcher.py` 是零依赖的自动更新器(纯标准库)。每次启动都会从 GitHub 获取最新脚本和依赖清单。 ``` DuoHacker-Python Launcher v1.0.0 @@ -70,76 +74,145 @@ python main.py ✓ Dependencies up to date ``` -### Launcher options +### 启动器参数 ``` python Launcher.py [options] - --offline Run from cache, skip update and dependency check - --help Show this help + --offline 使用缓存运行,跳过更新与依赖检查 + --help 显示帮助 ``` -### Cache structure +### 缓存结构 ``` DuoHacker-Python/ ├── Launcher.py ├── main.py -├── accounts.json ← created on first account add -├── config.json ← created on first settings change +├── accounts.json ← 添加第一个账号时创建 +├── config.json ← 首次修改设置时创建 └── .DuoHacker-Python_cache/ - ├── DuoHacker-Python.py ← cached script from GitHub - ├── requirements.txt ← cached requirements from GitHub - └── meta.json ← version, hash, timestamps + ├── DuoHacker-Python.py ← 从 GitHub 缓存的脚本 + ├── requirements.txt ← 从 GitHub 缓存的依赖清单 + └── meta.json ← 版本、哈希、时间戳 ``` --- -## Getting your JWT token +## 获取 JWT -JWT is the authentication token from your active Duolingo browser session. +JWT 是浏览器中已登录 Duolingo 会话的认证令牌。 -**Desktop (Chrome / Firefox / Edge)** +**桌面端(Chrome / Firefox / Edge)** -1. Go to [duolingo.com](https://www.duolingo.com) and log in -2. Open DevTools — `Ctrl+Shift+I` on Windows/Linux, `Cmd+Option+I` on Mac -3. Go to the **Console** tab -4. Paste and run: +1. 打开 [duolingo.com](https://www.duolingo.com) 并登录 +2. 打开开发者工具 —— Windows/Linux 按 `Ctrl+Shift+I`,Mac 按 `Cmd+Option+I` +3. 切到 **Console** 标签 +4. 粘贴并执行: ```js document.cookie.match(/jwt_token=([^;]+)/)[1] ``` -5. Copy the output and paste it into DuoHacker-Python when prompted +5. 复制输出的整串(`eyJ` 开头,不要带引号),在程序提示时粘贴 -**Mobile** +**移动端** -- iOS: [Web Inspector]() -- Android: [Kiwi Browser]() with DevTools enabled +- iOS:[Web Inspector]() +- Android:[Kiwi Browser]() 并启用开发者工具 -> JWT tokens expire after roughly 30 days or when you log out. DuoHacker-Python warns you when a token has 3 days or less remaining. If you receive a 403 error, get a fresh token and re-add the account. +> JWT 大约 30 天过期,退出登录也会失效。剩余 3 天及以内时程序会给出警告。若遇到 403 错误,重新获取一个新的令牌并重新添加账号。 --- -## Features +## GitHub Action —— 每日自动运行 + +仓库自带 [.github/workflows/daily.yml](.github/workflows/daily.yml),每天定时运行 [src/daily.py](src/daily.py),完成当日的每日任务(Daily Quests)并提交一次练习会话以保持连胜记录。 + +### 配置 + +1. Fork 或使用本仓库,进入 **Settings → Secrets and variables → Actions** +2. 新建 Repository secret: + + | Secret | 说明 | + |---|---| + | `DUOLINGO_JWT` | 你的 JWT。多账号可用换行、逗号或分号分隔 | + +3. 可选 Repository variables: + + | Variable | 默认值 | 说明 | + |---|---|---| + | `TZ` | `Asia/Shanghai` | 时区,影响 Duolingo 的「当日」判定 | + | `DELAY_MS` | `1500` | 请求间隔毫秒 | + | `MAX_RETRY` | `3` | 每个步骤的最大重试次数 | + +4. 打开 **Actions** 页签启用工作流。也可在该页手动点击 **Run workflow** 立即执行一次。 + +添加 secret 需要对仓库有 admin 权限。如果是 fork 的仓库,要在自己的 fork 下配置 —— fork 默认不继承上游 secret,且定时任务默认禁用,需手动启用。 -### XP Farm +### 运行时间 -Calls the Stories API endpoint for 499 XP per request. Falls back to UNIT_TEST sessions (~110 XP) when rate-limited. Displays live progress with a rich progress bar. +默认每天两次:UTC `01:00`(北京时间 09:00)和 UTC `13:00`(北京时间 21:00),后者作为当日补跑。修改 `cron` 即可调整。GitHub 的定时任务在高峰期可能延迟数十分钟。 + +### 执行逻辑 + +对每个 token 依次: + +1. 校验 JWT 是否可解析、是否过期,过期则跳过并在日志中提示更新 secret +2. 拉取用户信息,若当日连胜已完成则跳过连胜步骤 +3. 否则用当前时间戳提交一次 `GLOBAL_PRACTICE` 会话保持连胜 +4. 调用 Goals API 完成所有未完成的每日任务 +5. 重新拉取连胜天数,并写入 Actions 运行摘要 + +任一账号失败时任务以非零码退出,便于收到 GitHub 的失败通知。日志中的 token 始终以掩码形式输出。 + +### 本地测试 + +同一个脚本在本地跑,配置写进仓库根目录的 `.env`(已被 [.gitignore](.gitignore) 忽略,不会提交): + +```bash +cp .env.example .env +# 编辑 .env,填入 DUOLINGO_JWT +python src/daily.py +``` + +[.env.example](.env.example) 列出了全部可用变量。也可用 `ENV_FILE` 指定其他路径: + +```bash +ENV_FILE=/path/to/my.env python src/daily.py +``` + +真实环境变量优先级高于 `.env`,所以临时覆盖某一项直接写在命令前即可: + +```bash +DELAY_MS=3000 python src/daily.py +``` + +GitHub Actions 上不存在 `.env`,脚本会自动回退到 secret 与 variables,无需改代码。 + +> 这不是演练模式。脚本会真的提交练习会话并完成任务,所以第一次本地测试就已经对账号产生实际影响。 + +--- + +## 功能 + +### 刷 XP + +调用 Stories API,每次请求 499 XP。被限流时自动回退到 UNIT_TEST 会话(约 110 XP)。带实时进度条。 ``` ● XP ████████████░░░░░░░░ 4,970 0:00:03 ``` -### Gem Farm +### 刷宝石 -Calls the reward endpoint in configurable batches of 30 gems per call. Stops automatically after 5 consecutive errors. +调用奖励接口,每次 30 宝石,批量大小可配置。连续 5 次出错后自动停止。 ``` ● gems ████████████░░░░░░░░ 720 0:00:03 ``` -### Streak Farm — Safe mode +### 刷连胜 —— 安全模式 -Calculates your account age in days, then farms GLOBAL_PRACTICE sessions backwards from your streak start date. The streak is capped to the number of days your account has existed — cannot exceed a realistic value. +先计算账号注册至今的天数,再从连胜起始日往前提交 GLOBAL_PRACTICE 会话。连胜上限为账号存在的天数,不会超出合理范围。 ``` ╭──────────── Streak Farm — Safe Mode ────────────╮ @@ -152,27 +225,27 @@ Calculates your account age in days, then farms GLOBAL_PRACTICE sessions backwar ● streak days ████████░░░░░░░░░░ 440/1098 0:01:22 ``` -### Streak Farm — Normal mode +### 刷连胜 —— 普通模式 -No cap. Goes backwards from your current streak start date. Higher detection risk — confirm prompt required. +无上限,从当前连胜起始日一直往前刷。风控风险更高,需要二次确认。 -### Mixed Farm +### 混合模式 -Alternates one XP call and one gem call per iteration. Maximises both simultaneously with a single delay setting. +每轮交替执行一次 XP 请求和一次宝石请求,用同一个延迟设置同时刷两者。 -### Auto Daily Quest +### 自动每日任务 -Completes all pending daily quests instantly via the Goals API. No delay required — runs once and exits. +通过 Goals API 一次性完成所有待办的每日任务。无需延迟,跑完即退出。 -### Auto League +### 自动联赛 -Farms XP in a loop until your score is 1000 XP ahead of rank 2 in the current league. Stops automatically when the gap is achieved. +循环刷 XP,直到分数领先当前联赛第二名 1000 XP 为止,达到差距后自动停止。 --- -## Terminal UI +## 终端界面 -Navigation uses number keys — type the number and press Enter. All menus display per-category color coding. +用数字键导航 —— 输入序号后回车。各菜单按分类着色。 ``` DuoHacker-Python 1.0.0 · 14:32 @@ -193,85 +266,88 @@ Navigation uses number keys — type the number and press Enter. All menus displ --- -## Multi-account support +## 多账号支持 -Add as many accounts as needed. Each account stores its JWT token, user ID, and cached profile in `accounts.json`. The account selector shows username, streak, XP, and token expiry status. +可添加任意数量的账号。每个账号的 JWT、用户 ID 和缓存的资料都保存在 `accounts.json` 中。账号选择器会显示用户名、连胜、XP 和令牌过期状态。 -JWT expiry warnings appear automatically: -- **3 days or less remaining** — yellow warning in main menu subtitle and farm menu -- **Expired** — red label, blocked from farming +JWT 过期提醒会自动出现: +- **剩余 3 天及以内** —— 主菜单副标题和 Farm 菜单显示黄色警告 +- **已过期** —— 显示红色标签,禁止刷取 --- -## Config +## 配置文件 -Settings are stored in `config.json` (created automatically on first change). +设置保存在 `config.json`(首次修改时自动创建)。 -| Key | Default | Description | +| 键 | 默认值 | 说明 | |---|---|---| -| `delay_ms` | `1500` | Default delay between farm requests (ms) | -| `debug` | `false` | Print raw API responses | +| `delay_ms` | `1500` | 刷取请求之间的默认延迟(毫秒) | +| `debug` | `false` | 打印原始 API 响应 | -Editable via **Settings** in the main menu or directly in the JSON file. +可通过主菜单的 **Settings** 修改,也可直接编辑该 JSON 文件。 --- -## Settings menu +## 设置菜单 -- **Default delay** — change the farm request delay (minimum 200 ms) -- **Debug mode** — toggle raw API response logging -- **Clear all accounts** — wipe `accounts.json` -- **Show accounts file** — print paths to `accounts.json` and `config.json` +- **Default delay** —— 修改刷取请求延迟(最小 200 毫秒) +- **Debug mode** —— 开关原始 API 响应日志 +- **Clear all accounts** —— 清空 `accounts.json` +- **Show accounts file** —— 打印 `accounts.json` 和 `config.json` 的路径 --- -## File structure +## 目录结构 ``` DuoHacker-Python/ -├── Launcher.py Auto-updater and entry point -├── DuoHacker-Python.py Main tool -├── accounts.json Saved accounts (auto-created) -├── config.json Settings (auto-created) -├── README.md +├── .github/workflows/ +│ └── daily.yml 每日任务 + 连胜的定时工作流 +├── .env.example 本地测试配置模板 +├── .gitignore +├── Launcher/ +│ └── Launcher.py 自动更新器与入口 +├── src/ +│ ├── main.py 主程序(交互式终端界面) +│ ├── daily.py 无人值守入口,用于 CI 或本地定时任务 +│ ├── requirements.txt +│ ├── accounts.json 已保存的账号(自动创建,已忽略) +│ └── config.json 设置(自动创建,已忽略) +├── README.md 中文(默认) +├── README.en.md 英文 └── .DuoHacker-Python_cache/ - ├── DuoHacker-Python.py Cached version from GitHub + ├── DuoHacker-Python.py 从 GitHub 缓存的版本 ├── requirements.txt - └── meta.json Update metadata + └── meta.json 更新元数据 ``` --- -## Security - -- JWT tokens are stored in `accounts.json` in plain text. Keep this file private and do not commit it to version control. -- DuoHacker-Python makes HTTPS requests only to `www.duolingo.com` and `stories.duolingo.com`. -- The launcher fetches scripts only from `raw.githubusercontent.com/not2pixel/DuoHacker-Python`. -- No data is sent to any third-party service. +## 安全 -Add these to your `.gitignore`: - -``` -accounts.json -config.json -.DuoHacker-Python_cache/ -``` +- JWT 以明文保存在 `accounts.json` 中。请勿公开该文件,也不要提交到版本控制。 +- `.env`、`accounts.json`、`config.json` 和启动器缓存已列入 [.gitignore](.gitignore)。 +- 程序仅向 `www.duolingo.com` 和 `stories.duolingo.com` 发起 HTTPS 请求。 +- 启动器仅从 `raw.githubusercontent.com/not2pixel/DuoHacker-Python` 拉取脚本。 +- 不向任何第三方服务发送数据。 --- -## Credits +## 致谢 + +- API 端点与会话载荷参考自 [DuoXPy]() +- 浏览器自动化思路来自 [DuoHacker]() +- 界面主题来自 [DuoKLI]() -- API endpoints and session payloads referenced from [DuoXPy]() -- Browser automation approach inspired by [DuoHacker]() -- UI Theme from [DuoKLI]() --- -## Disclaimer +## 免责声明 -This project is for educational and research purposes. Automating Duolingo activity may violate their [Terms of Service](https://www.duolingo.com/terms). Use at your own risk. The authors are not responsible for any account actions taken by Duolingo. +本项目仅用于学习与研究。自动化 Duolingo 活动可能违反其[服务条款](https://www.duolingo.com/terms)。请自行承担风险。作者不对 Duolingo 对账号采取的任何处理负责。 --- -## License +## 许可证 MIT diff --git a/src/daily.py b/src/daily.py new file mode 100644 index 0000000..5bc9cdc --- /dev/null +++ b/src/daily.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""无人值守每日任务:完成每日任务(Daily Quests)并保持当日连胜(Streak)。 + +设计用于 GitHub Actions 定时运行,不依赖 accounts.json,全部凭据来自环境变量: + + DUOLINGO_JWT 一个或多个 JWT,使用换行、逗号或分号分隔(必填) + MAX_RETRY 单个步骤的最大重试次数,默认 3 + DELAY_MS 请求之间的间隔毫秒,默认 1500 + TZ 时区,例如 Asia/Shanghai,影响 Duolingo 的“当日”判定 + +本地测试时可把以上变量写进仓库根目录的 .env(已被 .gitignore 忽略), +或用 ENV_FILE 指定其他路径。真实环境变量优先级高于 .env。 +""" +import os +import re +import sys +import time +from datetime import datetime + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _HERE) + + +def load_env_file(): + """读取 .env 到 os.environ,已存在的真实环境变量不会被覆盖。""" + candidates = [os.environ["ENV_FILE"]] if os.environ.get("ENV_FILE") else [ + os.path.join(os.path.dirname(_HERE), ".env"), + os.path.join(_HERE, ".env"), + ] + path = next((p for p in candidates if os.path.isfile(p)), None) + if not path: + return None + + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + os.environ.setdefault(key, value) + + if os.environ.get("TZ") and hasattr(time, "tzset"): + time.tzset() + return path + + +_ENV_FILE = load_env_file() + +from main import ( # noqa: E402 + _streak_session_once, + auto_daily_quest, + get_user_info, + jwt_expired, + jwt_expires_at, + jwt_sub, + streak_done_today, +) + +MAX_RETRY = max(1, int(os.environ.get("MAX_RETRY") or 3)) +DELAY_MS = max(0, int(os.environ.get("DELAY_MS") or 1500)) + + +def out(msg): + print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True) + + +def parse_tokens(raw): + return [t.strip().strip("'\"") for t in re.split(r"[\s,;]+", raw or "") if t.strip()] + + +def mask(token): + return f"{token[:6]}…{token[-4:]}" if len(token) > 12 else "***" + + +def keep_streak(jwt, sub, user_info): + """在“今天”提交一次练习会话,以保持连胜记录。""" + for attempt in range(1, MAX_RETRY + 1): + now = int(time.time()) + if _streak_session_once(jwt, sub, user_info, now - 300, now): + return True + out(f" 连胜会话失败,重试 {attempt}/{MAX_RETRY}") + time.sleep(DELAY_MS / 1000 * attempt) + return False + + +def run_account(token, index, total): + label = f"账号 {index}/{total}" + out(f"{label} — token {mask(token)}") + + try: + sub = jwt_sub(token) + except Exception as e: + out(f"{label} — 无法解析 JWT: {e}") + return {"user": mask(token), "ok": False, "detail": "无法解析 JWT"} + if not sub: + return {"user": mask(token), "ok": False, "detail": "JWT 中缺少 sub 字段"} + + if jwt_expired(token): + out(f"{label} — JWT 已过期({jwt_expires_at(token)}),请更新 secret") + return {"user": mask(token), "ok": False, "detail": "JWT 已过期"} + + try: + info = get_user_info(token, sub) + except Exception as e: + out(f"{label} — 获取用户信息失败: {e}") + return {"user": mask(token), "ok": False, "detail": f"获取用户信息失败: {e}"} + + username = info.get("username") or sub + out(f"{label} — 用户 {username} | 连胜 {info.get('streak', 0)} 天 | JWT 到期 {jwt_expires_at(token)}") + + streak_ok = True + if streak_done_today(info.get("streakData", {})): + out(f"{label} — 今日连胜已完成,跳过") + streak_note = "已完成" + else: + streak_ok = keep_streak(token, sub, info) + streak_note = "已保持" if streak_ok else "失败" + out(f"{label} — 连胜{streak_note}") + + time.sleep(DELAY_MS / 1000) + + quest_ok = False + for attempt in range(1, MAX_RETRY + 1): + try: + quest_ok = bool(auto_daily_quest(token, sub, info)) + except Exception as e: + out(f" 每日任务异常: {e}") + quest_ok = False + if quest_ok: + break + out(f" 每日任务失败,重试 {attempt}/{MAX_RETRY}") + time.sleep(DELAY_MS / 1000 * attempt) + + streak_after = info.get("streak", 0) + try: + streak_after = get_user_info(token, sub).get("streak", streak_after) + except Exception: + pass + + return { + "user": username, + "ok": streak_ok and quest_ok, + "streak": streak_after, + "detail": f"连胜:{streak_note} / 每日任务:{'完成' if quest_ok else '失败'}", + } + + +def write_summary(results): + path = os.environ.get("GITHUB_STEP_SUMMARY") + if not path: + return + lines = [ + "## DuoHacker 每日运行结果", + "", + f"运行时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}", + "", + "| 账号 | 状态 | 连胜 | 详情 |", + "| --- | --- | --- | --- |", + ] + for r in results: + icon = "✅" if r["ok"] else "❌" + lines.append(f"| {r['user']} | {icon} | {r.get('streak', '-')} | {r['detail']} |") + with open(path, "a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + if _ENV_FILE: + out(f"已加载本地配置 {_ENV_FILE}") + + tokens = parse_tokens(os.environ.get("DUOLINGO_JWT", "")) + if not tokens: + out("未设置 DUOLINGO_JWT。本地测试请写入 .env,GitHub Actions 请添加同名 secret") + return 1 + + out(f"共 {len(tokens)} 个账号,时区 {os.environ.get('TZ', 'UTC')}") + results = [] + for i, token in enumerate(tokens, 1): + results.append(run_account(token, i, len(tokens))) + if i < len(tokens): + time.sleep(DELAY_MS / 1000) + + write_summary(results) + failed = [r for r in results if not r["ok"]] + out(f"完成:成功 {len(results) - len(failed)} / {len(results)}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/main.py b/src/main.py index 6024449..c5b95bd 100644 --- a/src/main.py +++ b/src/main.py @@ -848,17 +848,25 @@ def _goals_headers(jwt, sub): "x-amzn-trace-id": f"User={sub}", } +def _goals_timezone(): + """IANA 时区名(如 Asia/Shanghai),取不到时退回 UTC±HH:MM 偏移。""" + tz_name = (os.environ.get("TZ") or "").strip() + if "/" in tz_name or tz_name.upper() == "UTC": + return tz_name + try: + offset = -(time.altzone if time.daylight and time.localtime().tm_isdst else time.timezone) // 60 + sign = "+" if offset >= 0 else "-" + return f"UTC{sign}{abs(offset)//60:02d}:{abs(offset)%60:02d}" + except Exception: + return "UTC" + +def _goals_timestamp(): + """goals-api 只接受带 UTC 标识的 ISO-8601,例如 2026-08-09T10:49:49.703Z。""" + now = datetime.now(timezone.utc) + return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z" + def _get_quest_schema(jwt, sub): headers = _goals_headers(jwt, sub) - tz ="UTC" - try: - import time as _t - tz_offset = -(_t.timezone if _t.daylight == 0 else _t.altzone) // 60 - sign ="+" if tz_offset >= 0 else"-" - tz = f"UTC{sign}{abs(tz_offset)//60:02d}:{abs(tz_offset)%60:02d}" - except: - pass - status, data = duo_get(f"/schema?ui_language=en&_={int(time.time()*1000)}", jwt, sub) # goals-api is a different host — use http_request directly status, text = http_request("GET","goals-api.duolingo.com", f"/schema?ui_language=en&_={int(time.time()*1000)}", @@ -870,14 +878,8 @@ def _get_quest_schema(jwt, sub): def _get_quest_progress(jwt, sub): headers = _goals_headers(jwt, sub) - try: - import time as _t - tz_offset = -(_t.timezone if _t.daylight == 0 else _t.altzone) // 60 - sign ="+" if tz_offset >= 0 else"-" - tz = f"UTC{sign}{abs(tz_offset)//60:02d}:{abs(tz_offset)%60:02d}" - except: - tz ="UTC" - path = f"/users/{sub}/progress?timezone={urllib.parse.quote(tz)}&ui_language=en" + path = (f"/users/{sub}/progress" + f"?timezone={urllib.parse.quote(_goals_timezone())}&ui_language=en") status, text = http_request("GET","goals-api.duolingo.com", path, headers) try: return json.loads(text) @@ -887,25 +889,29 @@ def _get_quest_progress(jwt, sub): def _brute_force_quests(jwt, sub, metrics): """POST /users/{id}/progress/batch with quantity=2000 for each metric.""" headers = _goals_headers(jwt, sub) - try: - tz_offset = -(time.timezone if time.daylight == 0 else time.altzone) // 60 - sign ="+" if tz_offset >= 0 else"-" - tz = f"UTC{sign}{abs(tz_offset)//60:02d}:{abs(tz_offset)%60:02d}" - except: - tz ="UTC" updates = [{"metric": m,"quantity": 2000} for m in metrics] updates.append({"metric":"QUESTS","quantity": 1}) payload = { "metric_updates": updates, - "timezone": tz, - "timestamp": datetime.now().isoformat(), + "timezone": _goals_timezone(), + "timestamp": _goals_timestamp(), } - status, text = http_request( - "POST","goals-api.duolingo.com", - f"/users/{sub}/progress/batch", - headers, payload - ) - return status in (200, 201) + ok = False + # 调用两次:第一次写进度,第二次触发奖章结算(Duolingo 行为) + for i in range(2): + if i: + time.sleep(0.8) + payload["timestamp"] = _goals_timestamp() + status, text = http_request( + "POST","goals-api.duolingo.com", + f"/users/{sub}/progress/batch", + headers, payload + ) + ok = status in (200, 201) + if not ok: + log(f"batch HTTP {status}: {str(text)[:160]}","error") + break + return ok def auto_daily_quest(jwt, sub, user_info): """Complete all pending daily quests in one batch call. From f263b6a852961e6a3e077783ac987def001e399d Mon Sep 17 00:00:00 2001 From: Unicorn88b <118087634+Unicorn88b@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:38:40 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20Codex=20?= =?UTF-8?q?=E5=AE=A1=E9=98=85=E7=9A=84=E4=B8=89=E5=A4=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - streak_done_today 改用账号时区(updatedTimeZone / TZ)判定当日, 优先读取已是本地日期的 lastExtendedDate / endDate, 避免东八区凌晨把昨晚的连胜误判为今日已完成 - .gitignore 补上 Launcher 实际使用的 .pylingo_cache/, 该目录会存放含 JWT 的 accounts.json - README 中英文的启动命令改为 Launcher/Launcher.py 与 src/main.py, 缓存结构同步更新为真实布局 --- .gitignore | 3 ++- README.en.md | 29 +++++++++++++++++------------ README.md | 29 +++++++++++++++++------------ src/main.py | 40 +++++++++++++++++++++++++++++++++++----- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 6b0ff58..d2f58a3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,9 @@ config.json src/accounts.json src/config.json -# Launcher 缓存 +# Launcher 缓存(内含 accounts.json 等凭据) .DuoHacker-Python_cache/ +.pylingo_cache/ # Python __pycache__/ diff --git a/README.en.md b/README.en.md index 9a095ca..28091e4 100644 --- a/README.en.md +++ b/README.en.md @@ -48,7 +48,7 @@ No manual `pip install` needed. The launcher handles everything. ### Recommended — always use the Launcher ```bash -python Launcher.py +python Launcher/Launcher.py ``` The launcher fetches the latest `main.py` and `requirements.txt` from GitHub, installs any new dependencies, caches locally, then runs. You always get the most recent version automatically. @@ -56,14 +56,15 @@ The launcher fetches the latest `main.py` and `requirements.txt` from GitHub, in ### Run directly (skip update check) ```bash -python main.py +pip install -r src/requirements.txt +python src/main.py ``` --- ## Launcher -`Launcher.py` is a zero-dependency auto-updater (pure Python stdlib). Every launch fetches the latest script and requirements from GitHub. +`Launcher/Launcher.py` is a zero-dependency auto-updater (pure Python stdlib). Every launch fetches the latest script and requirements from GitHub. ``` DuoHacker-Python Launcher v1.0.0 @@ -77,7 +78,7 @@ python main.py ### Launcher options ``` -python Launcher.py [options] +python Launcher/Launcher.py [options] --offline Run from cache, skip update and dependency check --help Show this help @@ -87,14 +88,18 @@ python Launcher.py [options] ``` DuoHacker-Python/ -├── Launcher.py -├── main.py -├── accounts.json ← created on first account add -├── config.json ← created on first settings change -└── .DuoHacker-Python_cache/ - ├── DuoHacker-Python.py ← cached script from GitHub - ├── requirements.txt ← cached requirements from GitHub - └── meta.json ← version, hash, timestamps +├── Launcher/ +│ ├── Launcher.py +│ └── .pylingo_cache/ ← launcher cache (gitignored) +│ ├── pylingo.py ← cached script from GitHub +│ ├── requirements.txt ← cached requirements from GitHub +│ ├── meta.json ← version, hash, timestamps +│ ├── accounts.json ← created on first account add +│ └── config.json ← created on first settings change +└── src/ + ├── main.py + ├── daily.py + └── requirements.txt ``` --- diff --git a/README.md b/README.md index 3bbb738..47e75b3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ cd DuoHacker ### 推荐 —— 通过启动器运行 ```bash -python Launcher.py +python Launcher/Launcher.py ``` 启动器会从 GitHub 拉取最新的 `main.py` 和 `requirements.txt`,安装新增依赖并写入本地缓存,然后运行。这样始终使用最新版本。 @@ -56,14 +56,15 @@ python Launcher.py ### 直接运行(跳过更新检查) ```bash -python main.py +pip install -r src/requirements.txt +python src/main.py ``` --- ## 启动器 -`Launcher.py` 是零依赖的自动更新器(纯标准库)。每次启动都会从 GitHub 获取最新脚本和依赖清单。 +`Launcher/Launcher.py` 是零依赖的自动更新器(纯标准库)。每次启动都会从 GitHub 获取最新脚本和依赖清单。 ``` DuoHacker-Python Launcher v1.0.0 @@ -77,7 +78,7 @@ python main.py ### 启动器参数 ``` -python Launcher.py [options] +python Launcher/Launcher.py [options] --offline 使用缓存运行,跳过更新与依赖检查 --help 显示帮助 @@ -87,14 +88,18 @@ python Launcher.py [options] ``` DuoHacker-Python/ -├── Launcher.py -├── main.py -├── accounts.json ← 添加第一个账号时创建 -├── config.json ← 首次修改设置时创建 -└── .DuoHacker-Python_cache/ - ├── DuoHacker-Python.py ← 从 GitHub 缓存的脚本 - ├── requirements.txt ← 从 GitHub 缓存的依赖清单 - └── meta.json ← 版本、哈希、时间戳 +├── Launcher/ +│ ├── Launcher.py +│ └── .pylingo_cache/ ← 启动器缓存(已被 gitignore) +│ ├── pylingo.py ← 从 GitHub 缓存的脚本 +│ ├── requirements.txt ← 从 GitHub 缓存的依赖清单 +│ ├── meta.json ← 版本、哈希、时间戳 +│ ├── accounts.json ← 添加第一个账号时创建 +│ └── config.json ← 首次修改设置时创建 +└── src/ + ├── main.py + ├── daily.py + └── requirements.txt ``` --- diff --git a/src/main.py b/src/main.py index c5b95bd..0b0117b 100644 --- a/src/main.py +++ b/src/main.py @@ -344,16 +344,46 @@ def extract_skill_id(current_course): pass return None +def _account_tz(streak_data=None): + """账号所在时区,依次取 streakData.updatedTimeZone、TZ 环境变量,最后退回本地时区。""" + names = [(streak_data or {}).get("updatedTimeZone"), os.environ.get("TZ")] + for name in names: + if not name: + continue + try: + from zoneinfo import ZoneInfo + return ZoneInfo(str(name).strip()) + except Exception: + continue + return datetime.now().astimezone().tzinfo + def streak_done_today(streak_data): + """连胜是否已在账号本地“今天”完成。 + + Duolingo 以账号时区判定当日,用 UTC 比较会让东八区凌晨误判为已完成。 + """ try: - upd = streak_data.get("updatedAt") or streak_data.get("currentStreak", {}).get("endDate") - if not upd: return False + tz = _account_tz(streak_data) + today = datetime.now(tz=tz).date() + cur = streak_data.get("currentStreak") or {} + + # endDate / lastExtendedDate 已是账号本地日期,优先使用 + for key in ("lastExtendedDate","endDate"): + val = cur.get(key) + if isinstance(val, str) and val.strip(): + return datetime.fromisoformat(val.strip().split("T")[0]).date() >= today + + upd = streak_data.get("updatedTimestamp") or streak_data.get("updatedAt") + if not upd: + return False if isinstance(upd, (int, float)): - upd_dt = datetime.fromtimestamp(upd/1000, tz=timezone.utc) + secs = upd / 1000 if upd > 1e11 else upd + upd_dt = datetime.fromtimestamp(secs, tz=timezone.utc) else: upd_dt = datetime.fromisoformat(str(upd).replace("Z","+00:00")) - now = datetime.now(tz=timezone.utc) - return upd_dt.date() >= now.date() + if upd_dt.tzinfo is None: + upd_dt = upd_dt.replace(tzinfo=timezone.utc) + return upd_dt.astimezone(tz).date() >= today except: return False