diff --git a/.gitignore b/.gitignore index 356fb010..bc42a227 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,23 @@ dist/ .dev/ node_modules/ /config.json +# ClawBot 微信推送的扫码凭证(含 bot token),严禁提交 +/clawbot-auth.json +# 运行时配置/账号文件可能被放到 src/ 下(Load.ts 的查找路径之一),防止误提交 +/src/config.json +/src/accounts.json +/src/accounts.dev.json /.vscode /diagnostics note .DS_Store .playwright-chromium-installed .stfolder/ -.env \ No newline at end of file +.envlogs/ +/logs +# 本地私密配置与编辑器工作目录,严禁提交 +/.env +/.zcode/ + +# dynamic accounts (credentials) +/config/accounts.extra.json diff --git a/Dockerfile b/Dockerfile index a9268a22..89be3cf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ ############################################################################### # Stage 1: Builder ############################################################################### -FROM node:24-slim AS builder +# 国内镜像源加速拉取(m.daocloud.io 代理 Docker Hub);海外环境可改回 node:24-slim +FROM m.daocloud.io/docker.io/library/node:24-slim AS builder WORKDIR /usr/src/microsoft-rewards-script @@ -25,7 +26,7 @@ RUN rm -rf node_modules \ ############################################################################### # Stage 2: Runtime ############################################################################### -FROM node:24-slim AS runtime +FROM m.daocloud.io/docker.io/library/node:24-slim AS runtime WORKDIR /usr/src/microsoft-rewards-script @@ -37,13 +38,19 @@ ENV NODE_ENV=production \ NODE_OPTIONS=--disable-warning=ExperimentalWarning # Install minimal system libraries required for Chromium headless to run, -# plus jq (for config generation/patching) and gettext-base (for envsubst) +# plus jq (for config generation/patching) and gettext-base (for envsubst). +# xvfb/x11vnc/novnc/websockify power the dashboard's in-browser manual login +# (headed browser under a virtual display, viewed through noVNC). RUN apt-get update && apt-get install -y --no-install-recommends \ cron \ gettext-base \ jq \ tzdata \ ca-certificates \ + xvfb \ + x11vnc \ + novnc \ + websockify \ libglib2.0-0 \ libdbus-1-3 \ libexpat1 \ @@ -83,10 +90,11 @@ COPY --from=builder /usr/src/microsoft-rewards-script/dist ./dist COPY --from=builder /usr/src/microsoft-rewards-script/package*.json ./ COPY --from=builder /usr/src/microsoft-rewards-script/node_modules ./node_modules -# Install patchright's stealth-patched Chromium headless shell. -# The container is headless-only so the full browser isn't needed; then clean up +# Install patchright's stealth-patched Chromium. The full browser is required +# (not just the headless shell) because the dashboard's manual-login feature +# runs the browser headed under Xvfb; clean up apt lists afterwards. RUN set -eux; \ - npx patchright install --with-deps --only-shell chromium; \ + npx patchright install --with-deps chromium; \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* # Copy config example into the image so entrypoint can use it as a fallback @@ -103,8 +111,12 @@ COPY config.example.json ./config.example.json COPY --chmod=755 scripts/docker/run_daily.sh ./scripts/docker/run_daily.sh COPY --chmod=755 scripts/docker/healthcheck.sh ./scripts/docker/healthcheck.sh COPY --chmod=755 scripts/api/ ./scripts/api/ +COPY --chmod=755 scripts/main/ ./scripts/main/ +COPY --chmod=644 scripts/utils.js ./scripts/utils.js COPY --chmod=644 scripts/env.js ./scripts/env.js COPY --chmod=644 scripts/package.json ./scripts/package.json +# Embedded web dashboard (zero-dependency Node; started by entrypoint when DASHBOARD_ENABLED) +COPY --chmod=644 dashboard/ ./dashboard/ COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template COPY --chmod=755 scripts/docker/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/README.md b/README.md index fab229e9..bcae16eb 100644 --- a/README.md +++ b/README.md @@ -1,475 +1,574 @@ -[![Discord](https://img.shields.io/badge/Join%20Our%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/8BxYbV4pkj) -[![Latest Build](https://img.shields.io/github/actions/workflow/status/TheNetsky/Microsoft-Rewards-Script/auto-release.yml?branch=v4&style=for-the-badge&label=Latest%20Build)](https://github.com/TheNetsky/Microsoft-Rewards-Script/actions/workflows/auto-release.yml) -[![Docker](https://img.shields.io/badge/Docker-GHCR-blue?style=for-the-badge&logo=docker)](https://github.com/TheNetsky/Microsoft-Rewards-Script/pkgs/container/microsoft-rewards-script) +
-> [!TIP] -> This version supports the **new, modern Bing Rewards dashboard only** - it does **not** support the legacy dashboard. -> If your account still uses the old dashboard, use the [v3 branch](https://github.com/TheNetsky/Microsoft-Rewards-Script/tree/v3) and v3.x releases instead! +# 微软奖励脚本(V4-china) + +[![Version](https://img.shields.io/badge/version-4.3.0-blue.svg)](./package.json) +[![License](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](./LICENSE) +[![Node](https://img.shields.io/badge/node-%3E%3D24-green.svg)](./package.json) +[![Last Sync](https://img.shields.io/badge/最后同步-2026--08--16-orange.svg)](#-同步与致谢) +[![Upstream](https://img.shields.io/badge/上游-TheNetsky/Microsoft--Rewards--Script-informational.svg)](https://github.com/TheNetsky/Microsoft-Rewards-Script) + +**基于 TypeScript · Patchright(Playwright) 的新一代架构 · 微软奖励自动化脚本** + +针对国内用户深度本地化:✅ 中国热搜查询源(百度/头条/抖音/微博/知乎) · ✅ 日志中文化 · ✅ PushPlus / 微信 ClawBot 推送 · ✅ Docker 镜像加速 + +
+ +> ℹ️ **分支选择建议** > -> Use at your own risk - some features may not work as expected. +> - **`main` 分支(v3,稳定版)**:已兼容微软新版 UI,实测未发现明显异常,可放心日常使用。 +> - **`V4-china` 分支(v4 新架构,基本可用,本分支)**:搜索、活动、签到、Read-to-Earn、微信推送(PushPlus / ClawBot)等核心功能已实测通过(2026-08),**可以切换日常使用**;新架构仍在迭代,偶发小问题属正常,追求稳定可继续留在 `main` 分支。 +> +> 两分支配置文件结构不同,**互不兼容**;本分支账号改用 `.env` 环境变量配置(不再使用 `accounts.json`),从 v3 迁移请按本文档重新配置 `.env` / `config.json`。 + +> [!TIP] +> 本分支仅支持**新版(modern)Bing Rewards 面板**,不支持旧版面板。账户还在旧版面板的请使用本仓库 [`main` 分支](https://github.com/chiihero/Microsoft-Rewards-Script/tree/main)(v3)。 + +--- --- -## Table of Contents - -- [Table of Contents](#table-of-contents) -- [Quick Setup](#quick-setup) - - [Bare metal](#bare-metal) - - [Get the script](#get-the-script) -- [Account Setup](#account-setup) -- [Config Setup](#config-setup) - - [Build and run the script (bare metal version)](#build-and-run-the-script-bare-metal-version) -- [Docker](#docker) -- [Control API and Dashboard](#control-api-and-dashboard) -- [Nix Setup](#nix-setup) -- [Configuration Options](#configuration-options) - - [Core](#core) - - [Workers](#workers) - - [Activities](#activities) - - [Search Settings](#search-settings) - - [Query sources](#query-sources) - - [Experimental](#experimental) - - [Activity source layout](#activity-source-layout) - - [Logging](#logging) - - [Proxy](#proxy) - - [Webhooks](#webhooks) -- [Troubleshooting](#troubleshooting) - - [Manual login](#manual-login) - - [Session management](#session-management) -- [Disclaimer](#disclaimer) +## 📑 目录 + +- [✨ 核心特性](#-核心特性) +- [🚀 快速开始](#-快速开始) +- [📦 Windows 部署](#-windows-部署) +- [🐳 Docker 部署](#-docker-部署) +- [🖥️ 控制 API 与面板](#️-控制-api-与面板) +- [⚙️ 配置参考](#️-配置参考) +- [🔔 通知渠道](#-通知渠道) +- [❓ 常见问题](#-常见问题) +- [⚠️ 注意事项](#-注意事项) +- [📜 同步与致谢](#-同步与致谢) +- [⚠️ 免责声明](#-免责声明) --- -## Quick Setup +## ✨ 核心特性 -### Bare metal +**账户管理** -**Requirements:** Node.js >= 24 and Git -Works on Windows, Linux, macOS, and WSL. +- ✅ 多账户支持(`.env` 中 `ACCOUNT_N_*` 环境变量,编号无需连续) +- ✅ 会话存储与持久化(`sessions/`,含指纹持久化) +- ✅ 2FA 支持(TOTP 自动生成并填入验证码) +- ✅ 无密码登录支持 +- ✅ 每账户独立代理(HTTP/HTTPS/SOCKS4/SOCKS5) -#### Get the script +**自动化与控制** -```bash -git clone https://github.com/TheNetsky/Microsoft-Rewards-Script.git -cd Microsoft-Rewards-Script -``` +- ✅ 无头浏览器操作(Patchright 反检测内核) +- ✅ 集群支持(同时多个账户) +- ✅ 可配置任务开关 +- ✅ 控制 API + Web 面板(启动/停止/日志/积分/调度管理) +- ✅ 自动调度(Docker 内置 cron) -Or, download the latest release ZIP and extract it. +**搜索与活动** -## Account Setup +- ✅ 桌面与移动搜索(配额独立跟踪,互不阻塞) +- ✅ 加成搜索(超过上限后继续刷,可选) +- ✅ 每日集 / 更多促销 / 打卡 / App 活动 / 每日签到 / 阅读赚取(Read-to-Earn) +- ✅ 搜索加成 perk 激活("再搜 N 次得积分")/ 视觉搜索(可选) +- ✅ 连击保护 & 自动领取 dashboard 奖励积分 +- ✅ 模拟滚动与随机点击,降低风控风险 -- Copy and rename [`env.example`](env.example) to `.env` and add your account credentials: +**搜索词来源(国内推荐 china + local)** -```env -ACCOUNT_1_EMAIL=email@example.com -# ACCOUNT_1_PASSWORD=your_password -``` +- ✅ 中国热搜(百度/头条/抖音/微博/知乎,gmya.net 聚合,多源随机 + 限流退避) +- ✅ 本地查询词兜底(`search-queries.json` 中文词库) +- ✅ Bing 建议聚类扩展(`clusterSearch`,每个主词扩展一组相关词再搜索) +- ✅ 亦可选 google / wikipedia / reddit / hackernews / RSS 源(国内需代理) -> [!NOTE] -> `ACCOUNT_N_PASSWORD` is optional. Set it when password sign-in should be available; leave it unset for passwordless/Authenticator-only accounts. -> -> Add one `ACCOUNT_N_*` block per account. Account slots do not need to be contiguous: `ACCOUNT_2` or `ACCOUNT_4` can be configured even when earlier slots are missing. Accounts run in ascending slot order. Optional per-account fields cover recovery email, locale, language, proxy, and fingerprint persistence - see [`env.example`](env.example) for all of them. +**通知与监控** -`ACCOUNT_N_LANG_CODE` accepts a BCP 47 language tag such as `nl`, `it`, or `pt-BR`. `ACCOUNT_N_GEO_LOCALE` accepts a two-letter country code or defaults to `auto`. The selected language and country are applied consistently to browser fingerprints, `Accept-Language`, Microsoft Rewards app headers, and market-specific requests. In `auto` mode, the country reported by the Microsoft profile is cached after the first successful dashboard request; changing either locale setting automatically replaces an incompatible saved fingerprint. +- ✅ PushPlus 微信推送(国内推荐,无需翻墙) +- ✅ 微信 ClawBot 推送(腾讯官方 iLink 直连,免费无第三方服务) +- ✅ Discord / Telegram / ntfy 推送 +- ✅ 全面日志记录(日志过滤 + 落盘 `logs/` 按天分文件,自动清理 90 天前旧日志) -> [!TIP] -> For 2FA accounts, set `ACCOUNT_N_TOTP_SECRET` and the script will generate and enter the 6-digit code automatically. To get the secret: in your Microsoft Security settings open 'Manage how you sign in', add an Authenticator app, and when the QR code appears choose 'enter code manually' - use that code as the value in your `.env`. +--- + +## 🚀 快速开始 + +本脚本支持两种部署方式,**按你的场景二选一即可**: + +| 维度 | 📦 Windows 直跑 | 🐳 Docker | +| -------------- | ------------------------------------- | ---------------------------------------------------- | +| **配置方式** | `.env`(账号)+ `config.json`(行为) | `.env`(账号)+ `compose.yaml` 的 `CONFIG_*`(行为) | +| **调度** | 手动 / 计划任务 | 内置 cron + API 改调度 | +| **headless** | 可选(可见窗口) | 强制 `true`(无显示器) | +| **数据持久化** | `sessions/` 目录 | `./config/` + `./sessions/` 挂载 | +| **升级方式** | `git pull` + `npm run build` | `docker compose up -d --build` | +| **前置要求** | Node.js 24+ | Docker + Docker Compose | + +> ⚠️ 与 v3(`main` 分支)不同:**账号统一在 `.env` 中配置**(`ACCOUNT_1_EMAIL` 等),不再使用 `accounts.json`;行为配置仍在 `config.json`(Docker 下用 `CONFIG_*` 环境变量)。 + +--- + +## 📦 Windows 部署 + +> ⚠️ 本项目所有改动基于 Win11 系统测试,其他系统请参考[原项目](https://github.com/TheNetsky/Microsoft-Rewards-Script)相关配置。 + +
+🔧 自动设置(推荐,一键部署) + +1. 下载或克隆源代码: -> [!WARNING] -> You must rebuild your script after making any changes to the `.env`. + ```bash + git clone -b V4-china https://github.com/chiihero/Microsoft-Rewards-Script.git + ``` -## Config Setup + > 国内无法直连 GitHub 时可用镜像加速:在 URL 前加 `https://gh-proxy.com/`,或在本仓库页面 "Code → Download ZIP" 下载。 -> [!WARNING] -> Do **not** skip this step if you are running the script bare metal. +2. 双击运行 `setup.bat`:自动安装 Node.js 24、生成 `.env` 与 `config.json`、安装依赖并构建 +3. 编辑 `.env`,填入账户信息(`ACCOUNT_1_EMAIL` / `ACCOUNT_1_PASSWORD` 等) +4. 按需修改 `config.json` +5. 运行 `run.bat` 或执行 `npm start` 启动脚本 -- **Bare metal:** Copy or rename `config.example.json` to `config.json` (in the project root) and customize your preferences. -- **Docker:** A valid `config.json` is automatically created on first run and saved locally to `./config/`. You can optionally manually create a `config.json` (e.g., if you need to specify regex values) using the provided `config.example.json` +
-> [!CAUTION] -> Prior versions of accounts.json and config.json are not compatible with current release. +
+🛠 手动设置(自动设置失败时使用) -### Build and run the script (bare metal version) +1. 克隆源代码(同上) +2. 安装 Node.js 24+ +3. 安装依赖包: + + ```bash + npm install + ``` + +4. 若出现 `Error: browserType.launch: Executable doesn't exist` 报错,执行: + + ```bash + npx patchright install chromium + ``` + +5. 复制 `env.example` 为 `.env`,填入账户信息;国内账号推荐保留默认的 `ACCOUNT_1_LANG_CODE=zh-CN`、`ACCOUNT_1_GEO_LOCALE=CN` +6. 复制 `config.example.json` 为 `config.json`(项目根目录),按需修改 +7. 预构建(安装依赖与浏览器): + + ```bash + npm run pre-build + ``` + +8. 构建: + + ```bash + npm run build + ``` + +9. 启动: + + ```bash + npm start + ``` + +> 💡 调试时可直接源码运行:`npm run dev`(带 `-dev` 参数输出 DEBUG 日志)。 + +
+ +--- + +## 🐳 Docker 部署 + +
+部署步骤 + +> [!IMPORTANT] +> 本仓库 `compose.yaml` 默认 `build: .` 本地构建(运行本分支代码)。**不要**改回 `ghcr.io/thenetsky/...` 官方镜像——那是原作者的原版代码,不含中国热搜 / PushPlus 等国内适配。 + +### 1. 准备账号文件(.env) ```bash -npm run pre-build -npm run build -npm run start +cp env.example .env ``` -## Docker +编辑 `.env`,至少填一个账号: -- Copy the sample [`compose.yaml`](compose.yaml) -- Copy and rename [`env.example`](env.example) to `.env` and add your account credentials: - -```env -ACCOUNT_1_EMAIL=email@example.com -# ACCOUNT_1_PASSWORD=your_password +```dotenv +ACCOUNT_1_EMAIL=you@example.com +ACCOUNT_1_PASSWORD=your_password ``` -`ACCOUNT_N_PASSWORD` is optional; omit it for passwordless/Authenticator-only accounts. +> 多账号按 `ACCOUNT_2_*`、`ACCOUNT_3_*` 递增(编号无需连续,按编号升序运行)。完整字段见 `env.example`。 -- Review `compose.yaml` to adjust scheduling, timezone, and config options. +### 2. 编辑 compose.yaml(可选) -> [!NOTE] -> A valid `config.json` is auto-generated on first run using default values, and saved locally to `./config/`. -> Optionally, use `CONFIG_*` variables in the `environment:` section of the `compose.yaml` to customise your options (e.g., clusters, webhook, etc.). -> A full list of available options are in the [table below](#configuration-options). -> `CONFIG_*` variables are applied on every startup and always take precedence over `./config/config.json`. +默认配置开箱即用,如需调整取消对应行注释即可: -> [!TIP] -> If a new image adds config options you're missing, a warning will appear in the container logs. -> To update, delete `./config/config.json` and restart - a fresh one will be generated from the latest example, with your `compose.yaml` overrides re-applied. +- `TZ`:时区(默认 `Asia/Shanghai`) +- `CRON_SCHEDULE`:调度(默认 `0 7 * * *`,每天 7 点) +- `RUN_ON_START`:容器启动时是否立即跑一次(默认 `true`) +- `CONFIG_SEARCH_QUERY_ENGINES`:查询源,国内推荐 `china,local` +- `CONFIG_CHINA_API_APPKEY`:gmya.net appkey,解除免费档限流(留空走免费档) +- `CONFIG_PUSHPLUS_*` / `CONFIG_CLAWBOT_*`:微信推送 -- Start the container: `docker compose up -d` +> 完整的 `CONFIG_*` 环境变量列表见[配置参考](#️-配置参考)各表格。`CONFIG_*` 每次启动都会覆盖 `./config/config.json`。 -> [!TIP] -> Monitor logs with `docker logs microsoft-rewards-script`, useful for viewing passwordless login codes or diagnosing issues. -> You can also enable a webhook in `compose.yaml` for notifications. +### 3. 构建并启动 ---- +```bash +docker compose up -d --build +``` + +> **重要**:改了代码或 Dockerfile 后必须加 `--build` 重建镜像,否则跑的还是旧镜像。 + +### 4. 数据持久化 + +容器挂载了两个目录,重建容器不丢数据: + +- `./config/`:配置文件(首次运行自动生成 `config.json`) +- `./sessions/`:登录会话(首次登录后 cookie 存这里,后续自动复用) + +> 💡 使用微信 ClawBot 推送时,建议先在本地跑一次完成扫码,再把生成的 `clawbot-auth.json` 挂载进容器(或配合 `API_MODE` 在容器内完成扫码)。 -## Control API and Dashboard - -The optional Control API lets a local dashboard or another trusted tool monitor -and control the script over HTTP. See the [complete Control API -documentation](scripts/api/README.md) for setup, authentication, every endpoint, -request fields, response examples, and security guidance. - -Common uses include: - -- checking API health and the current run state with `GET /health` and - `GET /status`; -- reading live points, logs, errors, account summaries, run history, and error - diagnostics; -- listing safe stored-session metadata and deleting the mobile/desktop sessions - for one account; -- starting all accounts with `POST /start` and an empty JSON body; -- running only one account with `POST /start` and `{"accountIndex":2}`; -- running all accounts except selected slots with `POST /start` and - `{"excludedAccountIndexes":[2,4]}`; -- stopping or restarting a run with `POST /stop` or `POST /restart`; -- streaming live logs and status updates from `GET /events` using - Server-Sent Events (SSE); -- reading the active configuration and schedule, with config and schedule - changes available only when their explicit `API_ALLOW_*` options are enabled. - -For example, start only `ACCOUNT_2` with cURL: +### 常用命令 ```bash -curl --request POST \ - --url http://127.0.0.1:3010/start \ - --header 'Authorization: Bearer YOUR_API_TOKEN' \ - --header 'Content-Type: application/json' \ - --data '{"accountIndex":2}' +docker compose up -d --build # 构建+启动 +docker compose logs -f # 查看日志 +docker compose down # 停止并删除容器 +docker compose restart # 重启(不重建) ``` -For a ready-made web interface, use the supported and endorsed -[Rewards Dashboard](https://github.com/mgrimace/rewards-dashboard). It connects -to this Control API to manage runs, accounts, schedules, logs, points, and -related script settings. +
--- -## Nix Setup +## 🖥️ 控制 API 与面板 -If using Nix: `bash scripts/nix/run.sh` +可选的控制 API 允许本地面板或其他受信工具通过 HTTP 监控和控制脚本(`API_MODE=true` + `API_TOKEN` 开启): ---- +- 查询健康状态 / 运行状态 / 实时积分 / 日志 / 账户摘要 / 运行历史 +- `POST /start` 启动全部或指定账户,`POST /stop`、`POST /restart` 控制 +- `GET /events` SSE 实时日志流;会话查询与删除 +- 完整文档见 [`scripts/api/README.md`](scripts/api/README.md) -## Configuration Options - -Edit `config.json` to customize behavior, or set `CONFIG_*` environment variables in `compose.yaml` (Docker). Below are all currently available options. - -> [!WARNING] -> Rebuild the script (bare metal), or recreate the container (Docker) after all config changes. - -### Core - -| Setting | Type | Default | Description | Docker environment variable | -| --------------------------- | ------- | ------------ | ------------------------------------------------------------------ | ------------------------------------- | -| `sessionPath` | string | `"sessions"` | Directory to store browser sessions | | -| `headless` | boolean | `false` | Run browser invisibly | Always `true` in Docker | -| `clusters` | number | `1` | Number of concurrent account clusters | `CONFIG_CLUSTERS` | -| `errorDiagnostics` | boolean | `false` | Save error and unknown-login page diagnostics under `diagnostics/` | `CONFIG_ERROR_DIAGNOSTICS` | -| `ensureStreakProtection` | boolean | `true` | Ensure streak protection is enabled | `CONFIG_ENSURE_STREAK_PROTECTION` | -| `autoClaimPunchcardRewards` | boolean | `false` | Auto-claim completed punchcard rewards | `CONFIG_AUTO_CLAIM_PUNCHCARD_REWARDS` | -| `contintueOnBotWarning` | boolean | `false` | Continue despite the Microsoft bot-score warning (not recommended) | `CONFIG_CONTINTUE_ON_BOT_WARNING` | -| `skipNonPointTasks` | boolean | `true` | Skip tasks that award no points | `CONFIG_SKIP_NON_POINT_TASKS` | -| `accountDelay.min` | string | `"1min"` | Minimum delay before starting the next configured account | `CONFIG_ACCOUNT_DELAY_MIN` | -| `accountDelay.max` | string | `"3min"` | Maximum delay before starting the next configured account | `CONFIG_ACCOUNT_DELAY_MAX` | -| `searchOnBingLocalQueries` | boolean | `false` | Use the local query list for ExploreOnBing | `CONFIG_SEARCH_ON_BING_LOCAL` | -| `globalTimeout` | string | `"30sec"` | Timeout for all actions | `CONFIG_GLOBAL_TIMEOUT` | - -### Workers - -| Setting | Type | Default | Description | Docker environment variable | -| ------------------------------ | ------- | ------- | -------------------------------------------------------------------------- | ------------------------------------ | -| `workers.doDailySet` | boolean | `true` | Complete daily set | `CONFIG_WORKER_DAILY_SET` | -| `workers.doClaimBonusPoints` | boolean | `true` | Claim bonus points | `CONFIG_WORKER_CLAIM_BONUS_POINTS` | -| `workers.doMorePromotions` | boolean | `true` | Complete "more activities" | `CONFIG_WORKER_MORE_PROMOTIONS` | -| `workers.doPunchCards` | boolean | `true` | Complete punchcards | `CONFIG_WORKER_PUNCH_CARDS` | -| `workers.doAppPromotions` | boolean | `true` | Complete app promotions | `CONFIG_WORKER_APP_PROMOTIONS` | -| `workers.doDesktopSearch` | boolean | `true` | Perform desktop searches | `CONFIG_WORKER_DESKTOP_SEARCH` | -| `workers.doMobileSearch` | boolean | `true` | Perform mobile searches | `CONFIG_WORKER_MOBILE_SEARCH` | -| `workers.doBonusSearches` | boolean | `false` | Farm bonus searches beyond the cap | `CONFIG_WORKER_BONUS_SEARCHES` | -| `workers.doDailyCheckIn` | boolean | `true` | Complete daily check-in | `CONFIG_WORKER_DAILY_CHECKIN` | -| `workers.doReadToEarn` | boolean | `true` | Complete Read-to-Earn | `CONFIG_WORKER_READ_TO_EARN` | -| `workers.doActivateSearchPerk` | boolean | `true` | Activate the "search Nx more" perk when present (runs after the daily set) | `CONFIG_WORKER_ACTIVATE_SEARCH_PERK` | -| `workers.doVisualSearch` | boolean | `false` | Activate the visual-search streak and perform visual searches | `CONFIG_WORKER_VISUAL_SEARCH` | - -### Activities - -| Setting | Type | Default | Description | Docker environment variable | -| ------------------------- | ------- | ------- | ------------------------------ | -------------------------------- | -| `activities.urlReward` | boolean | `true` | Complete URL reward activities | `CONFIG_ACTIVITY_URL_REWARD` | -| `activities.searchOnBing` | boolean | `true` | Complete ExploreOnBing offers | `CONFIG_ACTIVITY_SEARCH_ON_BING` | - -### Search Settings - -| Setting | Type | Default | Description | Docker environment variable | -| -------------------------------------- | -------- | ----------------------------------- | --------------------------------------------------------- | ---------------------------------- | -| `searchSettings.scrollRandomResults` | boolean | `false` | Scroll randomly on results | `CONFIG_SEARCH_SCROLL_RANDOM` | -| `searchSettings.clickRandomResults` | boolean | `false` | Click random links | `CONFIG_SEARCH_CLICK_RANDOM` | -| `searchSettings.runOnZeroPoints` | boolean | `false` | Run searches even when no search points remain | `CONFIG_SEARCH_RUN_ON_ZERO_POINTS` | -| `searchSettings.maxBonusSearches` | number | `110` | Max bonus searches per run (when `doBonusSearches` is on) | `CONFIG_SEARCH_MAX_BONUS_SEARCHES` | -| `searchSettings.parallelSearching` | boolean | `true` | Run searches in parallel | `CONFIG_SEARCH_PARALLEL` | -| `searchSettings.clusterSearch` | boolean | `true` | Cluster each main topic with Bing suggestions | `CONFIG_SEARCH_CLUSTER` | -| `searchSettings.queryEngines` | string[] | see [Query sources](#query-sources) | Sources used to build the search query pool | `CONFIG_SEARCH_QUERY_ENGINES` \* | -| `searchSettings.searchResultVisitTime` | string | `"10sec"` | Time to spend on each search result | `CONFIG_SEARCH_VISIT_TIME` | -| `searchSettings.searchDelay.min` | string | `"30sec"` | Minimum delay between searches | `CONFIG_SEARCH_DELAY_MIN` | -| `searchSettings.searchDelay.max` | string | `"1min"` | Maximum delay between searches | `CONFIG_SEARCH_DELAY_MAX` | -| `searchSettings.readDelay.min` | string | `"30sec"` | Minimum delay for reading | `CONFIG_SEARCH_READ_DELAY_MIN` | -| `searchSettings.readDelay.max` | string | `"1min"` | Maximum delay for reading | `CONFIG_SEARCH_READ_DELAY_MAX` | - -Desktop and mobile search quotas are tracked independently from the dashboard counters. All `mobileSearch` entries are combined for the mobile quota, while all `pcSearch` entries are combined for desktop execution; a counter explicitly identified as Edge is also shown separately for diagnostics. The script skips only the completed platform, so a completed `60/60` mobile quota does not prevent an incomplete desktop quota from running. With `parallelSearching` enabled, both incomplete quotas can run concurrently in their own browser contexts. +配套 Web 面板推荐 [Rewards Dashboard](https://github.com/mgrimace/rewards-dashboard),可管理运行、账户、调度、日志、积分。 -> [!NOTE] -> \* Docker `CONFIG_*` array values are comma-separated strings e.g. `"error,warn"`. Regex patterns must be set directly in `config.json`. - -#### Query sources - -`searchSettings.queryEngines` controls where the main search topics come from. Pick any combination; topics from all selected sources are pooled and de-duplicated. When `searchSettings.clusterSearch` is enabled, each main topic is expanded on demand with Bing suggestions, that topic cluster is shuffled and completed, and only then does searching move to the next main topic. - -Core sources: - -| Selector | Source | -| ------------ | ------------------------------------------------ | -| `google` | Google Trends (trending searches) | -| `wikipedia` | Wikipedia most-read articles (previous day) | -| `wikirandom` | Random Wikipedia articles | -| `hackernews` | Hacker News front-page stories | -| `reddit` | Reddit r/popular post titles | -| `local` | Bundled `src/functions/search-queries.json` list | - -RSS feeds use a dotted path - `rss` for every feed, `rss.` for a whole site, or `rss..` for a single feed: - -| Selector | Feeds | -| ------------------ | -------------------------------------------------------------- | -| `rss.googleTrends` | Google Trends RSS (`gb`, `us`) | -| `rss.googleNews` | Google News (`gb`, `us`, `world`, `technology`, `business`) | -| `rss.bbc` | BBC News (`top`, `world`, `technology`, `business`, `science`) | -| `rss.guardian` | The Guardian (`international`, `world`, `technology`) | -| `rss.theVerge` | The Verge (`all`) | -| `rss.arsTechnica` | Ars Technica (`all`) | -| `rss.reddit` | Reddit listing feeds (`popular`, `worldnews`, `technology`) | - -Add your own feeds in `src/constants/rssFeeds.ts`. - -Default: - -```json -[ - "google", - "wikipedia", - "wikirandom", - "hackernews", - "reddit", - "local", - "rss.googleTrends", - "rss.googleNews", - "rss.bbc", - "rss.guardian.world", - "rss.theVerge.all" -] +--- + +## ⚙️ 配置参考 + +> 编辑 `config.json`(Windows)或通过 `CONFIG_*` 环境变量(Docker)自定义行为。数组类 `CONFIG_*` 值用逗号分隔(如 `"china,local"`);正则类只能写在 `config.json`。 +> 下面按功能分组,**点击各 summary 展开详情**。 + +
+🔵 Core / 核心配置 + +| 设置 | 描述 | 默认值 | Docker 环境变量 | +| --------------------------- | ----------------------------------- | --------------- | ------------------------------------- | +| `sessionPath` | 存储浏览器会话的目录 | `sessions` | — | +| `headless` | 在后台运行浏览器 | `false`(可见) | Docker 强制 `true` | +| `clusters` | 并发账户集群数 | `1` | `CONFIG_CLUSTERS` | +| `errorDiagnostics` | 出错时保存诊断信息到 `diagnostics/` | `false` | `CONFIG_ERROR_DIAGNOSTICS` | +| `ensureStreakProtection` | 确保连击保护已开启 | `true` | `CONFIG_ENSURE_STREAK_PROTECTION` | +| `autoClaimPunchcardRewards` | 自动领取已完成的打卡奖励 | `false` | `CONFIG_AUTO_CLAIM_PUNCHCARD_REWARDS` | +| `skipNonPointTasks` | 跳过无积分奖励的任务 | `true` | `CONFIG_SKIP_NON_POINT_TASKS` | +| `accountDelay.min` / `.max` | 下一账户开始前的延迟 | `1min` - `3min` | `CONFIG_ACCOUNT_DELAY_MIN` / `_MAX` | +| `searchOnBingLocalQueries` | ExploreOnBing 活动使用本地词库 | `false` | `CONFIG_SEARCH_ON_BING_LOCAL` | +| `globalTimeout` | 所有操作的超时时间 | `30sec` | `CONFIG_GLOBAL_TIMEOUT` | + +
+ +
+🗂 Workers / 任务开关 + +| 设置 | 描述 | 默认值 | Docker 环境变量 | +| ------------------------------ | ---------------------------- | ------- | ------------------------------------ | +| `workers.doDailySet` | 完成每日集活动 | `true` | `CONFIG_WORKER_DAILY_SET` | +| `workers.doClaimBonusPoints` | 领取 dashboard 奖励积分 | `true` | `CONFIG_WORKER_CLAIM_BONUS_POINTS` | +| `workers.doMorePromotions` | 完成更多促销活动 | `true` | `CONFIG_WORKER_MORE_PROMOTIONS` | +| `workers.doPunchCards` | 完成打卡活动 | `true` | `CONFIG_WORKER_PUNCH_CARDS` | +| `workers.doAppPromotions` | 完成 App 端活动 | `true` | `CONFIG_WORKER_APP_PROMOTIONS` | +| `workers.doDesktopSearch` | 执行桌面搜索 | `true` | `CONFIG_WORKER_DESKTOP_SEARCH` | +| `workers.doMobileSearch` | 执行移动搜索 | `true` | `CONFIG_WORKER_MOBILE_SEARCH` | +| `workers.doBonusSearches` | 超过上限后继续刷加成搜索 | `false` | `CONFIG_WORKER_BONUS_SEARCHES` | +| `workers.doDailyCheckIn` | 完成每日签到 | `true` | `CONFIG_WORKER_DAILY_CHECKIN` | +| `workers.doReadToEarn` | 完成阅读赚取(Read-to-Earn) | `true` | `CONFIG_WORKER_READ_TO_EARN` | +| `workers.doActivateSearchPerk` | 激活"再搜 N 次"加成 perk | `true` | `CONFIG_WORKER_ACTIVATE_SEARCH_PERK` | +| `workers.doVisualSearch` | 视觉搜索连击与搜索 | `false` | `CONFIG_WORKER_VISUAL_SEARCH` | + +| 设置(activities) | 描述 | 默认值 | Docker 环境变量 | +| ------------------------- | ----------------------- | ------ | -------------------------------- | +| `activities.urlReward` | 完成 URL 奖励活动 | `true` | `CONFIG_ACTIVITY_URL_REWARD` | +| `activities.searchOnBing` | 完成 ExploreOnBing 活动 | `true` | `CONFIG_ACTIVITY_SEARCH_ON_BING` | + +
+ +
+🔍 Search / 搜索配置 + +| 设置 | 描述 | 默认值 | Docker 环境变量 | +| ----------------------------------------- | --------------------------------- | -------------------- | --------------------------------------- | +| `searchSettings.scrollRandomResults` | 随机滚动搜索结果 | `true` | `CONFIG_SEARCH_SCROLL_RANDOM` | +| `searchSettings.clickRandomResults` | 点击随机结果链接 | `true` | `CONFIG_SEARCH_CLICK_RANDOM` | +| `searchSettings.runOnZeroPoints` | 无剩余搜索积分时仍执行搜索 | `false` | `CONFIG_SEARCH_RUN_ON_ZERO_POINTS` | +| `searchSettings.maxBonusSearches` | 单次运行最多加成搜索数 | `110` | `CONFIG_SEARCH_MAX_BONUS_SEARCHES` | +| `searchSettings.parallelSearching` | 桌面/移动搜索并行执行 | `false` | `CONFIG_SEARCH_PARALLEL` | +| `searchSettings.clusterSearch` | Bing 建议聚类扩展主词 | `true` | `CONFIG_SEARCH_CLUSTER` | +| `searchSettings.queryEngines` | 查询源数组 | `["china", "local"]` | `CONFIG_SEARCH_QUERY_ENGINES` | +| `searchSettings.searchResultVisitTime` | 搜索结果页停留时间 | `20sec` | `CONFIG_SEARCH_VISIT_TIME` | +| `searchSettings.searchDelay.min` / `.max` | 搜索之间的延迟 | `6min` - `12min` | `CONFIG_SEARCH_DELAY_MIN` / `_MAX` | +| `searchSettings.readDelay.min` / `.max` | 阅读赚取的阅读间隔 | `6min` - `11min` | `CONFIG_SEARCH_READ_DELAY_MIN` / `_MAX` | +| `searchSettings.chinaApi.appkey` | gmya.net appkey(解除免费档限流) | `""` | `CONFIG_CHINA_API_APPKEY` | + +> 📌 **注**:以上默认值为本仓库 `config.example.json` 的国内防风控调优值(官方上游默认更激进:30sec-1min 间隔、并行开启)。 + +#### 国内推荐配置 + +```jsonc +// config.json 关键项(.env 中账号 LANG_CODE=zh-CN、GEO_LOCALE=CN) +{ + "searchSettings": { + "queryEngines": ["china", "local"], + "chinaApi": { "appkey": "" } // 留空走免费档;填 appkey 解除限流 + } +} ``` -### Experimental +
-Opt-in features that may change. Disabled by default. +
+🌐 queryEngines 查询源说明(含国内可用性) -| Setting | Type | Default | Description | Docker environment variable | -| ------------------------------ | ------- | ------- | ----------------------------------------------------------------------- | ---------------------------------------- | -| `experimental.apiSearch` | boolean | `false` | Perform Bing searches over HTTP instead of driving a browser page | `CONFIG_EXPERIMENTAL_API_SEARCH` | -| `experimental.apiSearchOnBing` | boolean | `false` | Complete ExploreOnBing offers over HTTP instead of the browser | `CONFIG_EXPERIMENTAL_API_SEARCH_ON_BING` | -| `experimental.blockMedia` | boolean | `false` | Block post-login browser `image` and `media` requests to reduce traffic | `CONFIG_EXPERIMENTAL_BLOCK_MEDIA` | -| `experimental.edgeBrowsing` | boolean | `false` | Report the 30-minute Edge browsing activity as a background HTTP task | `CONFIG_EXPERIMENTAL_EDGE_BROWSING` | +`searchSettings.queryEngines` 决定搜索主词来源,多源词池合并去重;开启 `clusterSearch` 时每个主词再经 Bing 建议扩展成一组,完成一组才进入下一主词。 -When `experimental.blockMedia` is enabled, the automated flow activates it only after authentication has been -verified and the session has been saved. The manual-login flow keeps media enabled throughout; `npm run open-session` still -applies the configured blocker immediately because it opens an already-saved session. +**核心源:** -When `experimental.edgeBrowsing` is enabled, the task starts before the normal activity sequence and runs as a separate Promise alongside Daily Set, promotions, app activities, and searches. If foreground work finishes first, the account remains open until this Promise settles. Accounts without the promotion, an access token, or remaining Edge work are skipped immediately. +| 值 | 来源 | 国内可用性 | +| ------------ | ---------------------------------------------- | ------------- | +| `china` | 中国热搜(gmya.net:百度/头条/抖音/微博/知乎) | ✅ 直连,推荐 | +| `local` | 本地查询词(`search-queries.json` 中文词库) | ✅ 离线 | +| `google` | Google Trends 热搜 | ❌ 需代理 | +| `wikipedia` | 维基百科热门条目 | ❌ 需代理 | +| `wikirandom` | 随机维基百科条目 | ❌ 需代理 | +| `hackernews` | Hacker News 首页 | ❌ 需代理 | +| `reddit` | Reddit r/popular | ❌ 需代理 | -#### Activity source layout +**RSS 源**(点分路径:`rss` 全部 / `rss.` 站点 / `rss..` 单源): -Standard activities live under `src/functions/activities`, grouped by responsibility: +| 值 | 内容 | +| ---------------------------------- | ----------------------------------------------------------- | +| `rss.googleTrends` | Google Trends(`gb`、`us`) | +| `rss.googleNews` | Google News(`gb`/`us`/`world`/`technology`/`business`) | +| `rss.bbc` | BBC News(`top`/`world`/`technology`/`business`/`science`) | +| `rss.guardian` | The Guardian(`international`/`world`/`technology`) | +| `rss.theVerge` / `rss.arsTechnica` | The Verge / Ars Technica 全部 | +| `rss.reddit` | Reddit 列表源(`popular`/`worldnews`/`technology`) | -- `rewards`: Daily Set, More Promotions, Punch Cards, and shared promotion dispatch -- `api`: individual Rewards API actions -- `app`: individual mobile-app activities and App Promotions orchestration -- `search`: browser search flows, search tracking, and shared SearchOnBing behavior -- `visualSearch`: the visual-search activity and its browser transport -- `experimental`: API Search, API SearchOnBing, Edge Browsing, and their supporting transports +自定义 RSS 源在 `src/constants/rssFeeds.ts` 中添加。 -Experimental activities live with the other activities under `src/functions/activities/experimental`. The browser-only media blocker lives at `src/browser/MediaBlocker.ts`. `BrowserFunc` contains shared Rewards/browser/session transport only, rather than individual search or visual-search implementations. +**china 源策略:** -> [!NOTE] -> [Playwright documents](https://playwright.dev/docs/api/class-browsercontext#browser-context-route) that request routing disables the browser HTTP cache while routing is active. Image-heavy pages will usually transfer substantially less data with media blocking enabled, but cache-heavy sites are not guaranteed to use less total bandwidth or load faster. Keep this option disabled if a site depends on image/media load events. +- 随机选取热榜源聚合(免费档 1 个、配置 appkey 2 个),单源失败自动 fallback 到其余源 +- 命中免费档限流(403)时自动退避,并提示配置 `chinaApi.appkey` +- 主源优先:主源原始词累计达 20 即停止请求后续源;主源不足时后续源随机抽样 50 条补充 > [!NOTE] -> The API paths are faster but depend on the modern dashboard's endpoints. If an ExploreOnBing offer ever fails to be credited, turn `apiSearchOnBing` off to fall back to the browser path. +> `google` / `wikipedia` / `reddit` / `hackernews` 源及多数 RSS 源在国内网络不可直连,选了也会静默失败降级到其他源;无代理环境直接用 `china,local` 即可。 -Regardless of the experimental search settings, normal Rewards actions use the cookies and action data captured during bootstrap without refreshing the visible page. The browser remains idle until a browser-backed search starts. A failed or unacknowledged URL-reward request triggers one context refresh and one retry; successful requests use the balance returned by the server action. +
-### Logging +
+🧪 Experimental / 实验特性(默认关闭,可能变化) -| Setting | Type | Default | Description | Docker environment variable | -| -------------------------------- | -------- | ---------------------- | --------------------------------- | ------------------------------- | -| `debugLogs` | boolean | `false` | Enable debug logging | `CONFIG_DEBUG_LOGS` | -| `consoleLogFilter.enabled` | boolean | `false` | Enable console log filtering | `CONFIG_LOG_FILTER_ENABLED` | -| `consoleLogFilter.mode` | string | `"whitelist"` | Filter mode (whitelist/blacklist) | `CONFIG_LOG_FILTER_MODE` | -| `consoleLogFilter.levels` | string[] | `["error", "warn"]` | Log levels to filter | `CONFIG_LOG_FILTER_LEVELS` \* | -| `consoleLogFilter.keywords` | string[] | `["starting account"]` | Keywords to filter | `CONFIG_LOG_FILTER_KEYWORDS` \* | -| `consoleLogFilter.regexPatterns` | string[] | `[]` | Regex patterns for filtering | | +| 设置 | 描述 | 默认值 | Docker 环境变量 | +| ------------------------------ | ------------------------------------ | ------- | ---------------------------------------- | +| `experimental.apiSearch` | 用 HTTP 请求代替浏览器执行 Bing 搜索 | `false` | `CONFIG_EXPERIMENTAL_API_SEARCH` | +| `experimental.apiSearchOnBing` | 用 HTTP 完成 ExploreOnBing 活动 | `false` | `CONFIG_EXPERIMENTAL_API_SEARCH_ON_BING` | +| `experimental.blockMedia` | 拦截浏览器图片/媒体请求省流量 | `false` | `CONFIG_EXPERIMENTAL_BLOCK_MEDIA` | +| `experimental.edgeBrowsing` | 后台 HTTP 上报 30 分钟 Edge 浏览活动 | `false` | `CONFIG_EXPERIMENTAL_EDGE_BROWSING` | -> [!NOTE] -> \* Docker `CONFIG_*` array values are comma-separated strings e.g. `"error,warn"`. Regex patterns must be set directly in `config.json`. - -### Proxy - -| Setting | Type | Default | Description | Docker environment variable | -| ------------------------------- | ------- | ------- | ------------------------------------------------------------------ | ---------------------------------------- | -| `proxy.queryEngine` | boolean | `true` | Proxy query engine requests | `CONFIG_PROXY_QUERY_ENGINE` | -| `proxy.ignoreCertificateErrors` | boolean | `false` | Disable browser TLS certificate verification for intercept proxies | `CONFIG_PROXY_IGNORE_CERTIFICATE_ERRORS` | - -Leave `proxy.ignoreCertificateErrors` disabled for normal HTTP(S)/SOCKS proxies. Enabling it weakens TLS protection for the entire browser context and should only be used when a trusted intercepting proxy cannot otherwise present a valid certificate. - -`proxy.queryEngine` controls whether query-source HTTP requests use the account HTTP proxy. Set the corresponding `ACCOUNT_N_PROXY_HTTP=true` (and configure `ACCOUNT_N_PROXY_*`) for those HTTP requests to have a proxy available; browser traffic uses `ACCOUNT_N_PROXY_URL` independently. - -Account browser proxies support `http://`, `https://`, `socks4://`, and `socks5://` (a bare hostname is treated as HTTP). HTTP(S) proxies may use `ACCOUNT_N_PROXY_USERNAME` and `ACCOUNT_N_PROXY_PASSWORD`; Patchright does not support browser authentication for SOCKS4/SOCKS5 proxies. Invalid protocols, ports, partial credentials, and authenticated SOCKS proxy configurations are rejected during account validation before the browser starts. - -### Webhooks - -| Setting | Type | Default | Description | Docker environment variable | -| ---------------------------------------- | -------- | ---------------------------------------------------- | --------------------------------- | --------------------------------------- | -| `webhook.discord.enabled` | boolean | `false` | Enable Discord webhook | `CONFIG_DISCORD_ENABLED` | -| `webhook.discord.url` | string | `""` | Discord webhook URL | `CONFIG_DISCORD_URL` | -| `webhook.telegram.enabled` | boolean | `false` | Enable Telegram webhook | `CONFIG_TELEGRAM_ENABLED` | -| `webhook.telegram.botToken` | string | `""` | Telegram bot token | `CONFIG_TELEGRAM_BOTTOKEN` | -| `webhook.telegram.chatId` | string | `""` | Telegram chat id | `CONFIG_TELEGRAM_CHATID` | -| `webhook.ntfy.enabled` | boolean | `false` | Enable ntfy notifications | `CONFIG_NTFY_ENABLED` | -| `webhook.ntfy.url` | string | `""` | ntfy server URL | `CONFIG_NTFY_URL` | -| `webhook.ntfy.topic` | string | `""` | ntfy topic | `CONFIG_NTFY_TOPIC` | -| `webhook.ntfy.token` | string | `""` | ntfy authentication token | `CONFIG_NTFY_TOKEN` | -| `webhook.ntfy.title` | string | `"Microsoft-Rewards-Script"` | Notification title | `CONFIG_NTFY_TITLE` | -| `webhook.ntfy.tags` | string[] | `["bot", "notify"]` | Notification tags | `CONFIG_NTFY_TAGS` \* | -| `webhook.ntfy.priority` | number | `3` | Notification priority (1-5) | `CONFIG_NTFY_PRIORITY` | -| `webhook.webhookLogFilter.enabled` | boolean | `false` | Enable webhook log filtering | `CONFIG_WEBHOOK_LOG_FILTER_ENABLED` | -| `webhook.webhookLogFilter.mode` | string | `"whitelist"` | Filter mode (whitelist/blacklist) | `CONFIG_WEBHOOK_LOG_FILTER_MODE` | -| `webhook.webhookLogFilter.levels` | string[] | `["error"]` | Log levels to send | `CONFIG_WEBHOOK_LOG_FILTER_LEVELS` \* | -| `webhook.webhookLogFilter.keywords` | string[] | `["starting account", "select number", "collected"]` | Keywords to filter | `CONFIG_WEBHOOK_LOG_FILTER_KEYWORDS` \* | -| `webhook.webhookLogFilter.regexPatterns` | string[] | `[]` | Regex patterns for filtering | | +> 💡 API 路径更快但依赖新版面板接口;若 ExploreOnBing 活动积分未到账,关闭 `apiSearchOnBing` 回退浏览器路径。 -> [!NOTE] -> \* Docker `CONFIG_*` array values are comma-separated strings e.g. `"error,warn"`. Regex patterns must be set directly in `config.json`. +
+ +
+📝 Logging / 日志 + +| 设置 | 描述 | 默认值 | Docker 环境变量 | +| -------------------------------- | ----------------------------------- | ---------------------- | ---------------------------- | +| `debugLogs` | 输出 DEBUG 级别日志 | `false` | `CONFIG_DEBUG_LOGS` | +| `consoleLogFilter.enabled` | 启用控制台日志过滤 | `false` | `CONFIG_LOG_FILTER_ENABLED` | +| `consoleLogFilter.mode` | 过滤模式(`whitelist`/`blacklist`) | `whitelist` | `CONFIG_LOG_FILTER_MODE` | +| `consoleLogFilter.levels` | 过滤的日志级别 | `["error", "warn"]` | `CONFIG_LOG_FILTER_LEVELS` | +| `consoleLogFilter.keywords` | 过滤关键词 | `["starting account"]` | `CONFIG_LOG_FILTER_KEYWORDS` | +| `consoleLogFilter.regexPatterns` | 过滤正则 | `[]` | 仅 config.json | + +> 📌 日志默认全量写入 `logs/YYYY-MM-DD.log`(按本地日期分文件),进程启动时自动清理 90 天前的旧文件(`src/logging/Logger.ts` 中的 `LOG_RETENTION_DAYS` 可调整)。 + +
+ +
+🌍 Proxy / 代理 + +| 设置 | 描述 | 默认值 | Docker 环境变量 | +| ------------------------------- | ----------------------------------- | ------- | ---------------------------------------- | +| `proxy.queryEngine` | 查询源 HTTP 请求走账户代理 | `true` | `CONFIG_PROXY_QUERY_ENGINE` | +| `proxy.ignoreCertificateErrors` | 关闭浏览器 TLS 校验(拦截型代理用) | `false` | `CONFIG_PROXY_IGNORE_CERTIFICATE_ERRORS` | + +**账户级代理**(`.env` 中按账号配置): + +- 浏览器代理:`ACCOUNT_N_PROXY_URL`(支持 `http://`、`https://`、`socks4://`、`socks5://`,裸主机名视为 HTTP) +- HTTP 代理认证:`ACCOUNT_N_PROXY_USERNAME` / `ACCOUNT_N_PROXY_PASSWORD`(Patchright 不支持 SOCKS 浏览器认证) +- 查询源请求代理:`ACCOUNT_N_PROXY_HTTP=true` 并配置 `ACCOUNT_N_PROXY_*` -> [!WARNING] -> **NTFY** users set the `webhookLogFilter` to `enabled`, or you will receive push notifications for _all_ logs. -> When enabled, only account start, 2FA codes, and account completion summaries are delivered as push notifications. -> Customize which notifications you receive with the `keywords` options. +> ⚠️ `ignoreCertificateErrors` 会削弱整个浏览器上下文的 TLS 保护,仅在受信拦截代理无法出示有效证书时开启。 + +
--- -## Troubleshooting +## 🔔 通知渠道 -> [!TIP] -> Most login issues can be fixed by deleting your /sessions folder, and redeploying the script +均在 `webhook` 对象下,**可同时开启多个**: -### Manual login +| 设置 | 描述 | 默认值 | Docker 环境变量 | +| --------------------------------------------------------------------------- | ----------------------------------------------- | ------- | --------------------------------------------------- | +| `webhook.discord.enabled` / `.url` | Discord 推送 | `false` | `CONFIG_DISCORD_ENABLED` / `_URL` | +| `webhook.telegram.enabled` / `.botToken` / `.chatId` | Telegram 推送 | `false` | `CONFIG_TELEGRAM_ENABLED` / `_BOTTOKEN` / `_CHATID` | +| `webhook.ntfy.enabled` / `.url` / `.topic` / `.token` 等 | ntfy 推送 | `false` | `CONFIG_NTFY_*` | +| `webhook.pushplus.enabled` / `.token` / `.title` / `.template` / `.channel` | PushPlus 微信推送 | `false` | `CONFIG_PUSHPLUS_*` | +| `webhook.clawbot.enabled` / `.authFile` | 微信 ClawBot 推送 | `false` | `CONFIG_CLAWBOT_ENABLED` / `_AUTHFILE` | +| `webhook.webhookLogFilter.*` | Webhook 逐条日志过滤(结构同 consoleLogFilter) | `false` | `CONFIG_WEBHOOK_LOG_FILTER_*` | -Use the manual-login command when an account needs an interactive Microsoft sign-in. It opens the same bundled -patched Chromium browser in headful mode and does not invoke the automated login handler. After you finish signing -in, the command waits until the browser remains on `rewards.bing.com` for five continuous seconds, saves the session, -and closes the browser automatically. +> 💡 **国内推荐**:**PushPlus** 或 **微信 ClawBot**(均直达微信,无需翻墙)。Discord / Telegram / ntfy 需要能访问对应服务。 +> 运行结束的中文积分摘要会一次性推送(不逐条推日志)。 -```bash -# Create or refresh the mobile session used at the start of automated runs -npm run manual-login -- --email user@example.com +
+🤖 微信 ClawBot 推送(V4-china 特有) -# Create or refresh only the desktop session -npm run manual-login -- --email user@example.com --platform desktop +基于腾讯官方微信 ClawBot(iLink 灰度接口)直连推送,**免费、无第三方服务、消息直达微信聊天列表**。需要你的微信号有 ClawBot 灰度资格(微信 → 我 → 设置 → 插件)。 -# Create mobile and desktop sessions sequentially -npm run manual-login -- --email user@example.com --platform both +使用只需三步: -# Ignore existing cookies and perform a clean sign-in -npm run manual-login -- --email user@example.com --fresh -``` +1. `config.json` 中开启:`"webhook": { "clawbot": { "enabled": true } }` +2. 正常启动脚本(如 `npm start`)——检测到未登录时,终端会**自动弹出二维码**,手机微信扫码确认(等待 5 分钟,超时则本次跳过推送,任务照常运行) +3. 登录后终端提示激活时,在手机微信里给「微信 ClawBot」**发送一条任意消息**(等待 2 分钟)——脚本会自动抓取激活凭证并回发一条"✅ 通知已接通" -The email must match an `ACCOUNT_N_EMAIL` entry in `.env`. Existing cookies are restored by default, while `--fresh` -starts without them but retains a compatible saved fingerprint when fingerprint persistence is enabled. The command -uses the selected account's language, region, proxy, proxy credentials, and fingerprint settings from `.env`. For -consistent device identity between the manual and automated browser, enable `ACCOUNT_N_SAVE_FINGERPRINT_MOBILE=true` -and/or -`ACCOUNT_N_SAVE_FINGERPRINT_DESKTOP=true` for the platforms you use. +之后每次运行结束,积分摘要会自动推送到你的微信。凭证保存在项目根 `clawbot-auth.json`(已加入 .gitignore),多台机器可直接复制该文件共用。也可用 `npm run clawbot:login` 手动完成登录+激活。凭证过期(服务端 -14)时自动清除、下次运行重新扫码;推送上下文失效(-2)时下次运行会重新等待激活消息。 -The session path, global timeout, media-blocking option, and proxy TLS option come from `config.json`. The corresponding -`CONFIG_GLOBAL_TIMEOUT`, `CONFIG_EXPERIMENTAL_BLOCK_MEDIA`, and -`CONFIG_PROXY_IGNORE_CERTIFICATE_ERRORS` environment overrides are also applied after `.env` is loaded. Manual login -forces a visible browser and defers media blocking for the entire interactive authentication flow, even when -`experimental.blockMedia` is enabled. Automated runs resume the configured blocker only after login verification and -session saving, so authentication SVGs, QR codes, and videos cannot be blocked by this option. +
-Run this command directly on a computer with a graphical desktop and a full Patchright Chromium installation. The -default Docker image contains only the headless browser shell and cannot display this interactive window. +--- + +## ❓ 常见问题 -### Session management +
+报错 Error: browserType.launch: Executable doesn't exist 怎么办? -The session utility requires an explicit command, so running it without an -argument only displays help and never deletes anything. +Chromium 没装上,手动安装: ```bash -# List stored mobile and desktop sessions -npm run clear-sessions -- list +npx patchright install chromium +``` -# Delete the sessions belonging to one account -npm run clear-sessions -- email user@example.com +
-# Delete every stored session -npm run clear-sessions -- all +
+登录失败 / 卡住 / 每次都要重新登录? + +首次运行时请**手动完成网页登录**一次,等待脚本自动接管剩余流程。登录后的 cookie 会保存到 `sessions/` 目录,后续运行会自动复用。 + +⚠️ `sessions/` 目录**需要多备份**,丢了就要重新登录。 + +也可用会话管理命令查看/清理: + +```bash +npm run clear-sessions -- list # 列出所有账户会话 +npm run clear-sessions -- email user@example.com # 删除指定账户会话 +npm run clear-sessions -- all # 删除全部会话 ``` +
+ +
+修改 .env / config.json 后怎么生效? + +- **Win 环境**:`.env` 在脚本启动时读取,改完重新 `npm start` 即可;`config.json` 改完建议 `npm run build` 后再启动 +- **Docker 环境**:改 `.env` 或 `compose.yaml` 后用 `docker compose up -d --build` 生效;不要手动改容器内配置文件 + +
+ +
+Docker 改了配置为什么不生效? + +改完 `compose.yaml` 或代码后,必须加 `--build` 重建镜像: + ```bash -# List safe session metadata -curl --request GET \ - --url http://127.0.0.1:3010/sessions \ - --header 'Authorization: Bearer YOUR_API_TOKEN' - -# Delete only user@example.com's mobile and desktop sessions -curl --request DELETE \ - --url http://127.0.0.1:3010/sessions/user%40example.com \ - --header 'Authorization: Bearer YOUR_API_TOKEN' +docker compose up -d --build ``` -See the [Control API session documentation](scripts/api/README.md#session-management) -for response data, Axios examples, and error behavior. +不加 `--build` 跑的是旧镜像。 + +
+ +
+国内查询词被限流(403)怎么办? + +gmya.net 免费档对连续请求有频率限制。解决方法: + +到 [gmya.net](https://gmya.net) 申请 appkey,填入 `searchSettings.chinaApi.appkey`(Docker 用 `CONFIG_CHINA_API_APPKEY` 环境变量),即可解除限流。 + +
+ +
+v3 的 accounts.json / config.json 能直接用吗? + +不能。本分支与 v3 及更早版本**均不兼容**: + +- 账号迁移到 `.env`(`ACCOUNT_1_EMAIL` 等,参考 `env.example`) +- 配置文件基于本分支 `config.example.json` 重新生成 + +
+ +
+ClawBot 推送没收到 / 提示凭证过期? + +- 确认微信号有 ClawBot 灰度资格(微信 → 我 → 设置 → 插件),且首次扫码后给「微信 ClawBot」发过一条消息激活 +- 凭证过期(服务端 -14)会自动清除,下次运行重新扫码;或手动 `npm run clawbot:login` +- iLink 服务端限频约 7 条 / 5 分钟,摘要为一次性推送,一般不会触发 + +
+ +--- + +## ⚠️ 注意事项 + +- `.env` 含账户密码,**严禁提交或分享**(已加入 `.gitignore`);`clawbot-auth.json` 含推送凭证,同样勿提交。 +- 如果出现无法自动登录的情况,请在代码执行登录过程中**手动完成网页登录**,等待代码自动完成剩余流程。登录信息保存在 `sessions/` 目录(需要多备份)。 +- **Win 环境**:复制或重命名 `config.example.json` 为 `config.json`(项目根目录)并自定义偏好;修改后运行 `npm run build` 重新构建。 +- **Docker 环境**:账号和行为配置通过 `.env` 和 `compose.yaml` 传入;`CONFIG_*` 每次启动都会覆盖 `./config/config.json`。 +- 之前的 `accounts.json` 和旧版 `config.json` 与本分支不兼容。 + +--- + +## 📜 同步与致谢 + +本项目 fork 自 [TheNetsky/Microsoft-Rewards-Script](https://github.com/TheNetsky/Microsoft-Rewards-Script),`V4-china` 分支基于上游 **v4 分支**(新版 dashboard 架构)做国内本地化: + +- 中国热搜查询源(gmya.net 聚合百度/头条/抖音/微博/知乎,多源随机 + 限流退避 + appkey 解限流) +- PushPlus / 微信 ClawBot 微信推送渠道 +- 国内默认值调优(china+local 引擎、搜索间隔 6-12min、防风控默认项) +- 中文词库(`search-queries.json`)与全量日志中文化 +- Docker 基础镜像走 m.daocloud.io 加速;浏览器版本接口不可达时自动回退内置版本 + +若有侵权请联系删除。 + +**本项目所有改动基于 Win11 系统测试。其他系统未测试,请根据原项目相关配置设置。** + +| 项目 | 信息 | +| ------------ | --------------------------------------------------------------------------------------------------------- | +| 上游仓库 | [TheNetsky/Microsoft-Rewards-Script](https://github.com/TheNetsky/Microsoft-Rewards-Script)(v4 分支) | +| 本仓库 | [chiihero/Microsoft-Rewards-Script](https://github.com/chiihero/Microsoft-Rewards-Script) `V4-china` 分支 | +| 当前版本 | 4.3.0 | +| 最后同步上游 | 2026-08-16 | --- -## Disclaimer +## ⚠️ 免责声明 + +**风险自负!** 使用自动化脚本时,您的 Microsoft Rewards 账户可能会被暂停或禁止。 -Use at your own risk. -Automation of Microsoft Rewards may lead to account suspension or bans. -This software is provided for educational purposes only. -The authors are not responsible for any actions taken by Microsoft. +此脚本仅供教育目的。作者对 Microsoft 采取的任何账户操作不承担责任。 diff --git a/compose.yaml b/compose.yaml index d0ccc729..bd10bf57 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,9 +1,10 @@ services: microsoft-rewards-script: - image: ghcr.io/thenetsky/microsoft-rewards-script:4 - # To build locally instead of pulling the prebuilt image, use the build section below. - #build: - # context: . + # 本地构建本项目代码(V4-china 国内适配版)。 + # ghcr.io/thenetsky/... 是原作者 TheNetsky 的官方镜像,跑的是原版代码, + # 不含本项目的国内适配(中国热搜 / PushPlus 等)。 + build: . + image: microsoft-rewards-script:china container_name: microsoft-rewards-script restart: unless-stopped volumes: @@ -14,7 +15,7 @@ services: - path: .env environment: # ── Scheduler ────────────────────────────────────── - TZ: 'America/Toronto' + TZ: 'Asia/Shanghai' # Set your timezone for proper scheduling NODE_ENV: 'production' # CRON_SCHEDULE is the *initial* default. It will run as default unless changed by the API # API schedule changes are saved to ./config/schedule.json, delete @@ -36,6 +37,12 @@ services: #API_ALLOW_CONFIG_REVEAL: 'true' #API_ALLOW_CONFIG_WRITE: 'true' + # ── Embedded dashboard (dashboard/server.js) ────────────────────────────────────── + # Started automatically in API mode; web UI on :8300 (LAN, no auth - keep it internal). + # Its data (push-config/state) persists under ./config/dashboard/. + #DASHBOARD_ENABLED: 'true' + #DASHBOARD_PORT: '8300' + # ── Configuration ────────────────────────────────────── # Uncomment to override defaults, a full list of configuration options are in the README. #CONFIG_CLUSTERS: '1' @@ -51,10 +58,30 @@ services: #CONFIG_PROXY_IGNORE_CERTIFICATE_ERRORS: 'false' #... add additional configuration overrides as needed + # ── API mode + dashboard port mapping ────────────────────────────────────── + # Uncomment when API_MODE=true. 3010 should stay loopback-only (token-bearing API); + # 8300 is the embedded dashboard web UI (LAN, no auth). + # ── 国内特性(V4-china)────────────────────────────────── + # 查询源:中国热搜(gmya.net) + 本地词兜底;国内推荐,无需代理 + #CONFIG_SEARCH_QUERY_ENGINES: 'china,local' + # gmya.net appkey,填入解除免费档限流(留空走免费档,有频率限制) + #CONFIG_CHINA_API_APPKEY: '' + # PushPlus 微信推送(国内推荐,无需翻墙): + #CONFIG_PUSHPLUS_ENABLED: 'true' + #CONFIG_PUSHPLUS_TOKEN: 'your_pushplus_token' + #CONFIG_PUSHPLUS_TITLE: 'Microsoft-Rewards-Script' + #CONFIG_PUSHPLUS_TEMPLATE: 'txt' # txt / html / markdown + #CONFIG_PUSHPLUS_CHANNEL: '' # 默认微信;可选其他 channel + # 微信 ClawBot 推送(官方 iLink 直连,免费;需容器内完成一次扫码, + # 建议配合 API_MODE/tty 使用,或本地登录后把 clawbot-auth.json 挂载进容器): + #CONFIG_CLAWBOT_ENABLED: 'true' + #CONFIG_CLAWBOT_AUTHFILE: '' # 凭证路径,默认项目根 clawbot-auth.json + # ── API mode port mapping ────────────────────────────────────── # Uncomment when API_MODE=true #ports: - # - '3010:3010' + # - '127.0.0.1:3010:3010' + # - '8300:8300' healthcheck: test: ['CMD-SHELL', 'scripts/docker/healthcheck.sh'] diff --git a/config.example.json b/config.example.json index 32c96499..0bb6efa2 100644 --- a/config.example.json +++ b/config.example.json @@ -30,35 +30,29 @@ "searchOnBing": true }, "searchOnBingLocalQueries": false, - "globalTimeout": "30sec", + "globalTimeout": "50sec", "searchSettings": { - "scrollRandomResults": false, - "clickRandomResults": false, + "scrollRandomResults": true, + "clickRandomResults": true, "runOnZeroPoints": false, "maxBonusSearches": 110, - "parallelSearching": true, + "parallelSearching": false, "clusterSearch": true, "queryEngines": [ - "google", - "wikipedia", - "wikirandom", - "hackernews", - "reddit", - "local", - "rss.googleTrends", - "rss.googleNews", - "rss.bbc", - "rss.guardian.world", - "rss.theVerge.all" + "china", + "local" ], - "searchResultVisitTime": "10sec", + "searchResultVisitTime": "20sec", "searchDelay": { - "min": "30sec", - "max": "1min" + "min": "6min", + "max": "12min" }, "readDelay": { - "min": "30sec", - "max": "1min" + "min": "6min", + "max": "11min" + }, + "chinaApi": { + "appkey": "" } }, "experimental": { @@ -67,7 +61,7 @@ "blockMedia": false, "edgeBrowsing": false }, - "debugLogs": false, + "debugLogs": true, "consoleLogFilter": { "enabled": false, "mode": "whitelist", @@ -76,7 +70,7 @@ "regexPatterns": [] }, "proxy": { - "queryEngine": true, + "queryEngine": false, "ignoreCertificateErrors": false }, "webhook": { @@ -89,6 +83,17 @@ "botToken": "", "chatId": "" }, + "pushplus": { + "enabled": false, + "token": "", + "title": "Microsoft-Rewards-Script", + "template": "txt", + "channel": "" + }, + "clawbot": { + "enabled": false, + "authFile": "" + }, "ntfy": { "enabled": false, "url": "", diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 00000000..6e083281 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,46 @@ +# 内嵌积分看板 (dashboard) + +与脚本**同容器**运行的 Web 看板(零第三方依赖,纯 Node 内置模块),替代原宿主机 systemd 桥接方案。 + +## 功能 + +- 账号积分总览表格(余额 / 今日获得 / 状态 / 错误原因),打开即见、无需等运行 +- 「今日获得」按天持久化(Asia/Shanghai 跨天自动归零),失败账号保留上次已知余额 +- 多选账号批量运行(勾选 → 只跑选中;不选 → 全量),经 control API `excludedAccountIndexes` 实现 +- 订阅 control API `/events` SSE,按事件开关推送钉钉(在线可配,含去重) +- 自动刷新间隔可调(关闭/5/15/30/60 秒,localStorage 记忆) +- 🩺 **登录体检**:每行按钮,无头恢复已保存会话 → 验证 Rewards 可达 → 读取实时余额 → 回写刷新 cookie, + 结果直接更新账号余额/状态(无需为此跑一次全量任务) +- 🔑 **人工登录**:每行按钮,容器内拉起有头浏览器(Xvfb + noVNC),在内网页面里直接完成微软 + 身份验证(如 identity/confirm 卡点),停留在 Rewards 页面 5 秒自动保存会话;noVNC 经看板 + `:8300` 以一次性 token 代理(HTTP + WebSocket),不额外暴露端口,同一时间仅一个会话,20 分钟自动超时 + +## 运行方式 + +`entrypoint.sh` 在 `API_MODE=true` 且 `DASHBOARD_ENABLED`(默认开)时,以守护子进程拉起 `dashboard/server.js`, +崩溃 5 秒后自动重启;`healthcheck.sh` 会同时探测 control API 与看板。 + +| 环境变量 | 默认 | 说明 | +|---|---|---| +| `DASHBOARD_ENABLED` | `true` | 是否随容器启动看板 | +| `DASHBOARD_PORT` | `8300` | 看板监听端口(建议映射到 LAN) | +| `DASHBOARD_DATA_DIR` | `$SCRIPT_DIR/config/dashboard` | push-config.json / state.json 存放目录(config 卷内,持久化) | +| `API_PORT` | `3010` | 本容器 control API 端口(看板内部访问用) | +| `API_TOKEN` | - | control API Bearer token(由环境注入) | + +## 端口与安全 + +- `8300` 看板 Web UI:**无鉴权**,仅限内网访问,不要暴露公网;人工登录的 noVNC 通道由一次性随机 + token 门禁且仅存活于会话期间 +- `3010` control API:带 token,建议只绑宿主回环(`127.0.0.1:3010:3010`) + +## 数据迁移(自旧宿主机版) + +旧版(宿主机 systemd + `/opt/docker/ms-rewards-dashboard`)的数据在: + +``` +/opt/docker/ms-rewards-dashboard/config/push-config.json # 钉钉推送配置 +/opt/docker/ms-rewards-dashboard/config/state.json # 账号余额/今日获得/历史 +``` + +复制到挂载卷的 `config/dashboard/` 下即可无缝继承(格式兼容,含 v2 一次性迁移)。 diff --git a/dashboard/server.js b/dashboard/server.js new file mode 100644 index 00000000..7b3b4434 --- /dev/null +++ b/dashboard/server.js @@ -0,0 +1,1681 @@ +#!/usr/bin/env node +/** + * MS-Rewards 积分仪表盘(容器内嵌版) + 钉钉推送 + * + * 与脚本同容器运行(entrypoint 在 API_MODE 下拉起本进程, 崩溃自动重启): + * - 消费本容器 control API (127.0.0.1:$API_PORT, Bearer token 由环境注入) + * - 订阅其 /events SSE, 按在线可配的事件开关推送钉钉 + * - 自身提供 Web UI (:8300, 内网无鉴权): 账号积分总览 / 批量运行 / 推送配置弹窗 / 运行历史 + * - 数据(push-config/state)落在 $DASHBOARD_DATA_DIR(默认 config/dashboard/), 随宿主卷持久化 + * + * 零第三方依赖, 仅用 Node 内置模块。 + */ +'use strict'; + +const http = require('http'); +const https = require('https'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const net = require('net'); + +// ============ 常量 ============ +const BASE_DIR = __dirname; +// 数据目录: 容器内指向脚本 config/ 同卷子目录(持久化); 本地开发默认 dashboard/config +const CONFIG_DIR = process.env.DASHBOARD_DATA_DIR || path.join(BASE_DIR, 'config'); +const PUSH_CONFIG_FILE = path.join(CONFIG_DIR, 'push-config.json'); +const STATE_FILE = path.join(CONFIG_DIR, 'state.json'); + +const PORT = Number(process.env.DASHBOARD_PORT || 8300); +const API_PORT = Number(process.env.API_PORT || 3010); +const API_BASE = 'http://127.0.0.1:' + API_PORT; +const SSE_RECONNECT_MS = 5000; +const HISTORY_KEEP = 30; // dashboard 侧持久化的运行历史条数 + +// ============ 容器 API token(由容器环境注入) ============ +let API_TOKEN = process.env.API_TOKEN || null; + +// ============ 推送配置(在线可改) ============ +function defaultPushConfig() { + return { + dingtalk: { + enabled: true, + accessToken: '', + secret: '', + keyword: 'MS-Rewards', + }, + events: { + runStart: { enabled: true, label: '🚀 开始运行' }, + runEnd: { enabled: true, label: '✅ 全部完成' }, + accountEnd: { enabled: true, label: '👤 账号完成' }, + error: { enabled: true, label: '❌ 错误' }, + loginFail: { enabled: true, label: '⚠️ 登录/风控异常' }, + dailySummary: { enabled: false, label: '📊 运行摘要' }, + }, + dedupSeconds: 10, + updatedAt: null, + }; +} + +let pushConfig = null; +function loadPushConfig() { + try { + pushConfig = JSON.parse(fs.readFileSync(PUSH_CONFIG_FILE, 'utf8')); + } catch (e) { + pushConfig = defaultPushConfig(); + savePushConfig(); + } +} +function savePushConfig() { + pushConfig.updatedAt = new Date().toISOString(); + try { + if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(PUSH_CONFIG_FILE, JSON.stringify(pushConfig, null, 2)); + return true; + } catch (e) { + log('ERROR', '保存推送配置失败: ' + e.message); + return false; + } +} + +// ============ 运行状态缓存(state.json 持久化) ============ +let state = { accounts: {}, runs: [] }; +function loadState() { + try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (e) { + state = { accounts: {}, runs: [] }; + } + if (!state.accounts) state.accounts = {}; + if (!state.runs) state.runs = []; + // v2 一次性迁移: 旧格式 { collected, lastUpdateTs, success } → 新字段 + // (旧记录发生在今天才把 collected 计入今日获得; 迁移后删除旧字段, 保证只应用一次) + if (state.v !== 2) { + const t = todayStr(); + for (const em of Object.keys(state.accounts)) { + const a = state.accounts[em]; + if (!a || typeof a !== 'object') continue; + if (a.collected != null && a.lastUpdateTs) { + const sameDay = new Date(a.lastUpdateTs + 8 * 3600e3).toISOString().slice(0, 10) === t; + if (sameDay && !a.todayCollected) a.todayCollected = a.collected; + } + if (a.lastStatus == null && a.success != null) a.lastStatus = a.success ? 'success' : 'failed'; + delete a.collected; + delete a.lastUpdateTs; + delete a.success; + } + state.v = 2; + saveState(); + } +} +function saveState() { + try { + if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + } catch (e) { /* ignore */ } +} + +// 今日(以 Asia/Shanghai 日期为准)获得的积分按账号持久化, 打开页面即见, 无需等运行 +function todayStr() { + return new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10); +} +function ensureAccount(email) { + let a = state.accounts[email]; + if (!a || typeof a !== 'object') a = state.accounts[email] = { email }; + if (a.todayDate !== todayStr()) { a.todayDate = todayStr(); a.todayCollected = 0; } + return a; +} +// 日志消息中提取邮箱(去掉尾部标点, 如 "xxx@qq.com:") +function extractEmail(message, fallback) { + const m = String(message || '').match(/(\S+@\S+)/); + const em = String(m ? m[1] : (fallback || '')).replace(/[.:,;,。;]+$/, ''); + return em || null; +} + +// ============ 日志 ============ +function log(level, msg) { + const line = `[${new Date().toLocaleString('zh-CN')}] [${level}] ${msg}`; + console.log(line); +} + +// ============ 钉钉发送 ============ +function buildDingUrl(cfg) { + let u = `https://oapi.dingtalk.com/robot/send?access_token=${cfg.accessToken}`; + if (cfg.secret) { + const ts = Date.now(); + const sign = crypto.createHmac('sha256', cfg.secret) + .update(`${ts}\n${cfg.secret}`).digest('base64'); + u += `×tamp=${ts}&sign=${encodeURIComponent(sign)}`; + } + return u; +} + +function sendDingTalk(content) { + if (!pushConfig.dingtalk.enabled || !pushConfig.dingtalk.accessToken) { + return Promise.resolve({ ok: false, error: '钉钉未启用或缺少 accessToken' }); + } + const data = JSON.stringify({ + msgtype: 'text', + text: { content: content.slice(0, 1800) }, // 钉钉 text 上限 2000 + }); + const url = new URL(buildDingUrl(pushConfig.dingtalk)); + const opts = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, + timeout: 10000, + }; + return new Promise((resolve) => { + const req = https.request(opts, (res) => { + let b = ''; + res.on('data', (c) => (b += c)); + res.on('end', () => { + const ok = /"errcode":\s*0/.test(b); + resolve({ ok, resp: b }); + }); + }); + req.on('error', (e) => resolve({ ok: false, resp: 'NETWORK_ERR: ' + e.message })); + req.on('timeout', () => { req.destroy(); resolve({ ok: false, resp: 'TIMEOUT' }); }); + req.write(data); + req.end(); + }); +} + +// ============ 推送去重 ============ +const lastSentAt = {}; +function shouldSend(key) { + const now = Date.now(); + const dedup = (pushConfig.dedupSeconds || 10) * 1000; + if (lastSentAt[key] && now - lastSentAt[key] < dedup) return false; + lastSentAt[key] = now; + return true; +} + +// ============ 日志事件 → 推送 ============ +// SSE log 事件: { id, ts, user, level, platform, title, message, ... } (title/message 按 logParser 结构) +function extractField(msg, re) { + const m = String(msg || '').match(re); + return m ? m[1] : null; +} + +function pushForEntry(entry) { + const ev = pushConfig.events || {}; + const title = (entry.title || '').toUpperCase(); + const level = (entry.level || '').toLowerCase(); + const message = String(entry.message || ''); + // 优先从消息中提取邮箱(日志里邮箱形如 xxx@yyy.com), 兜底用日志 user 字段 + const email = (message.match(/(\S+@\S+)/) || [])[1] || entry.user || ''; + + let key = null; + let lines = null; + + if (title === 'RUN-START' && ev.runStart.enabled) { + key = 'runStart'; + lines = [`🚀 开始运行`, `微软积分脚本开始执行(账号数见日志)`]; + } else if (title === 'RUN-END' && ev.runEnd.enabled) { + key = 'runEnd'; + const accounts = extractField(message, /(?:accountsProcessed|处理账户数)=(\d+)/); + const gained = extractField(message, /(?:pointsGained|获得积分)=(-?\d+)/); + const balance = extractField(message, /(?:currentBalance|现余额)=(\d+)/); + const runtime = extractField(message, /(?:runtimeMinutes|运行分钟数)=([\d.]+)/); + lines = [ + `✅ 全部完成`, + `账号: ${accounts ?? '-'} | 本次获得: ${gained ?? '-'} 分`, + `当前总余额: ${balance ?? '-'} | 耗时: ${runtime ?? '-'} 分钟`, + ]; + } else if (title === 'ACCOUNT-END' && ev.accountEnd.enabled) { + key = 'accountEnd'; + const gained = extractField(message, /(?:pointsGained|获得积分)=(-?\d+)/); + const balance = extractField(message, /(?:currentBalance|现余额)=(\d+)/); + lines = [`👤 账号完成`, `${email || '账号'}: 本次 +${gained ?? '-'} 分 | 余额 ${balance ?? '-'}`]; + } else if ((/ERROR/.test(title) || level === 'error') && ev.error.enabled) { + key = 'error'; + lines = [`❌ 错误`, (email ? `账号 ${email}\n` : '') + message.slice(0, 300) || message.slice(0, 300)]; + } else if ((/LOGIN/.test(title) && /fail|失败|denied|error/i.test(message)) || /BOT-WARNING/.test(title)) { + if (ev.loginFail.enabled) { + key = 'loginFail'; + lines = [`⚠️ 登录/风控异常`, (email ? `账号 ${email}\n` : '') + message.slice(0, 300) || message.slice(0, 300)]; + } + } + + if (key && lines && shouldSend(key)) { + const kw = pushConfig.dingtalk.keyword || 'MS-Rewards'; + sendDingTalk(`[${kw}] ${lines.join('\n')}`).then((r) => { + log('INFO', `${r.ok ? '✓' : '✗'} 推送 ${key} -> ${r.resp ? r.resp.slice(0, 100) : 'ok'}`); + }); + } +} + +// ============ 运行状态收集(从日志事件) ============ +function recordEntry(entry) { + const title = (entry.title || '').toUpperCase(); + const level = (entry.level || '').toLowerCase(); + const message = String(entry.message || ''); + + if (title === 'ACCOUNT-END') { + // 中英双语兼容: 上游英文版 "Completed account: x | pointsGained=.." / 中文化分支 "账户完成: x | 获得积分=.." + const email = extractField(message, /(?:Completed account|账户完成)[::]\s*(\S+)/) || extractEmail(message, entry.user) || '未知'; + const gained = parseInt(extractField(message, /(?:pointsGained|获得积分)=(-?\d+)/) || '0', 10); + const balance = parseInt(extractField(message, /(?:currentBalance|现余额)=(\d+)/) || '0', 10); + const a = ensureAccount(email); + // 失败过的账号也保留上次已知余额, 仅在成功完成时刷新 + a.balance = balance; + a.balanceTs = Date.now(); + a.todayCollected = (a.todayCollected || 0) + gained; + a.lastStatus = 'success'; + a.lastError = null; + a.lastRunTs = Date.now(); + if (state.currentRun) { + state.currentRun.accounts.push({ email, gained, balance }); + state.currentRun.collected += gained; + } + saveState(); + } else if (title === 'ACCOUNT-START') { + const email = extractField(message, /(?:Starting account|开始处理账户)[::]\s*(\S+)/) || extractEmail(message, entry.user); + if (email) { + const a = ensureAccount(email); + a.lastStatus = 'running'; + a.lastRunTs = Date.now(); + a.lastError = null; + saveState(); + } + } else if (title === 'RUN-START') { + state.currentRun = { startedAt: Date.now(), accounts: [], collected: 0, finished: false }; + saveState(); + } else if (title === 'RUN-END') { + // 收尾: 仍处于 running 的账号视为未完成(登录失败被跳过等), 保留其余额仅标记状态 + for (const em of Object.keys(state.accounts)) { + const a = state.accounts[em]; + if (a && a.lastStatus === 'running') { + a.lastStatus = 'failed'; + a.lastError = a.lastError || '运行结束但未收到该账号完成事件'; + } + } + if (state.currentRun) { + state.currentRun.finished = true; + state.currentRun.endedAt = Date.now(); + state.currentRun.totalCollected = parseInt(extractField(message, /(?:pointsGained|获得积分)=(-?\d+)/) || '0', 10); + state.currentRun.totalBalance = parseInt(extractField(message, /(?:currentBalance|现余额)=(\d+)/) || '0', 10); + state.runs.unshift({ + startedAt: state.currentRun.startedAt, + endedAt: state.currentRun.endedAt, + accounts: state.currentRun.accounts, + collected: state.currentRun.collected, + totalCollected: state.currentRun.totalCollected, + totalBalance: state.currentRun.totalBalance, + }); + if (state.runs.length > HISTORY_KEEP) state.runs.length = HISTORY_KEEP; + state.currentRun = null; + } + saveState(); + } else if (level === 'error' || title.indexOf('ERROR') >= 0) { + // 错误事件: 关联到具体账号则标记失败并记录原因(不动余额) + if (title.indexOf('RUN') < 0) { + const email = extractEmail(message, entry.user); + if (email && email.indexOf('@') > 0) { + const a = ensureAccount(email); + a.lastStatus = 'failed'; + a.lastError = message.slice(0, 200); + saveState(); + } + } + } +} + +// ============ SSE 客户端(订阅容器 API /events) ============ +function handleFrame(frame) { + const lines = frame.split('\n'); + let event = null; + const datas = []; + for (const l of lines) { + if (l.startsWith('event:')) event = l.slice(6).trim(); + else if (l.startsWith('data:')) datas.push(l.slice(5).trim()); + else if (l.startsWith('id:')) { /* ignore */ } + } + if (!datas.length) return; + let data; + try { data = JSON.parse(datas.join('\n')); } catch (e) { return; } + + if (event === 'log') { + pushForEntry(data); + recordEntry(data); + } else if (event === 'hello') { + log('INFO', 'SSE 连接成功(hello 快照收到)'); + } else if (event === 'status') { + // 状态变化, 页面通过轮询 /points 反映, 此处无需处理 + } +} + +function connectSSE() { + if (!API_TOKEN) { + log('WARN', '未读取到 API_TOKEN, 无法订阅容器事件, 10s 后重试'); + setTimeout(connectSSE, 10000); + return; + } + const url = `${API_BASE}/events?token=${encodeURIComponent(API_TOKEN)}&replay=200`; + const req = http.get(url, (res) => { + if (res.statusCode !== 200) { + res.resume(); + log('WARN', `SSE 连接返回 ${res.statusCode}, ${SSE_RECONNECT_MS}ms 后重连`); + setTimeout(connectSSE, SSE_RECONNECT_MS); + return; + } + log('INFO', 'SSE 已连接: ' + url.replace(/token=.*$/, 'token=***')); + let buf = ''; + res.on('data', (chunk) => { + buf += chunk.toString(); + let idx; + while ((idx = buf.indexOf('\n\n')) >= 0) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + handleFrame(frame); + } + }); + res.on('end', () => { log('WARN', 'SSE 连接断开, 重连中'); setTimeout(connectSSE, SSE_RECONNECT_MS); }); + res.on('error', () => { setTimeout(connectSSE, SSE_RECONNECT_MS); }); + }); + req.on('error', () => { setTimeout(connectSSE, SSE_RECONNECT_MS); }); + req.setTimeout(60000, () => { req.destroy(); }); +} + +// ============ 容器 API 代理 ============ +function proxyContainerApi(reqPath, method, body, res) { + if (!API_TOKEN) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '未配置 API_TOKEN' })); + return; + } + const data = body ? JSON.stringify(body) : null; + const options = { + hostname: '127.0.0.1', + port: API_PORT, + path: reqPath, + method: method || 'GET', + headers: { + Authorization: 'Bearer ' + API_TOKEN, + Accept: 'application/json', + }, + timeout: 60000, // start 可能长 + }; + if (data) { + options.headers['Content-Type'] = 'application/json'; + options.headers['Content-Length'] = Buffer.byteLength(data); + } + const req = http.request(options, (r) => { + let b = ''; + r.on('data', (c) => (b += c)); + r.on('end', () => { + try { + res.writeHead(r.statusCode, { 'Content-Type': 'application/json' }); + res.end(b || '{}'); + } catch (e) { /* ignore */ } + }); + }); + req.on('error', (e) => { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: '容器 API 不可达: ' + e.message })); + }); + if (data) req.write(data); + req.end(); +} + +// ============ 登录体检(无头会话检查 + 实时余额) ============ +// 复用 scripts/main/loginCheck.js(与主脚本同一会话存取逻辑), 后台任务化并轮询结果 +const PROJECT_ROOT = path.join(BASE_DIR, '..'); +const LOGIN_CHECK_TIMEOUT_MS = 180000; +const loginCheckJobs = new Map(); // id -> job + +function startLoginCheck(accountIndex, email) { + for (const job of loginCheckJobs.values()) { + if (job.status === 'running') return { error: '已有登录体检在进行中,请稍候', code: 'BUSY' }; + } + const id = crypto.randomBytes(8).toString('hex'); + const job = { id, accountIndex, email, status: 'running', startedAt: Date.now(), result: null, logs: [] }; + loginCheckJobs.set(id, job); + for (const key of loginCheckJobs.keys()) { + if (loginCheckJobs.size > 6) loginCheckJobs.delete(key); + } + let buf = ''; + const onLine = (line) => { + const t = line.trim(); + if (!t) return; + job.logs.push(t); + if (job.logs.length > 60) job.logs.shift(); + const m = t.match(/LOGINCHECK_RESULT (\{.*\})/); + if (m) { + try { job.result = JSON.parse(m[1]); } catch (e) { /* 忽略解析失败 */ } + } + }; + const child = spawn('node', + [path.join(PROJECT_ROOT, 'scripts/main/loginCheck.js'), '--email', email, '--platform', 'both'], + { cwd: PROJECT_ROOT, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); + const feed = (c) => { + buf += c.toString(); + let idx; + while ((idx = buf.indexOf('\n')) >= 0) { + onLine(buf.slice(0, idx)); + buf = buf.slice(idx + 1); + } + }; + child.stdout.on('data', feed); + child.stderr.on('data', feed); + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch (e) { /* ignore */ } + job.status = job.result ? 'done' : 'error'; + job.error = job.error || '体检超时'; + }, LOGIN_CHECK_TIMEOUT_MS); + child.on('exit', (code) => { + clearTimeout(timer); + job.exitCode = code; + job.finishedAt = Date.now(); + job.status = job.result ? 'done' : 'error'; + if (!job.result) job.error = job.error || ('进程退出 code=' + code); + if (job.result) { + // 体检结果直接落到账号状态(余额/体检时间), 前端刷新即可见 + try { + const a = ensureAccount(job.result.email); + if (job.result.balance != null) { a.balance = job.result.balance; a.balanceTs = Date.now(); } + const first = Object.values(job.result.platforms || {})[0] || {}; + a.lastStatus = job.result.loggedIn ? 'success' : 'failed'; + a.lastError = job.result.loggedIn ? null : ('登录体检未通过: ' + (first.reason || 'unknown')); + a.lastCheckTs = Date.now(); + saveState(); + } catch (e) { /* ignore */ } + } + }); + return { ok: true, id, email }; +} + +function loginCheckStatus() { + const jobs = [...loginCheckJobs.values()].sort((a, b) => b.startedAt - a.startedAt).slice(0, 6); + return { + jobs: jobs.map(j => ({ + id: j.id, accountIndex: j.accountIndex, email: j.email, status: j.status, + startedAt: j.startedAt, finishedAt: j.finishedAt || null, + error: j.error || null, result: j.result || null, + lastLog: j.logs.length ? j.logs[j.logs.length - 1] : null, + })), + running: jobs.some(j => j.status === 'running'), + }; +} + +// ============ 人工登录(Xvfb + x11vnc + websockify + 上游 manualLogin) ============ +// 无头容器里拉起有头浏览器, 经 noVNC(带一次性 token 代理)让用户在内网页面里完成微软验证 +const ML_DISPLAY = ':99'; +const ML_VNC_PORT = 5999; +const ML_WS_PORT = 6080; +const ML_NOVNC_WEB = '/usr/share/novnc'; +const ML_WATCHDOG_MS = 20 * 60 * 1000; +let manualLoginSession = null; + +function mlPushLog(session, text) { + for (const line of String(text).split('\n')) { + const t = line.trim(); + if (!t) continue; + session.logs.push(t); + } + if (session.logs.length > 60) session.logs.splice(0, session.logs.length - 60); +} + +function mlCleanupProcs(session) { + for (const key of ['runner', 'websockify', 'x11vnc', 'xvfb']) { + const p = session.procs[key]; + if (!p) continue; + try { p.kill('SIGKILL'); } catch (e) { /* ignore */ } + session.procs[key] = null; + } +} + +function mlStop(session, reason) { + if (!session || session.stopped) return; + session.stopped = true; + session.stopReason = reason || null; + clearTimeout(session.watchdog); + try { session.procs.runner && session.procs.runner.kill('SIGTERM'); } catch (e) { /* ignore */ } + setTimeout(() => mlCleanupProcs(session), 2500); +} + +async function startManualLogin(accountIndex) { + if (manualLoginSession && !manualLoginSession.done && !manualLoginSession.stopped) { + return { error: '已有人工登录会话进行中(同一时间仅支持一个)', code: 'BUSY' }; + } + const accs = await fetchJson('/accounts'); + const acc = (((accs || {}).accounts) || []).find(a => a.index === accountIndex); + if (!acc) return { error: '未知账号序号: ' + accountIndex, code: 'BAD_REQUEST' }; + const email = acc.email; + + const procs = {}; + const session = manualLoginSession = { + token: crypto.randomBytes(16).toString('hex'), + accountIndex, email, startedAt: Date.now(), + logs: [], procs, stopped: false, done: false, + }; + // 上次会话若被强杀会残留 X 锁文件, 导致 Xvfb 起不来("Server is already active") + for (const stale of ['/tmp/.X99-lock', '/tmp/.X11-unix/X99']) { + try { fs.rmSync(stale, { force: true }); } catch (e) { /* ignore */ } + } + procs.xvfb = spawn('Xvfb', [ML_DISPLAY, '-screen', '0', '1280x900x24', '-nolisten', 'tcp'], { env: process.env }); + await new Promise(r => setTimeout(r, 1200)); + procs.x11vnc = spawn('x11vnc', + ['-display', ML_DISPLAY, '-rfbport', String(ML_VNC_PORT), '-listen', '127.0.0.1', '-nopw', '-forever', '-shared', '-quiet'], + { env: process.env }); + procs.websockify = spawn('websockify', + ['--web', ML_NOVNC_WEB, '127.0.0.1:' + ML_WS_PORT, '127.0.0.1:' + ML_VNC_PORT], + { env: process.env }); + procs.runner = spawn('node', + [path.join(PROJECT_ROOT, 'scripts/main/manualLogin.js'), '--email', email, '--platform', 'both'], + { cwd: PROJECT_ROOT, env: Object.assign({}, process.env, { DISPLAY: ML_DISPLAY }) }); + + session.watchdog = setTimeout(() => { + mlPushLog(session, '[dashboard] 会话超时(20 分钟), 自动结束'); + mlStop(session, 'timeout'); + }, ML_WATCHDOG_MS); + for (const name of ['xvfb', 'x11vnc', 'websockify']) { + procs[name].on('exit', (code) => mlPushLog(session, '[dashboard] ' + name + ' 退出 code=' + code)); + } + const feed = (c) => mlPushLog(session, c.toString()); + procs.runner.stdout.on('data', feed); + procs.runner.stderr.on('data', feed); + procs.runner.on('exit', (code) => { + session.done = true; + session.runnerExit = code; + mlPushLog(session, '[dashboard] manualLogin 退出 code=' + code + (code === 0 ? '(会话已保存)' : '(未保存完整登录)')); + if (code !== 0) { + // 找出 runner 的报错行, 透出到状态接口方便定位 + const errLine = [...session.logs].reverse().find(l => /ERROR|error|Failed|failed|doesn't exist|not found/i.test(l)); + session.failLog = errLine || session.logs[session.logs.length - 2] || null; + } + setTimeout(() => { mlCleanupProcs(session); }, 2000); + }); + log('INFO', '人工登录会话启动: ' + email + ' (token=' + session.token.slice(0, 8) + '...)'); + return { ok: true, token: session.token, email }; +} + +function manualLoginStatus() { + const s = manualLoginSession; + if (!s) return { active: false }; + const active = !s.done && !s.stopped; + return { + active, + done: s.done, + stopped: s.stopped, + email: s.email, + startedAt: s.startedAt, + token: active ? s.token : null, + runnerExit: s.runnerExit != null ? s.runnerExit : null, + failLog: s.failLog || null, + lastLog: s.logs.length ? s.logs[s.logs.length - 1] : null, + }; +} + +function handleNovncHttp(req, res) { + const url = new URL(req.url, 'http://localhost'); + const m = url.pathname.match(/^\/manual-vnc\/([0-9a-f]{32})(\/.*)?$/); + if (!m || !manualLoginSession || manualLoginSession.token !== m[1] || manualLoginSession.stopped || manualLoginSession.done) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + return res.end('无有效人工登录会话'); + } + const suffix = (m[2] || '/vnc.html') + (url.search || ''); + const up = http.request( + { hostname: '127.0.0.1', port: ML_WS_PORT, path: suffix, method: req.method, headers: Object.assign({}, req.headers, { host: '127.0.0.1:' + ML_WS_PORT }) }, + (r) => { res.writeHead(r.statusCode, r.headers); r.pipe(res); }); + up.on('error', () => { + try { res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('noVNC 未就绪'); } catch (e) { /* ignore */ } + }); + req.pipe(up); +} + +function handleUpgrade(req, socket, head) { + try { + const url = new URL(req.url, 'http://localhost'); + const m = url.pathname.match(/^\/manual-vnc\/([0-9a-f]{32})\/websockify$/); + if (!m || !manualLoginSession || manualLoginSession.token !== m[1] || manualLoginSession.stopped || manualLoginSession.done) { + socket.destroy(); + return; + } + const upstream = net.connect(ML_WS_PORT, '127.0.0.1', () => { + const prefix = '/manual-vnc/' + m[1]; + const lines = [req.method + ' ' + url.pathname.slice(prefix.length) + ' HTTP/1.1']; + for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(req.rawHeaders[i] + ': ' + req.rawHeaders[i + 1]); + socket.setTimeout(0); + socket.setNoDelay(true); + upstream.write(lines.join('\r\n') + '\r\n\r\n'); + if (head && head.length) upstream.write(head); + upstream.pipe(socket); + socket.pipe(upstream); + }); + upstream.on('error', () => socket.destroy()); + socket.on('error', () => upstream.destroy()); + } catch (e) { + try { socket.destroy(); } catch (e2) { /* ignore */ } + } +} + +// ============ 账号管理(动态账号, config/accounts.extra.json) ============ +// 与 fork 的动态账号源同文件: 添加/删除后, 下一次运行(cron 或手动)自动生效, 无需重建容器 +const EXTRA_ACCOUNTS_FILE = process.env.EXTRA_ACCOUNTS_FILE || path.join(CONFIG_DIR, '..', 'accounts.extra.json'); + +function readExtraFile() { + try { + if (!fs.existsSync(EXTRA_ACCOUNTS_FILE)) return []; + const parsed = JSON.parse(fs.readFileSync(EXTRA_ACCOUNTS_FILE, 'utf8')); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + log('ERROR', '读取动态账号文件失败: ' + e.message); + return []; + } +} + +function writeExtraFile(list) { + try { + fs.writeFileSync(EXTRA_ACCOUNTS_FILE, JSON.stringify(list, null, 2), { mode: 0o600 }); + try { fs.chmodSync(EXTRA_ACCOUNTS_FILE, 0o600); } catch (e) { /* ignore */ } + return true; + } catch (e) { + log('ERROR', '写入动态账号文件失败: ' + e.message); + return false; + } +} + +// ============ HTTP 服务(8300) ============ +function json(res, code, obj) { + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(obj)); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let b = ''; + req.on('data', (c) => { + b += c; + if (b.length > 1024 * 1024) { req.destroy(); reject(new Error('body too large')); } + }); + req.on('end', () => { try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(e); } }); + req.on('error', reject); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + const p = url.pathname.replace(/\/+$/, '') || '/'; + + try { + // ---- API ---- + if (p === '/api/points') return proxyContainerApi('/points', 'GET', null, res); + if (p === '/api/accounts') return proxyContainerApi('/accounts', 'GET', null, res); + if (p === '/api/schedule') return proxyContainerApi('/schedule', 'GET', null, res); + if (p === '/api/status') return proxyContainerApi('/status', 'GET', null, res); + if (p === '/api/history') { + // 合并容器 history 与 dashboard 自身持久化 runs + const container = await new Promise((resolve) => { + if (!API_TOKEN) return resolve(null); + const req2 = http.get({ hostname: '127.0.0.1', port: API_PORT, path: '/history', headers: { Authorization: 'Bearer ' + API_TOKEN } }, (r) => { + let b = ''; + r.on('data', (c) => (b += c)); + r.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { resolve(null); } }); + }); + req2.on('error', () => resolve(null)); + }); + return json(res, 200, { + dashboard: state.runs, + container: container, + containerInMemory: container ? container.inMemoryOnly : true, + }); + } + + if (p === '/api/push-config' && req.method === 'GET') { + return json(res, 200, pushConfig); + } + if (p === '/api/push-config' && req.method === 'PUT') { + const body = await readBody(req); + const merged = Object.assign(defaultPushConfig(), pushConfig, { + dingtalk: Object.assign({}, pushConfig.dingtalk, body.dingtalk), + events: Object.assign({}, pushConfig.events, body.events), + dedupSeconds: body.dedupSeconds != null ? body.dedupSeconds : pushConfig.dedupSeconds, + }); + pushConfig = merged; + savePushConfig(); + return json(res, 200, { ok: true, config: pushConfig }); + } + if (p === '/api/push-test' && req.method === 'POST') { + const r = await sendDingTalk(`[${pushConfig.dingtalk.keyword || 'MS-Rewards'}] 🧪 测试推送\n仪表盘推送配置测试成功 ✅\n(${new Date().toLocaleString('zh-CN')})`); + return json(res, 200, r); + } + if (p === '/api/start' && req.method === 'POST') { + // body: {} = 全部账号 | { accountIndex: N } = 单账号 | { indexes: [..] } = 选中账号(转换为排除未选) + let body = {}; + try { body = await readBody(req); } catch (e) { body = {}; } + let payload = {}; + if (Array.isArray(body.indexes) && body.indexes.length) { + const sel = [...new Set(body.indexes.map(Number))].filter(i => Number.isSafeInteger(i) && i >= 1); + if (!sel.length) return json(res, 400, { error: 'indexes 无效' }); + const accs = await fetchJson('/accounts'); + const all = (((accs || {}).accounts) || []).map(a => a.index).sort((a, b) => a - b); + if (!all.length) return json(res, 502, { error: '无法读取账号列表' }); + const unknown = sel.filter(i => !all.includes(i)); + if (unknown.length) return json(res, 400, { error: '未知账号序号: ' + unknown.join(',') }); + if (sel.length >= all.length) payload = {}; // 全选 = 全量 + else payload = { excludedAccountIndexes: all.filter(i => !sel.includes(i)) }; + } else { + const idx = Number(body.accountIndex); + if (Number.isSafeInteger(idx) && idx >= 1) payload = { accountIndex: idx }; + } + return proxyContainerApi('/start', 'POST', payload, res); + } + if (p === '/api/stop' && req.method === 'POST') { + return proxyContainerApi('/stop', 'POST', {}, res); + } + if (p === '/api/overview') { + // 聚合概览: 一次返回前端所需的全部数据(账号/余额/今日获得/运行状态/调度/历史) + // 容器 API 不可达时仍回退 state.json, 保证打开页面即可见上次已知数据 + const [points, accounts, schedule] = await Promise.all([ + fetchJson('/points'), + fetchJson('/accounts'), + fetchJson('/schedule'), + ]); + // 实时余额同步进 state(仅变化时写盘) + let dirty = false; + const liveAccounts = (points && points.accounts) || []; + for (const la of liveAccounts) { + if (!la.email || la.balance == null) continue; + const a = ensureAccount(la.email); + if (a.balance !== la.balance) { a.balance = la.balance; a.balanceTs = Date.now(); dirty = true; } + } + if (dirty) saveState(); + const mapStatus = (st, live, runningNow) => { + if (runningNow) return 'running'; + if (st.lastStatus) return st.lastStatus; + if (st.success === true) return 'success'; + if (st.success === false) return 'failed'; + if (live && live.done && live.success === true) return 'success'; + if (live && live.done && live.success === false) return 'failed'; + return 'pending'; + }; + const build = (index, email, st, live) => { + st = st || {}; + live = live || {}; + const runningNow = !!(points && points.running && points.currentAccount === email); + return { + index: index, + email: email, + balance: st.balance != null ? st.balance : (live.balance != null ? live.balance : null), + balanceTs: st.balanceTs || null, + todayCollected: st.todayCollected || 0, + liveCollected: (points && points.running && live.collected != null) ? live.collected : null, + status: mapStatus(st, live, runningNow), + lastError: st.lastError || live.error || null, + lastRunTs: st.lastRunTs || null, + }; + }; + let all; + if (accounts && accounts.accounts && accounts.accounts.length) { + all = accounts.accounts.map(acc => Object.assign( + build(acc.index, acc.email, state.accounts[acc.email], liveAccounts.find(x => x.email === acc.email)), + { extra: Boolean(acc.extra) } + )); + } else { + // 容器 API 不可达: 用持久化 state 兜底 + all = Object.keys(state.accounts) + .filter(em => em && em.indexOf('@') > 0) + .map((em, i) => build(i + 1, em, state.accounts[em], null)); + } + const summary = { + count: all.length, + totalBalance: all.reduce((s, a) => s + (a.balance || 0), 0), + totalToday: all.reduce((s, a) => s + (a.todayCollected || 0), 0), + }; + return json(res, 200, { + today: todayStr(), + serverTime: Date.now(), + run: points ? { + state: points.state, running: points.running, + currentAccount: points.currentAccount, startedAt: points.startedAt, + collected: points.collected, finished: points.finished, + accountsTotal: points.accountsTotal, + } : null, + accounts: all, + summary: summary, + schedule: schedule, + history: state.runs.slice(0, 10), + }); + } + + if (p === '/api/login-check' && req.method === 'POST') { + let body = {}; + try { body = await readBody(req); } catch (e) { body = {}; } + const index = Number(body.accountIndex); + if (!Number.isSafeInteger(index) || index < 1) return json(res, 400, { error: 'accountIndex 无效' }); + const accs = await fetchJson('/accounts'); + const acc = (((accs || {}).accounts) || []).find(a => a.index === index); + if (!acc) return json(res, 400, { error: '未知账号序号: ' + index }); + const r = startLoginCheck(index, acc.email); + if (r.error) return json(res, r.code === 'BUSY' ? 409 : 400, r); + return json(res, 202, r); + } + if (p === '/api/login-check/status' && req.method === 'GET') { + return json(res, 200, loginCheckStatus()); + } + if (p === '/api/manual-login' && req.method === 'POST') { + let body = {}; + try { body = await readBody(req); } catch (e) { body = {}; } + const index = Number(body.accountIndex); + if (!Number.isSafeInteger(index) || index < 1) return json(res, 400, { error: 'accountIndex 无效' }); + const r = await startManualLogin(index); + if (r.error) return json(res, r.code === 'BUSY' ? 409 : 400, r); + return json(res, 202, r); + } + if (p === '/api/manual-login/status' && req.method === 'GET') { + return json(res, 200, manualLoginStatus()); + } + if (p === '/api/manual-login/stop' && req.method === 'POST') { + if (!manualLoginSession) return json(res, 409, { error: '无进行中的人工登录会话' }); + mlStop(manualLoginSession, 'user-stopped'); + return json(res, 202, { stopping: true }); + } + if (p.startsWith('/manual-vnc/')) { + return handleNovncHttp(req, res); + } + + if (p === '/api/accounts-manage' && req.method === 'GET') { + return json(res, 200, { + accounts: readExtraFile().map(e => ({ + email: e.email, + geoLocale: e.geoLocale || 'auto', + hasPassword: Boolean(e.password), + hasTotp: Boolean(e.totpSecret), + })), + }); + } + if (p === '/api/accounts-manage' && req.method === 'POST') { + let body = {}; + try { body = await readBody(req); } catch (e) { body = {}; } + const email = String(body.email || '').trim().toLowerCase(); + const password = String(body.password || ''); + const geoLocale = String(body.geoLocale || 'gb').trim() || 'gb'; + const totpSecret = String(body.totpSecret || '').trim(); + if (!/^\S+@\S+\.\S+$/.test(email)) return json(res, 400, { error: '邮箱格式不正确' }); + if (!password) return json(res, 400, { error: '密码不能为空' }); + const list = readExtraFile(); + if (list.some(e => String(e.email || '').toLowerCase() === email)) { + return json(res, 409, { error: '该账号已在动态列表中' }); + } + const accs = await fetchJson('/accounts'); + const envEmails = (((accs || {}).accounts) || []).map(a => String(a.email || '').toLowerCase()); + if (envEmails.includes(email)) return json(res, 409, { error: '该账号已作为固定账号(环境变量)配置' }); + list.push({ email, password, geoLocale, langCode: 'en', totpSecret }); + if (!writeExtraFile(list)) return json(res, 500, { error: '写入失败' }); + return json(res, 200, { ok: true, count: list.length }); + } + if (p === '/api/accounts-manage/delete' && req.method === 'POST') { + let body = {}; + try { body = await readBody(req); } catch (e) { body = {}; } + const email = String(body.email || '').trim().toLowerCase(); + const list = readExtraFile(); + const next = list.filter(e => String(e.email || '').toLowerCase() !== email); + if (next.length === list.length) return json(res, 404, { error: '未找到该动态账号' }); + if (!writeExtraFile(next)) return json(res, 500, { error: '写入失败' }); + return json(res, 200, { ok: true, count: next.length }); + } + + // ---- 前端页面 ---- + if (p === '/') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(HTML); + } + if (p === '/favicon.ico') { + res.writeHead(204); return res.end(); + } + + return json(res, 404, { error: 'not found' }); + } catch (e) { + return json(res, 500, { error: e.message }); + } +}); + +function fetchJson(apiPath) { + return new Promise((resolve) => { + if (!API_TOKEN) return resolve(null); + const req = http.get( + { hostname: '127.0.0.1', port: API_PORT, path: apiPath, headers: { Authorization: 'Bearer ' + API_TOKEN } }, + (r) => { + let b = ''; + r.on('data', (c) => (b += c)); + r.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { resolve(null); } }); + } + ); + req.on('error', () => resolve(null)); + req.setTimeout(8000, () => { req.destroy(); resolve(null); }); // 防容器 API 挂起导致页面永久"连接中" + }); +} + +// ============ 前端页面 ============ +// 注意: 本 HTML 位于反引号模板字符串内 —— 内联 JS 禁用反引号/模板插值/${ 与反斜杠转义(含正则与 \n), +// 否则会被模板字面量二次解释导致下发页面语法错误(曾因此整页 JS 失效)。 +const HTML = ` + + + + +MS-Rewards 积分看板 + + + +
+
+

MS-Rewards 积分看板

+
+ 连接中… + 空闲 + + +
+
+ +
+ +
+
账号总数
-
+
总余额(上次已知)
-
+
今日获得(-)
-
+
当前运行
-
+
+ +
+ + 未选择账号时默认运行全部 +
+ + +
+ +
+ + + + + + + + + + + + + +
#账号余额今日获得状态最近活动备注 / 错误操作
加载中…
+
+
余额为脚本最近一次成功运行时的已知值,打开页面即可见、无需等待运行;「今日获得」按天累计、跨天自动归零。勾选账号后点「运行」只跑选中的账号;不勾选则跑全部。
+ +
+ 📜 运行历史(最近 10 次)与调度 +
+ + + +
开始结束账号明细本次获得总余额
暂无运行记录
+
+ + +

🔔 推送设置(钉钉)

+
+
+
+
+
+
+
钉钉推送总开关
+
+
+
🚀 开始运行
+
✅ 全部完成
+
👤 账号完成
+
❌ 错误
+
⚠️ 登录/风控异常
+
📊 运行摘要(每日)
+
+
+
钉钉安全设置:自定义关键词需与机器人配置一致,或使用加签模式(填 SECRET)。修改后点「保存」即时生效,无需重启服务。
+
+ + + + +
+
+ + +

🔑 人工登录

+
容器内浏览器已打开(先移动端、后桌面端, 依次完成)。在下方窗口完成微软登录;停留在 Rewards 页面 5 秒会自动保存会话并进入下一平台, 两个平台都完成后自动结束。关闭本弹窗会中止会话(已保存的平台不受影响)。
+ +
+
+ +
+
+ + +

👥 账号管理

+
动态账号保存在 config 卷的 accounts.extra.json 中,添加/删除后下一次运行自动生效(每日定时或手动触发均可),无需重启容器。固定账号(来自 .env 环境变量)仅可查看。新增账号首次运行需要自动登录;若遇到微软身份验证卡点,可在表格对应行点 🔑 人工登录完成。
+ + + +
账号来源地区2FA操作
加载中…
+
+
+
+
+
+ +
+
+
+
+ 说明:
· 动态账号与 .env 固定账号在运行、体检、人工登录中完全等价;
· 序号从 901 起编(批量勾选运行时可见);
· 凭据明文存放于 config 卷(600 权限),请勿暴露该目录;
· 删除动态账号不会清除其已保存的登录会话,如需彻底清理请用容器 API 的 DELETE /sessions/:邮箱。 +
+
+
+ + + +
+
+ +
数据来源: microsoft-rewards-script 容器 API · 每 15 秒自动刷新 · ms-rewards-dashboard
+
+ + +`; + +// ============ 启动 ============ +if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true }); +loadPushConfig(); +loadState(); +log('INFO', 'MS-Rewards dashboard 启动, 端口 ' + PORT + ' (内网无鉴权)'); +log('INFO', '容器 API: ' + API_BASE + (API_TOKEN ? ' (token 已加载)' : ' (⚠ 未读取到 token)')); + +server.on('upgrade', handleUpgrade); + +server.listen(PORT, '0.0.0.0', () => { + log('INFO', 'HTTP 监听 0.0.0.0:' + PORT); + connectSSE(); +}); diff --git a/env.example b/env.example index 63868736..c5f5d82c 100644 --- a/env.example +++ b/env.example @@ -16,16 +16,17 @@ ACCOUNT_1_EMAIL=email@example.com #ACCOUNT_1_PASSWORD=your_password #ACCOUNT_1_TOTP_SECRET= #ACCOUNT_1_RECOVERY_EMAIL= -#ACCOUNT_1_GEO_LOCALE=auto -#ACCOUNT_1_LANG_CODE=en +# 国内账号推荐:GEO_LOCALE=CN、LANG_CODE=zh-CN(配合 china 热搜源) +ACCOUNT_1_LANG_CODE=zh-CN +ACCOUNT_1_GEO_LOCALE=CN # Browser proxy: HTTP/HTTPS/SOCKS4/SOCKS5. SOCKS proxies must not require authentication. #ACCOUNT_1_PROXY_HTTP=false #ACCOUNT_1_PROXY_URL= #ACCOUNT_1_PROXY_PORT=0 #ACCOUNT_1_PROXY_USERNAME= #ACCOUNT_1_PROXY_PASSWORD= -#ACCOUNT_1_SAVE_FINGERPRINT_MOBILE=false -#ACCOUNT_1_SAVE_FINGERPRINT_DESKTOP=false +ACCOUNT_1_SAVE_FINGERPRINT_MOBILE=true +ACCOUNT_1_SAVE_FINGERPRINT_DESKTOP=true # Account 2 (duplicate and use any positive slot number for more accounts) #ACCOUNT_2_EMAIL=email_2@example.com diff --git a/eslint.config.mjs b/eslint.config.mjs index c1e6c787..4b58b6c9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,13 @@ export default tseslint.config( 'preserve-caught-error': 'off' } }, + // scripts/ 下用 ts-node/register 桥接 src TS 的 CJS 工具脚本 + { + files: ['scripts/**/*.cjs'], + rules: { + '@typescript-eslint/no-require-imports': 'off' + } + }, // Must come last: disables ESLint rules that conflict with Prettier formatting prettier ) diff --git a/package-lock.json b/package-lock.json index 46a74a14..85554408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "otpauth": "^9.5.1", "p-queue": "^9.3.0", "patchright": "^1.61.1", + "qrcode-terminal": "^0.12.0", "semver": "^7.8.1", "ts-node": "^10.9.2", "zod": "^4.4.3" @@ -27,6 +28,7 @@ "@eslint/js": "^10.0.1", "@types/ms": "^2.1.0", "@types/node": "^24.10.1", + "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", @@ -386,10 +388,18 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmmirror.com/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", @@ -442,6 +452,7 @@ "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", @@ -632,6 +643,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -784,6 +796,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -964,6 +977,7 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -1479,9 +1493,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1498,9 +1509,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1517,9 +1525,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1536,9 +1541,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1983,6 +1985,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2058,6 +2061,14 @@ "node": ">=6" } }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -2237,6 +2248,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 9e736bc9..17c6c305 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "clear-diagnostics": "rimraf diagnostics", "clear-sessions": "node ./scripts/main/clearSessions.js", "manual-login": "node ./scripts/main/manualLogin.js", - "open-session": "node ./scripts/main/browserSession.js -email" + "open-session": "node ./scripts/main/browserSession.js -email", + "clawbot:login": "node -r ts-node/register/transpile-only ./scripts/main/clawbotLogin.cjs", + "test:clawbot-smoke": "node -r ts-node/register/transpile-only ./scripts/tests/smokeClawbot.cjs" }, "keywords": [ "Bing Rewards", @@ -39,6 +41,7 @@ "@eslint/js": "^10.0.1", "@types/ms": "^2.1.0", "@types/node": "^24.10.1", + "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", @@ -59,6 +62,7 @@ "otpauth": "^9.5.1", "p-queue": "^9.3.0", "patchright": "^1.61.1", + "qrcode-terminal": "^0.12.0", "semver": "^7.8.1", "ts-node": "^10.9.2", "zod": "^4.4.3" diff --git a/run.bat b/run.bat new file mode 100644 index 00000000..6f86d664 --- /dev/null +++ b/run.bat @@ -0,0 +1,6 @@ +:: 运行脚本 +:: 更改下方目录为项目目录 +chcp 65001 >nul +%~d0 +cd %~dp0 +call npm start diff --git a/scripts/api/accounts.js b/scripts/api/accounts.js index 7b793205..6ddd6663 100644 --- a/scripts/api/accounts.js +++ b/scripts/api/accounts.js @@ -1,4 +1,8 @@ import { accountIndexesFromEnv, envStrFrom, normalizeGeoLocale, normalizeLanguageCode } from '../env.js' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadExtraAccounts } from '../utils.js' function sanitizeProxyUrl(value) { try { @@ -11,6 +15,23 @@ function sanitizeProxyUrl(value) { } } + +// 动态账号(看板"账号管理"维护): config/accounts.extra.json, 索引从 901 起, +// 运行/展示/单账号与排除运行都会无重启感知 +const apiProjectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const EXTRA_BASE_INDEX = 901 +const extraCache = new Map() + +function extraAccountEnvVars(entry, index) { + const prefix = `ACCOUNT_${index}_` + const env = { [`${prefix}EMAIL`]: entry.email } + if (entry.password) env[`${prefix}PASSWORD`] = entry.password + if (entry.geoLocale) env[`${prefix}GEO_LOCALE`] = entry.geoLocale + if (entry.langCode) env[`${prefix}LANG_CODE`] = entry.langCode + if (entry.totpSecret) env[`${prefix}TOTP_SECRET`] = entry.totpSecret + return env +} + export function loadAccounts(sourceEnv = process.env) { const accounts = [] @@ -39,6 +60,23 @@ export function loadAccounts(sourceEnv = process.env) { : null }) } + + extraCache.clear() + loadExtraAccounts(apiProjectRoot).forEach((entry, i) => { + const index = EXTRA_BASE_INDEX + i + extraCache.set(index, entry) + accounts.push({ + index, + email: entry.email, + emailKey: entry.email, + geoLocale: entry.geoLocale, + langCode: entry.langCode, + hasRecoveryEmail: Boolean(entry.recoveryEmail), + hasTotp: Boolean(entry.totpSecret), + proxy: null, + extra: true + }) + }) return accounts } @@ -50,6 +88,22 @@ export function buildSingleAccountEnv(accountIndex, sourceEnv = process.env) { throw err } + if (index >= EXTRA_BASE_INDEX) { + const extra = loadExtraAccounts(apiProjectRoot) + const entry = extra[index - EXTRA_BASE_INDEX] + if (!entry) { + const err = new Error(`ACCOUNT_${index} is not configured.`) + err.code = 'BAD_REQUEST' + throw err + } + const env = {} + for (const key of Object.keys(sourceEnv)) { + if (/^ACCOUNT_\d+_/.test(key)) env[key] = '' + } + Object.assign(env, extraAccountEnvVars(entry, 1)) + return { env, account: { index, email: entry.email } } + } + const selectedPrefix = `ACCOUNT_${index}_` const selected = Object.entries(sourceEnv).filter(([key]) => key.startsWith(selectedPrefix)) const email = envStrFrom(sourceEnv, `${selectedPrefix}EMAIL`) @@ -117,6 +171,13 @@ export function buildExcludedAccountsEnv(excludedAccountIndexes, sourceEnv = pro included.forEach((account, position) => { const sourcePrefix = `ACCOUNT_${account.index}_` const targetPrefix = `ACCOUNT_${position + 1}_` + if (account.extra) { + const generated = extraAccountEnvVars(extraCache.get(account.index) || {}, account.index) + for (const [key, value] of Object.entries(generated)) { + env[`${targetPrefix}${key.slice(sourcePrefix.length)}`] = value + } + return + } for (const [key, value] of Object.entries(sourceEnv)) { if (!key.startsWith(sourcePrefix)) continue env[`${targetPrefix}${key.slice(sourcePrefix.length)}`] = value diff --git a/scripts/api/logParser.js b/scripts/api/logParser.js index 2c6bada6..bfdb4402 100644 --- a/scripts/api/logParser.js +++ b/scripts/api/logParser.js @@ -95,23 +95,26 @@ function ensureAccount(state, email) { return state.accounts[email] } +// All patterns adapted for the Chinese-localized fork of Microsoft-Rewards-Script. +// The field names (offers=, pointsGained=, currentBalance=, etc.) remain in English +// inside the log messages, but the surrounding text is Chinese. const RE = { - runStart: /^Starting Microsoft Rewards Script \| v(\S+) \| Accounts: (\d+) \| Clusters: (\d+)/, + runStart: /^启动微软奖励脚本 \| v(\S+) \| 账户数: (\d+) \| 集群数: (\d+)/, accountStart: - /^Starting account: (\S+) \| geoLocale: ([^|]+?)(?: \| locale: (\S+))?(?: \| cachedRegion: (\S+))?\s*$/, - earnable: /^Earnable today \| Mobile: (\d+) \| Browser: (\d+) \| App: (\d+) \| (\S+) \| locale: (\S+)\s*$/, - searchSummary: /^Search summary \| mobile=(-?\d+) \| desktop=(-?\d+) \| bonus=(-?\d+) \| total=(-?\d+)/, + /^开始处理账户: (\S+) \| geoLocale: ([^|]+?)(?: \| locale: (\S+))?(?: \| 缓存区域: (\S+))?\s*$/, + earnable: /^今日可赚取 \| 移动端: (\d+) \| 浏览器: (\d+) \| App: (\d+) \| (\S+) \| locale: (\S+)\s*$/, + searchSummary: /^搜索汇总 \| 移动端=(-?\d+) \| 桌面端=(-?\d+) \| 额外=(-?\d+) \| 总计=(-?\d+)/, streakProtection: - /^Snapshot complete \| offers=(\d+) \| reportable=(\d+) \| streaks=(\d+) \| streakProtectionEnabled=(true|false|null) \| streakProtectionRemainingDays=(\d+|null) \| streakCounter=(\d+|null) \| level=([^|]+) \| account=(\S+@\S+)$/, + /^快照完成 \| offers=(\d+) \| 可上报=(\d+) \| streaks=(\d+) \| 连续保护已启用=(true|false|null) \| 连续保护剩余天数=(\d+|null) \| 连续计数=(\d+|null) \| 等级=([^|]+) \| 账户=(\S+@\S+)$/, accountEnd: - /^Completed account: (\S+) \| pointsGained=(-?\d+) \| previousBalance=(\d+) \| currentBalance=(\d+) \| durationSeconds=([\d.]+)/, - runEnd: /^Completed all accounts \| accountsProcessed=(\d+) \| pointsGained=(-?\d+) \| previousBalance=(\d+) \| currentBalance=(\d+) \| runtimeMinutes=([\d.]+)/, - accountError: /^(\S+@\S+): ([\s\S]+)$/, - flowFailed: /flow failed for (\S+@\S+):/i, - accountDelay: /^Waiting ([\d.]+) seconds before starting the next account(?: \((\S+@\S+)\))?$/, - - searchStart: /^Starting Bing searches \| currentBalance=(\d+)/, - flowCollected: /^Points collected \| pointsGained=(-?\d+) \| currentBalance=(\d+) \| account=(\S+@\S+)/ + /^账户完成: (\S+) \| 获得积分=(-?\d+) \| 原余额=(\d+) \| 现余额=(\d+) \| 持续秒数=([\d.]+)/, + runEnd: /^全部账户完成 \| 处理账户数=(\d+) \| 获得积分=(-?\d+) \| 原余额=(\d+) \| 现余额=(\d+) \| 运行分钟数=([\d.]+)/, + accountError: /^(\S+@\S+) \| 错误=([\s\S]+)$/, + flowFailed: /(\S+@\S+) 的.*流程失败:/i, + accountDelay: /^等待 ([\d.]+) 秒后开始下一个账户(?: \((\S+@\S+)\))?$/, + + searchStart: /^开始必应搜索 \| 当前余额=(\d+)/, + flowCollected: /^积分已收集 \| 获得积分=(-?\d+) \| 现余额=(\d+) \| 账户=(\S+@\S+)/ } function numericField(message, name) { @@ -135,33 +138,38 @@ function accountEmailForEntry(state, entry) { function pointEventSource(title, message) { switch (title) { case 'SEARCH-BING': - return message.startsWith('pointsGained=') ? 'search' : null + return message.startsWith('必应搜索完成') ? 'search' : null case 'SEARCH-BONUS': - return message.startsWith('pointsGained=') ? 'bonus' : null + return message.startsWith('必应搜索完成') ? 'bonus' : null case 'READ-TO-EARN': - return message.startsWith('Read article ') || message.startsWith('No points gained,') ? 'read' : null + return (message.startsWith('文章后的积分变化') || + message.startsWith('未获得积分,停止读文赚积分') || + message.startsWith('已阅读第') || + message.startsWith('读文赚积分完成')) ? 'read' : null case 'DAILY-CHECK-IN': - return message.startsWith('Completed Daily Check-In ') || message.startsWith('Daily Check-In completed ') - ? 'checkIn' - : null + return message.startsWith('每日签到完成') ? 'checkIn' : null case 'CLAIM-BONUS-POINTS': - return message.startsWith('Completed ClaimBonusPoints ') || message.startsWith('Nothing claimed ') + return (message.startsWith('领取奖励积分完成') || message.startsWith('没有可领取的积分')) ? 'claimBonus' : null case 'CLAIM-REWARD': - return message.startsWith('Reward claimed ') ? 'claimReward' : null + return message.startsWith('奖励已领取') ? 'claimReward' : null case 'URL-REWARD': - return message.startsWith('Completed UrlReward') || message.startsWith('UrlReward credited ') + return (message.startsWith('UrlReward 完成') || message.startsWith('UrlReward 未获得积分')) ? 'urlReward' : null case 'VISUAL-SEARCH': - return message.startsWith('Daily visual search done ') ? 'visualSearch' : null + return (message.startsWith('每日视觉搜索完成') || message.startsWith('每日视觉搜索已记录')) + ? 'visualSearch' + : null case 'APP-REWARD': - return message.startsWith('Completed AppReward') ? 'appReward' : null + return (message.startsWith('AppReward 完成') || message.startsWith('AppReward 完成但未获得积分')) + ? 'appReward' + : null case 'PUNCHCARD': - return message.startsWith('Reported child ') ? 'punchcard' : null + return (message.includes('COMPLETE') || message.includes('in progress')) ? 'punchcard' : null case 'SEARCH-ON-BING-SEARCH': - return message.startsWith('SearchOnBing activity completed ') ? 'searchOnBing' : null + return message.startsWith('SearchOnBing 活动完成') ? 'searchOnBing' : null default: return null } @@ -236,7 +244,7 @@ function applyEdgeBrowsing(state, entry) { if (!account) return null const message = entry.message ?? '' - const finalReports = message.startsWith('Finished background Edge browsing activity') + const finalReports = message.startsWith('后台 Edge 浏览活动结束') ? numericField(message, 'reports') : null const progress = @@ -261,7 +269,7 @@ function applyEdgeBrowsing(state, entry) { updatedAt: null } - if (message.startsWith('Started background Edge browsing activity')) { + if (message.startsWith('开始后台 Edge 浏览活动')) { previous.status = 'running' previous.targetMinutes = numericField(message, 'targetMinutes') previous.serverIntervalMinutes = numericField(message, 'serverIntervalMinutes') @@ -270,12 +278,12 @@ function applyEdgeBrowsing(state, entry) { previous.scheduledMinutesCovered = 0 previous.estimatedRemainingMinutes = numericField(message, 'estimatedDurationMinutes') previous.waitingForBackground = false - } else if (message.startsWith('Edge browsing progress')) { + } else if (message.startsWith('Edge 浏览进度')) { previous.status = 'running' - } else if (message.startsWith('Submitted Edge browsing report')) { + } else if (message.startsWith('已提交 Edge 浏览报告')) { previous.status = 'running' previous.nextReportInSeconds = null - } else if (message.startsWith('Finished background Edge browsing activity')) { + } else if (message.startsWith('后台 Edge 浏览活动结束')) { const duplicates = numericField(message, 'duplicates') ?? previous.duplicates const failed = numericField(message, 'failed') ?? previous.failed const serverCompleteMatch = message.match(/(?:^| \| )serverComplete=(true|false)(?= \| |$)/) @@ -289,28 +297,28 @@ function applyEdgeBrowsing(state, entry) { previous.nextReportInSeconds = null previous.estimatedRemainingMinutes = 0 previous.waitingForBackground = false - } else if (message === 'Browsing Streak on Edge is already complete') { + } else if (message === 'Edge 浏览连击(Browsing Streak)已完成') { previous.status = 'complete' previous.reportsRemaining = 0 previous.nextReportInSeconds = null previous.estimatedRemainingMinutes = 0 previous.waitingForBackground = false } else if ( - message === 'Browsing Streak on Edge is not available for this account' || - message.startsWith('Skipping:') + message === '该账户无法使用 Edge 浏览连击(Browsing Streak)' || + message.startsWith('跳过:') ) { previous.status = 'skipped' previous.waitingForBackground = false } else if ( - message.startsWith('Background Edge browsing activity failed') || - message.startsWith('Unexpected background task failure') + message.startsWith('后台 Edge 浏览活动失败') || + message.startsWith('意外的后台任务失败') ) { previous.status = 'failed' previous.waitingForBackground = false - } else if (message === 'Background activity cancelled') { + } else if (message === '后台活动已取消') { previous.status = 'cancelled' previous.waitingForBackground = false - } else if (message.startsWith('Foreground activities finished;')) { + } else if (message.startsWith('前台活动已完成;')) { previous.waitingForBackground = true } else { return null @@ -529,4 +537,4 @@ export function summarizeRunState(state) { }, accounts } -} +} \ No newline at end of file diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index 6b1c4fdb..68c4e56c 100644 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -125,6 +125,22 @@ if [ "${API_MODE:-false}" = "true" ]; then echo "[entrypoint] No CRON_SCHEDULE set and no schedule.json override - runs must be triggered manually via POST /start, or scheduled from the dashboard." fi echo "[entrypoint] Starting control API on ${API_HOST}:${API_PORT:-3010} at $(date)" + + # Optional embedded web dashboard (zero-dependency Node, talks to the local control API). + # Data (push-config/state) persists under $DASHBOARD_DATA_DIR inside the mounted config volume. + if [ "${DASHBOARD_ENABLED:-true}" = "true" ]; then + export DASHBOARD_DATA_DIR="${DASHBOARD_DATA_DIR:-$SCRIPT_DIR/config/dashboard}" + mkdir -p "$DASHBOARD_DATA_DIR" + echo "[entrypoint] Starting dashboard on ${DASHBOARD_PORT:-8300} (data: $DASHBOARD_DATA_DIR)" + ( + while true; do + node "$SCRIPT_DIR/dashboard/server.js" + echo "[entrypoint] dashboard exited, restarting in 5s" >&2 + sleep 5 + done + ) & + fi + exec node scripts/api/server.js fi diff --git a/scripts/docker/healthcheck.sh b/scripts/docker/healthcheck.sh index 28391bff..a7f5e59a 100644 --- a/scripts/docker/healthcheck.sh +++ b/scripts/docker/healthcheck.sh @@ -32,3 +32,19 @@ const req = http.get({ host: '127.0.0.1', port, path: '/health', headers }, res req.setTimeout(3000, () => req.destroy(new Error('health check timed out'))) req.on('error', () => process.exit(1)) NODE + +# Also verify the embedded dashboard when enabled (it runs as a supervised child). +if [ "${DASHBOARD_ENABLED:-true}" = "true" ]; then + node --input-type=module <<'NODE' +import http from 'node:http' + +const port = Number(process.env.DASHBOARD_PORT || 8300) +const req = http.get({ host: '127.0.0.1', port, path: '/' }, res => { + res.resume() + process.exit(res.statusCode === 200 ? 0 : 1) +}) + +req.setTimeout(3000, () => req.destroy(new Error('dashboard health check timed out'))) +req.on('error', () => process.exit(1)) +NODE +fi diff --git a/scripts/mac/local.npm-start.example.plist b/scripts/mac/local.npm-start.example.plist new file mode 100644 index 00000000..8cff5c69 --- /dev/null +++ b/scripts/mac/local.npm-start.example.plist @@ -0,0 +1,37 @@ + + + + + Label + local.npm-start + + ProgramArguments + + /Users/你的用户名/scripts/run-npm-start.sh + + + StartCalendarInterval + + + Hour + 8 + Minute + 0 + + + Hour + 17 + Minute + 0 + + + + RunAtLoad + + + StandardOutPath + /tmp/npm-start-stdout.log + StandardErrorPath + /tmp/npm-start-stderr.log + + \ No newline at end of file diff --git a/scripts/mac/local.npm-start.plist b/scripts/mac/local.npm-start.plist new file mode 100644 index 00000000..4af63e54 --- /dev/null +++ b/scripts/mac/local.npm-start.plist @@ -0,0 +1,36 @@ + + + + + Label + local.npm-start + + ProgramArguments + + /Users/ohnoon/Microsoft-Rewards-Script/scripts/mac_script.sh + + StartCalendarInterval + + + Hour + 8 + Minute + 0 + + + Hour + 17 + Minute + 0 + + + + RunAtLoad + + + StandardOutPath + /tmp/npm-start-stdout.log + StandardErrorPath + /tmp/npm-start-stderr.log + + \ No newline at end of file diff --git a/scripts/mac/mac_script.sh b/scripts/mac/mac_script.sh new file mode 100644 index 00000000..056c5042 --- /dev/null +++ b/scripts/mac/mac_script.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# 设置脚本所在目录的上级目录为工作目录 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="$(dirname "$SCRIPT_DIR")" + +# 随机延迟 0~1800 秒(0 到 30 分钟) +sleep $((RANDOM % 1801)) + +# 进入上级目录并运行 npm start +cd "$WORK_DIR" || exit 1 +echo "$(date): Running npm start in $WORK_DIR" >> "$HOME/npm-start.log" +exec npm start \ No newline at end of file diff --git "a/scripts/mac/mac\347\232\204\350\277\220\350\241\214\350\204\232\346\234\254" "b/scripts/mac/mac\347\232\204\350\277\220\350\241\214\350\204\232\346\234\254" new file mode 100644 index 00000000..968a3df1 --- /dev/null +++ "b/scripts/mac/mac\347\232\204\350\277\220\350\241\214\350\204\232\346\234\254" @@ -0,0 +1,3 @@ +chmod +x ~/scripts/mac_script.sh +然后local.npm-start.plist复制到~/Library/LaunchAgents/local.npm-start.plist + diff --git a/scripts/main/clawbotLogin.cjs b/scripts/main/clawbotLogin.cjs new file mode 100644 index 00000000..b8fa289f --- /dev/null +++ b/scripts/main/clawbotLogin.cjs @@ -0,0 +1,18 @@ +// 手动触发 ClawBot 登录+激活(可选入口)。 +// 正常情况下无需手动运行:config.json 开启 clawbot 后,主任务启动时会自动完成同样流程。 +// 流程:扫码登录(如需)→ 等待在微信给「微信 ClawBot」发一条消息完成激活 → 发送验证消息 +// 运行:npm run clawbot:login +const { ensureClawBotReady, flushClawBotQueue } = require('../../src/logging/ClawBot') + +async function main() { + const ok = await ensureClawBotReady({ enabled: true }) + // 等待队列中的验证消息发出后再退出 + await flushClawBotQueue(10000) + if (ok) { + process.stdout.write('\nClawBot 推送通道已就绪,可在 config.json 中开启 webhook.clawbot.enabled\n') + process.exit(0) + } + process.exit(1) +} + +main() diff --git a/scripts/main/loginCheck.js b/scripts/main/loginCheck.js new file mode 100644 index 00000000..8c6743d3 --- /dev/null +++ b/scripts/main/loginCheck.js @@ -0,0 +1,277 @@ +/* global window */ + +/** + * Headless login health-check + live balance probe for one account. + * + * Restores the saved mobile/desktop session, opens rewards.bing.com and + * verifies the session still lands on the Rewards page (not a login redirect), + * then reads the live balance from the Rewards user-info API. Refreshed cookies + * are saved back to the session store so a passing check also extends the + * session's life. + * + * Usage: + * node scripts/main/loginCheck.js --email user@example.com [--platform mobile|desktop|both] + * + * Output: the last stdout line is machine-readable: + * LOGINCHECK_RESULT {"email":"...","balance":1234,"platforms":{...}} + * Everything before it is human-readable progress logging. + */ + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { FingerprintGenerator } from 'fingerprint-generator' +import { newInjectedContext } from 'fingerprint-injector' +import ms from 'ms' +import { chromium } from 'patchright' + +import { + buildProxyConfig, + closeSessionDb, + ensureSessionSchema, + findAccountByEmail, + getDirname, + getProjectRoot, + getSessionDbPath, + loadAccountsFromEnv, + loadConfig, + loadResolvedRegionRow, + loadSessionRow, + log, + openSessionDb, + parseArgs, + resolveEmailArgument, + saveSessionRow +} from '../utils.js' +import { isRewardsPage, parsePlatforms, resolveGlobalTimeoutMs } from './manualLogin.js' + +const REWARDS_URL = 'https://rewards.bing.com/' +const USER_INFO_PATH = '/api/getuserinfo' +const OVERALL_TIMEOUT_MS = 150000 + +const BROWSER_ARGS = [ + '--mute-audio', + '--no-first-run', + '--no-default-browser-check', + '--disable-blink-features=AutomationControlled', + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding' +] + +const __dirname = getDirname(import.meta.url) +const projectRoot = getProjectRoot(__dirname) + +function normalizeCountry(value) { + if (!value) return undefined + const country = String(value).trim().toUpperCase() + return /^[A-Z]{2}$/.test(country) ? country : undefined +} + +function resolveLocale(account, resolvedCountry) { + const languageTag = new Intl.Locale(account.langCode || 'en').toString() + const language = new Intl.Locale(languageTag).language.toLowerCase() + const configuredCountry = account.geoLocale.toLowerCase() === 'auto' ? undefined : normalizeCountry(account.geoLocale) + const country = configuredCountry ?? normalizeCountry(resolvedCountry) ?? normalizeCountry(new Intl.Locale(languageTag).region) + const locale = country ? new Intl.Locale(languageTag, { region: country }).toString() : languageTag + return { locale, acceptedLocales: [locale] } +} + +function createFingerprint(isMobile, locale) { + const hostOs = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : 'windows' + return new FingerprintGenerator().getFingerprint({ + devices: isMobile ? ['mobile'] : ['desktop'], + operatingSystems: isMobile ? ['android'] : [hostOs], + browsers: [{ name: 'edge' }], + locales: locale.acceptedLocales + }) +} + +function loadStoredSession(dbPath, email, platform) { + if (!fs.existsSync(dbPath)) return { storageState: null, fingerprint: null, resolvedCountry: undefined } + const db = openSessionDb(dbPath, { readonly: true }) + try { + let session = null + try { + session = loadSessionRow(db, email, platform) + } catch (error) { + if (!String(error?.message ?? error).includes('no such table: sessions')) throw error + } + return { + storageState: session?.storageState ?? null, + fingerprint: session?.fingerprint ?? null, + resolvedCountry: loadResolvedRegionRow(db, email) + } + } finally { + closeSessionDb(db) + } +} + +function saveCheckedSession(dbPath, email, platform, storageState, fingerprint, persistFingerprint) { + const sessionDir = path.dirname(dbPath) + fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 }) + const db = openSessionDb(dbPath) + try { + ensureSessionSchema(db) + saveSessionRow(db, email, platform, storageState, fingerprint, persistFingerprint) + } finally { + closeSessionDb(db) + } +} + +async function readBalance(page) { + // 与主脚本 BrowserFunc.getDashboardData 同源:rewards.bing.com 的 user-info API, + // 结构上兼容 {dashboard:{userStatus:{availablePoints}}} 与 {userInfo:{balance}} + return await page.evaluate(async userInfoPath => { + try { + const res = await fetch(userInfoPath, { credentials: 'include' }) + if (!res.ok) return { ok: false, status: res.status } + const json = await res.json() + const dashboard = json?.dashboard ?? {} + const points = + dashboard?.userStatus?.availablePoints ?? + json?.userInfo?.balance ?? + json?.userStatus?.availablePoints ?? + null + return { ok: true, points: points === null ? null : Number(points) } + } catch (error) { + return { ok: false, error: String(error?.message ?? error) } + } + }, USER_INFO_PATH) +} + +async function checkPlatform({ account, config, dbPath, platform }) { + const result = { platform, loggedIn: false, balance: null, cookies: 0, reason: '' } + const stored = loadStoredSession(dbPath, account.email, platform) + if (!stored.storageState || !stored.storageState.cookies?.length) { + result.reason = 'no-saved-session' + return result + } + + const isMobile = platform === 'mobile' + const locale = resolveLocale(account, stored.resolvedCountry) + const savesFingerprint = isMobile ? account.saveFingerprint.mobile : account.saveFingerprint.desktop + const fingerprint = + savesFingerprint && stored.fingerprint ? stored.fingerprint : createFingerprint(isMobile, locale) + const screen = fingerprint.fingerprint.screen + const proxy = buildProxyConfig(account) + const runningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0 + const sandboxArgs = process.platform === 'linux' && runningAsRoot ? ['--no-sandbox', '--disable-setuid-sandbox'] : [] + const globalTimeout = resolveGlobalTimeoutMs(config.globalTimeout) + + const browser = await chromium.launch({ + headless: true, + ...(proxy ? { proxy } : {}), + args: [...BROWSER_ARGS, ...sandboxArgs] + }) + + try { + const context = await newInjectedContext(browser, { + fingerprint, + newContextOptions: { + ignoreHTTPSErrors: Boolean(proxy && config.proxy?.ignoreCertificateErrors), + storageState: stored.storageState, + ...(isMobile + ? { + isMobile: true, + hasTouch: true, + deviceScaleFactor: screen.devicePixelRatio, + viewport: { width: screen.width, height: screen.height }, + screen: { width: screen.width, height: screen.height } + } + : {}) + } + }) + context.setDefaultTimeout(globalTimeout) + + const page = await context.newPage() + await page.goto(REWARDS_URL, { waitUntil: 'domcontentloaded', timeout: globalTimeout }).catch(error => { + log('WARN', `Navigation to ${REWARDS_URL} did not finish cleanly: ${error.message}`) + }) + + await page.waitForLoadState('domcontentloaded', { timeout: globalTimeout }).catch(() => {}) + const finalUrl = page.url() + if (!isRewardsPage(finalUrl)) { + result.reason = 'redirected-to-login' + result.cookies = stored.storageState.cookies?.length ?? 0 + log('WARN', `${platform} session is NOT logged in (landed on ${finalUrl})`) + return result + } + + result.loggedIn = true + result.cookies = stored.storageState.cookies?.length ?? 0 + + const balance = await readBalance(page) + if (balance.ok && balance.points != null) result.balance = balance.points + + // 回写刷新后的 cookie, 顺带续期会话 + try { + const storageState = await context.storageState() + if (storageState.cookies?.length) { + saveCheckedSession(dbPath, account.email, platform, storageState, fingerprint, savesFingerprint) + result.sessionRefreshed = true + } + } catch (error) { + log('WARN', `Failed to persist refreshed session: ${error.message}`) + } + return result + } finally { + await browser.close().catch(() => {}) + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2), { boolean: ['help', 'h'] }) + if (args.help || args.h) { + console.log('Usage: node scripts/main/loginCheck.js --email [--platform mobile|desktop|both]') + return + } + const email = resolveEmailArgument(args) + const platforms = parsePlatforms(args.platform) + + const accounts = loadAccountsFromEnv(projectRoot) + const account = findAccountByEmail(accounts, email) + if (!account) throw new Error(`No ACCOUNT_N_* block found in .env for ${email}`) + + const { data: fileConfig } = loadConfig(projectRoot) + const { dbPath } = getSessionDbPath(projectRoot, fileConfig.sessionPath) + + const overallTimer = setTimeout(() => { + log('ERROR', `loginCheck timed out after ${OVERALL_TIMEOUT_MS / 1000}s`) + process.exit(3) + }, OVERALL_TIMEOUT_MS) + overallTimer.unref?.() + + const platformsResult = {} + for (const platform of platforms) { + log('INFO', `Checking ${platform} session for ${account.email}...`) + try { + platformsResult[platform] = await checkPlatform({ account, config: fileConfig, dbPath, platform }) + } catch (error) { + log('ERROR', `${platform} check failed: ${error.message}`) + platformsResult[platform] = { platform, loggedIn: false, balance: null, reason: 'error', error: error.message } + } + } + + const values = Object.values(platformsResult) + const balance = values.map(r => r.balance).find(v => v != null) ?? null + const loggedIn = values.some(r => r.loggedIn) + const payload = { + email: account.email, + balance, + loggedIn, + platforms: platformsResult, + checkedAt: new Date().toISOString() + } + console.log('LOGINCHECK_RESULT ' + JSON.stringify(payload)) +} + +const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +if (isDirectRun) { + main().catch(error => { + log('ERROR', `loginCheck failed: ${error?.message ?? error}`) + process.exitCode = 1 + }) +} diff --git a/scripts/tests/clawbotDiag.cjs b/scripts/tests/clawbotDiag.cjs new file mode 100644 index 00000000..c6818dd7 --- /dev/null +++ b/scripts/tests/clawbotDiag.cjs @@ -0,0 +1,48 @@ +/* 诊断:带 context_token 真实发送 + getupdates。运行:node -r ts-node/register/transpile-only scripts/tests/clawbotDiag.cjs */ +const { randomBytes } = require('crypto') +const { httpRequest } = require('../../src/util/Http') + +const auth = require(require('path').resolve('clawbot-auth.json')) +const BASE = 'https://ilinkai.weixin.qq.com' + +function headers() { + return { + 'Content-Type': 'application/json', + AuthorizationType: 'ilink_bot_token', + 'X-WECHAT-UIN': Buffer.from(String(randomBytes(4).readUInt32BE(0)), 'utf-8').toString('base64'), + 'iLink-App-Id': 'bot', + 'iLink-App-ClientVersion': String((2 << 16) | (4 << 8) | 3), + Authorization: `Bearer ${auth.token}` + } +} + +async function diag() { + console.log('contextToken present:', Boolean(auth.contextToken)) + console.log('=== sendmessage(带 context_token)===') + try { + const r = await httpRequest({ + method: 'POST', + url: `${BASE}/ilink/bot/sendmessage`, + headers: headers(), + data: { + msg: { + from_user_id: '', + to_user_id: auth.userId, + client_id: `openclaw-weixin-${randomBytes(16).toString('hex')}`, + message_type: 2, + message_state: 2, + ...(auth.contextToken ? { context_token: auth.contextToken } : {}), + item_list: [{ type: 1, text_item: { text: '🔧 ClawBot 发送链路验证:收到此消息说明推送正常' } }] + }, + base_info: { channel_version: '2.4.3', bot_agent: 'microsoft-rewards-script/4.3.0' } + }, + timeout: 15000, + responseType: 'text' + }) + console.log('HTTP', r.status, '| body:', String(r.data).slice(0, 300)) + } catch (e) { + console.log('SEND FAIL:', e?.message, '| status:', e?.status, '| body:', String(e?.response?.data ?? '').slice(0, 300)) + } +} + +diag() diff --git a/scripts/tests/smokeClawbot.cjs b/scripts/tests/smokeClawbot.cjs new file mode 100644 index 00000000..814522a5 --- /dev/null +++ b/scripts/tests/smokeClawbot.cjs @@ -0,0 +1,37 @@ +/* 冒烟测试:验证 iLink 接口连通性与请求头构造(不扫码、不写凭证) + 运行:npm run test:clawbot-smoke */ +const { randomBytes } = require('crypto') +const { httpRequest } = require('../../src/util/Http') + +async function main() { + const uin = Buffer.from(String(randomBytes(4).readUInt32BE(0)), 'utf-8').toString('base64') + const resp = await httpRequest({ + method: 'POST', + url: 'https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3', + headers: { + 'Content-Type': 'application/json', + AuthorizationType: 'ilink_bot_token', + 'X-WECHAT-UIN': uin, + 'iLink-App-Id': 'bot', + 'iLink-App-ClientVersion': String((2 << 16) | (4 << 8) | 3) + }, + data: { local_token_list: [] }, + timeout: 15000 + }) + const d = resp.data || {} + console.log('status:', resp.status) + console.log('has qrcode:', Boolean(d.qrcode)) + console.log('img url head:', String(d.qrcode_img_content || '').slice(0, 60)) + console.log('ret:', d.ret, 'errmsg:', d.errmsg) + + if (resp.status !== 200 || !d.qrcode) { + console.error('SMOKE FAIL') + process.exit(1) + } + console.log('SMOKE OK') +} + +main().catch(err => { + console.error('FAIL:', err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/scripts/utils.js b/scripts/utils.js index 58818d08..074cc40a 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -231,7 +231,36 @@ export function loadAccountsFromEnv(projectRoot) { }) } - return accounts + return accounts.concat(loadExtraAccounts(projectRoot)) +} + +export function getExtraAccountsPath(projectRoot) { + return path.join(projectRoot, 'config', 'accounts.extra.json') +} + +// 动态账号(看板"账号管理"维护, 免重启): config/accounts.extra.json +export function loadExtraAccounts(projectRoot) { + const filePath = getExtraAccountsPath(projectRoot) + try { + if (!fs.existsSync(filePath)) return [] + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) + if (!Array.isArray(parsed)) return [] + return parsed + .filter(entry => entry && typeof entry.email === 'string' && entry.email.trim()) + .map(entry => ({ + email: entry.email.trim(), + password: typeof entry.password === 'string' ? entry.password : '', + totpSecret: typeof entry.totpSecret === 'string' ? entry.totpSecret : undefined, + recoveryEmail: typeof entry.recoveryEmail === 'string' ? entry.recoveryEmail : '', + geoLocale: normalizeGeoLocale(typeof entry.geoLocale === 'string' ? entry.geoLocale : 'auto'), + langCode: normalizeLanguageCode(typeof entry.langCode === 'string' ? entry.langCode : 'en'), + proxy: { proxyHttp: false, url: '', port: 0, username: '', password: '' }, + saveFingerprint: { mobile: false, desktop: false } + })) + } catch (error) { + log('ERROR', `Failed to read extra accounts file: ${filePath} | ${error.message}`) + return [] + } } export function findAccountByEmail(accounts, email) { diff --git a/setup.bat b/setup.bat new file mode 100644 index 00000000..80ac9bb6 --- /dev/null +++ b/setup.bat @@ -0,0 +1,123 @@ +@echo off +setlocal enabledelayedexpansion +chcp 65001 >nul + +echo =================================== +echo 微软奖励脚本环境自动安装程序 +echo =================================== +echo. + +:: 检查Node.js是否已安装 +where node >nul 2>nul +if %ERRORLEVEL% neq 0 ( + echo Node.js未安装,正在下载并安装... + + :: 创建临时目录 + mkdir %TEMP%\node-install >nul 2>nul + cd %TEMP%\node-install + + :: 下载Node.js安装程序 + echo 正在下载Node.js安装程序... + powershell -Command "(New-Object System.Net.WebClient).DownloadFile('https://npmmirror.com/mirrors/node/v24.13.0/node-v24.13.0-x64.msi', 'node-installer.msi')" + + :: 安装Node.js + echo 正在安装Node.js... + start /wait msiexec /i node-installer.msi /quiet /norestart + + :: 清理临时文件 + cd %~dp0 + rmdir /s /q %TEMP%\node-install >nul 2>nul + + + :: 检查Node.js是否可用 + where node >nul 2>nul + if %ERRORLEVEL% neq 0 ( + echo 警告:Node.js安装完成,但环境变量可能未生效。 + echo 请关闭此窗口,重新打开命令提示符,然后运行setup.bat继续安装。 + pause + exit + ) +) else ( + echo Node.js已安装,版本信息: + node -v +) + +:: 检查npm是否已安装 +where npm >nul 2>nul +if %ERRORLEVEL% neq 0 ( + echo npm更新... + call npm install -g npm + echo npm未安装,正在安装... + + if %ERRORLEVEL% neq 0 ( + echo 安装npm失败,请检查网络连接或手动安装。 + pause + exit /b 1 + ) +) else ( + echo npm已安装 +) + +echo. + + +:: 检查并准备账户配置文件(.env 环境变量方式) +if not exist ".env" ( + if exist "env.example" ( + echo 正在创建账户配置文件... + copy "env.example" ".env" + echo 已创建.env文件,请在运行脚本前编辑此文件填入您的账户信息。 + ) else ( + echo 警告:未找到env.example文件,请手动创建.env文件。 + ) +) else ( + echo .env文件已存在。 +) + +:: 检查并准备全局配置文件(根目录 config.json) +if not exist "config.json" ( + if exist "config.example.json" ( + echo 正在创建全局配置文件... + copy "config.example.json" "config.json" + echo 已创建config.json文件,请按需修改该配置文件。 + ) else ( + echo 警告:未找到config.example.json文件,请手动创建config.json文件。 + ) +) else ( + echo config.json文件已存在。 +) +:: 预构建项目(安装依赖与浏览器) +echo 正在预构建项目... +call npm run pre-build +if %ERRORLEVEL% neq 0 ( + echo 预构建项目失败,请检查错误信息。 + pause + exit /b 1 +) + +:: 构建项目 +echo 正在构建项目... +call npm run build +if %ERRORLEVEL% neq 0 ( + echo 构建项目失败,请检查错误信息。 + pause + exit /b 1 +) + +:: 检查构建产物 +if exist "dist\index.js" ( + echo 已生成dist\index.js,构建成功。 +) else ( + echo 警告:未生成dist\index.js文件,请检查构建是否成功。 +) + +echo. +echo =================================== +echo 安装完成! +echo 后续步骤: +echo 1. 编辑.env文件添加您的账户信息(ACCOUNT_1_EMAIL 等) +echo 2. 检查并按需修改config.json配置文件 +echo 3. 执行终端命令:npm start,或运行脚本:run.bat +echo =================================== + +pause diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index 37850359..602bca10 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -78,14 +78,14 @@ class Browser { this.bot.logger.warn( this.bot.isMobile, 'BROWSER-SECURITY', - 'TLS certificate verification is disabled by proxy.ignoreCertificateErrors' + 'TLS 证书验证已被 proxy.ignoreCertificateErrors 禁用' ) } this.bot.logger.info( this.bot.isMobile, 'BROWSER', - `Launching bundled patched Chromium (Edge UA) | headless=${headless} | platform=${process.platform} | proxy=${hasProxy ? 'yes' : 'no'} | tls=${ignoreCertificateErrors ? 'verification-disabled' : 'verified'} | sandbox=${sandboxDisabled ? 'disabled-root' : 'enabled'}` + `正在启动内置的补丁版 Chromium (Edge UA) | headless=${headless} | platform=${process.platform} | proxy=${hasProxy ? 'yes' : 'no'} | tls=${ignoreCertificateErrors ? 'verification-disabled' : 'verified'} | sandbox=${sandboxDisabled ? 'disabled-root' : 'enabled'}` ) browser = await rebrowser.chromium.launch({ @@ -95,7 +95,7 @@ class Browser { }) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - this.bot.logger.error(this.bot.isMobile, 'BROWSER', `Browser launch failed: ${errorMessage}`) + this.bot.logger.error(this.bot.isMobile, 'BROWSER', `浏览器启动失败: ${errorMessage}`) throw error } @@ -107,16 +107,16 @@ class Browser { this.bot.logger.info( this.bot.isMobile, 'SESSION', - `Restoring saved browser session | cookies=${session.storageState.cookies.length} | origins=${session.storageState.origins.length} | ageMinutes=${ageMinutes}` + + `正在恢复已保存的浏览器会话 | Cookie数=${session.storageState.cookies.length} | origins=${session.storageState.origins.length} | 已保存分钟数=${ageMinutes}` + (session.expiredCookiesRemoved - ? ` | expiredCookiesRemoved=${session.expiredCookiesRemoved}` + ? ` | 已清除过期Cookie=${session.expiredCookiesRemoved}` : '') ) } else { this.bot.logger.info( this.bot.isMobile, 'SESSION', - 'No saved browser session found; login may be required' + '未找到已保存的浏览器会话;可能需要登录' ) } @@ -132,7 +132,7 @@ class Browser { this.bot.logger.info( this.bot.isMobile, 'BROWSER-FINGERPRINT', - `Saved fingerprint locale does not match ${this.bot.accountLocale.locale}; generating a replacement` + `已保存的指纹区域与 ${this.bot.accountLocale.locale} 不匹配;正在生成替代指纹` ) } @@ -175,10 +175,10 @@ class Browser { context.on('page', p => { p.on('crash', () => - this.bot.logger.error(this.bot.isMobile, 'BROWSER', `Renderer crashed | ${p.url()}`) + this.bot.logger.error(this.bot.isMobile, 'BROWSER', `渲染器崩溃 | ${p.url()}`) ) }) - context.on('close', () => this.bot.logger.warn(this.bot.isMobile, 'BROWSER', 'Browser context closed')) + context.on('close', () => this.bot.logger.warn(this.bot.isMobile, 'BROWSER', '浏览器上下文已关闭')) context.setDefaultTimeout(this.bot.utils.stringToNumber(this.bot.config?.globalTimeout ?? 30000)) @@ -189,7 +189,7 @@ class Browser { this.bot.logger.info( this.bot.isMobile, 'BROWSER', - `Created context | locale=${this.bot.accountLocale.locale} | Accept-Language="${this.bot.accountLocale.acceptLanguage}" | User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"` + `已创建上下文 | locale=${this.bot.accountLocale.locale} | Accept-Language="${this.bot.accountLocale.acceptLanguage}" | User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"` ) this.bot.logger.debug(this.bot.isMobile, 'BROWSER-FINGERPRINT', JSON.stringify(fingerprint)) diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index a576e801..55ebcd90 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -57,7 +57,7 @@ export default class BrowserFunc { this.bot.logger.warn( this.bot.isMobile, 'GET-DASHBOARD-DATA', - `Primary dashboard request failed; retrying once | message=${this.errorMessage(error)}` + `主接口请求失败,正在重试一次 | 信息=${this.errorMessage(error)}` ) await new Promise(resolve => setTimeout(resolve, 1000)) } @@ -68,7 +68,7 @@ export default class BrowserFunc { this.bot.logger.warn( this.bot.isMobile, 'GET-DASHBOARD-DATA', - `Primary dashboard unavailable after one retry; using Bing flyout fallback | message=${this.errorMessage(primaryError)}` + `重试后主接口仍不可用,改用 Bing flyout 兜底 | 信息=${this.errorMessage(primaryError)}` ) } @@ -99,14 +99,14 @@ export default class BrowserFunc { this.bot.logger.warn( this.bot.isMobile, 'GET-DASHBOARD-DATA', - `Using partial Bing flyout dashboard | suspectedLimited=${detection.likelyLimited} | botMarkers=${detection.hasBotProfileMarkers} | activitiesCollapsed=${detection.hasCollapsedActivities}` + `使用 Bing flyout 部分仪表板 | 疑似受限=${detection.likelyLimited} | bot标记=${detection.hasBotProfileMarkers} | 活动折叠=${detection.hasCollapsedActivities}` ) return mapFlyoutToDashboard(response.data) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'GET-DASHBOARD-DATA', - `Primary dashboard and Bing flyout fallback failed | message=${this.errorMessage(error)}` + `获取仪表板数据失败(主接口与 Bing flyout 兜底均失败): ${this.errorMessage(error)}` ) throw error } @@ -136,7 +136,7 @@ export default class BrowserFunc { this.bot.logger.error( this.bot.isMobile, 'GET-APP-DASHBOARD-DATA', - `Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}` + `获取 App 仪表板数据出错: ${error instanceof Error ? error.message : String(error)}` ) throw error } @@ -189,7 +189,7 @@ export default class BrowserFunc { this.bot.logger.error( this.bot.isMobile, 'GET-BROWSER-EARNABLE-POINTS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) throw error } @@ -249,7 +249,7 @@ export default class BrowserFunc { this.bot.logger.error( this.bot.isMobile, 'GET-APP-EARNABLE-POINTS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) throw error } @@ -264,7 +264,7 @@ export default class BrowserFunc { this.bot.logger.error( this.bot.isMobile, 'GET-CURRENT-POINTS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) throw error } @@ -308,7 +308,7 @@ export default class BrowserFunc { this.bot.logger.warn( this.bot.isMobile, 'BOOTSTRAP', - 'No offers parsed - page may not have rendered the RSC payload (check login/redirect)' + '未解析到任何优惠活动 - 页面可能未渲染 RSC 载荷(请检查登录/重定向)' ) } @@ -316,27 +316,27 @@ export default class BrowserFunc { this.bot.logger.warn( this.bot.isMobile, 'BOOTSTRAP', - 'No action ids discovered - server-action calls will fail (bundle may have stripped names)' + '未发现任何 action id - server-action 调用将失败(bundle 可能已剥离名称)' ) } this.bot.logger.info( this.bot.isMobile, 'BOOTSTRAP', - `Context ready | actions=${Object.keys(this.bot.nextActions).length} | reportable=${this.bot.reactSnapshot.reportable.length} | available=${this.bot.reactSnapshot.account.availablePoints}` + `上下文就绪 | actions=${Object.keys(this.bot.nextActions).length} | 可上报=${this.bot.reactSnapshot.reportable.length} | 可用积分=${this.bot.reactSnapshot.account.availablePoints}` ) this.bot.logger.info( this.bot.isMobile, 'BUILD', - `Rewards build | id=${this.rewardsDeploymentId || 'unknown'}`, + `Rewards 构建 | id=${this.rewardsDeploymentId || 'unknown'}`, 'cyan' ) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'BOOTSTRAP', - `Failed acquiring context | error=${error instanceof Error ? error.message : String(error)}` + `获取上下文失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) throw error } @@ -374,13 +374,13 @@ export default class BrowserFunc { this.bot.logger.warn( this.bot.isMobile, 'BOOTSTRAP', - `Failed to fetch ${route} HTML | status=${res.status()} - snapshot and action discovery may be incomplete` + `获取 ${route} HTML 失败 | 状态码=${res.status()} - 快照和 action 发现可能不完整` ) } catch (error) { this.bot.logger.warn( this.bot.isMobile, 'BOOTSTRAP', - `Failed to fetch ${route} HTML | error=${error instanceof Error ? error.message : String(error)} - snapshot and action discovery may be incomplete` + `获取 ${route} HTML 失败 | 错误=${error instanceof Error ? error.message : String(error)} - 快照和 action 发现可能不完整` ) } @@ -404,11 +404,11 @@ export default class BrowserFunc { this.bot.logger.warn( this.bot.isMobile, 'BOOTSTRAP', - 'No initial chunks discovered in HTML - chunk reference shape may have changed' + '未在 HTML 中发现初始代码块 - 代码块引用形式可能已变化' ) } - this.bot.logger.debug(this.bot.isMobile, 'BOOTSTRAP', `Fetching ${initialChunks.size} initial JS chunks`) + this.bot.logger.debug(this.bot.isMobile, 'BOOTSTRAP', `正在获取 ${initialChunks.size} 个初始 JS 代码块`) const jsByPath = await this.fetchJsChunks(page, [...initialChunks]) const dynamicPaths = new Set() @@ -422,7 +422,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'BOOTSTRAP', - `Fetching ${dynamicPaths.size} dynamic chunks discovered via webpack manifest` + `正在获取通过 webpack manifest 发现的 ${dynamicPaths.size} 个动态代码块` ) const moreJs = await this.fetchJsChunks(page, [...dynamicPaths]) for (const [path, js] of moreJs) jsByPath.set(path, js) @@ -438,10 +438,10 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'BOOTSTRAP', - `Found ${names.length} action id(s) in ${filename}: [${names.join(', ')}]` + `在 ${filename} 中发现 ${names.length} 个 action id: [${names.join(', ')}]` ) } else { - this.bot.logger.debug(this.bot.isMobile, 'BOOTSTRAP', `No server-action ids found in ${filename}`) + this.bot.logger.debug(this.bot.isMobile, 'BOOTSTRAP', `在 ${filename} 中未发现 server-action id`) } const namedSet = new Set(Object.values(ids.byName)) @@ -450,7 +450,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'BOOTSTRAP', - `Found ${unnamed.length} unnamed action id(s) in ${filename}: [${unnamed.join(', ')}]` + `在 ${filename} 中发现 ${unnamed.length} 个未命名 action id: [${unnamed.join(', ')}]` ) } } @@ -458,13 +458,13 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'BOOTSTRAP', - `Discovered ${Object.keys(result).length} action ids: [${Object.keys(result).join(', ')}]` + `已发现 ${Object.keys(result).length} 个 action id: [${Object.keys(result).join(', ')}]` ) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'BOOTSTRAP', - `Failed resolving action ids | error=${error instanceof Error ? error.message : String(error)}` + `解析 action id 失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) } @@ -485,7 +485,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'BOOTSTRAP', - `Chunk fetch failed | path=${path} | ${error instanceof Error ? error.message : String(error)}` + `代码块获取失败 | 路径=${path} | ${error instanceof Error ? error.message : String(error)}` ) } }) @@ -530,7 +530,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'CLOSE-BROWSER', - `Saving session | cookies=${storageState.cookies.length} | origins=${storageState.origins.length}` + `正在保存会话 | Cookie数=${storageState.cookies.length} | origins=${storageState.origins.length}` ) saveStorageState(this.bot.config.sessionPath, email, this.bot.isMobile, storageState) } @@ -539,10 +539,10 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'CLOSE-BROWSER', - `Session not saved (browser already closing): ${error instanceof Error ? error.message : String(error)}` + `会话未保存(浏览器已在关闭中): ${error instanceof Error ? error.message : String(error)}` ) } else { - this.bot.logger.error(this.bot.isMobile, 'CLOSE-BROWSER', `Failed to save session: ${error}`) + this.bot.logger.error(this.bot.isMobile, 'CLOSE-BROWSER', `保存会话失败: ${error}`) } } finally { try { @@ -552,15 +552,15 @@ export default class BrowserFunc { await rootBrowser.close().catch(() => {}) } - this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', 'All browser resources closed.') + this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', '所有浏览器资源已关闭。') } catch (error) { if (isBrowserClosedError(error)) { - this.bot.logger.debug(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser was already closed.') + this.bot.logger.debug(this.bot.isMobile, 'CLOSE-BROWSER', '浏览器已处于关闭状态。') } else { this.bot.logger.warn( this.bot.isMobile, 'CLOSE-BROWSER', - 'Shutdown encountered an error, but process exiting.' + '关闭时遇到错误,但进程仍在退出。' ) } } @@ -589,13 +589,13 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'REWARDS-PAGE', - `Failed to fetch ${route} | status=${response.status()}` + `获取 ${route} 失败 | 状态码=${response.status()}` ) } catch (error) { this.bot.logger.debug( this.bot.isMobile, 'REWARDS-PAGE', - `Browser fetch failed for ${route} | ${error instanceof Error ? error.message : String(error)}` + `浏览器请求 ${route} 失败 | ${error instanceof Error ? error.message : String(error)}` ) } @@ -613,7 +613,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, source, - 'Could not checkpoint session because no active browser page is available' + '无法保存会话检查点,因为没有可用的活动浏览器页面' ) return false } @@ -625,7 +625,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, source, - `Could not checkpoint active session | error=${error instanceof Error ? error.message : String(error)}` + `无法保存活动会话检查点 | 错误=${error instanceof Error ? error.message : String(error)}` ) return false } @@ -669,7 +669,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, source, - `Could not synchronize active browser cookies | error=${error instanceof Error ? error.message : String(error)}` + `无法同步活动浏览器 Cookie | 错误=${error instanceof Error ? error.message : String(error)}` ) return false } @@ -701,7 +701,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, source, - `Refreshed cookie cache | previous=${cachedCookies.length} | current=${liveCookies.length}` + `已刷新 Cookie 缓存 | 之前=${cachedCookies.length} | 当前=${liveCookies.length}` ) } @@ -723,13 +723,13 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, source, - `Persisted live browser session | cookies=${storageState.cookies.length} | origins=${storageState.origins.length}` + `已持久化活动浏览器会话 | Cookie数=${storageState.cookies.length} | origins=${storageState.origins.length}` ) } catch (error) { this.bot.logger.debug( this.bot.isMobile, source, - `Could not persist refreshed cookies | error=${error instanceof Error ? error.message : String(error)}` + `无法持久化刷新后的 Cookie | 错误=${error instanceof Error ? error.message : String(error)}` ) } } @@ -818,7 +818,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'COOKIE-SYNC', - `Applied ${rawCookies.length} response cookie(s) and persisted the updated session` + `已应用 ${rawCookies.length} 个响应 Cookie 并持久化更新后的会话` ) } @@ -983,7 +983,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'EARN-SNAPSHOT', - `Failed to fetch ${route} over http | ${error instanceof Error ? error.message : String(error)}` + `通过 HTTP 获取 ${route} 失败 | ${error instanceof Error ? error.message : String(error)}` ) return null } @@ -996,7 +996,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'EARN-SNAPSHOT', - `${offerId} absent from the cached snapshot (offers=${this.bot.reactSnapshot?.offers.length ?? 0}) - refetching /earn and /dashboard` + `${offerId} 不在缓存快照中 (offers=${this.bot.reactSnapshot?.offers.length ?? 0}) - 正在重新获取 /earn 和 /dashboard` ) const refreshed = await this.refreshEarnSnapshot() @@ -1011,7 +1011,7 @@ export default class BrowserFunc { this.bot.logger.debug( this.bot.isMobile, 'EARN-SNAPSHOT', - `Refetched /earn and /dashboard | offers=${refreshed.offers.length} | ${offerId} found=${!!live}` + `已重新获取 /earn 和 /dashboard | offers=${refreshed.offers.length} | ${offerId} 找到=${!!live}` ) return live diff --git a/src/browser/BrowserUtils.ts b/src/browser/BrowserUtils.ts index 5399507a..6a5d97b1 100644 --- a/src/browser/BrowserUtils.ts +++ b/src/browser/BrowserUtils.ts @@ -53,7 +53,7 @@ export default class BrowserUtils { this.bot.logger.debug( this.bot.isMobile, 'DISMISS-ALL-MESSAGES', - `Dismissed: ${b.label}` + `已关闭: ${b.label}` ) } } @@ -66,14 +66,14 @@ export default class BrowserUtils { if (overlay) { const rejected = await this.ghostClick(page, '#bnp_btn_reject, button[aria-label*="Reject" i]') if (rejected) { - this.bot.logger.debug(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject') + this.bot.logger.debug(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', '已关闭: Bing Overlay Reject') } else { const accepted = await this.ghostClick(page, '#bnp_btn_accept') if (accepted) { this.bot.logger.debug( this.bot.isMobile, 'DISMISS-ALL-MESSAGES', - 'Dismissed: Bing Overlay Accept' + '已关闭: Bing Overlay Accept' ) } } @@ -83,7 +83,7 @@ export default class BrowserUtils { this.bot.logger.warn( this.bot.isMobile, 'DISMISS-ALL-MESSAGES', - `Handler error: ${error instanceof Error ? error.message : String(error)}` + `处理器错误: ${error instanceof Error ? error.message : String(error)}` ) } } @@ -95,7 +95,8 @@ export default class BrowserUtils { const newTab = pages[pages.length - 1] if (!newTab) { - throw new Error('No tabs could be found!') + this.bot.logger.error(this.bot.isMobile, 'GET-NEW-TAB', '找不到任何标签页!') + throw new Error('找不到任何标签页!') } return newTab @@ -103,7 +104,7 @@ export default class BrowserUtils { this.bot.logger.error( this.bot.isMobile, 'GET-NEW-TAB', - `Unable to get latest tab: ${error instanceof Error ? error.message : String(error)}` + `无法获取最新标签页: ${error instanceof Error ? error.message : String(error)}` ) throw error } @@ -115,7 +116,7 @@ export default class BrowserUtils { const isBadPage = /]*\bclass=["'][^"']*\bneterror\b/i.test(html) if (isBadPage) { - this.bot.logger.info(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!') + this.bot.logger.info(this.bot.isMobile, 'RELOAD-BAD-PAGE', '检测到错误页面,正在重新加载!') try { await page.reload({ waitUntil: 'load' }) } catch { @@ -129,7 +130,7 @@ export default class BrowserUtils { this.bot.logger.error( this.bot.isMobile, 'RELOAD-BAD-PAGE', - `Reload check failed: ${error instanceof Error ? error.message : String(error)}` + `重载检查失败: ${error instanceof Error ? error.message : String(error)}` ) return true } @@ -143,11 +144,11 @@ export default class BrowserUtils { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-CLOSE-TABS', - `Found ${tabs.length} tab(s) open (min: ${config.minTabs}, max: ${config.maxTabs})` + `发现 ${tabs.length} 个打开的标签页 (最少: ${config.minTabs}, 最多: ${config.maxTabs})` ) if (config.minTabs < 1 || config.maxTabs < config.minTabs) { - this.bot.logger.warn(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'Invalid config, using defaults') + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-CLOSE-TABS', '配置无效,使用默认值') config = { minTabs: 1, maxTabs: 1 } } @@ -160,14 +161,14 @@ export default class BrowserUtils { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-CLOSE-TABS', - `Closed ${closedCount}/${tabsToClose.length} excess tab(s) to reach max of ${config.maxTabs}` + `已关闭 ${closedCount}/${tabsToClose.length} 个多余标签页,以达到最大值 ${config.maxTabs}` ) } else if (tabs.length < config.minTabs) { const tabsNeeded = config.minTabs - tabs.length this.bot.logger.debug( this.bot.isMobile, 'SEARCH-CLOSE-TABS', - `Opening ${tabsNeeded} tab(s) to reach min of ${config.minTabs}` + `正在打开 ${tabsNeeded} 个标签页,以达到最小值 ${config.minTabs}` ) const newTabPromises = Array.from({ length: tabsNeeded }, async () => { @@ -179,7 +180,7 @@ export default class BrowserUtils { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-CLOSE-TABS', - `Failed to create new tab: ${error instanceof Error ? error.message : String(error)}` + `创建新标签页失败: ${error instanceof Error ? error.message : String(error)}` ) return null } @@ -194,7 +195,7 @@ export default class BrowserUtils { this.bot.logger.error( this.bot.isMobile, 'SEARCH-CLOSE-TABS', - `Error: ${error instanceof Error ? error.message : String(error)}` + `错误: ${error instanceof Error ? error.message : String(error)}` ) return page } @@ -205,7 +206,7 @@ export default class BrowserUtils { this.bot.logger.debug( this.bot.isMobile, 'GHOST-CLICK', - `Trying to click selector: ${selector}, options: ${JSON.stringify(options)}` + `尝试点击选择器: ${selector},选项: ${JSON.stringify(options)}` ) await page.waitForSelector(selector, { timeout: 1000 }).catch(() => {}) @@ -219,7 +220,7 @@ export default class BrowserUtils { this.bot.logger.warn( this.bot.isMobile, 'GHOST-CLICK', - `Failed for ${selector}: ${error instanceof Error ? error.message : String(error)}` + `点击 ${selector} 失败: ${error instanceof Error ? error.message : String(error)}` ) return false } @@ -239,7 +240,7 @@ export default class BrowserUtils { this.bot.logger.debug( this.bot.isMobile, 'DISABLE-FIDO', - `Modified request body: isFidoSupported set to ${body.isFidoSupported}` + `已修改请求体: isFidoSupported 设为 ${body.isFidoSupported}` ) route.continue({ @@ -253,7 +254,7 @@ export default class BrowserUtils { this.bot.logger.debug( this.bot.isMobile, 'DISABLE-FIDO', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) route.continue() } diff --git a/src/browser/MediaBlocker.ts b/src/browser/MediaBlocker.ts index 27cfd349..960acef4 100644 --- a/src/browser/MediaBlocker.ts +++ b/src/browser/MediaBlocker.ts @@ -76,6 +76,6 @@ export async function configureMediaBlocking(bot: MicrosoftRewardsBot, context: bot.logger.info( bot.isMobile, 'BROWSER', - 'Media loading disabled after authentication | blockedResourceTypes=image,media | httpCache=disabled-by-routing' + '媒体加载已禁用 | 拦截资源类型=image,media | http缓存=已通过路由禁用' ) } diff --git a/src/browser/ReactFunc.ts b/src/browser/ReactFunc.ts index 01529d9b..f77aa6d3 100644 --- a/src/browser/ReactFunc.ts +++ b/src/browser/ReactFunc.ts @@ -92,7 +92,7 @@ export default class ReactFunc { this.bot.logger.info( this.bot.isMobile, 'REACT-PARSE', - `Snapshot complete | offers=${offers.length} | reportable=${offers.filter(o => o.reportable).length} | streaks=${streaks.length} | streakProtectionEnabled=${streakProtection?.isProtectionOn ?? 'null'} | streakProtectionRemainingDays=${streakProtection?.remainingDays ?? 'null'} | streakCounter=${streakProtection?.streakCounter ?? 'null'} | level=${account.level} | account=${accountEmail ?? 'null'}` + `快照完成 | offers=${offers.length} | 可上报=${offers.filter(o => o.reportable).length} | streaks=${streaks.length} | 连续保护已启用=${streakProtection?.isProtectionOn ?? 'null'} | 连续保护剩余天数=${streakProtection?.remainingDays ?? 'null'} | 连续计数=${streakProtection?.streakCounter ?? 'null'} | 等级=${account.level} | 账户=${accountEmail ?? 'null'}` ) return { @@ -151,7 +151,7 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Skipped undecodable flight chunk | error=${err instanceof Error ? err.message : String(err)}` + `跳过无法解码的 flight 代码块 | 错误=${err instanceof Error ? err.message : String(err)}` ) } } @@ -163,14 +163,14 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Concatenated flight chunks | pages=${pages.length} | chunks=${count} | length=${combined.length} | perSource=[${perPage.join(', ')}]` + `已拼接 flight 代码块 | 页面数=${pages.length} | 代码块=${count} | 长度=${combined.length} | 每来源=[${perPage.join(', ')}]` ) if (count === 0) { this.bot.logger.warn( this.bot.isMobile, 'REACT-PARSE', - 'No __next_f flight chunks found - page may not be an RSC render or markup changed' + '未找到 __next_f flight 代码块 - 页面可能不是 RSC 渲染或标记已变化' ) } @@ -179,7 +179,7 @@ export default class ReactFunc { this.bot.logger.error( this.bot.isMobile, 'REACT-PARSE', - `Failed concatenating flight chunks | error=${error instanceof Error ? error.message : String(error)}` + `拼接 flight 代码块失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) return '' } @@ -255,7 +255,7 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `extractObjects("${anchor}") had ${failures} unparseable matches` + `extractObjects("${anchor}") 有 ${failures} 个无法解析的匹配` ) } @@ -339,12 +339,12 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Parsed offers | total=${unique.length} | reportable=${unique.filter(o => o.reportable).length}` + `已解析 offers | 总数=${unique.length} | 可上报=${unique.filter(o => o.reportable).length}` ) this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Parsed offer ids | ${unique.map(o => `${o.offerId}${o.reportable ? '' : '(skip)'}`).join(', ') || 'none'}` + `已解析 offer id | ${unique.map(o => `${o.offerId}${o.reportable ? '' : '(跳过)'}`).join(', ') || '无'}` ) return unique @@ -352,7 +352,7 @@ export default class ReactFunc { this.bot.logger.error( this.bot.isMobile, 'REACT-PARSE', - `Failed parsing offers | error=${error instanceof Error ? error.message : String(error)}` + `解析 offers 失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -397,7 +397,7 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Parsed streaks | ${unique.map(s => `${s.partner}:${s.completedDays}/${s.totalDays}`).join(', ') || 'none'}` + `已解析 streaks | ${unique.map(s => `${s.partner}:${s.completedDays}/${s.totalDays}`).join(', ') || '无'}` ) return unique @@ -405,7 +405,7 @@ export default class ReactFunc { this.bot.logger.error( this.bot.isMobile, 'REACT-PARSE', - `Failed parsing streaks | error=${error instanceof Error ? error.message : String(error)}` + `解析 streaks 失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -430,7 +430,7 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Parsed streak protection | enabled=${state.isProtectionOn} | remainingDays=${state.remainingDays ?? 'null'} | streakCounter=${state.streakCounter}` + `已解析连续保护 | 已启用=${state.isProtectionOn} | 剩余天数=${state.remainingDays ?? 'null'} | 连续计数=${state.streakCounter}` ) return state @@ -438,7 +438,7 @@ export default class ReactFunc { this.bot.logger.error( this.bot.isMobile, 'REACT-PARSE', - `Failed parsing streak protection | error=${error instanceof Error ? error.message : String(error)}` + `解析连续保护失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) return null } @@ -473,7 +473,7 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Parsed account | level=${account.level} | available=${account.availablePoints} | toGo=${account.pointsRemaining} | lifetime=${account.lifetimeEarn}` + `已解析账户 | 等级=${account.level} | 可用积分=${account.availablePoints} | 还差=${account.pointsRemaining} | 累计=${account.lifetimeEarn}` ) if (account.level === null && account.availablePoints === null) { @@ -481,7 +481,7 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - 'Account state empty - membership/header objects not found in payload' + '账户状态为空 - 载荷中未找到 membership/header 对象' ) } @@ -490,7 +490,7 @@ export default class ReactFunc { this.bot.logger.error( this.bot.isMobile, 'REACT-PARSE', - `Failed parsing account | error=${error instanceof Error ? error.message : String(error)}` + `解析账户失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) return empty } @@ -626,13 +626,13 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Extracted action ids | named=${Object.keys(byName).length} | total=${all.size}` + `已提取 action id | 具名=${Object.keys(byName).length} | 总数=${all.size}` ) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'REACT-PARSE', - `Failed extracting action ids | error=${error instanceof Error ? error.message : String(error)}` + `提取 action id 失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) } @@ -648,7 +648,7 @@ export default class ReactFunc { this.bot.logger.info( this.bot.isMobile, 'REACT-PARSE', - `Quest snapshot | children=${children.length} | reportable=${children.filter(c => c.reportable).length}` + `任务快照 | 子项=${children.length} | 可上报=${children.filter(c => c.reportable).length}` ) return children @@ -656,7 +656,7 @@ export default class ReactFunc { this.bot.logger.error( this.bot.isMobile, 'REACT-PARSE', - `Failed parsing quest page | error=${error instanceof Error ? error.message : String(error)}` + `解析任务页失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -693,7 +693,7 @@ export default class ReactFunc { this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Parsed quest children | total=${out.length} | reportable=${out.filter(c => c.reportable).length}` + `已解析任务子项 | 总数=${out.length} | 可上报=${out.filter(c => c.reportable).length}` ) return out @@ -747,18 +747,18 @@ export default class ReactFunc { this.bot.logger.info( this.bot.isMobile, 'REACT-PARSE', - `Quest list | parents=${out.length} | incomplete=${out.filter(q => !q.complete).length}` + `任务列表 | 父任务=${out.length} | 未完成=${out.filter(q => !q.complete).length}` ) this.bot.logger.debug( this.bot.isMobile, 'REACT-PARSE', - `Quest points | ${out.map(q => `${q.title || q.offerId}=${q.pointProgressMax}`).join(' | ') || 'none'}` + `任务积分 | ${out.map(q => `${q.title || q.offerId}=${q.pointProgressMax}`).join(' | ') || '无'}` ) if (!out.length) { this.bot.logger.warn( this.bot.isMobile, 'REACT-PARSE', - 'No parent quests parsed - the fetched HTML may be missing the QuestSection chunks (Suspense/streaming or a login redirect)' + '未解析到父任务 - 抓取的 HTML 可能缺少 QuestSection 代码块(Suspense/流式渲染或登录重定向)' ) } return out @@ -766,7 +766,7 @@ export default class ReactFunc { this.bot.logger.error( this.bot.isMobile, 'REACT-PARSE', - `Failed parsing quest list | error=${error instanceof Error ? error.message : String(error)}` + `解析任务列表失败 | 错误=${error instanceof Error ? error.message : String(error)}` ) return [] } diff --git a/src/browser/UserAgent.ts b/src/browser/UserAgent.ts index 2127e851..961c3a1f 100644 --- a/src/browser/UserAgent.ts +++ b/src/browser/UserAgent.ts @@ -177,7 +177,7 @@ export class UserAgentManager { this.bot.logger.warn( isMobile, 'USERAGENT-CHROME-VERSION', - `Version lookup unavailable (${error instanceof Error ? error.message : String(error)}); using bundled fallback ${fallback}` + `版本查询不可用 (${error instanceof Error ? error.message : String(error)});使用内置回退版本 ${fallback}` ) return fallback } @@ -219,7 +219,7 @@ export class UserAgentManager { this.bot.logger.warn( isMobile, 'USERAGENT-EDGE-VERSION', - `Version lookup unavailable (${error instanceof Error ? error.message : String(error)}); using bundled fallbacks Android ${fallback.android}, Desktop ${fallback.windows}` + `版本查询不可用 (${error instanceof Error ? error.message : String(error)});使用内置回退版本 Android ${fallback.android},桌面版 ${fallback.windows}` ) return { android: fallback.android, desktop: fallback.windows } } @@ -306,7 +306,7 @@ export class UserAgentManager { this.bot.logger.error( isMobile, 'USER-AGENT-UPDATE', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) throw error } diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts index 4b5e84d8..113b636a 100644 --- a/src/browser/auth/Login.ts +++ b/src/browser/auth/Login.ts @@ -96,7 +96,7 @@ export class Login { this.capturedUnknownUrls.clear() this.signInMethodsLogged = false this.passwordlessMethodSelected = false - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting login process') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '开始登录流程') await page .goto(URLs.rewards.userLogin, { @@ -116,13 +116,13 @@ export class Login { if (page.isClosed()) throw new Error('Page closed unexpectedly') iteration++ - this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `State check iteration ${iteration}/${maxIterations}`) + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `状态检查迭代 ${iteration}/${maxIterations}`) const state = await this.detectCurrentState(page) - this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Current state: ${state}`) + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `当前状态: ${state}`) if (state !== previousState && previousState !== 'UNKNOWN') { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', `State transition: ${previousState} → ${state}`) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', `状态转换: ${previousState} → ${state}`) } if (state === previousState && state !== 'LOGGED_IN' && state !== 'UNKNOWN') { @@ -130,13 +130,13 @@ export class Login { this.bot.logger.debug( this.bot.isMobile, 'LOGIN', - `Same state count: ${sameStateCount}/4 for state "${state}"` + `相同状态计数: ${sameStateCount}/4 状态为 "${state}"` ) if (sameStateCount >= 4) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN', - `Stuck in state "${state}" for 4 loops, refreshing page` + `在状态 "${state}" 停滞4次循环,刷新页面` ) await page.reload({ waitUntil: 'domcontentloaded' }) await this.bot.utils.wait(3000) @@ -150,7 +150,7 @@ export class Login { previousState = state if (state === 'LOGGED_IN') { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Successfully logged in') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '登录成功') break } @@ -171,7 +171,7 @@ export class Login { this.bot.logger.error( this.bot.isMobile, 'LOGIN', - `Fatal error: ${error instanceof Error ? error.message : String(error)}` + `致命错误: ${error instanceof Error ? error.message : String(error)}` ) throw error } @@ -182,21 +182,21 @@ export class Login { const url = new URL(page.url()) const hostname = url.hostname.toLowerCase() - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Current URL: ${hostname}${url.pathname}`) + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `当前URL: ${hostname}${url.pathname}`) if (hostname === 'chromewebdata') { - this.bot.logger.warn(this.bot.isMobile, 'DETECT-STATE', 'Detected chromewebdata error page') + this.bot.logger.warn(this.bot.isMobile, 'DETECT-STATE', '检测到chromewebdata错误页面') return 'CHROMEWEBDATA_ERROR' } const isLocked = await this.checkSelector(page, this.selectors.accountLocked) if (isLocked) { - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'Account locked selector found') + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', '账户锁定选择器被发现') return 'ACCOUNT_LOCKED' } if (hostname === 'bing.com' || hostname.endsWith('.bing.com') || hostname === 'account.microsoft.com') { - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'On Bing/rewards/account page, assuming logged in') + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', '在Bing/奖励/账户页面,假设已登录') return 'LOGGED_IN' } @@ -242,7 +242,7 @@ export class Login { const visibleStates = results.filter((s): s is LoginState => s !== null) if (visibleStates.length > 0) { - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Visible states: [${visibleStates.join(', ')}]`) + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `可见状态: [${visibleStates.join(', ')}]`) } // Get a sign-in request - distinguish a generic methods footer from a direct email/phone fallback @@ -255,23 +255,23 @@ export class Login { !results.includes('EMAIL_VERIFICATION_INPUT') ) { const normalizedFooterAction = this.normalizeSignInText(footerActionText) - // Detect email or masked phone proof destinations without relying on localized footer text + // 通过邮箱/掩码手机号正则区分通用方法入口与具体验证回退,不依赖本地化文案 const footerTargetsSpecificProof = /[\w.+*-]+@[\w.*-]+\.[a-z]{2,}/i.test(normalizedFooterAction) || /(?:\+?\d|[*xX])(?:[\d\s().*xX-]{4,})(?:\d|[*xX])/.test(normalizedFooterAction) if (footerAction && !footerTargetsSpecificProof && !this.passwordlessMethodSelected) { - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'Alternative sign-in methods are available') + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', '有可用的备选登录方式') results.push('FOOTER_ACTION') } else { if (footerAction && footerTargetsSpecificProof) { this.bot.logger.debug( this.bot.isMobile, 'DETECT-STATE', - 'Footer targets a specific verification proof; keeping the primary sign-in method' + '页脚指向具体验证方式;保持主登录方式' ) } - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'Primary passwordless action detected') + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', '检测到主无密码登录操作') results.push('PASSWORDLESS_SEND_CODE') } } @@ -279,7 +279,7 @@ export class Login { let foundStates = results.filter((s): s is LoginState => s !== null) if (foundStates.length === 0) { - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'No matching states found') + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', '未找到匹配的状态') return 'UNKNOWN' } @@ -288,7 +288,7 @@ export class Login { this.bot.logger.debug( this.bot.isMobile, 'DETECT-STATE', - `ERROR_ALERT found - hostname: ${hostname}, has 2FA: ${foundStates.includes('2FA_TOTP')}, treating as real: ${errorIsReal}` + `发现ERROR_ALERT - 主机名: ${hostname}, 有2FA: ${foundStates.includes('2FA_TOTP')}, 视为真实错误: ${errorIsReal}` ) if (errorIsReal) return 'ERROR_ALERT' foundStates = foundStates.filter(s => s !== 'ERROR_ALERT') @@ -313,12 +313,12 @@ export class Login { for (const priority of priorities) { if (foundStates.includes(priority)) { - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Selected state by priority: ${priority}`) + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `按优先级选择状态: ${priority}`) return priority } } - this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Returning first found state: ${foundStates[0]}`) + this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `返回第一个找到的状态: ${foundStates[0]}`) return foundStates[0] as LoginState } @@ -435,12 +435,12 @@ export class Login { if (options.length === 0) return const labels = options.map(option => this.sanitizeSignInLabel(option.label)) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', `Available sign-in methods: ${labels.join(' | ')}`) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', `可用的登录方式: ${labels.join(' | ')}`) } private async waitForIdle(page: Page, note: string, timeout = 5000): Promise { await page.waitForLoadState('networkidle', { timeout }).catch(() => { - this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Network idle timeout: ${note}`) + this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `网络空闲超时: ${note}`) }) } @@ -452,12 +452,12 @@ export class Login { if (!clicked) return false await this.waitForIdle(page, `after ${label}`) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', `${label} clicked`) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', `${label} 已点击`) return true } private async handleState(state: LoginState, page: Page, account: Account): Promise { - this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Processing state: ${state}`) + this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `处理状态: ${state}`) switch (state) { case 'ACCOUNT_LOCKED': { @@ -469,7 +469,7 @@ export class Login { case 'ERROR_ALERT': { const alertEl = page.locator(this.selectors.errorAlert) const errorMsg = await alertEl.innerText().catch(() => 'Unknown Error') - this.bot.logger.error(this.bot.isMobile, 'LOGIN', `Account error: ${errorMsg}`) + this.bot.logger.error(this.bot.isMobile, 'LOGIN', `账户错误: ${errorMsg}`) throw new Error(`Microsoft login error: ${errorMsg}`) } @@ -477,11 +477,11 @@ export class Login { return true case 'EMAIL_INPUT': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering email') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '输入邮箱') const result = await this.emailLogin.enterEmail(page, account.email) if (result !== 'ok') return false await this.waitForIdle(page, 'after email entry') - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Email entered successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '邮箱输入成功') return true } @@ -491,32 +491,28 @@ export class Login { this.bot.logger.info( this.bot.isMobile, 'LOGIN', - 'Password input detected but no password is configured; returning to sign-in methods' + '检测到密码输入页但未配置密码;返回登录方式选择' ) if (await this.tryClick(page, this.selectors.backButton, 'Back button')) return true - this.bot.logger.warn( - this.bot.isMobile, - 'LOGIN', - 'No configured password and no way to return to sign-in methods' - ) + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '未配置密码且无法返回登录方式选择') return false } - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering password') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '输入密码') const result = await this.emailLogin.enterPassword(page, account.password) if (result === 'error') return false await this.waitForIdle(page, 'after password entry') - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password entered successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '密码输入成功') return true } - // Generic alternative-method footer - open the method picker before choosing a credential + // 通用备选方式页脚 - 先打开方式选择器再选择凭据 case 'FOOTER_ACTION': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Opening alternative sign-in methods') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '打开备选登录方式') const clicked = await this.bot.browser.utils.ghostClick(page, this.selectors.footerAction) if (!clicked) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not open alternative sign-in methods') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '无法打开备选登录方式') return false } this.passwordlessMethodSelected = false @@ -526,17 +522,17 @@ export class Login { // Get a sign-in request - keep the primary Authenticator action when footer is a proof fallback case 'PASSWORDLESS_SEND_CODE': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Continuing with primary sign-in method') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '继续使用主要登录方式') const clicked = await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton) if (!clicked) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not continue with primary sign-in method') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '无法继续使用主要登录方式') return false } await this.waitForIdle(page, 'after primary sign-in action') return true } - // Sign in another way - prefer offered password, then Authenticator, then interactive email code + // 其他登录方式 - 优先密码,其次Authenticator,最后交互式邮箱验证码 case 'SIGN_IN_METHOD_PICKER': { const options = await this.getSignInMethodOptions(page) this.logAvailableSignInMethods(options) @@ -547,11 +543,11 @@ export class Login { const emailOption = methods.find(method => method.type === 'EMAIL')?.option if (account.password && passwordOption) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password sign-in is available; selecting it') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '密码登录可用;正在选择') this.passwordlessMethodSelected = false if (!(await this.clickSignInMethodOption(page, passwordOption))) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not select password sign-in method') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '无法选择密码登录方式') return false } @@ -561,7 +557,7 @@ export class Login { const passwordlessOptionOldFound = await this.checkSelector(page, this.selectors.passwordlessOptionOld) if (authenticatorOption || passwordlessOptionOldFound) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting Microsoft Authenticator sign-in') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '选择Microsoft Authenticator登录') this.passwordlessMethodSelected = true const clicked = authenticatorOption @@ -570,7 +566,7 @@ export class Login { if (!clicked) { this.passwordlessMethodSelected = false - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not select Microsoft Authenticator') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '无法选择Microsoft Authenticator') return false } @@ -584,14 +580,14 @@ export class Login { !passwordlessChallengeVisible && (await this.checkSelector(page, this.selectors.primaryButton)) ) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Sending Microsoft Authenticator request') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '正在发送Microsoft Authenticator请求') const confirmed = await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton) if (!confirmed) { this.passwordlessMethodSelected = false this.bot.logger.warn( this.bot.isMobile, 'LOGIN', - 'Could not send Microsoft Authenticator request' + '无法发送Microsoft Authenticator请求' ) return false } @@ -607,12 +603,13 @@ export class Login { this.bot.logger.error( this.bot.isMobile, 'LOGIN', - 'No supported non-interactive sign-in method is available; email-code fallback requires interactive stdin' + '没有可用的非交互登录方式;邮箱验证码回退需要交互式终端输入' + ) return false } - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting email-code sign-in') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '选择邮箱验证码登录') this.passwordlessMethodSelected = false const clicked = emailOption @@ -620,7 +617,7 @@ export class Login { : await this.bot.browser.utils.ghostClick(page, this.selectors.emailIconOld) if (!clicked) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not select email-code sign-in method') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '无法选择邮箱验证码登录方式') return false } @@ -636,18 +633,18 @@ export class Login { this.bot.logger.error( this.bot.isMobile, 'LOGIN', - `No supported sign-in method available${ignoredMethods.length ? `; ignored=${ignoredMethods.join(',')}` : ''}` + `没有可用的受支持登录方式${ignoredMethods.length ? `;已忽略=${ignoredMethods.join(',')}` : ''}` ) return false } // Recovery email confirmation case 'RECOVERY_EMAIL_INPUT': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery email input detected') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '检测到恢复邮箱输入') await this.waitForIdle(page, 'on recovery page') - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating recovery email handler') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '启动恢复邮箱处理器') await this.recoveryLogin.handle(page, account?.recoveryEmail) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery email handler completed successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '恢复邮箱处理器完成') return true } @@ -658,12 +655,12 @@ export class Login { this.bot.logger.error( this.bot.isMobile, 'LOGIN', - 'Email verification requires a configured password or interactive stdin' + '邮箱验证需要已配置的密码或交互式终端输入' ) return false } - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Email verification input detected') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '检测到邮箱验证输入') await this.codeLogin.handle(page) return true } @@ -671,7 +668,7 @@ export class Login { this.bot.logger.info( this.bot.isMobile, 'LOGIN', - 'Email verification input detected; checking alternatives' + '检测到邮箱验证输入;正在检查备选方式' ) await this.waitForIdle(page, 'on email verification page') @@ -692,12 +689,12 @@ export class Login { await this.waitForIdle(page, 'after email verification alternative') if (await this.checkSelector(page, this.selectors.passwordEntry)) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password sign-in option selected') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '已选择密码登录选项') return true } if (await this.checkSelector(page, this.selectors.signInTile)) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Sign-in method picker opened') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '已打开登录方式选择器') return true } @@ -706,19 +703,19 @@ export class Login { } if (canPromptForInput()) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Falling back to interactive email verification') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '回退到交互式邮箱验证') await this.codeLogin.handle(page) return true } - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'No usable email verification alternative found') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '未找到可用的邮箱验证备选方式') return false } case 'CHROMEWEBDATA_ERROR': { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'chromewebdata error detected, attempting recovery') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '检测到chromewebdata错误,尝试恢复') try { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', `Navigating to ${REWARDS_BASE_URL}`) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', `导航到 ${REWARDS_BASE_URL}`) await page .goto(REWARDS_BASE_URL, { waitUntil: 'domcontentloaded', @@ -726,10 +723,10 @@ export class Login { }) .catch(() => {}) await this.bot.utils.wait(3000) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery navigation successful') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '恢复导航成功') return true } catch { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Fallback to login.live.com') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '回退到login.live.com') await page .goto(URLs.auth.loginLive, { waitUntil: 'domcontentloaded', @@ -737,52 +734,52 @@ export class Login { }) .catch(() => {}) await this.bot.utils.wait(3000) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Fallback navigation successful') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '回退导航成功') return true } } case '2FA_TOTP': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA authentication required') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '需要TOTP双因素认证') await this.totp2FALogin.handle(page, account.totpSecret) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA handler completed successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP双因素认证处理器完成') return true } - // Stay signed in / KMSI confirmation + // 保持登录 / KMSI 确认 case 'KMSI_PROMPT': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Accepting KMSI prompt') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '接受KMSI提示') const clicked = await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton) if (!clicked) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not accept KMSI prompt') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '无法接受KMSI提示') return false } await this.waitForIdle(page, 'after KMSI acceptance') - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'KMSI prompt accepted') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'KMSI提示已接受') return true } // Passkey prompt/error - skip back to a supported sign-in method case 'PASSKEY_VIDEO': case 'PASSKEY_ERROR': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Skipping Passkey prompt') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '跳过Passkey提示') const clicked = await this.bot.browser.utils.ghostClick(page, this.selectors.secondaryButton) if (!clicked) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not skip Passkey prompt') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '无法跳过Passkey提示') return false } await this.waitForIdle(page, 'after Passkey skip') - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passkey prompt skipped') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passkey提示已跳过') return true } // Microsoft Authenticator approval/number challenge case 'LOGIN_PASSWORDLESS': { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling passwordless authentication') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '处理无密码认证') await this.passwordlessLogin.handle(page) this.passwordlessMethodSelected = false await this.waitForIdle(page, 'after passwordless auth') - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passwordless authentication completed successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '无密码认证完成') return true } @@ -791,7 +788,7 @@ export class Login { this.bot.logger.info( this.bot.isMobile, 'LOGIN', - 'OTP code entry page detected; trying an available alternative sign-in method' + '检测到OTP代码输入页面;尝试可用的备选登录方式' ) if (await this.checkSelector(page, this.selectors.footerAction)) { @@ -816,10 +813,10 @@ export class Login { this.bot.isMobile, 'LOGIN', methodPickerVisible - ? 'Returned to sign-in method selection' + ? '已返回登录方式选择' : passwordlessLandingVisible - ? 'Switched to Microsoft Authenticator sign-in' - : 'Switched away from email-code authentication' + ? '已切换到Microsoft Authenticator登录' + : '已离开邮箱验证码登录' ) return true } @@ -827,14 +824,14 @@ export class Login { this.bot.logger.debug( this.bot.isMobile, 'LOGIN', - 'OTP footer action did not leave the code-entry page; falling back to Back' + 'OTP页脚操作未离开代码输入页;回退到返回按钮' ) } } this.passwordlessMethodSelected = false if (!(await this.tryClick(page, this.selectors.backButton, 'Back button'))) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'No usable alternative action found on OTP page') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'OTP页面上没有可用的备选操作') return false } @@ -848,7 +845,7 @@ export class Login { this.bot.logger.warn( this.bot.isMobile, 'LOGIN', - `Unknown state at ${url.hostname}${url.pathname}, waiting` + `在 ${url.hostname}${url.pathname} 的未知状态,等待中` ) if (this.bot.config.errorDiagnostics && !this.capturedUnknownUrls.has(rawUrl)) { @@ -861,13 +858,13 @@ export class Login { } default: - this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Unhandled state: ${state}, continuing`) + this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `未处理的状态: ${state},继续执行`) return true } } private async finalizeLogin(page: Page, account: Account) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Finalizing login') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '完成登录') await page.goto(REWARDS_BASE_URL, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}) @@ -876,25 +873,25 @@ export class Login { const loginRewardsSuccess = rewardsHostname === 'bing.com' || rewardsHostname.endsWith('.bing.com') if (loginRewardsSuccess) { if (rewardsHostname === 'rewards.bing.com') { - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Logged into Microsoft Rewards successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '成功登录Microsoft Rewards') } else { this.bot.logger.info( this.bot.isMobile, 'LOGIN', - `Rewards sign-in redirected to Bing home (${rewardsHostname}); continuing verification` + `Rewards登录重定向到Bing主页 (${rewardsHostname});继续验证` ) } } else { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not verify Rewards Dashboard, assuming login valid') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN', '无法验证奖励仪表板,假定登录有效') } // Dismiss at rewards dashboard await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => {}) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting Bing session verification') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '开始Bing会话验证') await this.verifyBingSession(page, account) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Acquiring rewards context') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '获取奖励上下文') await this.getRewardsSession(page) const context = page.context() @@ -902,19 +899,19 @@ export class Login { this.bot.logger.debug( this.bot.isMobile, 'LOGIN', - `Saving session | cookies=${storageState.cookies.length} | origins=${storageState.origins.length}` + `保存会话 | cookies=${storageState.cookies.length} | origins=${storageState.origins.length}` ) saveStorageState(this.bot.config.sessionPath, account.email, this.bot.isMobile, storageState) await configureMediaBlocking(this.bot, context) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Login completed, session saved') + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '登录完成,会话已保存') } async verifyBingSession(page: Page, account: Account) { const url = URLs.auth.bingSignIn const loopMax = 5 - this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing session') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', '验证Bing会话') try { await page.goto(url, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}) @@ -922,7 +919,7 @@ export class Login { for (let i = 0; i < loopMax; i++) { if (page.isClosed()) break - this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `Verification loop ${i + 1}/${loopMax}`) + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `验证循环 ${i + 1}/${loopMax}`) const u = new URL(page.url()) const hostname = u.hostname.toLowerCase() @@ -931,7 +928,7 @@ export class Login { if (!atBingPage) { const state = await this.detectCurrentState(page) if (state === 'PASSKEY_ERROR') { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Dismissing Passkey error state') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', '关闭Passkey错误状态') await this.bot.browser.utils.ghostClick(page, this.selectors.secondaryButton) } @@ -941,7 +938,7 @@ export class Login { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-BING', - `At Bing page: ${atBingPage} (${hostname}${u.pathname})` + `在Bing页面: ${atBingPage} (${hostname}${u.pathname})` ) if (atBingPage) { @@ -952,10 +949,10 @@ export class Login { .then(() => true) .catch(() => false) - this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `Profile element found: ${signedIn}`) + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `找到个人资料元素: ${signedIn}`) if (signedIn || this.bot.isMobile) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing session verified successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing会话验证成功') return } } @@ -963,18 +960,18 @@ export class Login { await this.bot.utils.wait(1000) } - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-BING', 'Could not verify Bing session, continuing anyway') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-BING', '无法验证Bing会话,继续执行') } catch (error) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-BING', - `Verification error: ${error instanceof Error ? error.message : String(error)}` + `验证错误: ${error instanceof Error ? error.message : String(error)}` ) } } private async getRewardsSession(page: Page) { - this.bot.logger.info(this.bot.isMobile, 'GET-REWARD-SESSION', 'Bootstrapping rewards context') + this.bot.logger.info(this.bot.isMobile, 'GET-REWARD-SESSION', '引导奖励上下文') try { await this.bot.browser.func.bootstrap(page) @@ -988,7 +985,7 @@ export class Login { this.bot.logger.warn( this.bot.isMobile, 'GET-REWARD-SESSION', - 'No action ids resolved - server-action calls (report/streak protection) will be skipped this run' + '未解析到操作ID - 本次运行将跳过服务器操作调用(报告/连击保护)' ) } @@ -996,14 +993,14 @@ export class Login { this.bot.logger.warn( this.bot.isMobile, 'GET-REWARD-SESSION', - 'Page snapshot empty - neither /earn nor /dashboard rendered a usable RSC payload' + '页面快照为空 - /earn 和 /dashboard 均未渲染出可用的RSC数据' ) } this.bot.logger.info( this.bot.isMobile, 'GET-REWARD-SESSION', - `Context ready | actions=${actionsCount} | reportable=${reportableCount} | available=${availablePoints}` + `上下文就绪 | actions=${actionsCount} | reportable=${reportableCount} | available=${availablePoints}` ) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -1011,15 +1008,15 @@ export class Login { this.bot.logger.error( this.bot.isMobile, 'GET-REWARD-SESSION', - `Failed to acquire rewards context: ${message}` + `获取奖励上下文失败: ${message}` ) - throw new Error(`Failed to acquire rewards context: ${message}`) + throw new Error(`获取奖励上下文失败: ${message}`) } } async getAppAccessToken(page: Page, email: string) { - this.bot.logger.info(this.bot.isMobile, 'GET-APP-TOKEN', 'Requesting mobile access token') + this.bot.logger.info(this.bot.isMobile, 'GET-APP-TOKEN', '请求移动访问令牌') return await new MobileAccessLogin(this.bot, page).get(email) } } diff --git a/src/browser/auth/methods/EmailLogin.ts b/src/browser/auth/methods/EmailLogin.ts index 5c795b06..1efcd6df 100644 --- a/src/browser/auth/methods/EmailLogin.ts +++ b/src/browser/auth/methods/EmailLogin.ts @@ -13,7 +13,7 @@ export class EmailLogin { .waitForSelector(emailInputSelector, { state: 'visible', timeout: 1000 }) .catch(() => {}) if (!emailField) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email field not found') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', '未找到邮箱字段') return 'error' } @@ -28,30 +28,30 @@ export class EmailLogin { await page.fill(emailInputSelector, email).catch(() => {}) await this.bot.utils.wait(1000) } else { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email prefilled') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', '邮箱已预填充') } const submitButton = await page .waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 }) .catch(() => null) if (!submitButton) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Submit button not found') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', '未找到提交按钮') return 'error' } const clicked = await this.bot.browser.utils.ghostClick(page, this.submitButton) if (!clicked) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Could not submit email') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', '无法提交邮箱') return 'error' } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email submitted') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', '邮箱已提交') return 'ok' } catch (error) { this.bot.logger.error( this.bot.isMobile, 'LOGIN-ENTER-EMAIL', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) return 'error' } @@ -64,7 +64,7 @@ export class EmailLogin { .waitForSelector(passwordInputSelector, { state: 'visible', timeout: 1000 }) .catch(() => {}) if (!passwordField) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password field not found') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', '未找到密码字段') return 'error' } @@ -79,23 +79,23 @@ export class EmailLogin { .catch(() => null) if (!submitButton) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Submit button not found') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', '未找到提交按钮') return 'error' } const clicked = await this.bot.browser.utils.ghostClick(page, this.submitButton) if (!clicked) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Could not submit password') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', '无法提交密码') return 'error' } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password submitted') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', '密码已提交') return 'ok' } catch (error) { this.bot.logger.error( this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) return 'error' } diff --git a/src/browser/auth/methods/GetACodeLogin.ts b/src/browser/auth/methods/GetACodeLogin.ts index 0d9fab16..a71b68ce 100644 --- a/src/browser/auth/methods/GetACodeLogin.ts +++ b/src/browser/auth/methods/GetACodeLogin.ts @@ -39,7 +39,7 @@ export class CodeLogin { if (stilOnPage) { await page.keyboard.press('Enter') } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', '已填写代码输入') return true } @@ -55,17 +55,17 @@ export class CodeLogin { await page.keyboard.press('Enter') } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', '已填写代码输入') return true } - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'No code input field found') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', '未找到代码输入字段') return false } catch (error) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-CODE', - `Failed to fill code input: ${error instanceof Error ? error.message : String(error)}` + `填写代码输入失败: ${error instanceof Error ? error.message : String(error)}` ) return false } @@ -79,17 +79,17 @@ export class CodeLogin { await visibleInput.input.click().catch(() => {}) await page.keyboard.type(email, { delay: 50 }) await page.keyboard.press('Enter') - this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Submitted verification email') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', '已提交验证邮箱') return true } - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'Email verification input not found') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', '未找到邮箱验证输入') return false } catch (error) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-CODE', - `Failed to fill email input: ${error instanceof Error ? error.message : String(error)}` + `填写邮箱输入失败: ${error instanceof Error ? error.message : String(error)}` ) return false } @@ -97,7 +97,7 @@ export class CodeLogin { async handle(page: Page): Promise { try { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code login authentication requested') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', '请求代码登录身份验证') if (!canPromptForInput()) { throw new Error('Email-code authentication requires interactive stdin; unavailable in background mode') @@ -105,9 +105,9 @@ export class CodeLogin { const emailMessage = await getSubtitleMessage(page) if (emailMessage) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', `Page message: "${emailMessage}"`) + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', `页面消息: "${emailMessage}"`) } else { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'Unable to retrieve email code destination') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', '无法检索邮件代码目的地') } const emailProofInput = await this.findEmailVerificationInput(page) @@ -118,13 +118,13 @@ export class CodeLogin { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-CODE', - `Using ${selectorVariant} email verification input selector` + `使用${selectorVariant}版邮箱验证输入选择器` ) const maskedHint = emailMessage?.match(/[A-Za-z0-9._*+-]+@[A-Za-z0-9.*-]+\.[A-Za-z]{2,}/)?.[0] this.bot.logger.info( this.bot.isMobile, 'LOGIN-CODE', - 'Email verification challenge detected, awaiting email address' + '检测到邮箱验证挑战,等待邮箱地址' ) let emailVerified = false @@ -141,7 +141,7 @@ export class CodeLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-CODE', - `Invalid or missing email (attempt ${attempt}/${this.maxManualAttempts}) | input length=${email?.length}` + `无效或缺少邮箱 (尝试 ${attempt}/${this.maxManualAttempts}) | 输入长度=${email?.length}` ) if (attempt === this.maxManualAttempts) { @@ -155,7 +155,7 @@ export class CodeLogin { this.bot.logger.error( this.bot.isMobile, 'LOGIN-CODE', - `Unable to fill email input (attempt ${attempt}/${this.maxManualAttempts})` + `无法填写邮箱输入 (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { @@ -172,7 +172,7 @@ export class CodeLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-CODE', - `Email rejected: ${emailError} (attempt ${attempt}/${this.maxManualAttempts})` + `邮箱被拒绝: ${emailError} (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { @@ -188,7 +188,7 @@ export class CodeLogin { continue } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Email accepted, verification code sent') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', '邮箱已接受,验证代码已发送') emailVerified = true break } @@ -211,7 +211,7 @@ export class CodeLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-CODE', - `Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}` + `无效或缺少代码 (尝试 ${attempt}/${this.maxManualAttempts}) | 输入长度=${code?.length}` ) if (attempt === this.maxManualAttempts) { @@ -225,7 +225,7 @@ export class CodeLogin { this.bot.logger.error( this.bot.isMobile, 'LOGIN-CODE', - `Unable to fill code input (attempt ${attempt}/${this.maxManualAttempts})` + `无法填写代码输入 (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { @@ -243,7 +243,7 @@ export class CodeLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-CODE', - `Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})` + `代码不正确: ${errorMessage} (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { @@ -260,7 +260,7 @@ export class CodeLogin { continue } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code authentication completed successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', '代码身份验证成功完成') return } @@ -269,7 +269,7 @@ export class CodeLogin { this.bot.logger.error( this.bot.isMobile, 'LOGIN-CODE', - `Error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) throw error } diff --git a/src/browser/auth/methods/MobileAccessLogin.ts b/src/browser/auth/methods/MobileAccessLogin.ts index 6de5f185..7e60ef8d 100644 --- a/src/browser/auth/methods/MobileAccessLogin.ts +++ b/src/browser/auth/methods/MobileAccessLogin.ts @@ -37,7 +37,7 @@ export class MobileAccessLogin { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-APP', - `Auth URL constructed: ${authorizeUrl.origin}${authorizeUrl.pathname}` + `认证URL构建完成: ${authorizeUrl.origin}${authorizeUrl.pathname}` ) let code = await this.resolveCodeViaRequest(authorizeUrl) @@ -45,7 +45,7 @@ export class MobileAccessLogin { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-APP', - 'Request-based OAuth did not return a code, retrying through the active browser context' + '基于请求的OAuth未返回代码,通过当前浏览器上下文重试' ) code = await this.resolveCodeViaBrowser(authorizeUrl) } @@ -54,12 +54,12 @@ export class MobileAccessLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-APP', - 'Could not resolve mobile OAuth code - app activities will be skipped this run' + '无法解析移动OAuth代码 - 本次运行将跳过应用活动' ) return '' } - this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'OAuth code resolved, exchanging for access token') + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', '已解析OAuth代码,正在交换访问令牌') const data = new URLSearchParams() data.append('grant_type', 'authorization_code') @@ -82,18 +82,18 @@ export class MobileAccessLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-APP', - `Mobile token exchange failed | error=${error} | description=${description}` + `移动令牌交换失败 | error=${error} | description=${description}` ) return '' } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Mobile access token received') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', '移动访问令牌已接收') return token } catch (error) { this.bot.logger.error( this.bot.isMobile, 'LOGIN-APP', - `MobileAccess error: ${error instanceof Error ? error.stack || error.message : String(error)}` + `MobileAccess错误: ${error instanceof Error ? error.stack || error.message : String(error)}` ) return '' } @@ -101,7 +101,7 @@ export class MobileAccessLogin { private async resolveCodeViaRequest(authorizeUrl: URL): Promise { try { - this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Resolving mobile OAuth code via request') + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', '通过请求解析移动OAuth代码') const response = await this.page.request.get(authorizeUrl.href, { maxRedirects: 20, timeout: 30000 }) const finalUrl = response.url() @@ -109,7 +109,7 @@ export class MobileAccessLogin { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-APP', - `OAuth request resolved → ${this.safeUrl(finalUrl)} (status ${response.status()})` + `OAuth请求解析完成 → ${this.safeUrl(finalUrl)} (状态 ${response.status()})` ) return this.extractCode(finalUrl) @@ -117,7 +117,7 @@ export class MobileAccessLogin { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-APP', - `OAuth code request failed: ${error instanceof Error ? error.message : String(error)}` + `OAuth代码请求失败: ${error instanceof Error ? error.message : String(error)}` ) return '' } @@ -141,7 +141,7 @@ export class MobileAccessLogin { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-APP', - `OAuth browser resolved → ${this.safeUrl(oauthPage.url())} | code=${code ? 'present' : 'missing'}` + `OAuth浏览器解析完成 → ${this.safeUrl(oauthPage.url())} | code=${code ? 'present' : 'missing'}` ) return code @@ -149,7 +149,7 @@ export class MobileAccessLogin { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-APP', - `OAuth browser fallback failed: ${error instanceof Error ? error.message : String(error)}` + `OAuth浏览器回退失败: ${error instanceof Error ? error.message : String(error)}` ) return '' } finally { diff --git a/src/browser/auth/methods/PasswordlessLogin.ts b/src/browser/auth/methods/PasswordlessLogin.ts index ff55bd01..5574bbc6 100644 --- a/src/browser/auth/methods/PasswordlessLogin.ts +++ b/src/browser/auth/methods/PasswordlessLogin.ts @@ -59,7 +59,7 @@ export class PasswordlessLogin { return number?.trim() || null } } catch { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Could not retrieve displayed number') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-PASSWORDLESS', '无法检索显示的号码') } return null } @@ -68,13 +68,13 @@ export class PasswordlessLogin { this.bot.logger.info( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `Waiting for approval... (timeout after ${this.approvalTimeoutSeconds} seconds)` + `等待批准... (${this.approvalTimeoutSeconds}秒后超时)` ) for (let elapsed = 1; elapsed <= this.approvalTimeoutSeconds; elapsed++) { const state = await this.getStableApprovalState(page) if (state === 'APPROVED') { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Approval detected') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', '检测到批准') return state } if (state === 'RETRY_AVAILABLE') return state @@ -83,7 +83,7 @@ export class PasswordlessLogin { this.bot.logger.debug( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `Still waiting... (${elapsed}/${this.approvalTimeoutSeconds} seconds elapsed)` + `仍在等待... (已过去 ${elapsed}/${this.approvalTimeoutSeconds} 秒)` ) } @@ -99,7 +99,7 @@ export class PasswordlessLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `Approval timeout after ${this.approvalTimeoutSeconds} seconds!` + `${this.approvalTimeoutSeconds} 秒后批准超时!` ) return 'TIMED_OUT' } @@ -127,7 +127,7 @@ export class PasswordlessLogin { this.bot.logger.info( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `Please approve login and select number: ${displayedNumber}`, + `请批准登录并选择数字: ${displayedNumber}`, 'yellowBright' ) return @@ -136,21 +136,21 @@ export class PasswordlessLogin { this.bot.logger.info( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - 'Please approve login on your authenticator app', + '请在您的身份验证器应用上批准登录', 'yellowBright' ) } async handle(page: Page): Promise { try { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Passwordless authentication requested') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', '请求无密码身份验证') for (let retry = 0; retry <= this.maxRequestRetries; retry++) { await this.logApprovalNumber(page) const state = await this.waitForApproval(page) if (state === 'APPROVED') { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approved successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', '登录批准成功') await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) return } @@ -159,7 +159,7 @@ export class PasswordlessLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `Approval request expired; sending retry ${retry + 1}/${this.maxRequestRetries}` + `批准请求已过期;发送重试 ${retry + 1}/${this.maxRequestRetries}` ) if (await this.sendAnotherRequest(page)) continue throw new Error('Could not send another passwordless authentication request') @@ -171,7 +171,7 @@ export class PasswordlessLogin { this.bot.logger.error( this.bot.isMobile, 'LOGIN-PASSWORDLESS', - `An error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) throw error } diff --git a/src/browser/auth/methods/RecoveryEmailLogin.ts b/src/browser/auth/methods/RecoveryEmailLogin.ts index 5874aef0..269839a6 100644 --- a/src/browser/auth/methods/RecoveryEmailLogin.ts +++ b/src/browser/auth/methods/RecoveryEmailLogin.ts @@ -11,7 +11,7 @@ export class RecoveryLogin { private async fillEmail(page: Page, email: string): Promise { try { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Filling recovery email') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '填写恢复邮箱') const visibleInput = await page .waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 }) @@ -20,21 +20,21 @@ export class RecoveryLogin { if (visibleInput) { await page.keyboard.type(email, { delay: 50 }) await page.keyboard.press('Enter') - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Successfully filled email input field') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '成功填写邮箱输入字段') return true } this.bot.logger.warn( this.bot.isMobile, 'LOGIN-RECOVERY', - `Email input field not found with selector: ${this.textInputSelector}` + `未找到邮箱输入字段,选择器: ${this.textInputSelector}` ) return false } catch (error) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-RECOVERY', - `Failed to fill email input: ${error instanceof Error ? error.message : String(error)}` + `填写邮箱输入失败: ${error instanceof Error ? error.message : String(error)}` ) return false } @@ -42,20 +42,20 @@ export class RecoveryLogin { async handle(page: Page, recoveryEmail: string): Promise { try { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email recovery authentication flow initiated') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '邮箱恢复身份验证流程已启动') if (recoveryEmail) { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Using configured recovery email') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '使用配置的恢复邮箱') const filled = await this.fillEmail(page, recoveryEmail) if (!filled) { throw new Error('Email input field not found') } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '等待页面响应') await this.bot.utils.wait(500) await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { - this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached') + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', '网络空闲超时到达') }) const errorMessage = await getErrorMessage(page) @@ -63,7 +63,7 @@ export class RecoveryLogin { throw new Error(`Email verification failed: ${errorMessage}`) } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '邮箱身份验证成功完成') return } @@ -74,20 +74,20 @@ export class RecoveryLogin { this.bot.logger.info( this.bot.isMobile, 'LOGIN-RECOVERY', - 'No recovery email provided, will prompt user for input' + '未提供恢复邮箱,将提示用户输入' ) for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) { this.bot.logger.info( this.bot.isMobile, 'LOGIN-RECOVERY', - `Starting attempt ${attempt}/${this.maxManualAttempts}` + `开始尝试 ${attempt}/${this.maxManualAttempts}` ) this.bot.logger.info( this.bot.isMobile, 'LOGIN-RECOVERY', - `Prompting user for email input (timeout: ${this.maxManualSeconds}s)` + `提示用户输入邮箱 (超时: ${this.maxManualSeconds}秒)` ) const email = await promptInput({ @@ -100,7 +100,7 @@ export class RecoveryLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-RECOVERY', - `No or invalid email input received (attempt ${attempt}/${this.maxManualAttempts})` + `未收到或收到无效邮箱输入 (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { @@ -113,7 +113,7 @@ export class RecoveryLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-RECOVERY', - `Invalid email format received (attempt ${attempt}/${this.maxManualAttempts}) | length=${email.length}` + `收到无效邮箱格式 (尝试 ${attempt}/${this.maxManualAttempts}) | 长度=${email.length}` ) if (attempt === this.maxManualAttempts) { @@ -122,14 +122,14 @@ export class RecoveryLogin { continue } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Valid recovery email received from user') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '从用户收到有效邮箱') const filled = await this.fillEmail(page, email) if (!filled) { this.bot.logger.error( this.bot.isMobile, 'LOGIN-RECOVERY', - `Failed to fill email input field (attempt ${attempt}/${this.maxManualAttempts})` + `无法填写邮箱输入字段 (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { @@ -140,10 +140,10 @@ export class RecoveryLogin { continue } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '等待页面响应') await this.bot.utils.wait(500) await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { - this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached') + this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', '网络空闲超时到达') }) const errorMessage = await getErrorMessage(page) @@ -151,36 +151,36 @@ export class RecoveryLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-RECOVERY', - `Error from page: "${errorMessage}" (attempt ${attempt}/${this.maxManualAttempts})` + `页面错误: "${errorMessage}" (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { throw new Error(`Maximum attempts reached. Last error: ${errorMessage}`) } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Clearing input field for retry') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '清除输入字段以重试') const inputToClear = await page.$(this.textInputSelector).catch(() => null) if (inputToClear) { await inputToClear.click() await page.keyboard.press('Control+A') await page.keyboard.press('Backspace') - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Input field cleared') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '输入字段已清除') } else { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-RECOVERY', 'Could not find input field to clear') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-RECOVERY', '找不到要清除的输入字段') } await this.bot.utils.wait(1000) continue } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', '邮箱身份验证成功完成') return } throw new Error(`Email input failed after ${this.maxManualAttempts} attempts`) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) - this.bot.logger.error(this.bot.isMobile, 'LOGIN-RECOVERY', `Fatal error: ${errorMsg}`) + this.bot.logger.error(this.bot.isMobile, 'LOGIN-RECOVERY', `致命错误: ${errorMsg}`) throw error } } diff --git a/src/browser/auth/methods/Totp2FALogin.ts b/src/browser/auth/methods/Totp2FALogin.ts index b3068c09..2418b22e 100644 --- a/src/browser/auth/methods/Totp2FALogin.ts +++ b/src/browser/auth/methods/Totp2FALogin.ts @@ -25,24 +25,24 @@ export class TotpLogin { if (visibleInput) { await visibleInput.fill(code) - this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', '已填写TOTP输入') return true } const secondairyInput = await page.$(this.secondairyInputSelector) if (secondairyInput) { await secondairyInput.fill(code) - this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', '已填写TOTP输入') return true } - this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found') + this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', '未找到TOTP输入字段') return false } catch (error) { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-TOTP', - `Failed to fill TOTP input: ${error instanceof Error ? error.message : String(error)}` + `填写TOTP输入失败: ${error instanceof Error ? error.message : String(error)}` ) return false } @@ -50,15 +50,15 @@ export class TotpLogin { async handle(page: Page, totpSecret?: string): Promise { try { - this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP 2FA authentication requested') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', '请求TOTP双因素身份验证') if (totpSecret) { const code = this.generateTotpCode(totpSecret) - this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Generated TOTP code from secret') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', '从密钥生成TOTP代码') const filled = await this.fillCode(page, code) if (!filled) { - this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to fill TOTP input field') + this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', '无法填写TOTP输入字段') throw new Error('TOTP input field not found') } @@ -70,11 +70,11 @@ export class TotpLogin { const errorMessage = await getErrorMessage(page) if (errorMessage) { - this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', `TOTP failed: ${errorMessage}`) + this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', `TOTP失败: ${errorMessage}`) throw new Error(`TOTP authentication failed: ${errorMessage}`) } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP身份验证成功完成') return } @@ -82,7 +82,7 @@ export class TotpLogin { throw new Error('TOTP secret is required because interactive stdin is unavailable') } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP secret provided, awaiting manual input') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', '未提供TOTP密钥,等待手动输入') for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) { const code = await promptInput({ @@ -95,7 +95,7 @@ export class TotpLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-TOTP', - `Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}` + `无效或缺少代码 (尝试 ${attempt}/${this.maxManualAttempts}) | 输入长度=${code?.length}` ) if (attempt === this.maxManualAttempts) { @@ -109,7 +109,7 @@ export class TotpLogin { this.bot.logger.error( this.bot.isMobile, 'LOGIN-TOTP', - `Unable to fill TOTP input (attempt ${attempt}/${this.maxManualAttempts})` + `无法填写TOTP输入 (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { @@ -130,7 +130,7 @@ export class TotpLogin { this.bot.logger.warn( this.bot.isMobile, 'LOGIN-TOTP', - `Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})` + `代码不正确: ${errorMessage} (尝试 ${attempt}/${this.maxManualAttempts})` ) if (attempt === this.maxManualAttempts) { @@ -139,7 +139,7 @@ export class TotpLogin { continue } - this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully') + this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP身份验证成功完成') return } @@ -148,7 +148,7 @@ export class TotpLogin { this.bot.logger.error( this.bot.isMobile, 'LOGIN-TOTP', - `Error occurred: ${error instanceof Error ? error.message : String(error)}` + `发生错误: ${error instanceof Error ? error.message : String(error)}` ) throw error } diff --git a/src/functions/QueryEngine.ts b/src/functions/QueryEngine.ts index 9d750fd1..a1edf53a 100644 --- a/src/functions/QueryEngine.ts +++ b/src/functions/QueryEngine.ts @@ -1,4 +1,4 @@ -import type { HttpRequestConfig } from '../util/Http' +import type { HttpRequestConfig, HttpResponse } from '../util/Http' import * as fs from 'fs' import path from 'path' import { XMLParser } from 'fast-xml-parser' @@ -21,6 +21,18 @@ const MAX_CLUSTER_SUGGESTIONS = 8 const MAX_RSS_XML_BYTES = 2 * 1024 * 1024 const XML_DECLARATION_RE = / { const { shuffle = false, @@ -75,10 +92,11 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'QUERY-MANAGER', - `Building main topic pool | sources=${sourceOrder.join(',')} | shuffle=${shuffle} | lang=${langCode} | geo=${geoLocale}` + `构建主题词池 | 源=${sourceOrder.join(',')} | shuffle=${shuffle} | lang=${langCode} | geo=${geoLocale}` ) const sourceHandlers: Record Promise | string[]> = { + china: () => this.getChinaTrends(geoLocale.toUpperCase()).catch(() => []), google: () => this.getGoogleTrends(geoLocale.toUpperCase()).catch(() => []), wikipedia: () => this.getWikipediaTrending(langCode).catch(() => []), wikirandom: () => this.getWikipediaRandom(langCode).catch(() => []), @@ -91,39 +109,61 @@ export class QueryCore { const coreSources = sourceOrder.filter(source => !isRss(source)) as QueryEngine[] const rssSelectors = sourceOrder.filter(isRss) - const topicLists = await Promise.all([ - ...coreSources.map(async source => { - const handler = sourceHandlers[source] - if (!handler) return [] + const primarySource = coreSources[0] + const threshold = QueryCore.FALLBACK_THRESHOLD + const collectedCount = () => topicLists.flat().length - const topics = await Promise.resolve(handler()) + const topicLists: string[][] = [] + for (const source of coreSources) { + // 已累计达标就停,不再请求后续源(主源或前序兜底源已够用) + if (collectedCount() >= threshold) { this.bot.logger.debug( this.bot.isMobile, 'QUERY-MANAGER', - `Source "${source}" returned ${topics.length}` + `兜底命中阈值,停止取后续源 | 已累计=${collectedCount()} | 阈值=${threshold} | 停在源=${source}` ) - return topics - }), - ...(rssSelectors.length - ? [ - this.getRssTopics(rssSelectors) - .catch(() => []) - .then(topics => { - this.bot.logger.debug( - this.bot.isMobile, - 'QUERY-MANAGER', - `Source "rss" returned ${topics.length} (${rssSelectors.length} selector(s))` - ) - return topics - }) - ] - : []) - ]) + break + } + + const handler = sourceHandlers[source] + if (!handler) continue + + const topics = await Promise.resolve(handler()) + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `源 "${source}" 返回 ${topics.length} 条` + ) + if (!topics.length) continue + + // 主源全量纳入;后续源只随机抽样 FALLBACK_LOCAL_SAMPLE 个补充 + if (source !== primarySource) { + const sampled = this.bot.utils.shuffleArray([...topics]).slice(0, QueryCore.FALLBACK_LOCAL_SAMPLE) + topicLists.push(sampled) + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `兜底补充 | 源=${source} | 原始=${topics.length} | 抽样=${sampled.length} | 累计=${collectedCount()}` + ) + } else { + topicLists.push(topics) + } + } + + if (rssSelectors.length) { + const rssTopics = await this.getRssTopics(rssSelectors).catch(() => []) + this.bot.logger.debug( + this.bot.isMobile, + 'QUERY-MANAGER', + `源 "rss" 返回 ${rssTopics.length} 条(${rssSelectors.length} 个选择器)` + ) + if (rssTopics.length) topicLists.push(rssTopics) + } const rawTopics = topicLists.flat() const topics = this.normalizeAndDedupe(rawTopics) if (!topics.length) { - this.bot.logger.warn(this.bot.isMobile, 'QUERY-MANAGER', 'No topics returned by any source') + this.bot.logger.warn(this.bot.isMobile, 'QUERY-MANAGER', '所有源均未返回主题') return [] } @@ -132,21 +172,21 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'QUERY-MANAGER', - `Shuffled main topic pool | first="${topics[0] ?? ''}"` + `主题词池已打乱 | 首个="${topics[0] ?? ''}"` ) } this.bot.logger.debug( this.bot.isMobile, 'QUERY-MANAGER', - `Built main topic pool | raw=${rawTopics.length} | unique=${topics.length} | duplicatesRemoved=${rawTopics.length - topics.length}` + `主题词池已构建 | 原始=${rawTopics.length} | 去重后=${topics.length} | 移除重复=${rawTopics.length - topics.length}` ) return topics } catch (error) { this.bot.logger.error( this.bot.isMobile, 'QUERY-MANAGER', - `Failed building main topic pool | ${error instanceof Error ? error.message : String(error)}` + `构建主题词池失败 | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -177,7 +217,7 @@ export class QueryCore { const normalizedMain = this.normalizeAndDedupe([mainTopic])[0] if (!normalizedMain) return [] if (!this.bot.config.searchSettings.clusterSearch) { - this.bot.logger.debug(this.bot.isMobile, 'QUERY-CLUSTER', `Clustering disabled | main="${normalizedMain}"`) + this.bot.logger.debug(this.bot.isMobile, 'QUERY-CLUSTER', `聚类已禁用 | 主词="${normalizedMain}"`) return [normalizedMain] } @@ -185,7 +225,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'QUERY-CLUSTER', - `Fetching related queries | main="${normalizedMain}" | lang=${langCode} | limit=${MAX_CLUSTER_SUGGESTIONS}` + `正在获取相关查询 | 主词="${normalizedMain}" | lang=${langCode} | 上限=${MAX_CLUSTER_SUGGESTIONS}` ) const [suggestionsResult, relatedResult] = await Promise.allSettled([ @@ -199,14 +239,14 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'QUERY-CLUSTER', - `Related source unavailable | source=v7 | main="${normalizedMain}" | ${String(suggestionsResult.reason)}` + `相关词源不可用 | 源=v7 | 主词="${normalizedMain}" | ${String(suggestionsResult.reason)}` ) } if (relatedResult.status === 'rejected') { this.bot.logger.debug( this.bot.isMobile, 'QUERY-CLUSTER', - `Related source unavailable | source=osjson | main="${normalizedMain}" | ${String(relatedResult.reason)}` + `相关词源不可用 | 源=osjson | 主词="${normalizedMain}" | ${String(relatedResult.reason)}` ) } @@ -216,13 +256,13 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'QUERY-CLUSTER', - `Related source ready | source=v7 | main="${normalizedMain}" | raw=${rawSuggestions.length} | unique=${suggestions.length} | queries=${JSON.stringify(suggestions)}` + `相关词源就绪 | 源=v7 | 主词="${normalizedMain}" | 原始=${rawSuggestions.length} | 去重后=${suggestions.length} | 查询=${JSON.stringify(suggestions)}` ) this.bot.logger.debug( this.bot.isMobile, 'QUERY-CLUSTER', - `Related source ready | source=osjson | main="${normalizedMain}" | raw=${rawRelated.length} | unique=${related.length} | queries=${JSON.stringify(related)}` + `相关词源就绪 | 源=osjson | 主词="${normalizedMain}" | 原始=${rawRelated.length} | 去重后=${related.length} | 查询=${JSON.stringify(related)}` ) const interleaved: string[] = [] @@ -241,7 +281,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'QUERY-CLUSTER', - `Related queries merged | main="${normalizedMain}" | availableSources=${Number(suggestions.length > 0) + Number(related.length > 0)} | unique=${merged.length} | selected=${selected.length} | queries=${JSON.stringify(selected)}` + `相关查询已合并 | 主词="${normalizedMain}" | 可用源数=${Number(suggestions.length > 0) + Number(related.length > 0)} | 去重后=${merged.length} | 选中=${selected.length} | 查询=${JSON.stringify(selected)}` ) const cluster = [normalizedMain, ...selected] @@ -250,7 +290,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'QUERY-CLUSTER', - `Cluster ready | main="${normalizedMain}" | related=${Math.max(0, cluster.length - 1)} | total=${cluster.length} | order=${JSON.stringify(cluster)}` + `聚类就绪 | 主词="${normalizedMain}" | 相关词=${Math.max(0, cluster.length - 1)} | 总数=${cluster.length} | 顺序=${JSON.stringify(cluster)}` ) return cluster } @@ -289,7 +329,7 @@ export class QueryCore { const response = await this.bot.http.request(request, this.bot.config.proxy.queryEngine) const trendsData = this.extractJsonFromResponse(response.data) if (!trendsData) { - this.bot.logger.debug(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'No trends data parsed from response') + this.bot.logger.debug(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', '未能从响应中解析趋势数据') return [] } @@ -306,7 +346,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', - `Request failed | ${error instanceof Error ? error.message : String(error)}` + `请求失败 | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -341,7 +381,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', - `Request failed | query="${query}" | ${error instanceof Error ? error.message : String(error)}` + `请求失败 | 查询="${query}" | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -364,7 +404,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-BING-RELATED', - `Request failed | query="${query}" | ${error instanceof Error ? error.message : String(error)}` + `请求失败 | 查询="${query}" | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -391,7 +431,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-WIKIPEDIA-TRENDING', - `Request failed | lang=${langCode} | ${error instanceof Error ? error.message : String(error)}` + `请求失败 | 语言=${langCode} | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -414,7 +454,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-REDDIT', - `Request failed | subreddit=${safe} | ${error instanceof Error ? error.message : String(error)}` + `请求失败 | 子版块=${safe} | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -436,7 +476,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-HACKERNEWS', - `Request failed | ${error instanceof Error ? error.message : String(error)}` + `请求失败 | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -462,7 +502,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-WIKIPEDIA-RANDOM', - `Request failed | lang=${lang} | ${error instanceof Error ? error.message : String(error)}` + `请求失败 | 语言=${lang} | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -491,7 +531,7 @@ export class QueryCore { const feeds = RSS_FEEDS[site] if (!feeds) { - this.bot.logger.warn(this.bot.isMobile, 'SEARCH-RSS', `Unknown RSS site "${site}" in "${selector}"`) + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-RSS', `未知的 RSS 站点 "${site}"(选择器 "${selector}")`) continue } @@ -502,7 +542,7 @@ export class QueryCore { const url = feeds[endpoint] if (url) urls.add(url) - else this.bot.logger.warn(this.bot.isMobile, 'SEARCH-RSS', `Unknown RSS feed "${site}.${endpoint}"`) + else this.bot.logger.warn(this.bot.isMobile, 'SEARCH-RSS', `未知的 RSS 订阅源 "${site}.${endpoint}"`) } return [...urls] @@ -523,7 +563,7 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-RSS', - `Feed failed | ${url} | ${error instanceof Error ? error.message : String(error)}` + `订阅源失败 | ${url} | ${error instanceof Error ? error.message : String(error)}` ) return [] } @@ -558,9 +598,262 @@ export class QueryCore { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-LOCAL-QUERY-LIST', - `Failed reading search-queries.json | ${error instanceof Error ? error.message : String(error)}` + `读取 search-queries.json 失败 | ${error instanceof Error ? error.message : String(error)}` ) return [] } } + + /** + * 获取中国地区的热门搜索词(百度、抖音、微博、头条、知乎等)。 + * 数据源:gmya.net 热门词 API。 + * 策略: + * - appkey 配置在 searchSettings.chinaApi.appkey;留空走免费档。 + * - 随机选取若干源聚合结果,分散 API 负载、增加搜索词多样性。 + * - 免费档(无 appkey)有激进的频率限制:源与源之间插入随机退避, + * 命中限流(403)后对后续源做指数退避;有 appkey 则不退避。 + * - 某个源失败时自动 fallback 到剩余源,确保至少拿到 1 个源的数据。 + * + * @param geoLocale - 地理区域代码,默认为'CN' + * @returns 热搜标题字符串数组 + */ + async getChinaTrends(geoLocale: string = 'CN'): Promise { + const allSources = ['BaiduHot', 'TouTiaoHot', 'DouYinHot', 'WeiBoHot', 'ZhiHuHot'] + const baseUrl = 'https://api.gmya.net/Api/' + // appkey 来自配置;留空走免费档(有频率限制),填入则解除限流 + const appkey = this.bot.config.searchSettings.chinaApi?.appkey?.trim() ?? '' + const hasAppkey = appkey.length > 0 + // 免费档容易被限流:减少首选源数量以降低触发面;有 appkey 则保持 2 个兼顾多样性 + const pickedCount = hasAppkey ? 2 : 1 + // 免费档源间退避参数(毫秒);有 appkey 不需要退避 + const backoffMin = 1200 + const backoffMax = 2500 + + // 随机打乱源顺序,取前 pickedCount 个作为首选;其余作为 fallback 备用 + const shuffled = this.bot.utils.shuffleArray([...allSources]) + const picked = shuffled.slice(0, pickedCount) + const fallback = shuffled.slice(pickedCount) + + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-CHINA-TRENDS', + `正在获取中国热搜 | 地区=${geoLocale} | appkey=${hasAppkey ? '已配置' : '免费档'} | 首选源=${picked.join(', ')} | 备用源=${fallback.length}个` + ) + + /** + * 免费档在源与源之间插入随机退避,降低连续请求触发 403 限流的概率。 + * 命中限流后对后续源做指数退避(multiplier 递增)。 + * @param multiplier 基础退避倍数,限流后递增 + */ + const maybeBackoff = async (multiplier: number): Promise => { + if (hasAppkey) return + await this.bot.utils.wait(this.bot.utils.randomDelay(backoffMin * multiplier, backoffMax * multiplier)) + } + + const titles = new Set() + const failedSources: string[] = [] + let backoffMultiplier = 1 // 限流命中后递增 + + // 先依次尝试首选源 + for (let i = 0; i < picked.length; i++) { + if (i > 0) await maybeBackoff(backoffMultiplier) + const source = picked[i]! + try { + const result = await this.fetchChinaHotWords(this.buildChinaApiUrl(baseUrl, source, appkey), source) + if (result.length) { + result.forEach(t => titles.add(t)) + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-CHINA-TRENDS', + `获取 ${source} 成功 | 数量=${result.length} | 累计=${titles.size}` + ) + } else { + this.bot.logger.warn(this.bot.isMobile, 'SEARCH-CHINA-TRENDS', `${source} 返回空列表`) + failedSources.push(source) + } + } catch (error) { + failedSources.push(source) + if (error instanceof ChinaApiRateLimitError) backoffMultiplier *= 1.5 + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-CHINA-TRENDS', + `${source} 请求失败 | 错误=${error instanceof Error ? error.message : String(error)}` + ) + } + } + + // 如果首选源全部失败,逐个 fallback 直到拿到数据 + if (titles.size === 0 && fallback.length) { + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-CHINA-TRENDS', + `首选源全部失败(${failedSources.join(', ')}),尝试备用源 ${fallback.join(', ')}` + ) + for (let i = 0; i < fallback.length; i++) { + await maybeBackoff(backoffMultiplier) + const source = fallback[i]! + try { + const result = await this.fetchChinaHotWords( + this.buildChinaApiUrl(baseUrl, source, appkey), + source + ) + if (result.length) { + result.forEach(t => titles.add(t)) + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-CHINA-TRENDS', + `备用源 ${source} 成功 | 数量=${result.length} | 累计=${titles.size}` + ) + break // 拿到数据就停 + } + } catch (error) { + if (error instanceof ChinaApiRateLimitError) backoffMultiplier *= 1.5 + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-CHINA-TRENDS', + `备用源 ${source} 也失败 | 错误=${error instanceof Error ? error.message : String(error)}` + ) + } + } + } + + if (titles.size === 0) { + this.bot.logger.warn( + this.bot.isMobile, + 'SEARCH-CHINA-TRENDS', + `所有 ${allSources.length} 个热搜源均失败,将仅依赖其他查询源` + ) + } else { + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-CHINA-TRENDS', + `中国热搜获取完成 | 最终词数=${titles.size} | 成功源=${picked.filter(s => !failedSources.includes(s)).join(',') || fallback.filter(() => titles.size > 0).join(',')}`, + 'green' + ) + } + + return Array.from(titles) + } + + /** + * 构造 gmya.net 热搜 API 的请求 URL。 + */ + private buildChinaApiUrl(baseUrl: string, source: string, appkey: string): string { + return appkey ? `${baseUrl}${source}?format=json&appkey=${appkey}` : `${baseUrl}${source}` + } + + /** + * 请求单个中国热搜源并解析标题。 + * 走 bot.http(统一代理、错误诊断、fingerprint headers),带 10s 超时。 + * + * 诊断策略:正常就 return;任何异常都把"原始返回值"打到日志里,让看日志的人直接判断 + * 是限流、HTML 拦截页、维护 JSON 还是接口结构变更——比预先贴标签更有用。 + * 唯一例外是限流:上层退避需要它做控制流,所以用 ChinaApiRateLimitError 单独标记, + * 但错误信息同样带上原始响应。 + * + * 注:HttpClient(Impit)原生解压 gzip/br/zstd,fingerprint 注入的 accept-encoding + * 无需像 axios 版本那样手动覆盖。 + */ + private async fetchChinaHotWords(url: string, source: string): Promise { + const request: HttpRequestConfig = { + url, + method: 'GET', + headers: { ...(this.bot.fingerprint?.headers ?? {}) }, + timeout: 10000 + } + + // 请求失败(HTTP 非 2xx / 超时 / 网络错误):直接吐原始返回,不再预先贴标签 + let response: HttpResponse + try { + response = await this.bot.http.request(request, this.bot.config.proxy.queryEngine) + } catch (error) { + const { rateLimited, text } = this.describeFetchError(error) + if (rateLimited) throw new ChinaApiRateLimitError(source, text) + throw new Error(`${source} 失败 | 原始响应=${text}`) + } + + const data = response.data + + // 限流:上层退避需要这个标记;信息里仍带原始响应 + if (this.isChinaRateLimited(response)) { + throw new ChinaApiRateLimitError(source, `原始响应=${this.summarizeBody(data)}`) + } + + // 正常结构:{ data: [{ title: string }, ...] } + if (data && Array.isArray((data as { data?: unknown }).data)) { + return ((data as { data: unknown[] }).data as { title?: unknown }[]) + .filter(item => item && typeof item.title === 'string') + .map(item => item.title as string) + .filter((title: string) => title.trim().length > 0) + } + + // 结构非预期:直接吐原始返回,由人判断(HTML 拦截页 / 维护 JSON / 结构变更) + throw new Error(`${source} 失败 | 原始响应=${this.summarizeBody(data)}`) + } + + /** + * 判断响应是否为 gmya.net 免费档限流。 + * 免费档限流响应:{ code: "403", msg: "您请求过于频繁,未使用账号appkey请求将限制请求频率" } + * 没有 data 数组,需和真正的格式异常区分,否则会误导排查方向。 + */ + private isChinaRateLimited(response: HttpResponse): boolean { + const status = response.status + const data = response.data as { code?: unknown; msg?: unknown } | null | undefined + const code = data?.code + const msg = typeof data?.msg === 'string' ? data.msg : '' + return ( + status === 403 || + status === 429 || + code === '403' || + code === 403 || + code === '429' || + msg.includes('请求过于频繁') || + msg.includes('appkey') + ) + } + + /** + * 把响应体序列化为可读字符串,诊断失败时用。 + * - 对象走 JSON.stringify + * - 字符串原样返回(可能是 HTML 拦截/维护页) + * - undefined/空记为 <无响应体> + * - 非 UTF-8 响应体(gzip 压缩流 / GBK HTML 错误页 / CDN 二进制拦截页): + * 默认按 UTF-8 解码,非法字节被替换成 U+FFFD(�),原始字节已丢失。 + * 原样写日志会产生乱码,且二进制流里的 0x0A(换行字节) 会把一条日志拆成 + * 多行、污染日志结构。这里检测到高密度替换符时改写为可读的诊断摘要。 + * 兜底截断到 1000 字符,防止上游误返回超大 HTML 污染日志。 + */ + private summarizeBody(body: unknown): string { + if (body === undefined || body === null || body === '') return '<无响应体>' + const text = typeof body === 'string' ? body : JSON.stringify(body) + // 检测损坏的非 UTF-8 内容:替换符 U+FFFD 占比 >= 5% 即判定为二进制/非文本响应体 + const replacementCount = (text.match(/\uFFFD/g) ?? []).length + if (replacementCount > 0 && replacementCount / Math.max(text.length, 1) >= 0.05) { + // hex 指纹便于人工判断内容类型(gzip=1F8B、HTML=3C68746D6C、GBK错误页 等) + const hex = Buffer.from(text, 'utf8').subarray(0, 32).toString('hex') + return `<非UTF-8响应体 | 长度=${text.length} | 替换符=${replacementCount} | 疑似gzip/二进制/GBK错误页 | hex前32=${hex}>` + } + return text.length > 1000 ? `${text.slice(0, 1000)}...(+${text.length - 1000}字符)` : text + } + + /** + * 描述 HttpClient 抛出的错误,返回可读文本 + 是否为限流。 + * - 带 status/response(HTTP 非 2xx,重试耗尽后抛出):吐原始响应体, + * 限流标记由 HTTP 状态码 403/429 判定 + * - 无 status/response(超时/断网/DNS):吐错误 message + */ + private describeFetchError(error: unknown): { rateLimited: boolean; text: string } { + const status = (error as { status?: number } | null)?.status + const response = (error as { response?: { data?: unknown } } | null)?.response + if (status !== undefined || response) { + return { + rateLimited: status === 403 || status === 429, + text: this.summarizeBody(response?.data ?? '<无响应体>') + } + } + return { + rateLimited: false, + text: `<无响应体> | ${error instanceof Error ? error.message : String(error)}` + } + } } diff --git a/src/functions/SearchQueryQueue.ts b/src/functions/SearchQueryQueue.ts index ac19b9f4..14f53929 100644 --- a/src/functions/SearchQueryQueue.ts +++ b/src/functions/SearchQueryQueue.ts @@ -22,7 +22,7 @@ export class SearchQueryQueue { this.bot.logger.debug( this.bot.isMobile, 'QUERY-QUEUE', - `Queue prepared | mainTopics=${this.topics.length} | clusterSearch=${this.bot.config.searchSettings.clusterSearch}` + `队列已准备 | 主题数=${this.topics.length} | 聚类搜索=${this.bot.config.searchSettings.clusterSearch}` ) return this.topics.length } @@ -33,7 +33,7 @@ export class SearchQueryQueue { this.bot.logger.debug( this.bot.isMobile, 'QUERY-QUEUE', - `Cluster exhausted | main="${this.activeMainTopic}" | emitted=${this.activeClusterIndex}/${this.activeClusterSize}` + `聚类已用尽 | 主题="${this.activeMainTopic}" | 已发出=${this.activeClusterIndex}/${this.activeClusterSize}` ) } @@ -42,7 +42,7 @@ export class SearchQueryQueue { this.bot.logger.debug( this.bot.isMobile, 'QUERY-QUEUE', - `Queue exhausted | topicsSeen=${this.seenTopics.size} | queriesSeen=${this.seenQueries.size}` + `队列已耗尽 | 已见主题=${this.seenTopics.size} | 已见查询=${this.seenQueries.size}` ) return null } @@ -65,7 +65,7 @@ export class SearchQueryQueue { this.bot.logger.debug( this.bot.isMobile, 'QUERY-QUEUE', - `Cluster activated | main="${mainTopic}" | topic=${this.topicIndex}/${this.topics.length} | received=${cluster.length} | queued=${this.activeCluster.length} | skippedSeen=${skippedSeen}` + `聚类已激活 | 主题="${mainTopic}" | 主题序号=${this.topicIndex}/${this.topics.length} | 接收=${cluster.length} | 入队=${this.activeCluster.length} | 跳过已见=${skippedSeen}` ) } @@ -76,7 +76,7 @@ export class SearchQueryQueue { this.bot.logger.debug( this.bot.isMobile, 'QUERY-QUEUE', - `Dequeued query | main="${this.activeMainTopic}" | query=${this.activeClusterIndex}/${this.activeClusterSize} | remaining=${this.activeCluster.length} | value="${query}"` + `出队查询 | 主题="${this.activeMainTopic}" | 查询=${this.activeClusterIndex}/${this.activeClusterSize} | 剩余=${this.activeCluster.length} | 值="${query}"` ) return query } @@ -104,7 +104,7 @@ export class SearchQueryQueue { this.bot.logger.debug( this.bot.isMobile, 'QUERY-QUEUE', - `Topic pool refilled | received=${topics.length} | queued=${this.topics.length} | previouslySeen=${this.seenTopics.size}` + `主题池已补充 | 接收=${topics.length} | 入队=${this.topics.length} | 此前已见=${this.seenTopics.size}` ) return this.topics.length } diff --git a/src/functions/activities/api/ActivateSearchPerk.ts b/src/functions/activities/api/ActivateSearchPerk.ts index b79052ce..cc1ef878 100644 --- a/src/functions/activities/api/ActivateSearchPerk.ts +++ b/src/functions/activities/api/ActivateSearchPerk.ts @@ -52,7 +52,7 @@ export class ActivateSearchPerk extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - 'No search-multiplier perk present on the dashboard' + 'dashboard 上没有搜索倍数加成' ) return } @@ -62,7 +62,7 @@ export class ActivateSearchPerk extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - `${perk.multiplier}x search perk present in dashboard but missing from the page snapshot - cannot activate | offerId=${perk.offerId}` + `dashboard 上存在 ${perk.multiplier}x 搜索加成但页面快照中缺失 - 无法激活 | offerId=${perk.offerId}` ) return } @@ -71,7 +71,7 @@ export class ActivateSearchPerk extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - `${perk.multiplier}x search perk already active (or not activatable) | offerId=${perk.offerId}`, + `${perk.multiplier}x 搜索加成已激活(或不可激活) | offerId=${perk.offerId}`, 'green' ) return @@ -82,7 +82,7 @@ export class ActivateSearchPerk extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - 'Skipping: "reportActivity" action id not discovered in bundle' + '跳过:bundle 中未发现 "reportActivity" action id' ) return } @@ -92,7 +92,7 @@ export class ActivateSearchPerk extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - `Activating ${perk.multiplier}x search perk | offerId=${perk.offerId} | geo=${this.bot.userData.geoLocale}` + `正在激活 ${perk.multiplier}x 搜索加成 | offerId=${perk.offerId} | geo=${this.bot.userData.geoLocale}` ) try { @@ -109,21 +109,21 @@ export class ActivateSearchPerk extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - `Response | offerId=${perk.offerId} | status=${status} | acknowledged=${acknowledged}` + `响应 | offerId=${perk.offerId} | status=${status} | acknowledged=${acknowledged}` ) if (acknowledged) { this.bot.logger.info( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - `Activated ${perk.multiplier}x search perk | offerId=${perk.offerId} | daily search cap is now boosted`, + `已激活 ${perk.multiplier}x 搜索加成 | offerId=${perk.offerId} | 每日搜索积分上限已提升`, 'green' ) } else { this.bot.logger.warn( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - `Activation not acknowledged | offerId=${perk.offerId} | status=${status}` + `激活未被确认 | offerId=${perk.offerId} | status=${status}` ) } @@ -132,7 +132,7 @@ export class ActivateSearchPerk extends BaseActivity { this.bot.logger.error( this.bot.isMobile, 'ACTIVATE-SEARCH-PERK', - `Error activating search perk | offerId=${perk.offerId} | message=${error instanceof Error ? error.message : String(error)}` + `激活搜索加成出错 | offerId=${perk.offerId} | message=${error instanceof Error ? error.message : String(error)}` ) } } diff --git a/src/functions/activities/api/ClaimBonusPoints.ts b/src/functions/activities/api/ClaimBonusPoints.ts index 2f98d352..a1b8bf3f 100644 --- a/src/functions/activities/api/ClaimBonusPoints.ts +++ b/src/functions/activities/api/ClaimBonusPoints.ts @@ -7,7 +7,7 @@ export class ClaimBonusPoints extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'CLAIM-BONUS-POINTS', - 'Skipping: "reportClaimAllPoints" action id not discovered in bundle' + '跳过:bundle 中未发现 "reportClaimAllPoints" action id' ) return } @@ -17,7 +17,7 @@ export class ClaimBonusPoints extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'CLAIM-BONUS-POINTS', - `Starting ClaimBonusPoints | geo=${this.bot.userData.geoLocale} | currentBalance=${oldBalance}` + `开始领取奖励积分 | geo=${this.bot.userData.geoLocale} | currentBalance=${oldBalance}` ) try { @@ -29,7 +29,7 @@ export class ClaimBonusPoints extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'CLAIM-BONUS-POINTS', - `Response | status=${status} | acknowledged=${acknowledged} | previousBalance=${oldBalance} | currentBalance=${newBalance} | pointsGained=${gainedPoints}` + `响应 | status=${status} | acknowledged=${acknowledged} | previousBalance=${oldBalance} | currentBalance=${newBalance} | pointsGained=${gainedPoints}` ) if (acknowledged) { @@ -41,14 +41,14 @@ export class ClaimBonusPoints extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'CLAIM-BONUS-POINTS', - `Completed ClaimBonusPoints | acknowledged=true | pointsGained=${gainedPoints} | currentBalance=${newBalance}`, + `领取奖励积分完成 | acknowledged=true | pointsGained=${gainedPoints} | currentBalance=${newBalance}`, 'green' ) } else { this.bot.logger.info( this.bot.isMobile, 'CLAIM-BONUS-POINTS', - `Nothing claimed | status=${status} | pointsGained=0 | currentBalance=${newBalance}` + `没有可领取的积分 | status=${status} | pointsGained=0 | currentBalance=${newBalance}` ) } @@ -57,7 +57,7 @@ export class ClaimBonusPoints extends BaseActivity { this.bot.logger.error( this.bot.isMobile, 'CLAIM-BONUS-POINTS', - `Error in claimBonusPoints | message=${error instanceof Error ? error.message : String(error)}` + `claimBonusPoints 出错 | message=${error instanceof Error ? error.message : String(error)}` ) } } diff --git a/src/functions/activities/api/ClaimReward.ts b/src/functions/activities/api/ClaimReward.ts index 5f2d0449..4886a580 100644 --- a/src/functions/activities/api/ClaimReward.ts +++ b/src/functions/activities/api/ClaimReward.ts @@ -11,20 +11,20 @@ export class ClaimReward extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'CLAIM-REWARD', - `Skipping ${offerId}: "reportActivity" not discovered in bundle` + `跳过 ${offerId}:bundle 中未发现 "reportActivity"` ) return } if (!child.hash) { - this.bot.logger.warn(this.bot.isMobile, 'CLAIM-REWARD', `Skipping ${offerId}: no live hash on quest child`) + this.bot.logger.warn(this.bot.isMobile, 'CLAIM-REWARD', `跳过 ${offerId}:任务子项没有有效的 hash`) return } if (!child.reportable) { this.bot.logger.warn( this.bot.isMobile, 'CLAIM-REWARD', - `Skipping ${offerId}: not reportable (completed/locked/disabled)` + `跳过 ${offerId}:不可上报(已完成/已锁定/已禁用)` ) return } @@ -34,7 +34,7 @@ export class ClaimReward extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'CLAIM-REWARD', - `Claiming reward | offerId=${offerId} | geo=${this.bot.userData.geoLocale}` + `正在领取奖励 | offerId=${offerId} | geo=${this.bot.userData.geoLocale}` ) try { @@ -59,7 +59,7 @@ export class ClaimReward extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'CLAIM-REWARD', - `Claim response | offerId=${offerId} | status=${status} | acknowledged=${acknowledged} | pointsGained=${gained} | currentBalance=${newBalance}` + `领取响应 | offerId=${offerId} | status=${status} | acknowledged=${acknowledged} | pointsGained=${gained} | currentBalance=${newBalance}` ) if (acknowledged) { @@ -71,21 +71,21 @@ export class ClaimReward extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'CLAIM-REWARD', - `Reward claimed | offerId=${offerId} | status=${status} | pointsGained=${gained} | currentBalance=${newBalance}`, + `奖励已领取 | offerId=${offerId} | status=${status} | pointsGained=${gained} | currentBalance=${newBalance}`, 'green' ) } else { this.bot.logger.warn( this.bot.isMobile, 'CLAIM-REWARD', - `Claim not acknowledged by server | offerId=${offerId} | status=${status}` + `服务器未确认领取 | offerId=${offerId} | status=${status}` ) } } catch (error) { this.bot.logger.error( this.bot.isMobile, 'CLAIM-REWARD', - `Error in claimReward | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + `claimReward 出错 | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` ) } } diff --git a/src/functions/activities/api/EnsureStreakProtection.ts b/src/functions/activities/api/EnsureStreakProtection.ts index 452d63a4..b9082c4d 100644 --- a/src/functions/activities/api/EnsureStreakProtection.ts +++ b/src/functions/activities/api/EnsureStreakProtection.ts @@ -16,7 +16,7 @@ export class EnsureStreakProtection extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - `Skipping: streak-protection action id not discovered in bundle (looked for [${STREAK_PROTECTION_ACTION_NAMES.join(', ')}] + any "*streak*protect*" key)` + `跳过:bundle 中未发现连击保护 action id(查找过 [${STREAK_PROTECTION_ACTION_NAMES.join(', ')}] 及任何 "*streak*protect*" 键)` ) return } @@ -27,7 +27,7 @@ export class EnsureStreakProtection extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - `Already enabled | remainingDays=${before.remainingDays ?? 'null'}`, + `连击保护已启用 | remainingDays=${before.remainingDays ?? 'null'}`, 'green' ) return @@ -37,7 +37,7 @@ export class EnsureStreakProtection extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - 'No protection days remaining - toggle is disabled, skipping' + '没有剩余的保护天数 - 开关已禁用,跳过' ) return } @@ -48,7 +48,7 @@ export class EnsureStreakProtection extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - `Starting EnsureStreakProtection | action=${resolved.name} | before=${beforeDesc}` + `开始确保连击保护 | action=${resolved.name} | before=${beforeDesc}` ) try { @@ -64,20 +64,20 @@ export class EnsureStreakProtection extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - `Completed | streakProtectionEnabled=true | remainingDays=${after.remainingDays ?? 'null'} | status=${status}`, + `完成 | streakProtectionEnabled=true | remainingDays=${after.remainingDays ?? 'null'} | status=${status}`, 'green' ) } else if (after === null) { this.bot.logger.warn( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - `Fired but could not confirm state from a fresh snapshot | acknowledged=${acknowledged} | status=${status}` + `已触发但无法从最新快照确认状态 | acknowledged=${acknowledged} | status=${status}` ) } else { this.bot.logger.warn( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - `Toggle did not take - still off after firing | status=${status}` + `开关未生效 - 触发后仍处于关闭状态 | status=${status}` ) } @@ -86,7 +86,7 @@ export class EnsureStreakProtection extends BaseActivity { this.bot.logger.error( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - `Error in ensureStreakProtection | message=${error instanceof Error ? error.message : String(error)}` + `ensureStreakProtection 出错 | message=${error instanceof Error ? error.message : String(error)}` ) } } @@ -95,7 +95,7 @@ export class EnsureStreakProtection extends BaseActivity { try { const html = await this.bot.browser.func.getRewardsPageHtml(URLs.rewards.earn, '/earn') if (!html) { - this.bot.logger.warn(this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', 'Verify fetch failed') + this.bot.logger.warn(this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', '验证请求失败') return null } return this.bot.browser.react.getStreakProtection(html) @@ -103,7 +103,7 @@ export class EnsureStreakProtection extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'ENABLE-STREAK-PROTECTION', - `Verify read errored | ${error instanceof Error ? error.message : String(error)}` + `验证读取出错 | ${error instanceof Error ? error.message : String(error)}` ) return null } diff --git a/src/functions/activities/api/UrlReward.ts b/src/functions/activities/api/UrlReward.ts index edc76785..704fe6d6 100644 --- a/src/functions/activities/api/UrlReward.ts +++ b/src/functions/activities/api/UrlReward.ts @@ -15,7 +15,7 @@ export class UrlReward extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'URL-REWARD', - `Skipping ${offerId}: "reportActivity" not discovered in bundle` + `跳过 ${offerId}:bundle 中未发现 "reportActivity"` ) return } @@ -25,7 +25,7 @@ export class UrlReward extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'URL-REWARD', - `Skipping ${offerId}: not present in page snapshot, even after refetching /earn and /dashboard` + `跳过 ${offerId}:页面快照中不存在,即使重新获取 /earn 和 /dashboard 后也没有` ) return } @@ -33,7 +33,7 @@ export class UrlReward extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'URL-REWARD', - `Skipping ${offerId}: not reportable (completed/locked/no-hash/future-dated)` + `跳过 ${offerId}:不可上报(已完成/已锁定/无 hash/未到期)` ) return } @@ -42,7 +42,7 @@ export class UrlReward extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'URL-REWARD', - `Skipping ${offerId}: awards no points (points=${live.points}${live.promotionSubtype ? ` subtype=${live.promotionSubtype}` : ''}) - likely a free trial/non-crediting offer. Set skipNonPointTasks=false to attempt anyway.` + `跳过 ${offerId}:不产生积分(points=${live.points}${live.promotionSubtype ? ` subtype=${live.promotionSubtype}` : ''})- 可能是免费试用/不计分优惠。设置 skipNonPointTasks=false 可强制尝试。` ) return } @@ -58,7 +58,7 @@ export class UrlReward extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'URL-REWARD', - `Starting UrlReward | offerId=${offerId} | geo=${this.bot.userData.geoLocale} | currentBalance=${oldBalance}` + `开始处理 UrlReward | offerId=${offerId} | geo=${this.bot.userData.geoLocale} | currentBalance=${oldBalance}` ) try { @@ -84,7 +84,7 @@ export class UrlReward extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'URL-REWARD', - `UrlReward request was not acknowledged | offerId=${offerId} | status=${status}` + `UrlReward 请求未被确认 | offerId=${offerId} | status=${status}` ) if (await this.retryAfterRequestFailure(promotion, allowSessionRepair)) return } @@ -95,7 +95,7 @@ export class UrlReward extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'URL-REWARD', - `Response | offerId=${offerId} | status=${status} | acknowledged=${acknowledged} | pointsGained=${gainedPoints} | currentBalance=${newBalance}` + `响应 | offerId=${offerId} | status=${status} | acknowledged=${acknowledged} | pointsGained=${gainedPoints} | currentBalance=${newBalance}` ) if (gainedPoints > 0) { @@ -106,28 +106,28 @@ export class UrlReward extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'URL-REWARD', - `Completed UrlReward | offerId=${offerId} | pointsGained=${gainedPoints} | currentBalance=${newBalance}${shortfall ? ' | WARNING: credited less than advertised' : ''}`, + `UrlReward 完成 | offerId=${offerId} | pointsGained=${gainedPoints} | currentBalance=${newBalance}${shortfall ? ' | WARNING: credited less than advertised' : ''}`, 'green' ) } else if (acknowledged && expectedPoints === 0) { this.bot.logger.info( this.bot.isMobile, 'URL-REWARD', - `Completed UrlReward (no points by design) | offerId=${offerId} | acknowledged=true | pointsGained=0 | currentBalance=${newBalance}`, + `UrlReward 完成(设计上无积分) | offerId=${offerId} | acknowledged=true | pointsGained=0 | currentBalance=${newBalance}`, 'green' ) } else { this.bot.logger.warn( this.bot.isMobile, 'URL-REWARD', - `UrlReward credited no points | offerId=${offerId} | acknowledged=${acknowledged} | expected=${expectedPoints} | pointsGained=0 | currentBalance=${newBalance}` + `UrlReward 未获得积分 | offerId=${offerId} | acknowledged=${acknowledged} | expected=${expectedPoints} | pointsGained=0 | currentBalance=${newBalance}` ) } } catch (error) { this.bot.logger.error( this.bot.isMobile, 'URL-REWARD', - `Error in doUrlReward | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + `doUrlReward 出错 | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` ) await this.retryAfterRequestFailure(promotion, allowSessionRepair) } @@ -142,7 +142,7 @@ export class UrlReward extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'URL-REWARD', - `Retrying UrlReward once with refreshed cookies and bootstrap data | offerId=${promotion.offerId}` + `使用刷新后的 cookie 和引导数据重试一次 UrlReward | offerId=${promotion.offerId}` ) await this.runUrlReward(promotion, false) return true diff --git a/src/functions/activities/app/AppPromotions.ts b/src/functions/activities/app/AppPromotions.ts index e3421866..43ff8f63 100644 --- a/src/functions/activities/app/AppPromotions.ts +++ b/src/functions/activities/app/AppPromotions.ts @@ -16,7 +16,7 @@ export class AppPromotions extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'APP-PROMOTIONS', - 'All "App Promotions" items have already been completed' + '所有"应用推广"项目已完成' ) return } @@ -24,7 +24,7 @@ export class AppPromotions extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'APP-PROMOTIONS', - `Started solving "App Promotions" items | remaining=${pending.length}` + `开始解决"应用推广"项目 | remaining=${pending.length}` ) for (const [index, promotion] of pending.entries()) { await this.bot.activities.doAppReward(promotion) @@ -32,6 +32,6 @@ export class AppPromotions extends BaseActivity { await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000)) } } - this.bot.logger.info(this.bot.isMobile, 'APP-PROMOTIONS', 'Finished processing "App Promotions" items') + this.bot.logger.info(this.bot.isMobile, 'APP-PROMOTIONS', '"应用推广"项目处理完毕') } } diff --git a/src/functions/activities/app/AppReward.ts b/src/functions/activities/app/AppReward.ts index 6b01276d..0b4cf67f 100644 --- a/src/functions/activities/app/AppReward.ts +++ b/src/functions/activities/app/AppReward.ts @@ -15,7 +15,7 @@ export class AppReward extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'APP-REWARD', - 'Skipping: App access token not available, this activity requires it!' + '跳过:应用访问令牌不可用,此活动需要它!' ) return } @@ -25,7 +25,7 @@ export class AppReward extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'APP-REWARD', - `Starting AppReward | offerId=${offerId} | country=${this.bot.userData.geoLocale} | currentBalance=${this.oldBalance}` + `开始处理 AppReward | offerId=${offerId} | country=${this.bot.userData.geoLocale} | currentBalance=${this.oldBalance}` ) try { @@ -42,7 +42,7 @@ export class AppReward extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'APP-REWARD', - `Prepared activity payload | offerId=${offerId} | id=${jsonData.id} | amount=${jsonData.amount} | type=${jsonData.type} | country=${jsonData.country}` + `已准备活动载荷 | offerId=${offerId} | id=${jsonData.id} | amount=${jsonData.amount} | type=${jsonData.type} | country=${jsonData.country}` ) const request: HttpRequestConfig = { @@ -62,7 +62,7 @@ export class AppReward extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'APP-REWARD', - `Sending activity request | offerId=${offerId} | url=${request.url}` + `正在发送活动请求 | offerId=${offerId} | url=${request.url}` ) const response = await this.bot.http.request<{ response?: { balance?: number } }>(request) @@ -70,7 +70,7 @@ export class AppReward extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'APP-REWARD', - `Received activity response | offerId=${offerId} | status=${response.status}` + `已收到活动响应 | offerId=${offerId} | status=${response.status}` ) const newBalance = Number(response?.data?.response?.balance ?? this.oldBalance) @@ -79,7 +79,7 @@ export class AppReward extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'APP-REWARD', - `Balance delta after AppReward | offerId=${offerId} | previousBalance=${this.oldBalance} | currentBalance=${newBalance} | pointsGained=${this.gainedPoints}` + `AppReward 后的积分变化 | offerId=${offerId} | previousBalance=${this.oldBalance} | currentBalance=${newBalance} | pointsGained=${this.gainedPoints}` ) if (this.gainedPoints > 0) { @@ -89,27 +89,27 @@ export class AppReward extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'APP-REWARD', - `Completed AppReward | offerId=${offerId} | pointsGained=${this.gainedPoints} | currentBalance=${newBalance}`, + `AppReward 完成 | offerId=${offerId} | pointsGained=${this.gainedPoints} | currentBalance=${newBalance}`, 'green' ) } else { this.bot.logger.warn( this.bot.isMobile, 'APP-REWARD', - `Completed AppReward with no points | offerId=${offerId} | pointsGained=0 | currentBalance=${newBalance}` + `AppReward 完成但未获得积分 | offerId=${offerId} | pointsGained=0 | currentBalance=${newBalance}` ) } this.bot.logger.info( this.bot.isMobile, 'APP-REWARD', - `Finished AppReward | offerId=${offerId} | currentBalance=${this.bot.userData.currentPoints}` + `AppReward 已结束 | offerId=${offerId} | currentBalance=${this.bot.userData.currentPoints}` ) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'APP-REWARD', - `Error in doAppReward | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + `doAppReward 出错 | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` ) } } diff --git a/src/functions/activities/app/DailyCheckIn.ts b/src/functions/activities/app/DailyCheckIn.ts index 40ff01f8..31570343 100644 --- a/src/functions/activities/app/DailyCheckIn.ts +++ b/src/functions/activities/app/DailyCheckIn.ts @@ -13,7 +13,7 @@ export class DailyCheckIn extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'DAILY-CHECK-IN', - 'Skipping: App access token not available, this activity requires it!' + '跳过:应用访问令牌不可用,此活动需要它!' ) return } @@ -23,7 +23,7 @@ export class DailyCheckIn extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'DAILY-CHECK-IN', - `Starting Daily Check-In | geo=${this.bot.userData.geoLocale} | currentBalance=${this.oldBalance}` + `开始每日签到 | geo=${this.bot.userData.geoLocale} | currentBalance=${this.oldBalance}` ) try { @@ -32,7 +32,7 @@ export class DailyCheckIn extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'DAILY-CHECK-IN', - `Received Daily Check-In response | status=${response?.status ?? 'unknown'}` + `已收到每日签到响应 | status=${response?.status ?? 'unknown'}` ) const newBalance = Number(response?.data?.response?.balance ?? this.oldBalance) @@ -41,7 +41,7 @@ export class DailyCheckIn extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'DAILY-CHECK-IN', - `Balance delta after Daily Check-In | type=103 | previousBalance=${this.oldBalance} | currentBalance=${newBalance} | pointsGained=${this.gainedPoints}` + `每日签到后的积分变化 | type=103 | previousBalance=${this.oldBalance} | currentBalance=${newBalance} | pointsGained=${this.gainedPoints}` ) if (this.gainedPoints > 0) { @@ -51,21 +51,21 @@ export class DailyCheckIn extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'DAILY-CHECK-IN', - `Completed Daily Check-In | type=103 | pointsGained=${this.gainedPoints} | currentBalance=${newBalance}`, + `每日签到完成 | type=103 | pointsGained=${this.gainedPoints} | currentBalance=${newBalance}`, 'green' ) } else { this.bot.logger.warn( this.bot.isMobile, 'DAILY-CHECK-IN', - `Daily Check-In completed but no points gained | type=103 | pointsGained=0 | currentBalance=${newBalance}` + `每日签到完成但未获得积分 | type=103 | pointsGained=0 | currentBalance=${newBalance}` ) } } catch (error) { this.bot.logger.error( this.bot.isMobile, 'DAILY-CHECK-IN', - `Error during Daily Check-In | message=${error instanceof Error ? error.message : String(error)}` + `每日签到出错 | message=${error instanceof Error ? error.message : String(error)}` ) } } @@ -85,7 +85,7 @@ export class DailyCheckIn extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'DAILY-CHECK-IN', - `Preparing Daily Check-In payload | type=${jsonData.type} | id=${jsonData.id} | amount=${jsonData.amount} | country=${jsonData.country}` + `正在准备每日签到载荷 | type=${jsonData.type} | id=${jsonData.id} | amount=${jsonData.amount} | country=${jsonData.country}` ) const request: HttpRequestConfig = { @@ -110,7 +110,7 @@ export class DailyCheckIn extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'DAILY-CHECK-IN', - `Sending Daily Check-In request | type=${jsonData.type} | url=${request.url}` + `正在发送每日签到请求 | type=${jsonData.type} | url=${request.url}` ) return this.bot.http.request<{ response?: { balance?: number } }>(request) @@ -118,7 +118,7 @@ export class DailyCheckIn extends BaseActivity { this.bot.logger.error( this.bot.isMobile, 'DAILY-CHECK-IN', - `Error in submitDaily | message=${error instanceof Error ? error.message : String(error)}` + `submitDaily 出错 | message=${error instanceof Error ? error.message : String(error)}` ) throw error } diff --git a/src/functions/activities/app/ReadToEarn.ts b/src/functions/activities/app/ReadToEarn.ts index 7fda9ba9..46052b2b 100644 --- a/src/functions/activities/app/ReadToEarn.ts +++ b/src/functions/activities/app/ReadToEarn.ts @@ -10,7 +10,7 @@ export class ReadToEarn extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'READ-TO-EARN', - 'Skipping: App access token not available, this activity requires it!' + '跳过:应用访问令牌不可用,此活动需要它!' ) return } @@ -22,7 +22,7 @@ export class ReadToEarn extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'READ-TO-EARN', - `Starting Read to Earn | geo=${this.bot.userData.geoLocale} | delayRange=${delayMin}-${delayMax} | currentBalance=${startBalance}` + `开始读文赚积分 | geo=${this.bot.userData.geoLocale} | delayRange=${delayMin}-${delayMax} | currentBalance=${startBalance}` ) try { @@ -47,7 +47,7 @@ export class ReadToEarn extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'READ-TO-EARN', - `Submitting Read to Earn activity | article=${i + 1}/${articleCount} | id=${jsonData.id} | country=${jsonData.country}` + `正在提交读文赚积分活动 | article=${i + 1}/${articleCount} | id=${jsonData.id} | country=${jsonData.country}` ) const request: HttpRequestConfig = { @@ -69,7 +69,7 @@ export class ReadToEarn extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'READ-TO-EARN', - `Received Read to Earn response | article=${i + 1}/${articleCount} | status=${response?.status ?? 'unknown'}` + `已收到读文赚积分响应 | article=${i + 1}/${articleCount} | status=${response?.status ?? 'unknown'}` ) const newBalance = Number(response?.data?.response?.balance ?? oldBalance) @@ -78,14 +78,14 @@ export class ReadToEarn extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'READ-TO-EARN', - `Balance delta after article | article=${i + 1}/${articleCount} | previousBalance=${oldBalance} | currentBalance=${newBalance} | pointsGained=${gainedPoints}` + `文章后的积分变化 | article=${i + 1}/${articleCount} | previousBalance=${oldBalance} | currentBalance=${newBalance} | pointsGained=${gainedPoints}` ) if (gainedPoints <= 0) { this.bot.logger.info( this.bot.isMobile, 'READ-TO-EARN', - `No points gained, stopping Read to Earn | article=${i + 1}/${articleCount} | status=${response.status} | pointsGained=0 | currentBalance=${newBalance}` + `未获得积分,停止读文赚积分 | article=${i + 1}/${articleCount} | status=${response.status} | pointsGained=0 | currentBalance=${newBalance}` ) break } @@ -99,14 +99,14 @@ export class ReadToEarn extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'READ-TO-EARN', - `Read article ${i + 1}/${articleCount} | status=${response.status} | pointsGained=${gainedPoints} | currentBalance=${newBalance}`, + `已阅读第 ${i + 1}/${articleCount} 篇文章 | status=${response.status} | pointsGained=${gainedPoints} | currentBalance=${newBalance}`, 'green' ) this.bot.logger.debug( this.bot.isMobile, 'READ-TO-EARN', - `Waiting between articles | article=${i + 1}/${articleCount} | delayRange=${delayMin}-${delayMax}` + `文章间等待 | article=${i + 1}/${articleCount} | delayRange=${delayMin}-${delayMax}` ) if (i < articleCount - 1) { @@ -119,13 +119,13 @@ export class ReadToEarn extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'READ-TO-EARN', - `Completed Read to Earn | articlesRead=${articlesRead} | pointsGained=${totalGained} | previousBalance=${startBalance} | currentBalance=${finalBalance}` + `读文赚积分完成 | articlesRead=${articlesRead} | pointsGained=${totalGained} | previousBalance=${startBalance} | currentBalance=${finalBalance}` ) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'READ-TO-EARN', - `Error during Read to Earn | message=${error instanceof Error ? error.message : String(error)}` + `读文赚积分出错 | message=${error instanceof Error ? error.message : String(error)}` ) } } diff --git a/src/functions/activities/experimental/ApiSearch.ts b/src/functions/activities/experimental/ApiSearch.ts index 902b349d..30a2b872 100644 --- a/src/functions/activities/experimental/ApiSearch.ts +++ b/src/functions/activities/experimental/ApiSearch.ts @@ -16,17 +16,17 @@ export class ApiSearch extends BaseActivity { const startBalance = Number(this.bot.userData.currentPoints ?? 0) let totalGained = 0 - this.bot.logger.info(isMobile, 'SEARCH-BING', `Starting Bing searches | currentBalance=${startBalance}`) + this.bot.logger.info(isMobile, 'SEARCH-BING', `开始 Bing 搜索 | 当前余额=${startBalance}`) try { const missing = await this.searchProgress.getMissing(isMobile) this.bot.logger.info( isMobile, 'SEARCH-BING', - `Search points remaining | edge=${missing.edgePoints} | desktop=${missing.desktopPoints} | mobile=${missing.mobilePoints}` + `剩余搜索积分 | edge=${missing.edgePoints} | desktop=${missing.desktopPoints} | mobile=${missing.mobilePoints}` ) if (missing.totalPoints <= 0) { - this.bot.logger.info(isMobile, 'SEARCH-BING', 'No search points to earn, skipping') + this.bot.logger.info(isMobile, 'SEARCH-BING', '没有可赚取的搜索积分,跳过') return 0 } let remainingPoints = missing.totalPoints @@ -34,13 +34,13 @@ export class ApiSearch extends BaseActivity { const queryQueue = new SearchQueryQueue(this.bot) const topicCount = await queryQueue.prepare() if (!topicCount) { - this.bot.logger.warn(isMobile, 'SEARCH-BING', 'No main search topics available, skipping') + this.bot.logger.warn(isMobile, 'SEARCH-BING', '没有可用的主搜索主题,跳过') return 0 } this.bot.logger.info( isMobile, 'SEARCH-BING', - `Query queue ready | mainTopics=${topicCount} | clusterSearch=${this.bot.config.searchSettings.clusterSearch}` + `查询队列就绪 | mainTopics=${topicCount} | clusterSearch=${this.bot.config.searchSettings.clusterSearch}` ) let stagnant = 0 @@ -50,7 +50,7 @@ export class ApiSearch extends BaseActivity { while (performed < MAX_SEARCHES) { const query = await queryQueue.next() if (!query) { - this.bot.logger.warn(isMobile, 'SEARCH-BING', 'Query queue exhausted, stopping') + this.bot.logger.warn(isMobile, 'SEARCH-BING', '查询队列已耗尽,停止') break } @@ -58,7 +58,7 @@ export class ApiSearch extends BaseActivity { performed++ if (!res.ig) { - this.bot.logger.warn(isMobile, 'SEARCH-BING', `No IG for query="${query}" - skipping`) + this.bot.logger.warn(isMobile, 'SEARCH-BING', `查询 "${query}" 无 IG - 跳过`) continue } @@ -93,7 +93,7 @@ export class ApiSearch extends BaseActivity { this.bot.logger.debug( isMobile, 'SEARCH-BING', - `Could not refresh the ${isMobile ? 'mobile' : 'desktop'} search quota | ${ + `无法刷新${isMobile ? '移动端' : '桌面端'}搜索配额 | ${ error instanceof Error ? error.message : String(error) }` ) @@ -114,8 +114,8 @@ export class ApiSearch extends BaseActivity { this.bot.logger.info( isMobile, 'SEARCH-BING', - `pointsGained=${gained} | currentBalance=${res.balance} | query="${query}"` + - ` | remaining=${remainingPoints} | searchPts=${cap}`, + `获得积分=${gained} | 当前余额=${res.balance} | 查询="${query}"` + + ` | 剩余=${remainingPoints} | 搜索积分=${cap}`, 'green' ) } else { @@ -123,8 +123,8 @@ export class ApiSearch extends BaseActivity { this.bot.logger.info( isMobile, 'SEARCH-BING', - `No points gained ${stagnant}/${STAGNANT_LIMIT} | query="${query}"` + - ` | remaining=${remainingPoints} | searchPts=${cap}` + `未获得积分 ${stagnant}/${STAGNANT_LIMIT} | 查询="${query}"` + + ` | 剩余=${remainingPoints} | 搜索积分=${cap}` ) } @@ -132,8 +132,8 @@ export class ApiSearch extends BaseActivity { this.bot.logger.info( isMobile, 'SEARCH-BING', - `${isMobile ? 'Mobile' : 'Desktop'} search quota complete` + - ` | remaining=${remainingPoints} | responseSearchPts=${cap}`, + `${isMobile ? '移动端' : '桌面端'}搜索配额已完成` + + ` | 剩余=${remainingPoints} | 响应搜索积分=${cap}`, 'green' ) break @@ -143,7 +143,7 @@ export class ApiSearch extends BaseActivity { this.bot.logger.warn( isMobile, 'SEARCH-BING', - `No points for ${STAGNANT_LIMIT} searches in a row, aborting` + `连续 ${STAGNANT_LIMIT} 次搜索未得分,中止` ) break } @@ -159,14 +159,14 @@ export class ApiSearch extends BaseActivity { this.bot.logger.info( isMobile, 'SEARCH-BING', - `Completed Bing searches | pointsGained=${totalGained} | currentBalance=${this.bot.userData.currentPoints} | previousBalance=${startBalance} | searches=${performed}` + `Bing 搜索完成 | 获得积分=${totalGained} | 当前余额=${this.bot.userData.currentPoints} | 原余额=${startBalance} | 搜索次数=${performed}` ) return totalGained } catch (error) { this.bot.logger.error( isMobile, 'SEARCH-BING', - `Error in doSearch | ${error instanceof Error ? error.message : String(error)}` + `doSearch 出错 | ${error instanceof Error ? error.message : String(error)}` ) return totalGained } @@ -187,19 +187,19 @@ export class ApiSearch extends BaseActivity { const queryQueue = new SearchQueryQueue(this.bot) const topicCount = await queryQueue.prepare() if (!topicCount) { - this.bot.logger.warn(isMobile, tracker.context, 'No main search topics available, skipping') + this.bot.logger.warn(isMobile, tracker.context, '没有可用的主搜索主题,跳过') return 0 } this.bot.logger.info( isMobile, tracker.context, - `Query queue ready | mainTopics=${topicCount} | clusterSearch=${this.bot.config.searchSettings.clusterSearch}` + `查询队列就绪 | mainTopics=${topicCount} | clusterSearch=${this.bot.config.searchSettings.clusterSearch}` ) while (!tracker.done() && performed < tracker.maxSearches && stagnant < tracker.stagnantLimit) { const query = await queryQueue.next() if (!query) { - this.bot.logger.warn(isMobile, tracker.context, 'Query queue exhausted, stopping') + this.bot.logger.warn(isMobile, tracker.context, '查询队列已耗尽,停止') break } @@ -207,7 +207,7 @@ export class ApiSearch extends BaseActivity { performed++ if (!res.ig) { - this.bot.logger.warn(isMobile, tracker.context, `No IG for query="${query}" - skipping`) + this.bot.logger.warn(isMobile, tracker.context, `查询 "${query}" 无 IG - 跳过`) continue } @@ -218,7 +218,7 @@ export class ApiSearch extends BaseActivity { this.bot.logger.info( isMobile, tracker.context, - `pointsGained=${gained} | currentBalance=${this.bot.userData.currentPoints} | query="${query}" | ${tracker.progress()}`, + `获得积分=${gained} | 当前余额=${this.bot.userData.currentPoints} | 查询="${query}" | ${tracker.progress()}`, 'green' ) } else { @@ -226,7 +226,7 @@ export class ApiSearch extends BaseActivity { this.bot.logger.info( isMobile, tracker.context, - `no points ${stagnant}/${tracker.stagnantLimit} | query="${query}" | ${tracker.progress()}` + `未得分 ${stagnant}/${tracker.stagnantLimit} | 查询="${query}" | ${tracker.progress()}` ) } @@ -241,25 +241,25 @@ export class ApiSearch extends BaseActivity { this.bot.logger.error( isMobile, tracker.context, - `Bonus session error | ${error instanceof Error ? error.message : String(error)}` + `加成搜索会话出错 | ${error instanceof Error ? error.message : String(error)}` ) } const done = tracker.done() && !tracker.offerLost const reason = done - ? 'offer complete' + ? '活动完成' : tracker.offerLost - ? 'offer no longer present' + ? '活动已不存在' : performed >= tracker.maxSearches - ? 'reached maxBonusSearches' + ? '达到最大加成搜索次数' : stagnant >= tracker.stagnantLimit - ? `${tracker.stagnantLimit} idle searches` - : 'query pool exhausted' + ? `连续 ${tracker.stagnantLimit} 次无积分` + : '查询池已耗尽' this.bot.logger.info( isMobile, tracker.context, - `Bonus farming ${done ? 'complete' : 'stopped'} (${reason}) | pointsGained=${totalGained} | currentBalance=${this.bot.userData.currentPoints} | ${tracker.progress()} | searches=${performed}`, + `加成搜索刷分 ${done ? '完成' : '中止'} (${reason}) | 获得积分=${totalGained} | 当前余额=${this.bot.userData.currentPoints} | ${tracker.progress()} | 搜索次数=${performed}`, done || totalGained > 0 ? 'green' : undefined ) return totalGained diff --git a/src/functions/activities/experimental/ApiSearchOnBing.ts b/src/functions/activities/experimental/ApiSearchOnBing.ts index c9a2fece..f2a16956 100644 --- a/src/functions/activities/experimental/ApiSearchOnBing.ts +++ b/src/functions/activities/experimental/ApiSearchOnBing.ts @@ -20,7 +20,7 @@ export class ApiSearchOnBing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'SEARCH-ON-BING', - `Starting SearchOnBing | offerId=${offerId} | title="${promotion.title}" | currentBalance=${this.oldBalance}` + `开始 SearchOnBing | offerId=${offerId} | 标题="${promotion.title}" | 当前余额=${this.oldBalance}` ) try { @@ -28,7 +28,7 @@ export class ApiSearchOnBing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING', - `Search activity couldn't be activated, aborting | offerId=${offerId}` + `搜索活动无法激活,中止 | offerId=${offerId}` ) return } @@ -40,21 +40,21 @@ export class ApiSearchOnBing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'SEARCH-ON-BING', - `Completed SearchOnBing | offerId=${offerId} | pointsGained=${this.gainedPoints} | currentBalance=${this.bot.userData.currentPoints} | previousBalance=${this.oldBalance}`, + `SearchOnBing 完成 | offerId=${offerId} | 获得积分=${this.gainedPoints} | 当前余额=${this.bot.userData.currentPoints} | 原余额=${this.oldBalance}`, 'green' ) } else { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING', - `Failed SearchOnBing | offerId=${offerId} | pointsGained=${this.gainedPoints} | currentBalance=${this.bot.userData.currentPoints} | previousBalance=${this.oldBalance}` + `SearchOnBing 失败 | offerId=${offerId} | 获得积分=${this.gainedPoints} | 当前余额=${this.bot.userData.currentPoints} | 原余额=${this.oldBalance}` ) } } catch (error) { this.bot.logger.error( this.bot.isMobile, 'SEARCH-ON-BING', - `Error in doSearchOnBing | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + `doSearchOnBing 出错 | offerId=${offerId} | 错误=${error instanceof Error ? error.message : String(error)}` ) } } @@ -65,26 +65,26 @@ export class ApiSearchOnBing extends BaseActivity { const cgDashboard = (await this.bot.browser.func.getDashboardData()).dashboard const cg = this.buildCategoryGroup(cgDashboard, offerId) - this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `Category group | cg=${cg || '(none)'}`) + this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `分类组 | cg=${cg || '(none)'}`) this.bot.logger.debug( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `Starting search loop | queriesCount=${queries.length} | targetPoints=${promotion.pointProgressMax} | currentBalance=${this.oldBalance}` + `开始搜索循环 | 查询数=${queries.length} | 目标积分=${promotion.pointProgressMax} | 当前余额=${this.oldBalance}` ) let lastBalance = this.oldBalance for (const [index, query] of queries.entries()) { try { - this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `Processing query | query="${query}"`) + this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `处理查询 | 查询="${query}"`) const { ig } = await this.searchApi.report(query, cg ? { cg } : undefined) if (!ig) { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `No IG returned for query="${query}" - skipping this query` + `查询 "${query}" 未返回 IG - 跳过该查询` ) continue } @@ -111,7 +111,7 @@ export class ApiSearchOnBing extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `Progress check | query="${query}" | offerProgress=${offerProgress} | offerComplete=${offerComplete} | currentBalance=${newBalance}` + `进度检查 | 查询="${query}" | 活动进度=${offerProgress} | 活动完成=${offerComplete} | 当前余额=${newBalance}` ) if (offerComplete) { @@ -119,7 +119,7 @@ export class ApiSearchOnBing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `SearchOnBing activity completed | pointsGained=${this.gainedPoints} | currentBalance=${newBalance} | query="${query}" | offerProgress=${offerProgress}`, + `SearchOnBing 活动完成 | 获得积分=${this.gainedPoints} | 当前余额=${newBalance} | 查询="${query}" | 活动进度=${offerProgress}`, 'green' ) return @@ -128,13 +128,13 @@ export class ApiSearchOnBing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `${index + 1}/${queries.length} | activity not complete | offerProgress=${offerProgress} | query="${query}"` + `${index + 1}/${queries.length} | 活动未完成 | 活动进度=${offerProgress} | 查询="${query}"` ) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `Error during search loop | query="${query}" | message=${error instanceof Error ? error.message : String(error)}` + `搜索循环出错 | 查询="${query}" | 错误=${error instanceof Error ? error.message : String(error)}` ) } finally { if (!this.success && index < queries.length - 1) { @@ -146,7 +146,7 @@ export class ApiSearchOnBing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `Finished all queries without completing the activity | queriesTried=${queries.length} | offerId=${offerId} | pointsGained=${this.gainedPoints} | currentBalance=${this.bot.userData.currentPoints} | previousBalance=${this.oldBalance}` + `所有查询已用完但活动未完成 | 已尝试查询数=${queries.length} | offerId=${offerId} | 获得积分=${this.gainedPoints} | 当前余额=${this.bot.userData.currentPoints} | 原余额=${this.oldBalance}` ) } diff --git a/src/functions/activities/experimental/BingSearchApi.ts b/src/functions/activities/experimental/BingSearchApi.ts index 4111f96b..321490af 100644 --- a/src/functions/activities/experimental/BingSearchApi.ts +++ b/src/functions/activities/experimental/BingSearchApi.ts @@ -60,7 +60,7 @@ export class BingSearchApi { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-REPORT', - `No IG for "${query}" - SERP not served as expected` + `查询 "${query}" 无 IG - SERP 未按预期返回` ) return { ig: null, ...this.emptyReport(), gained: null } } @@ -101,7 +101,7 @@ export class BingSearchApi { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-REPORT', - `Reported "${query}" | ig=${ig} | pointsGained=${gained ?? 'n/a'}` + + `已上报 "${query}" | ig=${ig} | pointsGained=${gained ?? 'n/a'}` + ` | currentBalance=${parsed.balance ?? 'n/a'}` + ` | searchPts=${parsed.searchPointsEarned ?? 'n/a'}/${parsed.searchPointsLimit ?? 'n/a'}` ) diff --git a/src/functions/activities/experimental/EdgeBrowsing.ts b/src/functions/activities/experimental/EdgeBrowsing.ts index 2a687d50..d95bc062 100644 --- a/src/functions/activities/experimental/EdgeBrowsing.ts +++ b/src/functions/activities/experimental/EdgeBrowsing.ts @@ -61,7 +61,7 @@ export class EdgeBrowsing extends BaseActivity { public async run(data: DashboardData, signal?: AbortSignal): Promise { const accessToken = this.bot.accessToken if (!accessToken) { - this.bot.logger.warn(this.bot.isMobile, LOG_TAG, 'Skipping: mobile app access token is unavailable') + this.bot.logger.warn(this.bot.isMobile, LOG_TAG, '跳过:移动应用访问令牌不可用') return } @@ -77,7 +77,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, LOG_TAG, - 'Browsing Streak on Edge is not available for this account' + '该账户无法使用 Edge 浏览连击(Browsing Streak)' ) return } @@ -93,7 +93,7 @@ export class EdgeBrowsing extends BaseActivity { const complete = settings.promotion.attributes['complete']?.toLowerCase() === 'true' if (complete) { - this.bot.logger.info(this.bot.isMobile, LOG_TAG, 'Browsing Streak on Edge is already complete') + this.bot.logger.info(this.bot.isMobile, LOG_TAG, 'Edge 浏览连击(Browsing Streak)已完成') return } @@ -117,7 +117,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, LOG_TAG, - `Started background Edge browsing activity | offerId=${settings.offerId} | type=${settings.activityType}` + + `开始后台 Edge 浏览活动 | offerId=${settings.offerId} | type=${settings.activityType}` + ` | targetMinutes=${TARGET_DURATION_MINUTES} | reports=${reportCount}` + ` | serverIntervalMinutes=${settings.reportIntervalMinutes}` + ` | jitterSeconds=${REPORT_JITTER_MIN_MS / 1000}-${REPORT_JITTER_MAX_MS / 1000}` + @@ -134,7 +134,7 @@ export class EdgeBrowsing extends BaseActivity { ) if (!(await this.wait(progress.delayBeforeReport(reportNumber), signal))) { - this.bot.logger.debug(this.bot.isMobile, LOG_TAG, 'Background activity cancelled') + this.bot.logger.debug(this.bot.isMobile, LOG_TAG, '后台活动已取消') return } @@ -147,7 +147,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, LOG_TAG, - `Edge browsing report failed | report=${reportNumber}/${reportCount}` + + `Edge 浏览报告提交失败 | report=${reportNumber}/${reportCount}` + ` | status=${status ?? 'unknown'}` + ` | message=${error instanceof Error ? error.message : String(error)}` ) @@ -179,7 +179,7 @@ export class EdgeBrowsing extends BaseActivity { const afterReport = progress.snapshot(reportNumber) const message = - `Submitted Edge browsing report | report=${reportNumber}/${reportCount} | status=${result.status}` + + `已提交 Edge 浏览报告 | report=${reportNumber}/${reportCount} | status=${result.status}` + ` | duplicate=${result.duplicate}` + ` | cookies=${result.cookieNames.join(',') || 'none'}` + ` | reportsRemaining=${afterReport.reportsRemaining}` + @@ -204,7 +204,7 @@ export class EdgeBrowsing extends BaseActivity { const finished = progress.snapshot(reportsProcessed) const summary = - `Finished background Edge browsing activity | reports=${reportsProcessed}` + + `后台 Edge 浏览活动结束 | reports=${reportsProcessed}` + ` | reportsCompleted=${reportsProcessed}/${reportCount}` + ` | reportsRemaining=${serverComplete ? 0 : finished.reportsRemaining}` + ` | scheduledMinutesCovered=${finished.scheduledMinutesCovered}/${TARGET_DURATION_MINUTES}` + @@ -221,14 +221,14 @@ export class EdgeBrowsing extends BaseActivity { } } catch (error) { if (signal?.aborted) { - this.bot.logger.debug(this.bot.isMobile, LOG_TAG, 'Background activity cancelled') + this.bot.logger.debug(this.bot.isMobile, LOG_TAG, '后台活动已取消') return } this.bot.logger.error( this.bot.isMobile, LOG_TAG, - `Background Edge browsing activity failed | message=${ + `后台 Edge 浏览活动失败 | message=${ error instanceof Error ? error.message : String(error) }` ) @@ -260,7 +260,7 @@ export class EdgeBrowsing extends BaseActivity { }) if (response.data.code !== 0) { - throw new Error(`Edge profile returned code ${response.data.code ?? 'unknown'}`) + throw new Error(`Edge 配置接口返回 code ${response.data.code ?? 'unknown'}`) } return response.data @@ -294,7 +294,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, LOG_TAG, - `Edge browsing activation offer already completed | offerId=${offer.offerId}`, + `Edge 浏览激活 offer 已完成 | offerId=${offer.offerId}`, 'green' ) return 'already-active' @@ -304,7 +304,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, LOG_TAG, - `Activation offer present but missing a hash | offerId=${offer.offerId}` + `激活 offer 存在但缺少 hash | offerId=${offer.offerId}` ) return 'failed' } @@ -313,7 +313,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, LOG_TAG, - `Activation offer is not actionable | offerId=${offer.offerId}` + `激活 offer 不可操作 | offerId=${offer.offerId}` ) return 'failed' } @@ -323,7 +323,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, LOG_TAG, - 'Skipping activation: "reportActivity" action id not discovered in bundle' + '跳过激活:bundle 中未发现 "reportActivity" action id' ) return 'failed' } @@ -334,7 +334,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, LOG_TAG, - `Skipping activation: no valid activity type found | offerId=${offer.offerId}` + `跳过激活:未找到有效的 activityType | offerId=${offer.offerId}` ) return 'failed' } @@ -342,7 +342,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, LOG_TAG, - `Activating Browsing Streak on Edge | offerId=${offer.offerId} | activationSource=${offer.activationSource} | activityType=${metadata.activityType} | activityTypeSource=${metadata.activityTypeSource} | promotional=${metadata.isPromotional} | geo=${this.bot.userData.geoLocale}` + `正在激活 Edge 浏览连击 | offerId=${offer.offerId} | activationSource=${offer.activationSource} | activityType=${metadata.activityType} | activityTypeSource=${metadata.activityTypeSource} | promotional=${metadata.isPromotional} | geo=${this.bot.userData.geoLocale}` ) try { @@ -363,7 +363,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, LOG_TAG, - `Activated Browsing Streak on Edge | offerId=${offer.offerId} | acknowledged=${acknowledged} | confirmed=${confirmed}`, + `Edge 浏览连击已激活 | offerId=${offer.offerId} | acknowledged=${acknowledged} | confirmed=${confirmed}`, 'green' ) return 'activated' @@ -372,14 +372,14 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, LOG_TAG, - `Activation not acknowledged | offerId=${offer.offerId} | status=${status}` + `激活未被服务器确认 | offerId=${offer.offerId} | status=${status}` ) return 'failed' } catch (error) { this.bot.logger.error( this.bot.isMobile, LOG_TAG, - `Activation error | offerId=${offer.offerId} | ${error instanceof Error ? error.message : String(error)}` + `激活出错 | offerId=${offer.offerId} | ${error instanceof Error ? error.message : String(error)}` ) return 'failed' } @@ -399,7 +399,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Could not verify activation state | offerId=${offerId} | ${error instanceof Error ? error.message : String(error)}` + `无法验证激活状态 | offerId=${offerId} | ${error instanceof Error ? error.message : String(error)}` ) return false } @@ -521,13 +521,13 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Activation metadata missing from the current snapshot; using cached ${source} streak snapshot | offerId=${streak.activationOfferId}` + `当前快照缺少激活元数据;使用缓存的 ${source} streak 快照 | offerId=${streak.activationOfferId}` ) } else { this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Using Edge browsing activation metadata from streak model | offerId=${streak.activationOfferId}` + `使用 streak 模型中的 Edge 浏览激活元数据 | offerId=${streak.activationOfferId}` ) } @@ -568,13 +568,13 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Activation offer missing from the current snapshot; using cached ${source} offer snapshot | offerId=${offer.offerId}` + `当前快照缺少激活 offer;使用缓存的 ${source} offer 快照 | offerId=${offer.offerId}` ) } else { this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Using Edge browsing activation metadata from generic offer | offerId=${offer.offerId}` + `使用通用 offer 中的 Edge 浏览激活元数据 | offerId=${offer.offerId}` ) } @@ -605,7 +605,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Could not verify Edge browsing completion: promotion is absent | report=${reportNumber}/${reportCount}` + `无法验证 Edge 浏览完成状态:优惠不存在 | report=${reportNumber}/${reportCount}` ) return false } @@ -614,14 +614,14 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Refreshed Edge browsing server state | report=${reportNumber}/${reportCount} | complete=${complete}` + `已刷新 Edge 浏览服务器状态 | report=${reportNumber}/${reportCount} | complete=${complete}` ) return complete } catch (error) { this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Could not refresh Edge browsing server state | report=${reportNumber}/${reportCount}` + + `无法刷新 Edge 浏览服务器状态 | report=${reportNumber}/${reportCount}` + ` | message=${error instanceof Error ? error.message : String(error)}` ) return false @@ -638,7 +638,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, LOG_TAG, - `Microsoft reports Edge browsing activity complete | report=${reportNumber}/${reportCount}` + + `Microsoft 已确认 Edge 浏览活动完成 | report=${reportNumber}/${reportCount}` + ` | accepted=${acceptedReports} | duplicates=${duplicateReports} | failed=${failedReports}`, 'green' ) @@ -650,7 +650,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, LOG_TAG, - 'Browsing Streak on Edge is not available for this account' + '该账户无法使用 Edge 浏览连击(Browsing Streak)' ) return null } @@ -661,7 +661,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, LOG_TAG, - `Skipping: promotion metadata is incomplete | offerId=${offerId || 'missing'}` + + `跳过:优惠元数据不完整 | offerId=${offerId || 'missing'}` + ` | type=${activityType || 'missing'}` ) return null @@ -678,7 +678,7 @@ export class EdgeBrowsing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, LOG_TAG, - `Invalid server report interval; using fallback | received=${ + `服务器上报间隔无效,使用回退值 | received=${ promotion.attributes['report_per_minutes'] ?? 'missing' } | fallbackMinutes=${DEFAULT_REPORT_INTERVAL_MINUTES}` ) @@ -721,14 +721,14 @@ export class EdgeBrowsing extends BaseActivity { }) if (response.data.code !== 0) { - throw new Error(`Edge activity returned code ${response.data.code ?? 'unknown'}`) + throw new Error(`Edge 活动接口返回 code ${response.data.code ?? 'unknown'}`) } const activity = response.data.response?.activity this.bot.logger.debug( this.bot.isMobile, LOG_TAG, - `Edge activity response | offerId=${activity?.a?.offerid ?? 'unknown'}` + + `Edge 活动响应 | offerId=${activity?.a?.offerid ?? 'unknown'}` + ` | type=${activity?.type ?? 'unknown'} | quantity=${activity?.q ?? 'unknown'}` ) @@ -798,7 +798,7 @@ export class EdgeBrowsing extends BaseActivity { failedReports: number ): string { return ( - `Edge browsing progress | reportsCompleted=${progress.reportsCompleted}/${progress.reportsTotal}` + + `Edge 浏览进度 | reportsCompleted=${progress.reportsCompleted}/${progress.reportsTotal}` + ` | reportsRemaining=${progress.reportsRemaining}` + ` | scheduledMinutesCovered=${progress.scheduledMinutesCovered}/${TARGET_DURATION_MINUTES}` + `${progress.nextReportInSeconds === null ? '' : ` | nextReportInSeconds=${progress.nextReportInSeconds}`}` + diff --git a/src/functions/activities/rewards/DailySet.ts b/src/functions/activities/rewards/DailySet.ts index c16c4202..e55a4352 100644 --- a/src/functions/activities/rewards/DailySet.ts +++ b/src/functions/activities/rewards/DailySet.ts @@ -9,16 +9,16 @@ export class DailySet extends BaseActivity { data.dashboard.dailySetPromotions[today]?.filter(item => !item.complete && item.pointProgressMax > 0) ?? [] if (!pending.length) { - this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have already been completed') + this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', '所有"每日任务"项目已完成') return } this.bot.logger.info( this.bot.isMobile, 'DAILY-SET', - `Started solving "Daily Set" items | remaining=${pending.length}` + `开始解决"每日任务"项目 | remaining=${pending.length}` ) await new PromotionActivityRunner(this.bot).run(pending) - this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'Finished processing "Daily Set" items') + this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', '"每日任务"项目处理完毕') } } diff --git a/src/functions/activities/rewards/MorePromotions.ts b/src/functions/activities/rewards/MorePromotions.ts index 81284a66..907d731e 100644 --- a/src/functions/activities/rewards/MorePromotions.ts +++ b/src/functions/activities/rewards/MorePromotions.ts @@ -20,7 +20,7 @@ export class MorePromotions extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'MORE-PROMOTIONS', - 'All "More Promotions" items have already been completed' + '所有"更多推广"项目已完成' ) return } @@ -28,10 +28,10 @@ export class MorePromotions extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'MORE-PROMOTIONS', - `Started solving "More Promotions" items | remaining=${pending.length}` + `开始解决"更多推广"项目 | remaining=${pending.length}` ) await new PromotionActivityRunner(this.bot).run(pending) - this.bot.logger.info(this.bot.isMobile, 'MORE-PROMOTIONS', 'Finished processing "More Promotions" items') + this.bot.logger.info(this.bot.isMobile, 'MORE-PROMOTIONS', '"更多推广"项目处理完毕') } private isActionable(promotion: BasePromotion): boolean { diff --git a/src/functions/activities/rewards/PromotionActivityRunner.ts b/src/functions/activities/rewards/PromotionActivityRunner.ts index 46728342..ae659a52 100644 --- a/src/functions/activities/rewards/PromotionActivityRunner.ts +++ b/src/functions/activities/rewards/PromotionActivityRunner.ts @@ -13,7 +13,7 @@ export class PromotionActivityRunner extends BaseActivity { this.bot.logger.error( this.bot.isMobile, 'ACTIVITY', - `Error while solving activity "${promotion.title}" | message=${ + `解决活动 "${promotion.title}" 时出错 | message=${ error instanceof Error ? error.message : String(error) }` ) @@ -29,14 +29,14 @@ export class PromotionActivityRunner extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'ACTIVITY', - `Processing activity | title="${promotion.title}" | offerId=${offerId} | type=${type}` + `处理活动 | 标题="${promotion.title}" | offerId=${offerId} | 类型=${type}` ) if (type !== 'urlreward') { this.bot.logger.warn( this.bot.isMobile, 'ACTIVITY', - `Skipped activity "${promotion.title}" | offerId=${offerId} | reason=unsupported type "${ + `跳过活动 "${promotion.title}" | offerId=${offerId} | 原因:不支持的类型 "${ promotion.promotionType }"` ) @@ -57,7 +57,7 @@ export class PromotionActivityRunner extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ACTIVITY', - `Found activity type "SearchOnBing" | title="${promotion.title}" | offerId=${offerId}` + `发现活动类型 "SearchOnBing" | 标题="${promotion.title}" | offerId=${offerId}` ) const page = this.bot.isMobile ? this.bot.mainMobilePage : this.bot.mainDesktopPage await this.bot.activities.doSearchOnBing(promotion, page) @@ -67,7 +67,7 @@ export class PromotionActivityRunner extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ACTIVITY', - `Found activity type "UrlReward" | title="${promotion.title}" | offerId=${offerId}` + `发现活动类型 "UrlReward" | 标题="${promotion.title}" | offerId=${offerId}` ) await this.bot.activities.doUrlReward(promotion) } @@ -76,7 +76,7 @@ export class PromotionActivityRunner extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'ACTIVITY', - `Skipping "${activity}" (disabled in config) | offerId=${offerId}` + `跳过 "${activity}"(配置中已禁用) | offerId=${offerId}` ) } } diff --git a/src/functions/activities/rewards/PunchCards.ts b/src/functions/activities/rewards/PunchCards.ts index 13bf381e..92152db9 100644 --- a/src/functions/activities/rewards/PunchCards.ts +++ b/src/functions/activities/rewards/PunchCards.ts @@ -11,7 +11,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.error( 'main', 'PUNCHCARD', - `Mobile punchcards failed | ${error instanceof Error ? error.message : String(error)}` + `移动端打卡任务失败 | ${error instanceof Error ? error.message : String(error)}` ) } } @@ -26,7 +26,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.warn( 'main', 'PUNCHCARD', - `Desktop punchcard data unavailable | ${error instanceof Error ? error.message : String(error)}` + `桌面端打卡数据不可用 | ${error instanceof Error ? error.message : String(error)}` ) return } @@ -37,7 +37,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.error( 'main', 'PUNCHCARD', - `Desktop punchcards failed | ${error instanceof Error ? error.message : String(error)}` + `桌面端打卡任务失败 | ${error instanceof Error ? error.message : String(error)}` ) } } @@ -59,14 +59,14 @@ export class PunchCards extends BaseActivity { }) if (!pending.length) { - this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', 'No actionable quests') + this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', '没有可执行的任务') return } this.bot.logger.info( this.bot.isMobile, 'PUNCHCARD', - `Found ${pending.length} incomplete quest(s) | apiMatched=${pending.filter(parent => apiById.has(parent.offerId)).length}` + `发现 ${pending.length} 个未完成任务 | apiMatched=${pending.filter(parent => apiById.has(parent.offerId)).length}` ) for (const parent of pending) { @@ -76,21 +76,21 @@ export class PunchCards extends BaseActivity { this.bot.logger.error( this.bot.isMobile, 'PUNCHCARD', - `Error solving quest "${parent.title || parent.offerId}" | message=${ + `解决任务 "${parent.title || parent.offerId}" 时出错 | message=${ error instanceof Error ? error.message : String(error) }` ) } } - this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', 'Finished processing quests') + this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', '任务处理完毕') } private async getParentQuests(): Promise { try { const earn = await this.bot.browser.func.getRewardsPageHtml(URLs.rewards.earn, '/earn') if (!earn) { - this.bot.logger.warn(this.bot.isMobile, 'PUNCHCARD', '/earn unavailable - cannot list quests') + this.bot.logger.warn(this.bot.isMobile, 'PUNCHCARD', '/earn 不可用 - 无法获取任务列表') return null } @@ -103,7 +103,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'PUNCHCARD', - `Failed fetching quest list | ${error instanceof Error ? error.message : String(error)}` + `获取任务列表失败 | ${error instanceof Error ? error.message : String(error)}` ) return null } @@ -151,7 +151,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'PUNCHCARD', - `Solving "${title}" | children=${ordered.length} | reportable=${ordered.filter(child => child.reportable).length}` + `正在解决 "${title}" | children=${ordered.length} | reportable=${ordered.filter(child => child.reportable).length}` ) const startBalance = this.bot.userData.currentPoints @@ -166,14 +166,14 @@ export class PunchCards extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'PUNCHCARD', - `Skip ${child.offerId}: not reportable (locked=${child.isLocked} disabled=${child.isDisabled} done=${child.isCompleted} hash=${Boolean(child.hash)})` + `跳过 ${child.offerId}:不可上报(locked=${child.isLocked} disabled=${child.isDisabled} done=${child.isCompleted} hash=${Boolean(child.hash)})` ) continue } if (this.isSearchQuotaChild(child.offerId, apiChild)) { remaining += 1 - this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', `Skip ${child.offerId}: multi-day search task`) + this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', `跳过 ${child.offerId}:多天搜索任务`) continue } @@ -183,7 +183,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'PUNCHCARD', - `Reward for "${title}" is ready for manual redemption | offerId=${child.offerId}` + `"${title}" 的奖励可手动兑换 | offerId=${child.offerId}` ) continue } @@ -200,7 +200,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'PUNCHCARD', - `Quest "${title}" ${remaining === 0 ? 'COMPLETE' : 'in progress'} | reported=${reported}` + + `任务 "${title}" ${remaining === 0 ? 'COMPLETE' : 'in progress'} | reported=${reported}` + `${remaining ? ` | remaining=${remaining}` : ''} | pointsGained=${gained}` + ` | currentBalance=${this.bot.userData.currentPoints}` + `${parent.pointProgressMax > 0 ? ` | targetPoints=${parent.pointProgressMax}` : ''}`, @@ -213,13 +213,13 @@ export class PunchCards extends BaseActivity { const questUrl = URLs.rewards.quest(parentId) const html = await this.bot.browser.func.getRewardsPageHtml(questUrl, `/earn/quest/${parentId}`) if (!html) { - this.bot.logger.warn(this.bot.isMobile, 'PUNCHCARD', `Quest page unavailable for "${title}"`) + this.bot.logger.warn(this.bot.isMobile, 'PUNCHCARD', `"${title}" 的任务页面不可用`) return null } const children = this.bot.browser.react.snapshotQuestPage(html) if (!children.length) { - this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', `No actionable children for "${title}"`) + this.bot.logger.info(this.bot.isMobile, 'PUNCHCARD', `"${title}" 没有可执行的子任务`) return null } return children @@ -227,7 +227,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'PUNCHCARD', - `Failed fetching quest page for "${title}" | ${error instanceof Error ? error.message : String(error)}` + `获取 "${title}" 的任务页面失败 | ${error instanceof Error ? error.message : String(error)}` ) return null } @@ -239,12 +239,12 @@ export class PunchCards extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'PUNCHCARD', - `Skip ${child.offerId}: "reportActivity" was not discovered` + `跳过 ${child.offerId}:未发现 "reportActivity"` ) return } if (!child.hash) { - this.bot.logger.warn(this.bot.isMobile, 'PUNCHCARD', `Skip ${child.offerId}: no live hash`) + this.bot.logger.warn(this.bot.isMobile, 'PUNCHCARD', `跳过 ${child.offerId}:没有有效的 hash`) return } @@ -279,7 +279,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'PUNCHCARD', - `Reported child | offerId=${child.offerId} | status=${status} | acknowledged=${acknowledged}` + + `已上报子任务 | offerId=${child.offerId} | status=${status} | acknowledged=${acknowledged}` + ` | pointsGained=${gained} | currentBalance=${newBalance}`, gained > 0 || acknowledged ? 'green' : undefined ) @@ -287,7 +287,7 @@ export class PunchCards extends BaseActivity { this.bot.logger.error( this.bot.isMobile, 'PUNCHCARD', - `Error reporting child | offerId=${child.offerId} | message=${ + `上报子任务出错 | offerId=${child.offerId} | message=${ error instanceof Error ? error.message : String(error) }` ) diff --git a/src/functions/activities/search/BonusTracker.ts b/src/functions/activities/search/BonusTracker.ts index b1759aee..819ce5d6 100644 --- a/src/functions/activities/search/BonusTracker.ts +++ b/src/functions/activities/search/BonusTracker.ts @@ -29,7 +29,7 @@ export class BonusTracker implements SearchTracker { async prepare(): Promise { if (this.maxSearches <= 0) { - this.bot.logger.info(this.isMobile, this.context, 'maxBonusSearches is 0, skipping bonus farming') + this.bot.logger.info(this.isMobile, this.context, 'maxBonusSearches 为 0,跳过奖励刷取') return false } @@ -40,14 +40,14 @@ export class BonusTracker implements SearchTracker { this.bot.logger.warn( this.isMobile, this.context, - `Could not fetch dashboard, skipping bonus farming | ${error instanceof Error ? error.message : String(error)}` + `无法获取仪表盘数据,跳过奖励刷取 | ${error instanceof Error ? error.message : String(error)}` ) return false } const offer = this.findSearchBonusOffer(dashboard) if (!offer) { - this.bot.logger.info(this.isMobile, this.context, 'No active search-bonus offer in the dashboard, skipping') + this.bot.logger.info(this.isMobile, this.context, '仪表盘中没有活跃的搜索奖励活动,跳过') return false } @@ -60,7 +60,7 @@ export class BonusTracker implements SearchTracker { this.bot.logger.info( this.isMobile, this.context, - `Found search bonus "${offer.title}" | offerId=${this.offerId} | progress=${this.current}/${this.max} | maxSearches=${this.maxSearches}` + `已找到搜索奖励 "${offer.title}" | offerId=${this.offerId} | 进度=${this.current}/${this.max} | 最大搜索次数=${this.maxSearches}` ) return true } @@ -83,7 +83,7 @@ export class BonusTracker implements SearchTracker { const cur = this.findOfferById(dash, this.offerId) if (!cur) { - this.bot.logger.warn(this.isMobile, this.context, `Offer ${this.offerId} no longer present, stopping`) + this.bot.logger.warn(this.isMobile, this.context, `活动 ${this.offerId} 已不存在,停止`) this.offerLost = true return 0 } diff --git a/src/functions/activities/search/BrowserSearch.ts b/src/functions/activities/search/BrowserSearch.ts index ab9ef8b3..01c8ed5b 100644 --- a/src/functions/activities/search/BrowserSearch.ts +++ b/src/functions/activities/search/BrowserSearch.ts @@ -29,7 +29,7 @@ export class Search extends BaseActivity { public async doSearch(page: Page, isMobile: boolean): Promise { const startBalance = Number(this.bot.userData.currentPoints ?? 0) - this.bot.logger.info(isMobile, 'SEARCH-BING', `Starting Bing searches | currentBalance=${startBalance}`) + this.bot.logger.info(isMobile, 'SEARCH-BING', `开始必应搜索 | 当前余额=${startBalance}`) const tracker = new PointsTracker(this.bot, isMobile) try { @@ -39,14 +39,14 @@ export class Search extends BaseActivity { this.bot.logger.warn( isMobile, tracker.context, - `Hit the ${tracker.maxSearches}-search ceiling with points still missing | ${tracker.progress()}` + `已达 ${tracker.maxSearches} 次搜索上限但积分仍有缺口 | ${tracker.progress()}` ) } this.bot.logger.info( isMobile, tracker.context, - `Completed Bing searches | pointsGained=${stats.totalGained} | currentBalance=${this.bot.userData.currentPoints} | previousBalance=${startBalance} | searches=${stats.performed} | ${tracker.progress()}` + `必应搜索完成 | 获得积分=${stats.totalGained} | 当前余额=${this.bot.userData.currentPoints} | 之前余额=${startBalance} | 搜索次数=${stats.performed} | ${tracker.progress()}` ) return stats.totalGained } finally { @@ -76,7 +76,7 @@ export class Search extends BaseActivity { this.bot.logger.info( isMobile, tracker.context, - `Bonus farming ${done ? 'complete' : 'stopped'} (${reason}) | pointsGained=${stats.totalGained} | currentBalance=${this.bot.userData.currentPoints} | ${tracker.progress()} | searches=${stats.performed}`, + `奖励搜索${done ? '完成' : '中止'}(${reason})| 获得积分=${stats.totalGained} | 当前余额=${this.bot.userData.currentPoints} | ${tracker.progress()} | 搜索次数=${stats.performed}`, done || stats.totalGained > 0 ? 'green' : undefined ) return stats.totalGained @@ -92,13 +92,13 @@ export class Search extends BaseActivity { const queryQueue = new SearchQueryQueue(this.bot) const topicCount = await queryQueue.prepare() if (!topicCount) { - this.bot.logger.warn(isMobile, tracker.context, 'No main search topics available, skipping') + this.bot.logger.warn(isMobile, tracker.context, '无可用主搜索主题,跳过') return stats } this.bot.logger.info( isMobile, tracker.context, - `Query queue ready | mainTopics=${topicCount} | clusterSearch=${this.bot.config.searchSettings.clusterSearch}` + `查询队列就绪 | 主题数=${topicCount} | clusterSearch=${this.bot.config.searchSettings.clusterSearch}` ) await this.bot.browser.func.synchronizeActiveBrowserCookies('SEARCH-COOKIE-SEED', true) @@ -109,7 +109,7 @@ export class Search extends BaseActivity { while (!tracker.done() && stats.performed < tracker.maxSearches && stats.stagnant < tracker.stagnantLimit) { const query = await queryQueue.next() if (!query) { - this.bot.logger.warn(isMobile, tracker.context, 'Query queue exhausted, stopping') + this.bot.logger.warn(isMobile, tracker.context, '查询队列已耗尽,停止') break } @@ -125,7 +125,7 @@ export class Search extends BaseActivity { this.bot.logger.info( isMobile, tracker.context, - `pointsGained=${gained} | currentBalance=${this.bot.userData.currentPoints} | query="${query}" | ${tracker.progress()}`, + `获得积分=${gained} | 当前余额=${this.bot.userData.currentPoints} | 查询="${query}" | ${tracker.progress()}`, 'green' ) } else { @@ -133,7 +133,7 @@ export class Search extends BaseActivity { this.bot.logger.info( isMobile, tracker.context, - `no points ${stats.stagnant}/${tracker.stagnantLimit} | query="${query}" | ${tracker.progress()}` + `未获得积分 ${stats.stagnant}/${tracker.stagnantLimit} | 查询="${query}" | ${tracker.progress()}` ) } } @@ -143,7 +143,7 @@ export class Search extends BaseActivity { this.bot.logger.error( isMobile, tracker.context, - `Search session error | ${error instanceof Error ? error.message : String(error)}` + `搜索会话出错 | ${error instanceof Error ? error.message : String(error)}` ) return stats } @@ -194,7 +194,7 @@ export class Search extends BaseActivity { this.bot.logger.warn( isMobile, 'SEARCH-BING', - `Search attempt ${attempt}/${MAX_QUERY_ATTEMPTS} failed | query="${query}" | ${error instanceof Error ? error.message : String(error)}` + `搜索尝试 ${attempt}/${MAX_QUERY_ATTEMPTS} 失败 | 查询="${query}" | ${error instanceof Error ? error.message : String(error)}` ) if (attempt === MAX_QUERY_ATTEMPTS) throw error await this.bot.utils.wait(2000) @@ -212,7 +212,7 @@ export class Search extends BaseActivity { this.bot.logger.error( isMobile, 'SEARCH-RANDOM-SCROLL', - `Failed during random scroll | ${error instanceof Error ? error.message : String(error)}` + `随机滚动过程中出现错误 | ${error instanceof Error ? error.message : String(error)}` ) } } @@ -233,7 +233,7 @@ export class Search extends BaseActivity { this.bot.logger.error( isMobile, 'SEARCH-RANDOM-CLICK', - `Failed during random click | ${error instanceof Error ? error.message : String(error)}` + `随机点击过程中出现错误 | ${error instanceof Error ? error.message : String(error)}` ) } } @@ -261,7 +261,7 @@ class PointsTracker implements SearchTracker { this.bot.logger.info( this.isMobile, this.context, - `Search points remaining | edge=${this.missing.edgePoints} | desktop=${this.missing.desktopPoints} | mobile=${this.missing.mobilePoints}` + `剩余搜索积分 | Edge=${this.missing.edgePoints} | 桌面端=${this.missing.desktopPoints} | 移动端=${this.missing.mobilePoints}` ) if (this.missing.totalPoints <= 0) { @@ -269,14 +269,14 @@ class PointsTracker implements SearchTracker { this.bot.logger.info( this.isMobile, this.context, - 'No search points to earn, skipping (runOnZeroPoints is disabled)' + '无可赚搜索积分,跳过(runOnZeroPoints 已禁用)' ) return false } this.bot.logger.info( this.isMobile, this.context, - 'No search points reported, but runOnZeroPoints is enabled, searching anyway' + '未报告搜索积分,但 runOnZeroPoints 已启用,仍继续搜索' ) } return true diff --git a/src/functions/activities/search/BrowserSearchOnBing.ts b/src/functions/activities/search/BrowserSearchOnBing.ts index e46e3010..d1d3d666 100644 --- a/src/functions/activities/search/BrowserSearchOnBing.ts +++ b/src/functions/activities/search/BrowserSearchOnBing.ts @@ -19,7 +19,7 @@ export class SearchOnBing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'SEARCH-ON-BING', - `Starting SearchOnBing | offerId=${offerId} | title="${promotion.title}" | currentBalance=${this.oldBalance}` + `开始 SearchOnBing | offerId=${offerId} | 标题="${promotion.title}" | 当前余额=${this.oldBalance}` ) try { @@ -28,7 +28,7 @@ export class SearchOnBing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING', - `Search activity couldn't be activated, aborting | offerId=${offerId}` + `搜索活动无法激活,中止 | offerId=${offerId}` ) return } @@ -40,21 +40,21 @@ export class SearchOnBing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'SEARCH-ON-BING', - `Completed SearchOnBing | offerId=${offerId} | pointsGained=${this.gainedPoints} | currentBalance=${this.bot.userData.currentPoints} | previousBalance=${this.oldBalance}`, + `SearchOnBing 完成 | offerId=${offerId} | 获得积分=${this.gainedPoints} | 当前余额=${this.bot.userData.currentPoints} | 之前余额=${this.oldBalance}`, 'green' ) } else { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING', - `Failed SearchOnBing | offerId=${offerId} | pointsGained=${this.gainedPoints} | currentBalance=${this.bot.userData.currentPoints} | previousBalance=${this.oldBalance}` + `SearchOnBing 失败 | offerId=${offerId} | 获得积分=${this.gainedPoints} | 当前余额=${this.bot.userData.currentPoints} | 之前余额=${this.oldBalance}` ) } } catch (error) { this.bot.logger.error( this.bot.isMobile, 'SEARCH-ON-BING', - `Error in doSearchOnBing | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + `doSearchOnBing 中出现错误 | offerId=${offerId} | 错误信息=${error instanceof Error ? error.message : String(error)}` ) } finally { await page.goto(URLs.rewards.earn).catch(() => {}) @@ -68,7 +68,7 @@ export class SearchOnBing extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `Starting search loop | queriesCount=${queries.length} | targetPoints=${promotion.pointProgressMax} | currentBalance=${this.oldBalance}` + `开始搜索循环 | 查询数=${queries.length} | 目标积分=${promotion.pointProgressMax} | 当前余额=${this.oldBalance}` ) await this.bot.browser.func.synchronizeActiveBrowserCookies('SEARCH-ON-BING-COOKIE-SEED', true) @@ -78,7 +78,7 @@ export class SearchOnBing extends BaseActivity { for (const [index, query] of queries.entries()) { try { - this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `Processing query | query="${query}"`) + this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `正在处理查询 | 查询="${query}"`) await this.bot.browser.func.synchronizeActiveBrowserCookies('SEARCH-ON-BING-COOKIE-SEED', true) await this.typeSearch(page, query) @@ -106,7 +106,7 @@ export class SearchOnBing extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `Progress check | query="${query}" | offerProgress=${offerProgress} | offerComplete=${offerComplete} | currentBalance=${newBalance}` + `进度检查 | 查询="${query}" | 活动进度=${offerProgress} | 活动完成=${offerComplete} | 当前余额=${newBalance}` ) if (offerComplete) { @@ -114,7 +114,7 @@ export class SearchOnBing extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `SearchOnBing activity completed | pointsGained=${this.gainedPoints} | currentBalance=${newBalance} | query="${query}" | offerProgress=${offerProgress}`, + `SearchOnBing 活动完成 | 获得积分=${this.gainedPoints} | 当前余额=${newBalance} | 查询="${query}" | 活动进度=${offerProgress}`, 'green' ) return @@ -123,13 +123,13 @@ export class SearchOnBing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `${index + 1}/${queries.length} | activity not complete | offerProgress=${offerProgress} | query="${query}"` + `${index + 1}/${queries.length} | 活动未完成 | 活动进度=${offerProgress} | 查询="${query}"` ) } catch (error) { this.bot.logger.error( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `Error during search loop | query="${query}" | message=${error instanceof Error ? error.message : String(error)}` + `搜索循环中出错 | 查询="${query}" | 错误信息=${error instanceof Error ? error.message : String(error)}` ) } finally { if (!this.success && index < queries.length - 1) { @@ -141,7 +141,7 @@ export class SearchOnBing extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', - `Finished all queries without completing the activity | queriesTried=${queries.length} | offerId=${offerId} | pointsGained=${this.gainedPoints} | currentBalance=${this.bot.userData.currentPoints} | previousBalance=${this.oldBalance}` + `已完成全部查询但活动仍未完成 | 已尝试查询数=${queries.length} | offerId=${offerId} | 获得积分=${this.gainedPoints} | 当前余额=${this.bot.userData.currentPoints} | 之前余额=${this.oldBalance}` ) } diff --git a/src/functions/activities/search/SearchManager.ts b/src/functions/activities/search/SearchManager.ts index 4b210107..e35c001f 100644 --- a/src/functions/activities/search/SearchManager.ts +++ b/src/functions/activities/search/SearchManager.ts @@ -30,8 +30,8 @@ export class SearchManager { this.bot.logger.info( 'main', 'SEARCH-MANAGER', - `Mobile: ${this.describeQuota(this.bot.config.workers.doMobileSearch, quotas.mobile)}` + - ` | Desktop: ${this.describeQuota(this.bot.config.workers.doDesktopSearch, desktopQuota)}` + + `移动端: ${this.describeQuota(this.bot.config.workers.doMobileSearch, quotas.mobile)}` + + ` | 桌面端: ${this.describeQuota(this.bot.config.workers.doDesktopSearch, desktopQuota)}` + `${quotas.edge.max > 0 ? ` | Edge: ${quotas.edge.earned}/${quotas.edge.max}` : ''}` ) @@ -74,7 +74,7 @@ export class SearchManager { this.bot.logger.error( 'main', 'SEARCH-MANAGER', - `${platform} search failed | ${error instanceof Error ? error.message : String(error)}` + `${platform} 搜索失败 | ${error instanceof Error ? error.message : String(error)}` ) return 0 } @@ -82,7 +82,7 @@ export class SearchManager { } async bonusMobile(account: Account): Promise { - this.bot.logger.info('main', 'SEARCH-MANAGER', 'Starting bonus search farming') + this.bot.logger.info('main', 'SEARCH-MANAGER', '开始奖励搜索') const gained = await executionContext.run({ isMobile: true, account }, async () => { try { @@ -91,7 +91,7 @@ export class SearchManager { this.bot.logger.error( 'main', 'SEARCH-MANAGER', - `Bonus search failed | ${error instanceof Error ? error.message : String(error)}` + `奖励搜索失败 | ${error instanceof Error ? error.message : String(error)}` ) return 0 } finally { @@ -104,7 +104,7 @@ export class SearchManager { this.bot.logger.info( 'main', 'SEARCH-MANAGER', - `Bonus search summary | pointsGained=${gained} | currentBalance=${this.bot.userData.currentPoints}` + `奖励搜索汇总 | 获得积分=${gained} | 当前余额=${this.bot.userData.currentPoints}` ) return gained } diff --git a/src/functions/activities/search/SearchOnBingShared.ts b/src/functions/activities/search/SearchOnBingShared.ts index 363b1bc7..1c91383d 100644 --- a/src/functions/activities/search/SearchOnBingShared.ts +++ b/src/functions/activities/search/SearchOnBingShared.ts @@ -18,7 +18,7 @@ export async function activateSearchOnBing(bot: MicrosoftRewardsBot, promotion: bot.logger.warn( bot.isMobile, 'SEARCH-ON-BING-ACTIVATE', - `Skipping ${offerId}: "reportActivity" not discovered in bundle` + `跳过 ${offerId}: 未在 bundle 中发现 "reportActivity"` ) return false } @@ -29,7 +29,7 @@ export async function activateSearchOnBing(bot: MicrosoftRewardsBot, promotion: bot.logger.warn( bot.isMobile, 'SEARCH-ON-BING-ACTIVATE', - `Skipping ${offerId}: no live hash for the activation offer` + `跳过 ${offerId}: 激活活动无可用 hash` ) return false } @@ -48,14 +48,14 @@ export async function activateSearchOnBing(bot: MicrosoftRewardsBot, promotion: bot.logger.info( bot.isMobile, 'SEARCH-ON-BING-ACTIVATE', - `Activated activity | offerId=${offerId} | status=${status} | acknowledged=${acknowledged}` + `活动已激活 | offerId=${offerId} | 状态=${status} | 已确认=${acknowledged}` ) return acknowledged } catch (error) { bot.logger.error( bot.isMobile, 'SEARCH-ON-BING-ACTIVATE', - `Activation failed | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}` + `激活失败 | offerId=${offerId} | 错误=${error instanceof Error ? error.message : String(error)}` ) return false } @@ -76,12 +76,12 @@ export async function getSearchOnBingQueries(bot: MicrosoftRewardsBot, promotion let activities: ActivityQueries[] if (bot.config.searchOnBingLocalQueries) { - bot.logger.debug(bot.isMobile, 'SEARCH-ON-BING-QUERY', 'Using local queries config file') + bot.logger.debug(bot.isMobile, 'SEARCH-ON-BING-QUERY', '使用本地查询词配置文件') activities = JSON.parse( fs.readFileSync(path.join(__dirname, '../../bing-search-activity-queries.json'), 'utf8') ) as ActivityQueries[] } else { - bot.logger.debug(bot.isMobile, 'SEARCH-ON-BING-QUERY', 'Fetching queries config from remote repository') + bot.logger.debug(bot.isMobile, 'SEARCH-ON-BING-QUERY', '从远程仓库获取查询词配置') activities = ( await bot.http.request({ method: 'GET', @@ -98,7 +98,7 @@ export async function getSearchOnBingQueries(bot: MicrosoftRewardsBot, promotion bot.logger.info( bot.isMobile, 'SEARCH-ON-BING-QUERY', - `Found ${shuffled.length} queries for "${promotion.title}" | source=${bot.config.searchOnBingLocalQueries ? 'local' : 'remote'}` + `为 "${promotion.title}" 找到 ${shuffled.length} 条查询词 | 来源=${bot.config.searchOnBingLocalQueries ? '本地' : '远程'}` ) return shuffled } @@ -106,14 +106,14 @@ export async function getSearchOnBingQueries(bot: MicrosoftRewardsBot, promotion bot.logger.info( bot.isMobile, 'SEARCH-ON-BING-QUERY', - `No curated queries for "${promotion.title}", falling back to the activity title and description` + `"${promotion.title}" 无精选查询词,回退使用活动标题与描述` ) return fallbackQueries(promotion) } catch (error) { bot.logger.error( bot.isMobile, 'SEARCH-ON-BING-QUERY', - `Error resolving search queries | title="${promotion.title}" | message=${error instanceof Error ? error.message : String(error)} | fallback=titleAndDescription` + `解析查询词出错 | 标题="${promotion.title}" | 错误=${error instanceof Error ? error.message : String(error)} | 回退=标题与描述` ) return fallbackQueries(promotion) } diff --git a/src/functions/activities/visualSearch/VisualSearch.ts b/src/functions/activities/visualSearch/VisualSearch.ts index 91250d29..912e76a0 100644 --- a/src/functions/activities/visualSearch/VisualSearch.ts +++ b/src/functions/activities/visualSearch/VisualSearch.ts @@ -32,7 +32,7 @@ export class VisualSearch extends BaseActivity { public async doVisualSearch(data: DashboardData): Promise { if (this.bot.isMobile) { - this.bot.logger.debug(this.bot.isMobile, 'VISUAL-SEARCH', 'Skipping on mobile - desktop-only activity') + this.bot.logger.debug(this.bot.isMobile, 'VISUAL-SEARCH', '移动端跳过 - 仅限桌面端的活动') return 0 } @@ -43,7 +43,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - `Already completed today | visualSearchStreak=${streak.completedDays}/${streak.totalDays}`, + `今日已完成 | visualSearchStreak=${streak.completedDays}/${streak.totalDays}`, 'green' ) return 0 @@ -56,7 +56,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - 'Visual search not available for this account, skipping' + '该账户无法使用视觉搜索,跳过' ) return 0 } @@ -82,7 +82,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - 'No visual-search streak in the snapshot - falling back to the activation offer' + '快照中没有视觉搜索连击 - 回退到激活优惠' ) return } @@ -90,14 +90,14 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - `Streak state | partner="${streak.partner}" | enabled=${streak.isEnabled} | dayCompleted=${streak.isCurrentDayCompleted} | days=${streak.completedDays}/${streak.totalDays} | currentDay=${streak.currentDay} | activities=${streak.activitiesCompleted}/${streak.activitiesTotal}` + `连击状态 | partner="${streak.partner}" | enabled=${streak.isEnabled} | dayCompleted=${streak.isCurrentDayCompleted} | days=${streak.completedDays}/${streak.totalDays} | currentDay=${streak.currentDay} | activities=${streak.activitiesCompleted}/${streak.activitiesTotal}` ) if (!streak.isEnabled) { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - 'Streak is present but not enabled - searches will not register until it is switched on' + '连击存在但未启用 - 开启前搜索不会被记录' ) } } @@ -108,7 +108,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH', - 'No visual-search activation metadata present in the streak model or generic offers across the current, desktop, or cached mobile Rewards snapshots' + '当前、桌面或缓存的移动端 Rewards 快照的连击模型和通用优惠中均无视觉搜索激活元数据' ) return 'absent' } @@ -117,7 +117,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - `Visual search activation offer already completed | offerId=${offer.offerId}`, + `视觉搜索激活优惠已完成 | offerId=${offer.offerId}`, 'green' ) return 'already-active' @@ -127,7 +127,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `Activation offer present but missing a hash | offerId=${offer.offerId}` + `激活优惠存在但缺少 hash | offerId=${offer.offerId}` ) return 'failed' } @@ -136,7 +136,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `Activation offer is not actionable | offerId=${offer.offerId}` + `激活优惠无法执行 | offerId=${offer.offerId}` ) return 'failed' } @@ -146,7 +146,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - 'Skipping activation: "reportActivity" action id not discovered in bundle' + '跳过激活:bundle 中未发现 "reportActivity" action id' ) return 'failed' } @@ -157,7 +157,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `Skipping activation: no valid activity type found | offerId=${offer.offerId}` + `跳过激活:未找到有效的活动类型 | offerId=${offer.offerId}` ) return 'failed' } @@ -165,7 +165,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - `Activating visual search | offerId=${offer.offerId} | activationSource=${offer.activationSource} | activityType=${metadata.activityType} | activityTypeSource=${metadata.activityTypeSource} | promotional=${metadata.isPromotional} | geo=${this.bot.userData.geoLocale}` + `正在激活视觉搜索 | offerId=${offer.offerId} | activationSource=${offer.activationSource} | activityType=${metadata.activityType} | activityTypeSource=${metadata.activityTypeSource} | promotional=${metadata.isPromotional} | geo=${this.bot.userData.geoLocale}` ) try { @@ -186,7 +186,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - `Activated visual search | offerId=${offer.offerId} | acknowledged=${acknowledged} | confirmed=${confirmed}`, + `视觉搜索已激活 | offerId=${offer.offerId} | acknowledged=${acknowledged} | confirmed=${confirmed}`, 'green' ) return 'activated' @@ -195,14 +195,14 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `Activation not acknowledged | offerId=${offer.offerId} | status=${status}` + `激活未被确认 | offerId=${offer.offerId} | status=${status}` ) return 'failed' } catch (error) { this.bot.logger.error( this.bot.isMobile, 'VISUAL-SEARCH', - `Activation error | offerId=${offer.offerId} | ${error instanceof Error ? error.message : String(error)}` + `激活出错 | offerId=${offer.offerId} | ${error instanceof Error ? error.message : String(error)}` ) return 'failed' } @@ -222,7 +222,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH', - `Could not verify activation state | offerId=${offerId} | ${error instanceof Error ? error.message : String(error)}` + `无法验证激活状态 | offerId=${offerId} | ${error instanceof Error ? error.message : String(error)}` ) return false } @@ -235,7 +235,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH', - 'Desktop dashboard fetch failed - falling back to the dashboard from the mobile pass' + '桌面端 dashboard 获取失败 - 回退到移动端阶段获取的 dashboard' ) return fallback } @@ -344,13 +344,13 @@ export class VisualSearch extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH', - `Activation metadata missing from the current snapshot; using cached ${source} streak snapshot | offerId=${streak.activationOfferId}` + `当前快照缺少激活元数据;使用缓存的 ${source} 连击快照 | offerId=${streak.activationOfferId}` ) } else { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH', - `Using visual-search activation metadata from streak model | offerId=${streak.activationOfferId}` + `使用来自连击模型的视觉搜索激活元数据 | offerId=${streak.activationOfferId}` ) } @@ -391,13 +391,13 @@ export class VisualSearch extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH', - `Activation offer missing from the current snapshot; using cached ${source} offer snapshot | offerId=${offer.offerId}` + `当前快照缺少激活优惠;使用缓存的 ${source} 优惠快照 | offerId=${offer.offerId}` ) } else { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH', - `Using visual-search activation metadata from generic offer | offerId=${offer.offerId}` + `使用来自通用优惠的视觉搜索激活元数据 | offerId=${offer.offerId}` ) } @@ -428,7 +428,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - `Daily visual search done | pointsGained=${gained} | currentBalance=${res.balance} | query="${visual.query}"`, + `每日视觉搜索完成 | pointsGained=${gained} | currentBalance=${res.balance} | query="${visual.query}"`, 'green' ) return gained @@ -438,7 +438,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH', - `Daily visual search registered | pointsGained=0 (streak pays out on milestones) | query="${visual.query}"`, + `每日视觉搜索已记录 | pointsGained=0(连击在里程碑时发放积分) | query="${visual.query}"`, 'green' ) return 0 @@ -448,13 +448,13 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `Visual search was reported but not credited (attempt ${attempt}/${MAX_ATTEMPTS}) | query="${visual.query}"` + `视觉搜索已上报但未记分(第 ${attempt}/${MAX_ATTEMPTS} 次尝试) | query="${visual.query}"` ) } else { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `No reportActivity acknowledgement (attempt ${attempt}/${MAX_ATTEMPTS}) | query="${visual.query}"` + `reportActivity 未确认(第 ${attempt}/${MAX_ATTEMPTS} 次尝试) | query="${visual.query}"` ) } @@ -464,7 +464,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `Daily visual search did not credit after ${MAX_ATTEMPTS} attempts` + `尝试 ${MAX_ATTEMPTS} 次后每日视觉搜索仍未记分` ) return 0 } @@ -486,7 +486,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `Could not obtain a visual search from one candidate (attempt ${attempt}/${MAX_ATTEMPTS})` + `无法从该候选种子获取视觉搜索(第 ${attempt}/${MAX_ATTEMPTS} 次尝试)` ) acquisitionFailures++ if (acquisitionFailures >= MAX_ACQUISITION_FAILURES_PER_ATTEMPT) return null @@ -501,7 +501,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `Skipping already-tried bcid=${visual.bcid.slice(0, 14)} | candidatesRemaining=${candidateSeeds.length}` + `跳过已尝试过的 bcid=${visual.bcid.slice(0, 14)} | candidatesRemaining=${candidateSeeds.length}` ) await this.bot.utils.wait(this.bot.utils.randomDelay(1000, 2000)) } @@ -509,7 +509,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH', - `No unused visual-search seed remains (attempt ${attempt}/${MAX_ATTEMPTS})` + `没有剩余未使用的视觉搜索种子(第 ${attempt}/${MAX_ATTEMPTS} 次尝试)` ) return null } @@ -536,7 +536,7 @@ export class VisualSearch extends BaseActivity { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH', - `Streak still open after reporting | days=${streak.completedDays}/${streak.totalDays} | activities=${streak.activitiesCompleted}/${streak.activitiesTotal}` + `上报后当日连击仍未完成 | days=${streak.completedDays}/${streak.totalDays} | activities=${streak.activitiesCompleted}/${streak.activitiesTotal}` ) return false } diff --git a/src/functions/activities/visualSearch/VisualSearchBrowser.ts b/src/functions/activities/visualSearch/VisualSearchBrowser.ts index fa29a2ba..dc21897a 100644 --- a/src/functions/activities/visualSearch/VisualSearchBrowser.ts +++ b/src/functions/activities/visualSearch/VisualSearchBrowser.ts @@ -39,7 +39,7 @@ export class VisualSearchBrowser { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH-REPORT', - 'Desktop page is unavailable - cannot run the visual-search browser flow' + '桌面端页面不可用 - 无法执行视觉搜索浏览器流程' ) return this.emptyReport() } @@ -72,7 +72,7 @@ export class VisualSearchBrowser { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH-REPORT', - `Bing did not issue reportActivity for "${candidate.query}" | bcid=${candidate.bcid.slice(0, 12)}` + `Bing 未对 "${candidate.query}" 发出 reportActivity | bcid=${candidate.bcid.slice(0, 12)}` ) return this.emptyReport() } @@ -88,7 +88,7 @@ export class VisualSearchBrowser { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH-REPORT', - `Browser reported "${candidate.query}" | status=${response.status()}` + + `浏览器已上报 "${candidate.query}" | status=${response.status()}` + ` | acknowledged=${acknowledged} | ig=${ig ?? 'n/a'} | bcid=${candidate.bcid.slice(0, 12)}` + ` | pointsGained=${gained ?? 'n/a'} | currentBalance=${parsed.balance ?? 'n/a'}` + ` | searchPts=${parsed.searchPointsEarned ?? 'n/a'}/${parsed.searchPointsLimit ?? 'n/a'}` @@ -99,7 +99,7 @@ export class VisualSearchBrowser { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH-REPORT', - `Browser flow failed for "${candidate.query}" | ${ + `视觉搜索浏览器流程失败 "${candidate.query}" | ${ error instanceof Error ? error.message : String(error) }` ) @@ -116,7 +116,7 @@ export class VisualSearchBrowser { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - 'Desktop page is unavailable - cannot acquire a visual search' + '桌面端页面不可用 - 无法获取视觉搜索' ) return null } @@ -152,12 +152,12 @@ export class VisualSearchBrowser { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - `kblob returned no redirectUrl | status=${response.status()} - endpoint shape may have changed` + `kblob 未返回 redirectUrl | status=${response.status()} - 接口结构可能已变化` ) this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - `kblob response: ${responseData.slice(0, 400)}` + `kblob 响应: ${responseData.slice(0, 400)}` ) return null } @@ -165,7 +165,7 @@ export class VisualSearchBrowser { const redirect = new URL(redirectUrl, URLs.bing.origin) const bcid = redirect.searchParams.get('bcid') if (!bcid) { - this.bot.logger.warn(this.bot.isMobile, 'VISUAL-SEARCH-BCID', `Redirect had no bcid | ${redirectUrl}`) + this.bot.logger.warn(this.bot.isMobile, 'VISUAL-SEARCH-BCID', `重定向中没有 bcid | ${redirectUrl}`) return null } @@ -173,7 +173,7 @@ export class VisualSearchBrowser { this.bot.logger.info( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - `Acquired bcid=${bcid.slice(0, 14)} | q="${query}" | status=${response.status()}` + + `已获取 bcid=${bcid.slice(0, 14)} | q="${query}" | status=${response.status()}` + ` | seed=${seed.slice(0, 80)}`, 'green' ) @@ -182,7 +182,7 @@ export class VisualSearchBrowser { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - `Failed to acquire visual search | ${error instanceof Error ? error.message : String(error)}` + `获取视觉搜索失败 | ${error instanceof Error ? error.message : String(error)}` ) return null } @@ -194,7 +194,7 @@ export class VisualSearchBrowser { this.bot.logger.warn( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - 'Desktop page is unavailable - using the static visual-search seed' + '桌面端页面不可用 - 使用静态视觉搜索种子' ) return [STATIC_SEED_URL] } @@ -221,7 +221,7 @@ export class VisualSearchBrowser { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - `Prepared ${uniqueSeeds.length} randomized Bing wallpaper seed(s)` + `已准备 ${uniqueSeeds.length} 个随机化的 Bing 壁纸种子` ) return uniqueSeeds } @@ -230,14 +230,14 @@ export class VisualSearchBrowser { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - `HPImageArchive returned no usable urls | status=${response.status()} - using the static seed` + `HPImageArchive 未返回可用 URL | status=${response.status()} - 使用静态种子` ) } catch (error) { this.bot.logger.debug( this.bot.isMobile, 'VISUAL-SEARCH-BCID', - `HPImageArchive lookup failed | ${error instanceof Error ? error.message : String(error)}` + - ' - using the static seed' + `HPImageArchive 查询失败 | ${error instanceof Error ? error.message : String(error)}` + + ' - 使用静态种子' ) } diff --git a/src/functions/search-queries.json b/src/functions/search-queries.json index 23abe6fa..5e7fb638 100644 --- a/src/functions/search-queries.json +++ b/src/functions/search-queries.json @@ -1,7002 +1,394 @@ [ - "weather tomorrow", - "how to cook pasta", - "best movies 2024", - "latest tech news", - "how tall is the eiffel tower", - "easy dinner recipes", - "what time is it in japan", - "how does photosynthesis work", - "best budget smartphones", - "coffee vs espresso difference", - "how to improve wifi signal", - "popular netflix series", - "how many calories in an apple", - "world population today", - "best free pc games", - "how to clean a keyboard", - "what is artificial intelligence", - "simple home workouts", - "how long do cats live", - "famous paintings in museums", - "how to boil eggs", - "latest windows updates", - "how to screenshot on windows", - "best travel destinations europe", - "what is cloud computing", - "how to save money monthly", - "best youtube channels", - "how fast is light", - "how to learn programming", - "popular board games", - "how to make pancakes", - "capital cities of europe", - "how does a vpn work", - "best productivity apps", - "how to grow plants indoors", - "difference between hdd and ssd", - "how to fix slow computer", - "most streamed songs", - "how to tie a tie", - "what causes rain", - "best laptops for students", - "how to reset router", - "healthy breakfast ideas", - "how many continents are there", - "latest smartphone features", - "how to meditate beginners", - "what is renewable energy", - "best pc accessories", - "how to clean glasses", - "famous landmarks worldwide", - "how to make coffee at home", - "what is machine learning", - "best programming languages", - "how to backup files", - "how does bluetooth work", - "top video games right now", - "how to improve sleep quality", - "what is cryptocurrency", - "easy lunch ideas", - "how to check internet speed", - "best noise cancelling headphones", - "how to take screenshots on mac", - "what is the milky way", - "how to organize files", - "popular mobile apps", - "how to learn faster", - "how does gps work", - "best free antivirus", - "how to clean a monitor", - "what is an electric car", - "simple math tricks", - "how to update drivers", - "famous scientists", - "a series of tubes!", - "how to cook rice", - "what is the tallest mountain", - "best tv shows all time", - "how to improve typing speed", - "how does solar power work", - "easy dessert recipes", - "how to fix bluetooth issues", - "what is the internet", - "best pc keyboards", - "how to stay focused", - "popular science facts", - "how to convert files to pdf", - "how long does it take to sleep", - "best travel tips", - "how to clean headphones", - "what is open source software", - "how to manage time better", - "latest gaming news", - "how to check laptop temperature", - "what is a firewall", - "easy meal prep ideas", - "how to reduce eye strain", - "best budget headphones", - "how does email work", - "what is virtual reality", - "how to compress files", - "popular programming tools", - "how to improve concentration", - "how to make smoothies", - "best desk setup ideas", - "best alien movie", - "how to block ads", - "what is 5g technology", - "how to clean a mouse", - "famous world wonders", - "how to improve battery life", - "best cloud storage services", - "how to learn a new language", - "what is dark mode", - "how to clear browser cache", - "popular tech podcasts", - "how to stay motivated", - "how to speed up pc", - "best gaming mouse", - "how to clear storage on phone", - "how to change wifi password", - "best android apps 2026", - "how to use chatgpt", - "what is blockchain", - "how to learn javascript", - "best free vpn", - "how to edit videos", - "how to remove virus from pc", - "best browsers 2026", - "how to improve internet speed", - "how to install windows 11", - "best coding websites", - "who made skynet?", - "how to use excel formulas", - "how to make a website", - "what is html", - "who was the first terminator", - "what is css", - "what is javascript", - "how to build a gaming pc", - "best graphics cards 2026", - "how to clean pc fans", - "how to upgrade ram", - "how to install ssd", - "what is ram", - "what is cpu", - "can netsky fly?", - "how to check gpu temperature", - "best pc games 2026", - "best multiplayer games", - "how to stream on twitch", - "how to start youtube channel", - "best video editing software", - "how to record screen", - "how to download videos", - "how to convert video formats", - "best file compression tools", - "how to unzip files", - "is the internet a series of tubes?", - "how to use winrar", - "how to password protect files", - "best password managers", - "how to create strong password", - "how to enable two factor authentication", - "what is phishing", - "how to stay safe online", - "who came first, the chicken or the egg?", - "how to block spam emails", - "how to use google drive", - "best note taking apps", - "how to sync phone and pc", - "how to transfer files phone to pc", - "best wireless earbuds", - "best smartwatches 2026", - "how to save phone battery", - "how to cool down phone", - "how to free up storage android", - "how to free up storage iphone", - "how to factory reset phone", - "how to track phone", - "how to find lost phone", - "netsky or netki?", - "best navigation apps", - "how to use google maps offline", - "how to share location", - "how to scan qr code", - "what is nfc", - "how to use bluetooth headphones", - "how to connect controller to pc", - "best gaming controllers", - "how to fix controller drift", - "how to improve gaming performance", - "best fps settings", - "how to reduce lag", - "how to get better ping", - "how to port forward router", - "how to setup mesh wifi", - "best routers 2026", - "how to change dns", - "why is netsky so cool?", - "best dns for gaming", - "how to hide ip address", - "how to use proxy", - "how to install linux", - "best linux distro", - "how to use terminal", - "basic linux commands", - "how to use git", - "what is github", - "how to push to github", - "how to clone repository", - "how to learn python", - "best python libraries", - "how to automate tasks", - "what is api", - "how to use rest api", - "how to parse json", - "how to use nodejs", - "best npm packages", - "how to debug code", - "how to fix bugs", - "what is software development", - "how to become programmer", - "best online courses", - "how to study effectively", - "how to memorize fast", - "how to stay productive", - "morning routine ideas", - "how to stop procrastinating", - "how to set goals", - "how to plan day", - "how to track habits", - "best habit apps", - "how to drink more water", - "how to eat healthier", - "simple fitness tips", - "how to lose weight safely", - "how to gain muscle", - "home gym ideas", - "best running apps", - "how to improve posture", - "how to reduce stress", - "how to relax quickly", - "how to fall asleep fast", - "how to wake up early", - "how to build confidence", - "how to speak confidently", - "how to improve communication", - "how to network effectively", - "how to prepare for interview", - "common interview questions", - "how to write resume", - "how to write cover letter", - "how to get a job fast", - "how to start side hustle", - "online business ideas", - "how to make money online", - "best freelance websites", - "how to sell online", - "how to start ecommerce", - "what is dropshipping", - "how to invest money", - "how to start investing", - "best savings tips", - "how to budget money", - "how to track expenses", - "best finance apps", - "best hiking trails near me", - "how to train for a 5k", - "easy vegetarian dinner ideas", - "how to bake sourdough bread", - "best beaches in italy", - "how to start a garden", - "how to prune roses", - "easy soup recipes", - "how to grill steak perfectly", - "best coffee beans for espresso", - "how to make homemade pizza dough", - "romantic date night ideas", - "how to declutter your home", - "minimalist living tips", - "how to plan a wedding", - "best honeymoon destinations", - "how to take better photos", - "portrait photography tips", - "how to start journaling", - "daily gratitude prompts", - "how to paint a room", - "best indoor plants", - "how to compost at home", - "how to grow tomatoes", - "how to make candles", - "how to crochet for beginners", - "easy knitting patterns", - "how to play chess better", - "chess openings explained", - "how to solve a rubiks cube", - "best family board games", - "how to train a puppy", - "why do dogs bark", - "how to litter train a cat", - "how to build a treehouse", - "how to write a novel", - "creative writing prompts", - "how to overcome writers block", - "best fantasy books", - "how to start reading more", - "how to memorize a speech", - "public speaking tips", - "how to negotiate salary", - "how to save for a house", - "first time home buyer tips", - "how to refinance mortgage", - "how to invest in real estate", - "best cities to live in europe", - "how to travel on a budget", - "solo travel tips", - "how to pack light for travel", - "best road trip routes", - "how to plan a weekend getaway", - "how to make friends as an adult", - "how to improve relationships", - "signs of healthy relationship", - "how to resolve conflict", - "how to practice mindfulness", - "breathing exercises for anxiety", - "how to boost immune system", - "benefits of drinking water", - "healthy meal plan ideas", - "how to meal prep for the week", - "easy vegan recipes", - "how to cook salmon", - "how to make risotto", - "how to bake chocolate cake", - "quick breakfast recipes", - "how to make homemade ice cream", - "how to start running", - "beginner yoga routine", - "how to stretch properly", - "benefits of walking daily", - "how to train for a marathon", - "how to stay hydrated", - "how to clean white shoes", - "how to remove stains from clothes", - "how to organize closet", - "spring cleaning checklist", - "how to decorate small apartment", - "budget home decor ideas", - "how to fix squeaky door", - "how to unclog a drain", - "how to save electricity at home", - "how to reduce waste", - "eco friendly lifestyle tips", - "how to recycle properly", - "how to start meditation practice", - "daily affirmations examples", - "how to improve handwriting", - "how to learn guitar", - "basic piano lessons", - "how to sing better", - "famous historical events", - "who was cleopatra", - "how tall is mount everest", - "why is the sky blue", - "how do airplanes fly", - "what causes earthquakes", - "how to make slime", - "fun science experiments at home", - "how to throw a birthday party", - "party games for adults", - "how to plan a baby shower", - "how to choose a baby name", - "how to prepare for a baby", - "how to potty train toddler", - "parenting tips for teenagers", - "how to study for exams", - "time management tips for students", - "how to write an essay", - "how to learn faster in school", - "how to start a podcast", - "how to start a blog", - "how to grow on instagram", - "how to build self discipline", - "how to break bad habits", - "how to build morning routine", - "how to stay positive", - "how to forgive someone", - "how to apologize sincerely", - "how to build good habits", - "how to deal with failure", - "how to handle criticism", - "how to increase creativity", - "how to find your passion", - "how to set realistic goals", - "how to stay consistent", - "how does artificial intelligence work", - "artificial intelligence explained simply", - "beginner guide to artificial intelligence", - "best artificial intelligence tips", - "common artificial intelligence mistakes", - "pros and cons of artificial intelligence", - "how does machine learning work", - "machine learning explained simply", - "beginner guide to machine learning", - "best machine learning tips", - "common machine learning mistakes", - "pros and cons of machine learning", - "what is deep learning", - "how does deep learning work", - "deep learning explained simply", - "beginner guide to deep learning", - "best deep learning tips", - "common deep learning mistakes", - "pros and cons of deep learning", - "what is neural networks", - "how does neural networks work", - "neural networks explained simply", - "beginner guide to neural networks", - "best neural networks tips", - "common neural networks mistakes", - "pros and cons of neural networks", - "what is large language models", - "how does large language models work", - "large language models explained simply", - "beginner guide to large language models", - "best large language models tips", - "common large language models mistakes", - "pros and cons of large language models", - "what is chatbots", - "how does chatbots work", - "chatbots explained simply", - "beginner guide to chatbots", - "best chatbots tips", - "common chatbots mistakes", - "pros and cons of chatbots", - "what is computer vision", - "how does computer vision work", - "computer vision explained simply", - "beginner guide to computer vision", - "best computer vision tips", - "common computer vision mistakes", - "pros and cons of computer vision", - "what is natural language processing", - "how does natural language processing work", - "natural language processing explained simply", - "beginner guide to natural language processing", - "best natural language processing tips", - "common natural language processing mistakes", - "pros and cons of natural language processing", - "what is robotics", - "how does robotics work", - "robotics explained simply", - "beginner guide to robotics", - "best robotics tips", - "common robotics mistakes", - "pros and cons of robotics", - "what is automation", - "how does automation work", - "automation explained simply", - "beginner guide to automation", - "best automation tips", - "common automation mistakes", - "pros and cons of automation", - "how does cloud computing work", - "cloud computing explained simply", - "beginner guide to cloud computing", - "best cloud computing tips", - "common cloud computing mistakes", - "pros and cons of cloud computing", - "what is edge computing", - "how does edge computing work", - "edge computing explained simply", - "beginner guide to edge computing", - "best edge computing tips", - "common edge computing mistakes", - "pros and cons of edge computing", - "what is quantum computing", - "how does quantum computing work", - "quantum computing explained simply", - "beginner guide to quantum computing", - "best quantum computing tips", - "common quantum computing mistakes", - "pros and cons of quantum computing", - "how does blockchain work", - "blockchain explained simply", - "beginner guide to blockchain", - "best blockchain tips", - "common blockchain mistakes", - "pros and cons of blockchain", - "what is cryptocurrency wallets", - "how does cryptocurrency wallets work", - "cryptocurrency wallets explained simply", - "beginner guide to cryptocurrency wallets", - "best cryptocurrency wallets tips", - "common cryptocurrency wallets mistakes", - "pros and cons of cryptocurrency wallets", - "what is smart contracts", - "how does smart contracts work", - "smart contracts explained simply", - "beginner guide to smart contracts", - "best smart contracts tips", - "common smart contracts mistakes", - "pros and cons of smart contracts", - "what is web3", - "how does web3 work", - "web3 explained simply", - "beginner guide to web3", - "best web3 tips", - "common web3 mistakes", - "pros and cons of web3", - "what is cybersecurity", - "how does cybersecurity work", - "cybersecurity explained simply", - "beginner guide to cybersecurity", - "best cybersecurity tips", - "common cybersecurity mistakes", - "pros and cons of cybersecurity", - "what is phishing emails", - "how does phishing emails work", - "phishing emails explained simply", - "beginner guide to phishing emails", - "best phishing emails tips", - "common phishing emails mistakes", - "pros and cons of phishing emails", - "what is password managers", - "how does password managers work", - "password managers explained simply", - "beginner guide to password managers", - "best password managers tips", - "common password managers mistakes", - "pros and cons of password managers", - "what is two factor authentication", - "how does two factor authentication work", - "two factor authentication explained simply", - "beginner guide to two factor authentication", - "best two factor authentication tips", - "common two factor authentication mistakes", - "pros and cons of two factor authentication", - "what is passkeys", - "how does passkeys work", - "passkeys explained simply", - "beginner guide to passkeys", - "best passkeys tips", - "common passkeys mistakes", - "pros and cons of passkeys", - "what is vpn services", - "how does vpn services work", - "vpn services explained simply", - "beginner guide to vpn services", - "best vpn services tips", - "common vpn services mistakes", - "pros and cons of vpn services", - "what is proxy servers", - "how does proxy servers work", - "proxy servers explained simply", - "beginner guide to proxy servers", - "best proxy servers tips", - "common proxy servers mistakes", - "pros and cons of proxy servers", - "what is dns servers", - "how does dns servers work", - "dns servers explained simply", - "beginner guide to dns servers", - "best dns servers tips", - "common dns servers mistakes", - "pros and cons of dns servers", - "what is mesh wifi", - "how does mesh wifi work", - "mesh wifi explained simply", - "beginner guide to mesh wifi", - "best mesh wifi tips", - "common mesh wifi mistakes", - "pros and cons of mesh wifi", - "what is wifi 6", - "how does wifi 6 work", - "wifi 6 explained simply", - "beginner guide to wifi 6", - "best wifi 6 tips", - "common wifi 6 mistakes", - "pros and cons of wifi 6", - "what is wifi 7", - "how does wifi 7 work", - "wifi 7 explained simply", - "beginner guide to wifi 7", - "best wifi 7 tips", - "common wifi 7 mistakes", - "pros and cons of wifi 7", - "what is fiber internet", - "how does fiber internet work", - "fiber internet explained simply", - "beginner guide to fiber internet", - "best fiber internet tips", - "common fiber internet mistakes", - "pros and cons of fiber internet", - "what is mobile hotspots", - "how does mobile hotspots work", - "mobile hotspots explained simply", - "beginner guide to mobile hotspots", - "best mobile hotspots tips", - "common mobile hotspots mistakes", - "pros and cons of mobile hotspots", - "what is bluetooth", - "bluetooth explained simply", - "beginner guide to bluetooth", - "best bluetooth tips", - "common bluetooth mistakes", - "pros and cons of bluetooth", - "how does nfc work", - "nfc explained simply", - "beginner guide to nfc", - "best nfc tips", - "common nfc mistakes", - "pros and cons of nfc", - "what is usb c", - "how does usb c work", - "usb c explained simply", - "beginner guide to usb c", - "best usb c tips", - "common usb c mistakes", - "pros and cons of usb c", - "what is thunderbolt", - "how does thunderbolt work", - "thunderbolt explained simply", - "beginner guide to thunderbolt", - "best thunderbolt tips", - "common thunderbolt mistakes", - "pros and cons of thunderbolt", - "what is solid state drives", - "how does solid state drives work", - "solid state drives explained simply", - "beginner guide to solid state drives", - "best solid state drives tips", - "common solid state drives mistakes", - "pros and cons of solid state drives", - "what is hard drives", - "how does hard drives work", - "hard drives explained simply", - "beginner guide to hard drives", - "best hard drives tips", - "common hard drives mistakes", - "pros and cons of hard drives", - "how does ram work", - "ram explained simply", - "beginner guide to ram", - "best ram tips", - "common ram mistakes", - "pros and cons of ram", - "how does cpu work", - "cpu explained simply", - "beginner guide to cpu", - "best cpu tips", - "common cpu mistakes", - "pros and cons of cpu", - "what is gpu", - "how does gpu work", - "gpu explained simply", - "beginner guide to gpu", - "best gpu tips", - "common gpu mistakes", - "pros and cons of gpu", - "what is motherboards", - "how does motherboards work", - "motherboards explained simply", - "beginner guide to motherboards", - "best motherboards tips", - "common motherboards mistakes", - "pros and cons of motherboards", - "what is pc cooling", - "how does pc cooling work", - "pc cooling explained simply", - "beginner guide to pc cooling", - "best pc cooling tips", - "common pc cooling mistakes", - "pros and cons of pc cooling", - "what is thermal paste", - "how does thermal paste work", - "thermal paste explained simply", - "beginner guide to thermal paste", - "best thermal paste tips", - "common thermal paste mistakes", - "pros and cons of thermal paste", - "what is gaming monitors", - "how does gaming monitors work", - "gaming monitors explained simply", - "beginner guide to gaming monitors", - "best gaming monitors tips", - "common gaming monitors mistakes", - "pros and cons of gaming monitors", - "what is mechanical keyboards", - "how does mechanical keyboards work", - "mechanical keyboards explained simply", - "beginner guide to mechanical keyboards", - "best mechanical keyboards tips", - "common mechanical keyboards mistakes", - "pros and cons of mechanical keyboards", - "what is gaming mice", - "how does gaming mice work", - "gaming mice explained simply", - "beginner guide to gaming mice", - "best gaming mice tips", - "common gaming mice mistakes", - "pros and cons of gaming mice", - "what is noise cancelling headphones", - "how does noise cancelling headphones work", - "noise cancelling headphones explained simply", - "beginner guide to noise cancelling headphones", - "best noise cancelling headphones tips", - "common noise cancelling headphones mistakes", - "pros and cons of noise cancelling headphones", - "what is wireless earbuds", - "how does wireless earbuds work", - "wireless earbuds explained simply", - "beginner guide to wireless earbuds", - "best wireless earbuds tips", - "common wireless earbuds mistakes", - "pros and cons of wireless earbuds", - "what is smartwatches", - "how does smartwatches work", - "smartwatches explained simply", - "beginner guide to smartwatches", - "best smartwatches tips", - "common smartwatches mistakes", - "pros and cons of smartwatches", - "what is smart home devices", - "how does smart home devices work", - "smart home devices explained simply", - "beginner guide to smart home devices", - "best smart home devices tips", - "common smart home devices mistakes", - "pros and cons of smart home devices", - "what is security cameras", - "how does security cameras work", - "security cameras explained simply", - "beginner guide to security cameras", - "best security cameras tips", - "common security cameras mistakes", - "pros and cons of security cameras", - "what is nas storage", - "how does nas storage work", - "nas storage explained simply", - "beginner guide to nas storage", - "best nas storage tips", - "common nas storage mistakes", - "pros and cons of nas storage", - "what is home servers", - "how does home servers work", - "home servers explained simply", - "beginner guide to home servers", - "best home servers tips", - "common home servers mistakes", - "pros and cons of home servers", - "what is docker containers", - "how does docker containers work", - "docker containers explained simply", - "beginner guide to docker containers", - "best docker containers tips", - "common docker containers mistakes", - "pros and cons of docker containers", - "what is kubernetes", - "how does kubernetes work", - "kubernetes explained simply", - "beginner guide to kubernetes", - "best kubernetes tips", - "common kubernetes mistakes", - "pros and cons of kubernetes", - "what is linux servers", - "how does linux servers work", - "linux servers explained simply", - "beginner guide to linux servers", - "best linux servers tips", - "common linux servers mistakes", - "pros and cons of linux servers", - "what is ubuntu", - "how does ubuntu work", - "ubuntu explained simply", - "beginner guide to ubuntu", - "best ubuntu tips", - "common ubuntu mistakes", - "pros and cons of ubuntu", - "what is debian", - "how does debian work", - "debian explained simply", - "beginner guide to debian", - "best debian tips", - "common debian mistakes", - "pros and cons of debian", - "what is arch linux", - "how does arch linux work", - "arch linux explained simply", - "beginner guide to arch linux", - "best arch linux tips", - "common arch linux mistakes", - "pros and cons of arch linux", - "what is windows 11", - "how does windows 11 work", - "windows 11 explained simply", - "beginner guide to windows 11", - "best windows 11 tips", - "common windows 11 mistakes", - "pros and cons of windows 11", - "what is macos", - "how does macos work", - "macos explained simply", - "beginner guide to macos", - "best macos tips", - "common macos mistakes", - "pros and cons of macos", - "what is android", - "how does android work", - "android explained simply", - "beginner guide to android", - "best android tips", - "common android mistakes", - "pros and cons of android", - "what is ios", - "how does ios work", - "ios explained simply", - "beginner guide to ios", - "best ios tips", - "common ios mistakes", - "pros and cons of ios", - "what is chrome browser", - "how does chrome browser work", - "chrome browser explained simply", - "beginner guide to chrome browser", - "best chrome browser tips", - "common chrome browser mistakes", - "pros and cons of chrome browser", - "what is edge browser", - "how does edge browser work", - "edge browser explained simply", - "beginner guide to edge browser", - "best edge browser tips", - "common edge browser mistakes", - "pros and cons of edge browser", - "what is firefox browser", - "how does firefox browser work", - "firefox browser explained simply", - "beginner guide to firefox browser", - "best firefox browser tips", - "common firefox browser mistakes", - "pros and cons of firefox browser", - "what is brave browser", - "how does brave browser work", - "brave browser explained simply", - "beginner guide to brave browser", - "best brave browser tips", - "common brave browser mistakes", - "pros and cons of brave browser", - "what is google drive", - "how does google drive work", - "google drive explained simply", - "beginner guide to google drive", - "best google drive tips", - "common google drive mistakes", - "pros and cons of google drive", - "what is onedrive", - "how does onedrive work", - "onedrive explained simply", - "beginner guide to onedrive", - "best onedrive tips", - "common onedrive mistakes", - "pros and cons of onedrive", - "what is icloud", - "how does icloud work", - "icloud explained simply", - "beginner guide to icloud", - "best icloud tips", - "common icloud mistakes", - "pros and cons of icloud", - "what is dropbox", - "how does dropbox work", - "dropbox explained simply", - "beginner guide to dropbox", - "best dropbox tips", - "common dropbox mistakes", - "pros and cons of dropbox", - "how does github work", - "github explained simply", - "beginner guide to github", - "best github tips", - "common github mistakes", - "pros and cons of github", - "what is git", - "how does git work", - "git explained simply", - "beginner guide to git", - "best git tips", - "common git mistakes", - "pros and cons of git", - "what is visual studio code", - "how does visual studio code work", - "visual studio code explained simply", - "beginner guide to visual studio code", - "best visual studio code tips", - "common visual studio code mistakes", - "pros and cons of visual studio code", - "what is nodejs", - "how does nodejs work", - "nodejs explained simply", - "beginner guide to nodejs", - "best nodejs tips", - "common nodejs mistakes", - "pros and cons of nodejs", - "what is typescript", - "how does typescript work", - "typescript explained simply", - "beginner guide to typescript", - "best typescript tips", - "common typescript mistakes", - "pros and cons of typescript", - "how does javascript work", - "javascript explained simply", - "beginner guide to javascript", - "best javascript tips", - "common javascript mistakes", - "pros and cons of javascript", - "what is python", - "how does python work", - "python explained simply", - "beginner guide to python", - "best python tips", - "common python mistakes", - "pros and cons of python", - "what is rust programming", - "how does rust programming work", - "rust programming explained simply", - "beginner guide to rust programming", - "best rust programming tips", - "common rust programming mistakes", - "pros and cons of rust programming", - "what is go programming", - "how does go programming work", - "go programming explained simply", - "beginner guide to go programming", - "best go programming tips", - "common go programming mistakes", - "pros and cons of go programming", - "what is react", - "how does react work", - "react explained simply", - "beginner guide to react", - "best react tips", - "common react mistakes", - "pros and cons of react", - "what is sveltekit", - "how does sveltekit work", - "sveltekit explained simply", - "beginner guide to sveltekit", - "best sveltekit tips", - "common sveltekit mistakes", - "pros and cons of sveltekit", - "what is vue", - "how does vue work", - "vue explained simply", - "beginner guide to vue", - "best vue tips", - "common vue mistakes", - "pros and cons of vue", - "what is express js", - "how does express js work", - "express js explained simply", - "beginner guide to express js", - "best express js tips", - "common express js mistakes", - "pros and cons of express js", - "what is postgresql", - "how does postgresql work", - "postgresql explained simply", - "beginner guide to postgresql", - "best postgresql tips", - "common postgresql mistakes", - "pros and cons of postgresql", - "what is mysql", - "how does mysql work", - "mysql explained simply", - "beginner guide to mysql", - "best mysql tips", - "common mysql mistakes", - "pros and cons of mysql", - "what is sqlite", - "how does sqlite work", - "sqlite explained simply", - "beginner guide to sqlite", - "best sqlite tips", - "common sqlite mistakes", - "pros and cons of sqlite", - "what is redis", - "how does redis work", - "redis explained simply", - "beginner guide to redis", - "best redis tips", - "common redis mistakes", - "pros and cons of redis", - "what is prisma orm", - "how does prisma orm work", - "prisma orm explained simply", - "beginner guide to prisma orm", - "best prisma orm tips", - "common prisma orm mistakes", - "pros and cons of prisma orm", - "what is nginx", - "how does nginx work", - "nginx explained simply", - "beginner guide to nginx", - "best nginx tips", - "common nginx mistakes", - "pros and cons of nginx", - "what is openresty", - "how does openresty work", - "openresty explained simply", - "beginner guide to openresty", - "best openresty tips", - "common openresty mistakes", - "pros and cons of openresty", - "what is grafana", - "how does grafana work", - "grafana explained simply", - "beginner guide to grafana", - "best grafana tips", - "common grafana mistakes", - "pros and cons of grafana", - "what is prometheus", - "how does prometheus work", - "prometheus explained simply", - "beginner guide to prometheus", - "best prometheus tips", - "common prometheus mistakes", - "pros and cons of prometheus", - "what is discord bots", - "how does discord bots work", - "discord bots explained simply", - "beginner guide to discord bots", - "best discord bots tips", - "common discord bots mistakes", - "pros and cons of discord bots", - "what is spotify api", - "how does spotify api work", - "spotify api explained simply", - "beginner guide to spotify api", - "best spotify api tips", - "common spotify api mistakes", - "pros and cons of spotify api", - "what is rest api", - "how does rest api work", - "rest api explained simply", - "beginner guide to rest api", - "best rest api tips", - "common rest api mistakes", - "pros and cons of rest api", - "what is graphql", - "how does graphql work", - "graphql explained simply", - "beginner guide to graphql", - "best graphql tips", - "common graphql mistakes", - "pros and cons of graphql", - "what is json files", - "how does json files work", - "json files explained simply", - "beginner guide to json files", - "best json files tips", - "common json files mistakes", - "pros and cons of json files", - "how does html work", - "html explained simply", - "beginner guide to html", - "best html tips", - "common html mistakes", - "pros and cons of html", - "how does css work", - "css explained simply", - "beginner guide to css", - "best css tips", - "common css mistakes", - "pros and cons of css", - "what is tailwind css", - "how does tailwind css work", - "tailwind css explained simply", - "beginner guide to tailwind css", - "best tailwind css tips", - "common tailwind css mistakes", - "pros and cons of tailwind css", - "what is web accessibility", - "how does web accessibility work", - "web accessibility explained simply", - "beginner guide to web accessibility", - "best web accessibility tips", - "common web accessibility mistakes", - "pros and cons of web accessibility", - "what is responsive design", - "how does responsive design work", - "responsive design explained simply", - "beginner guide to responsive design", - "best responsive design tips", - "common responsive design mistakes", - "pros and cons of responsive design", - "what is software testing", - "how does software testing work", - "software testing explained simply", - "beginner guide to software testing", - "best software testing tips", - "common software testing mistakes", - "pros and cons of software testing", - "what is unit tests", - "how does unit tests work", - "unit tests explained simply", - "beginner guide to unit tests", - "best unit tests tips", - "common unit tests mistakes", - "pros and cons of unit tests", - "what is code review", - "how does code review work", - "code review explained simply", - "beginner guide to code review", - "best code review tips", - "common code review mistakes", - "pros and cons of code review", - "what is api rate limits", - "how does api rate limits work", - "api rate limits explained simply", - "beginner guide to api rate limits", - "best api rate limits tips", - "common api rate limits mistakes", - "pros and cons of api rate limits", - "what is server monitoring", - "how does server monitoring work", - "server monitoring explained simply", - "beginner guide to server monitoring", - "best server monitoring tips", - "common server monitoring mistakes", - "pros and cons of server monitoring", - "what is log rotation", - "how does log rotation work", - "log rotation explained simply", - "beginner guide to log rotation", - "best log rotation tips", - "common log rotation mistakes", - "pros and cons of log rotation", - "what is backup strategy", - "how does backup strategy work", - "backup strategy explained simply", - "beginner guide to backup strategy", - "best backup strategy tips", - "common backup strategy mistakes", - "pros and cons of backup strategy", - "what is database indexing", - "how does database indexing work", - "database indexing explained simply", - "beginner guide to database indexing", - "best database indexing tips", - "common database indexing mistakes", - "pros and cons of database indexing", - "what is search engines", - "how does search engines work", - "search engines explained simply", - "beginner guide to search engines", - "best search engines tips", - "common search engines mistakes", - "pros and cons of search engines", - "what is typesense", - "how does typesense work", - "typesense explained simply", - "beginner guide to typesense", - "best typesense tips", - "common typesense mistakes", - "pros and cons of typesense", - "what is elasticsearch", - "how does elasticsearch work", - "elasticsearch explained simply", - "beginner guide to elasticsearch", - "best elasticsearch tips", - "common elasticsearch mistakes", - "pros and cons of elasticsearch", - "what is load balancing", - "how does load balancing work", - "load balancing explained simply", - "beginner guide to load balancing", - "best load balancing tips", - "common load balancing mistakes", - "pros and cons of load balancing", - "what is reverse proxies", - "how does reverse proxies work", - "reverse proxies explained simply", - "beginner guide to reverse proxies", - "best reverse proxies tips", - "common reverse proxies mistakes", - "pros and cons of reverse proxies", - "what is time management", - "how does time management work", - "time management explained simply", - "beginner guide to time management", - "best time management tips", - "common time management mistakes", - "pros and cons of time management", - "what is productivity", - "how does productivity work", - "productivity explained simply", - "beginner guide to productivity", - "best productivity tips", - "common productivity mistakes", - "pros and cons of productivity", - "what is focus", - "how does focus work", - "focus explained simply", - "beginner guide to focus", - "best focus tips", - "common focus mistakes", - "pros and cons of focus", - "what is motivation", - "how does motivation work", - "motivation explained simply", - "beginner guide to motivation", - "best motivation tips", - "common motivation mistakes", - "pros and cons of motivation", - "what is habit tracking", - "how does habit tracking work", - "habit tracking explained simply", - "beginner guide to habit tracking", - "best habit tracking tips", - "common habit tracking mistakes", - "pros and cons of habit tracking", - "what is morning routines", - "how does morning routines work", - "morning routines explained simply", - "beginner guide to morning routines", - "best morning routines tips", - "common morning routines mistakes", - "pros and cons of morning routines", - "what is evening routines", - "how does evening routines work", - "evening routines explained simply", - "beginner guide to evening routines", - "best evening routines tips", - "common evening routines mistakes", - "pros and cons of evening routines", - "what is sleep quality", - "how does sleep quality work", - "sleep quality explained simply", - "beginner guide to sleep quality", - "best sleep quality tips", - "common sleep quality mistakes", - "pros and cons of sleep quality", - "what is stress management", - "how does stress management work", - "stress management explained simply", - "beginner guide to stress management", - "best stress management tips", - "common stress management mistakes", - "pros and cons of stress management", - "what is mindfulness", - "how does mindfulness work", - "mindfulness explained simply", - "beginner guide to mindfulness", - "best mindfulness tips", - "common mindfulness mistakes", - "pros and cons of mindfulness", - "what is meditation", - "how does meditation work", - "meditation explained simply", - "beginner guide to meditation", - "best meditation tips", - "common meditation mistakes", - "pros and cons of meditation", - "what is journaling", - "how does journaling work", - "journaling explained simply", - "beginner guide to journaling", - "best journaling tips", - "common journaling mistakes", - "pros and cons of journaling", - "what is gratitude", - "how does gratitude work", - "gratitude explained simply", - "beginner guide to gratitude", - "best gratitude tips", - "common gratitude mistakes", - "pros and cons of gratitude", - "what is confidence", - "how does confidence work", - "confidence explained simply", - "beginner guide to confidence", - "best confidence tips", - "common confidence mistakes", - "pros and cons of confidence", - "what is communication skills", - "how does communication skills work", - "communication skills explained simply", - "beginner guide to communication skills", - "best communication skills tips", - "common communication skills mistakes", - "pros and cons of communication skills", - "what is public speaking", - "how does public speaking work", - "public speaking explained simply", - "beginner guide to public speaking", - "best public speaking tips", - "common public speaking mistakes", - "pros and cons of public speaking", - "what is negotiation", - "how does negotiation work", - "negotiation explained simply", - "beginner guide to negotiation", - "best negotiation tips", - "common negotiation mistakes", - "pros and cons of negotiation", - "what is conflict resolution", - "how does conflict resolution work", - "conflict resolution explained simply", - "beginner guide to conflict resolution", - "best conflict resolution tips", - "common conflict resolution mistakes", - "pros and cons of conflict resolution", - "what is emotional intelligence", - "how does emotional intelligence work", - "emotional intelligence explained simply", - "beginner guide to emotional intelligence", - "best emotional intelligence tips", - "common emotional intelligence mistakes", - "pros and cons of emotional intelligence", - "what is creativity", - "how does creativity work", - "creativity explained simply", - "beginner guide to creativity", - "best creativity tips", - "common creativity mistakes", - "pros and cons of creativity", - "what is goal setting", - "how does goal setting work", - "goal setting explained simply", - "beginner guide to goal setting", - "best goal setting tips", - "common goal setting mistakes", - "pros and cons of goal setting", - "what is decision making", - "how does decision making work", - "decision making explained simply", - "beginner guide to decision making", - "best decision making tips", - "common decision making mistakes", - "pros and cons of decision making", - "what is critical thinking", - "how does critical thinking work", - "critical thinking explained simply", - "beginner guide to critical thinking", - "best critical thinking tips", - "common critical thinking mistakes", - "pros and cons of critical thinking", - "what is memory improvement", - "how does memory improvement work", - "memory improvement explained simply", - "beginner guide to memory improvement", - "best memory improvement tips", - "common memory improvement mistakes", - "pros and cons of memory improvement", - "what is study habits", - "how does study habits work", - "study habits explained simply", - "beginner guide to study habits", - "best study habits tips", - "common study habits mistakes", - "pros and cons of study habits", - "what is exam preparation", - "how does exam preparation work", - "exam preparation explained simply", - "beginner guide to exam preparation", - "best exam preparation tips", - "common exam preparation mistakes", - "pros and cons of exam preparation", - "what is language learning", - "how does language learning work", - "language learning explained simply", - "beginner guide to language learning", - "best language learning tips", - "common language learning mistakes", - "pros and cons of language learning", - "what is reading habits", - "how does reading habits work", - "reading habits explained simply", - "beginner guide to reading habits", - "best reading habits tips", - "common reading habits mistakes", - "pros and cons of reading habits", - "what is speed reading", - "how does speed reading work", - "speed reading explained simply", - "beginner guide to speed reading", - "best speed reading tips", - "common speed reading mistakes", - "pros and cons of speed reading", - "what is note taking", - "how does note taking work", - "note taking explained simply", - "beginner guide to note taking", - "best note taking tips", - "common note taking mistakes", - "pros and cons of note taking", - "what is personal finance", - "how does personal finance work", - "personal finance explained simply", - "beginner guide to personal finance", - "best personal finance tips", - "common personal finance mistakes", - "pros and cons of personal finance", - "what is budgeting", - "how does budgeting work", - "budgeting explained simply", - "beginner guide to budgeting", - "best budgeting tips", - "common budgeting mistakes", - "pros and cons of budgeting", - "what is saving money", - "how does saving money work", - "saving money explained simply", - "beginner guide to saving money", - "best saving money tips", - "common saving money mistakes", - "pros and cons of saving money", - "what is investing basics", - "how does investing basics work", - "investing basics explained simply", - "beginner guide to investing basics", - "best investing basics tips", - "common investing basics mistakes", - "pros and cons of investing basics", - "what is emergency funds", - "how does emergency funds work", - "emergency funds explained simply", - "beginner guide to emergency funds", - "best emergency funds tips", - "common emergency funds mistakes", - "pros and cons of emergency funds", - "what is credit scores", - "how does credit scores work", - "credit scores explained simply", - "beginner guide to credit scores", - "best credit scores tips", - "common credit scores mistakes", - "pros and cons of credit scores", - "what is mortgages", - "how does mortgages work", - "mortgages explained simply", - "beginner guide to mortgages", - "best mortgages tips", - "common mortgages mistakes", - "pros and cons of mortgages", - "what is renting apartments", - "how does renting apartments work", - "renting apartments explained simply", - "beginner guide to renting apartments", - "best renting apartments tips", - "common renting apartments mistakes", - "pros and cons of renting apartments", - "what is buying a house", - "how does buying a house work", - "buying a house explained simply", - "beginner guide to buying a house", - "best buying a house tips", - "common buying a house mistakes", - "pros and cons of buying a house", - "what is career planning", - "how does career planning work", - "career planning explained simply", - "beginner guide to career planning", - "best career planning tips", - "common career planning mistakes", - "pros and cons of career planning", - "what is job interviews", - "how does job interviews work", - "job interviews explained simply", - "beginner guide to job interviews", - "best job interviews tips", - "common job interviews mistakes", - "pros and cons of job interviews", - "what is resume writing", - "how does resume writing work", - "resume writing explained simply", - "beginner guide to resume writing", - "best resume writing tips", - "common resume writing mistakes", - "pros and cons of resume writing", - "what is cover letters", - "how does cover letters work", - "cover letters explained simply", - "beginner guide to cover letters", - "best cover letters tips", - "common cover letters mistakes", - "pros and cons of cover letters", - "what is remote work", - "how does remote work work", - "remote work explained simply", - "beginner guide to remote work", - "best remote work tips", - "common remote work mistakes", - "pros and cons of remote work", - "what is freelancing", - "how does freelancing work", - "freelancing explained simply", - "beginner guide to freelancing", - "best freelancing tips", - "common freelancing mistakes", - "pros and cons of freelancing", - "what is side hustles", - "how does side hustles work", - "side hustles explained simply", - "beginner guide to side hustles", - "best side hustles tips", - "common side hustles mistakes", - "pros and cons of side hustles", - "what is small business ideas", - "how does small business ideas work", - "small business ideas explained simply", - "beginner guide to small business ideas", - "best small business ideas tips", - "common small business ideas mistakes", - "pros and cons of small business ideas", - "what is ecommerce", - "how does ecommerce work", - "ecommerce explained simply", - "beginner guide to ecommerce", - "best ecommerce tips", - "common ecommerce mistakes", - "pros and cons of ecommerce", - "what is personal branding", - "how does personal branding work", - "personal branding explained simply", - "beginner guide to personal branding", - "best personal branding tips", - "common personal branding mistakes", - "pros and cons of personal branding", - "what is networking", - "how does networking work", - "networking explained simply", - "beginner guide to networking", - "best networking tips", - "common networking mistakes", - "pros and cons of networking", - "what is work life balance", - "how does work life balance work", - "work life balance explained simply", - "beginner guide to work life balance", - "best work life balance tips", - "common work life balance mistakes", - "pros and cons of work life balance", - "what is leadership skills", - "how does leadership skills work", - "leadership skills explained simply", - "beginner guide to leadership skills", - "best leadership skills tips", - "common leadership skills mistakes", - "pros and cons of leadership skills", - "what is teamwork", - "how does teamwork work", - "teamwork explained simply", - "beginner guide to teamwork", - "best teamwork tips", - "common teamwork mistakes", - "pros and cons of teamwork", - "what is project management", - "how does project management work", - "project management explained simply", - "beginner guide to project management", - "best project management tips", - "common project management mistakes", - "pros and cons of project management", - "what is minimalism", - "how does minimalism work", - "minimalism explained simply", - "beginner guide to minimalism", - "best minimalism tips", - "common minimalism mistakes", - "pros and cons of minimalism", - "what is decluttering", - "how does decluttering work", - "decluttering explained simply", - "beginner guide to decluttering", - "best decluttering tips", - "common decluttering mistakes", - "pros and cons of decluttering", - "what is home organization", - "how does home organization work", - "home organization explained simply", - "beginner guide to home organization", - "best home organization tips", - "common home organization mistakes", - "pros and cons of home organization", - "what is digital organization", - "how does digital organization work", - "digital organization explained simply", - "beginner guide to digital organization", - "best digital organization tips", - "common digital organization mistakes", - "pros and cons of digital organization", - "what is email inbox cleanup", - "how does email inbox cleanup work", - "email inbox cleanup explained simply", - "beginner guide to email inbox cleanup", - "best email inbox cleanup tips", - "common email inbox cleanup mistakes", - "pros and cons of email inbox cleanup", - "what is healthy relationships", - "how does healthy relationships work", - "healthy relationships explained simply", - "beginner guide to healthy relationships", - "best healthy relationships tips", - "common healthy relationships mistakes", - "pros and cons of healthy relationships", - "what is making friends as an adult", - "how does making friends as an adult work", - "making friends as an adult explained simply", - "beginner guide to making friends as an adult", - "best making friends as an adult tips", - "common making friends as an adult mistakes", - "pros and cons of making friends as an adult", - "what is dating ideas", - "how does dating ideas work", - "dating ideas explained simply", - "beginner guide to dating ideas", - "best dating ideas tips", - "common dating ideas mistakes", - "pros and cons of dating ideas", - "what is family activities", - "how does family activities work", - "family activities explained simply", - "beginner guide to family activities", - "best family activities tips", - "common family activities mistakes", - "pros and cons of family activities", - "what is parenting tips", - "how does parenting tips work", - "parenting tips explained simply", - "beginner guide to parenting tips", - "best parenting tips tips", - "common parenting tips mistakes", - "pros and cons of parenting tips", - "what is pet care", - "how does pet care work", - "pet care explained simply", - "beginner guide to pet care", - "best pet care tips", - "common pet care mistakes", - "pros and cons of pet care", - "what is dog training", - "how does dog training work", - "dog training explained simply", - "beginner guide to dog training", - "best dog training tips", - "common dog training mistakes", - "pros and cons of dog training", - "what is cat behavior", - "how does cat behavior work", - "cat behavior explained simply", - "beginner guide to cat behavior", - "best cat behavior tips", - "common cat behavior mistakes", - "pros and cons of cat behavior", - "what is meal planning", - "how does meal planning work", - "meal planning explained simply", - "beginner guide to meal planning", - "best meal planning tips", - "common meal planning mistakes", - "pros and cons of meal planning", - "what is hydration", - "how does hydration work", - "hydration explained simply", - "beginner guide to hydration", - "best hydration tips", - "common hydration mistakes", - "pros and cons of hydration", - "what is posture", - "how does posture work", - "posture explained simply", - "beginner guide to posture", - "best posture tips", - "common posture mistakes", - "pros and cons of posture", - "what is walking daily", - "how does walking daily work", - "walking daily explained simply", - "beginner guide to walking daily", - "best walking daily tips", - "common walking daily mistakes", - "pros and cons of walking daily", - "what is home workouts", - "how does home workouts work", - "home workouts explained simply", - "beginner guide to home workouts", - "best home workouts tips", - "common home workouts mistakes", - "pros and cons of home workouts", - "what is running plans", - "how does running plans work", - "running plans explained simply", - "beginner guide to running plans", - "best running plans tips", - "common running plans mistakes", - "pros and cons of running plans", - "what is yoga", - "how does yoga work", - "yoga explained simply", - "beginner guide to yoga", - "best yoga tips", - "common yoga mistakes", - "pros and cons of yoga", - "what is stretching", - "how does stretching work", - "stretching explained simply", - "beginner guide to stretching", - "best stretching tips", - "common stretching mistakes", - "pros and cons of stretching", - "what is strength training", - "how does strength training work", - "strength training explained simply", - "beginner guide to strength training", - "best strength training tips", - "common strength training mistakes", - "pros and cons of strength training", - "what is cycling", - "how does cycling work", - "cycling explained simply", - "beginner guide to cycling", - "best cycling tips", - "common cycling mistakes", - "pros and cons of cycling", - "what is hiking", - "how does hiking work", - "hiking explained simply", - "beginner guide to hiking", - "best hiking tips", - "common hiking mistakes", - "pros and cons of hiking", - "what is swimming", - "how does swimming work", - "swimming explained simply", - "beginner guide to swimming", - "best swimming tips", - "common swimming mistakes", - "pros and cons of swimming", - "what is healthy snacks", - "how does healthy snacks work", - "healthy snacks explained simply", - "beginner guide to healthy snacks", - "best healthy snacks tips", - "common healthy snacks mistakes", - "pros and cons of healthy snacks", - "what is immune support", - "how does immune support work", - "immune support explained simply", - "beginner guide to immune support", - "best immune support tips", - "common immune support mistakes", - "pros and cons of immune support", - "what is first aid basics", - "how does first aid basics work", - "first aid basics explained simply", - "beginner guide to first aid basics", - "best first aid basics tips", - "common first aid basics mistakes", - "pros and cons of first aid basics", - "what is skin care routine", - "how does skin care routine work", - "skin care routine explained simply", - "beginner guide to skin care routine", - "best skin care routine tips", - "common skin care routine mistakes", - "pros and cons of skin care routine", - "what is dental hygiene", - "how does dental hygiene work", - "dental hygiene explained simply", - "beginner guide to dental hygiene", - "best dental hygiene tips", - "common dental hygiene mistakes", - "pros and cons of dental hygiene", - "what is eye strain", - "how does eye strain work", - "eye strain explained simply", - "beginner guide to eye strain", - "best eye strain tips", - "common eye strain mistakes", - "pros and cons of eye strain", - "what is screen time", - "how does screen time work", - "screen time explained simply", - "beginner guide to screen time", - "best screen time tips", - "common screen time mistakes", - "pros and cons of screen time", - "what is ergonomics", - "how does ergonomics work", - "ergonomics explained simply", - "beginner guide to ergonomics", - "best ergonomics tips", - "common ergonomics mistakes", - "pros and cons of ergonomics", - "what is digital wellbeing", - "how does digital wellbeing work", - "digital wellbeing explained simply", - "beginner guide to digital wellbeing", - "best digital wellbeing tips", - "common digital wellbeing mistakes", - "pros and cons of digital wellbeing", - "what is photosynthesis", - "photosynthesis explained simply", - "beginner guide to photosynthesis", - "best photosynthesis tips", - "common photosynthesis mistakes", - "pros and cons of photosynthesis", - "what is gravity", - "how does gravity work", - "gravity explained simply", - "beginner guide to gravity", - "best gravity tips", - "common gravity mistakes", - "pros and cons of gravity", - "what is black holes", - "how does black holes work", - "black holes explained simply", - "beginner guide to black holes", - "best black holes tips", - "common black holes mistakes", - "pros and cons of black holes", - "how does the milky way work", - "the milky way explained simply", - "beginner guide to the milky way", - "best the milky way tips", - "common the milky way mistakes", - "pros and cons of the milky way", - "what is solar eclipses", - "how does solar eclipses work", - "solar eclipses explained simply", - "beginner guide to solar eclipses", - "best solar eclipses tips", - "common solar eclipses mistakes", - "pros and cons of solar eclipses", - "what is lunar eclipses", - "how does lunar eclipses work", - "lunar eclipses explained simply", - "beginner guide to lunar eclipses", - "best lunar eclipses tips", - "common lunar eclipses mistakes", - "pros and cons of lunar eclipses", - "what is northern lights", - "how does northern lights work", - "northern lights explained simply", - "beginner guide to northern lights", - "best northern lights tips", - "common northern lights mistakes", - "pros and cons of northern lights", - "what is tides", - "how does tides work", - "tides explained simply", - "beginner guide to tides", - "best tides tips", - "common tides mistakes", - "pros and cons of tides", - "what is volcanoes", - "how does volcanoes work", - "volcanoes explained simply", - "beginner guide to volcanoes", - "best volcanoes tips", - "common volcanoes mistakes", - "pros and cons of volcanoes", - "what is earthquakes", - "how does earthquakes work", - "earthquakes explained simply", - "beginner guide to earthquakes", - "best earthquakes tips", - "common earthquakes mistakes", - "pros and cons of earthquakes", - "what is tsunamis", - "how does tsunamis work", - "tsunamis explained simply", - "beginner guide to tsunamis", - "best tsunamis tips", - "common tsunamis mistakes", - "pros and cons of tsunamis", - "what is hurricanes", - "how does hurricanes work", - "hurricanes explained simply", - "beginner guide to hurricanes", - "best hurricanes tips", - "common hurricanes mistakes", - "pros and cons of hurricanes", - "what is tornadoes", - "how does tornadoes work", - "tornadoes explained simply", - "beginner guide to tornadoes", - "best tornadoes tips", - "common tornadoes mistakes", - "pros and cons of tornadoes", - "what is rainbows", - "how does rainbows work", - "rainbows explained simply", - "beginner guide to rainbows", - "best rainbows tips", - "common rainbows mistakes", - "pros and cons of rainbows", - "what is cloud formation", - "how does cloud formation work", - "cloud formation explained simply", - "beginner guide to cloud formation", - "best cloud formation tips", - "common cloud formation mistakes", - "pros and cons of cloud formation", - "what is lightning", - "how does lightning work", - "lightning explained simply", - "beginner guide to lightning", - "best lightning tips", - "common lightning mistakes", - "pros and cons of lightning", - "what is thunder", - "how does thunder work", - "thunder explained simply", - "beginner guide to thunder", - "best thunder tips", - "common thunder mistakes", - "pros and cons of thunder", - "what is snowflakes", - "how does snowflakes work", - "snowflakes explained simply", - "beginner guide to snowflakes", - "best snowflakes tips", - "common snowflakes mistakes", - "pros and cons of snowflakes", - "what is deserts", - "how does deserts work", - "deserts explained simply", - "beginner guide to deserts", - "best deserts tips", - "common deserts mistakes", - "pros and cons of deserts", - "what is rainforests", - "how does rainforests work", - "rainforests explained simply", - "beginner guide to rainforests", - "best rainforests tips", - "common rainforests mistakes", - "pros and cons of rainforests", - "what is coral reefs", - "how does coral reefs work", - "coral reefs explained simply", - "beginner guide to coral reefs", - "best coral reefs tips", - "common coral reefs mistakes", - "pros and cons of coral reefs", - "what is ocean currents", - "how does ocean currents work", - "ocean currents explained simply", - "beginner guide to ocean currents", - "best ocean currents tips", - "common ocean currents mistakes", - "pros and cons of ocean currents", - "what is plate tectonics", - "how does plate tectonics work", - "plate tectonics explained simply", - "beginner guide to plate tectonics", - "best plate tectonics tips", - "common plate tectonics mistakes", - "pros and cons of plate tectonics", - "what is evolution", - "how does evolution work", - "evolution explained simply", - "beginner guide to evolution", - "best evolution tips", - "common evolution mistakes", - "pros and cons of evolution", - "what is natural selection", - "how does natural selection work", - "natural selection explained simply", - "beginner guide to natural selection", - "best natural selection tips", - "common natural selection mistakes", - "pros and cons of natural selection", - "what is dna", - "how does dna work", - "dna explained simply", - "beginner guide to dna", - "best dna tips", - "common dna mistakes", - "pros and cons of dna", - "what is genes", - "how does genes work", - "genes explained simply", - "beginner guide to genes", - "best genes tips", - "common genes mistakes", - "pros and cons of genes", - "what is cells", - "how does cells work", - "cells explained simply", - "beginner guide to cells", - "best cells tips", - "common cells mistakes", - "pros and cons of cells", - "what is bacteria", - "how does bacteria work", - "bacteria explained simply", - "beginner guide to bacteria", - "best bacteria tips", - "common bacteria mistakes", - "pros and cons of bacteria", - "what is viruses", - "how does viruses work", - "viruses explained simply", - "beginner guide to viruses", - "best viruses tips", - "common viruses mistakes", - "pros and cons of viruses", - "what is vaccines", - "how does vaccines work", - "vaccines explained simply", - "beginner guide to vaccines", - "best vaccines tips", - "common vaccines mistakes", - "pros and cons of vaccines", - "what is antibiotics", - "how does antibiotics work", - "antibiotics explained simply", - "beginner guide to antibiotics", - "best antibiotics tips", - "common antibiotics mistakes", - "pros and cons of antibiotics", - "what is the immune system", - "how does the immune system work", - "the immune system explained simply", - "beginner guide to the immune system", - "best the immune system tips", - "common the immune system mistakes", - "pros and cons of the immune system", - "what is human brain", - "how does human brain work", - "human brain explained simply", - "beginner guide to human brain", - "best human brain tips", - "common human brain mistakes", - "pros and cons of human brain", - "what is memory", - "how does memory work", - "memory explained simply", - "beginner guide to memory", - "best memory tips", - "common memory mistakes", - "pros and cons of memory", - "what is dreams", - "how does dreams work", - "dreams explained simply", - "beginner guide to dreams", - "best dreams tips", - "common dreams mistakes", - "pros and cons of dreams", - "what is sleep cycles", - "how does sleep cycles work", - "sleep cycles explained simply", - "beginner guide to sleep cycles", - "best sleep cycles tips", - "common sleep cycles mistakes", - "pros and cons of sleep cycles", - "what is muscles", - "how does muscles work", - "muscles explained simply", - "beginner guide to muscles", - "best muscles tips", - "common muscles mistakes", - "pros and cons of muscles", - "what is bones", - "how does bones work", - "bones explained simply", - "beginner guide to bones", - "best bones tips", - "common bones mistakes", - "pros and cons of bones", - "what is heart function", - "how does heart function work", - "heart function explained simply", - "beginner guide to heart function", - "best heart function tips", - "common heart function mistakes", - "pros and cons of heart function", - "what is lungs", - "how does lungs work", - "lungs explained simply", - "beginner guide to lungs", - "best lungs tips", - "common lungs mistakes", - "pros and cons of lungs", - "what is digestion", - "how does digestion work", - "digestion explained simply", - "beginner guide to digestion", - "best digestion tips", - "common digestion mistakes", - "pros and cons of digestion", - "what is metabolism", - "how does metabolism work", - "metabolism explained simply", - "beginner guide to metabolism", - "best metabolism tips", - "common metabolism mistakes", - "pros and cons of metabolism", - "what is vitamins", - "how does vitamins work", - "vitamins explained simply", - "beginner guide to vitamins", - "best vitamins tips", - "common vitamins mistakes", - "pros and cons of vitamins", - "what is protein", - "how does protein work", - "protein explained simply", - "beginner guide to protein", - "best protein tips", - "common protein mistakes", - "pros and cons of protein", - "what is carbohydrates", - "how does carbohydrates work", - "carbohydrates explained simply", - "beginner guide to carbohydrates", - "best carbohydrates tips", - "common carbohydrates mistakes", - "pros and cons of carbohydrates", - "what is fats", - "how does fats work", - "fats explained simply", - "beginner guide to fats", - "best fats tips", - "common fats mistakes", - "pros and cons of fats", - "what is water cycle", - "how does water cycle work", - "water cycle explained simply", - "beginner guide to water cycle", - "best water cycle tips", - "common water cycle mistakes", - "pros and cons of water cycle", - "what is climate change", - "how does climate change work", - "climate change explained simply", - "beginner guide to climate change", - "best climate change tips", - "common climate change mistakes", - "pros and cons of climate change", - "how does renewable energy work", - "renewable energy explained simply", - "beginner guide to renewable energy", - "best renewable energy tips", - "common renewable energy mistakes", - "pros and cons of renewable energy", - "what is solar panels", - "how does solar panels work", - "solar panels explained simply", - "beginner guide to solar panels", - "best solar panels tips", - "common solar panels mistakes", - "pros and cons of solar panels", - "what is wind turbines", - "how does wind turbines work", - "wind turbines explained simply", - "beginner guide to wind turbines", - "best wind turbines tips", - "common wind turbines mistakes", - "pros and cons of wind turbines", - "what is hydropower", - "how does hydropower work", - "hydropower explained simply", - "beginner guide to hydropower", - "best hydropower tips", - "common hydropower mistakes", - "pros and cons of hydropower", - "what is nuclear energy", - "how does nuclear energy work", - "nuclear energy explained simply", - "beginner guide to nuclear energy", - "best nuclear energy tips", - "common nuclear energy mistakes", - "pros and cons of nuclear energy", - "what is electric cars", - "how does electric cars work", - "electric cars explained simply", - "beginner guide to electric cars", - "best electric cars tips", - "common electric cars mistakes", - "pros and cons of electric cars", - "what is batteries", - "how does batteries work", - "batteries explained simply", - "beginner guide to batteries", - "best batteries tips", - "common batteries mistakes", - "pros and cons of batteries", - "what is recycling", - "how does recycling work", - "recycling explained simply", - "beginner guide to recycling", - "best recycling tips", - "common recycling mistakes", - "pros and cons of recycling", - "what is composting", - "how does composting work", - "composting explained simply", - "beginner guide to composting", - "best composting tips", - "common composting mistakes", - "pros and cons of composting", - "what is ecosystems", - "how does ecosystems work", - "ecosystems explained simply", - "beginner guide to ecosystems", - "best ecosystems tips", - "common ecosystems mistakes", - "pros and cons of ecosystems", - "what is food chains", - "how does food chains work", - "food chains explained simply", - "beginner guide to food chains", - "best food chains tips", - "common food chains mistakes", - "pros and cons of food chains", - "what is bees", - "how does bees work", - "bees explained simply", - "beginner guide to bees", - "best bees tips", - "common bees mistakes", - "pros and cons of bees", - "what is butterflies", - "how does butterflies work", - "butterflies explained simply", - "beginner guide to butterflies", - "best butterflies tips", - "common butterflies mistakes", - "pros and cons of butterflies", - "what is whales", - "how does whales work", - "whales explained simply", - "beginner guide to whales", - "best whales tips", - "common whales mistakes", - "pros and cons of whales", - "what is dolphins", - "how does dolphins work", - "dolphins explained simply", - "beginner guide to dolphins", - "best dolphins tips", - "common dolphins mistakes", - "pros and cons of dolphins", - "what is octopuses", - "how does octopuses work", - "octopuses explained simply", - "beginner guide to octopuses", - "best octopuses tips", - "common octopuses mistakes", - "pros and cons of octopuses", - "what is sharks", - "how does sharks work", - "sharks explained simply", - "beginner guide to sharks", - "best sharks tips", - "common sharks mistakes", - "pros and cons of sharks", - "what is penguins", - "how does penguins work", - "penguins explained simply", - "beginner guide to penguins", - "best penguins tips", - "common penguins mistakes", - "pros and cons of penguins", - "what is elephants", - "how does elephants work", - "elephants explained simply", - "beginner guide to elephants", - "best elephants tips", - "common elephants mistakes", - "pros and cons of elephants", - "what is wolves", - "how does wolves work", - "wolves explained simply", - "beginner guide to wolves", - "best wolves tips", - "common wolves mistakes", - "pros and cons of wolves", - "what is dinosaurs", - "how does dinosaurs work", - "dinosaurs explained simply", - "beginner guide to dinosaurs", - "best dinosaurs tips", - "common dinosaurs mistakes", - "pros and cons of dinosaurs", - "what is fossils", - "how does fossils work", - "fossils explained simply", - "beginner guide to fossils", - "best fossils tips", - "common fossils mistakes", - "pros and cons of fossils", - "what is mars", - "how does mars work", - "mars explained simply", - "beginner guide to mars", - "best mars tips", - "common mars mistakes", - "pros and cons of mars", - "what is the moon", - "how does the moon work", - "the moon explained simply", - "beginner guide to the moon", - "best the moon tips", - "common the moon mistakes", - "pros and cons of the moon", - "what is saturn rings", - "how does saturn rings work", - "saturn rings explained simply", - "beginner guide to saturn rings", - "best saturn rings tips", - "common saturn rings mistakes", - "pros and cons of saturn rings", - "what is jupiter", - "how does jupiter work", - "jupiter explained simply", - "beginner guide to jupiter", - "best jupiter tips", - "common jupiter mistakes", - "pros and cons of jupiter", - "what is asteroids", - "how does asteroids work", - "asteroids explained simply", - "beginner guide to asteroids", - "best asteroids tips", - "common asteroids mistakes", - "pros and cons of asteroids", - "what is comets", - "how does comets work", - "comets explained simply", - "beginner guide to comets", - "best comets tips", - "common comets mistakes", - "pros and cons of comets", - "what is space telescopes", - "how does space telescopes work", - "space telescopes explained simply", - "beginner guide to space telescopes", - "best space telescopes tips", - "common space telescopes mistakes", - "pros and cons of space telescopes", - "what is rockets", - "how does rockets work", - "rockets explained simply", - "beginner guide to rockets", - "best rockets tips", - "common rockets mistakes", - "pros and cons of rockets", - "what is satellites", - "how does satellites work", - "satellites explained simply", - "beginner guide to satellites", - "best satellites tips", - "common satellites mistakes", - "pros and cons of satellites", - "what is gps", - "gps explained simply", - "beginner guide to gps", - "best gps tips", - "common gps mistakes", - "pros and cons of gps", - "what is radio waves", - "how does radio waves work", - "radio waves explained simply", - "beginner guide to radio waves", - "best radio waves tips", - "common radio waves mistakes", - "pros and cons of radio waves", - "what is x rays", - "how does x rays work", - "x rays explained simply", - "beginner guide to x rays", - "best x rays tips", - "common x rays mistakes", - "pros and cons of x rays", - "what is lasers", - "how does lasers work", - "lasers explained simply", - "beginner guide to lasers", - "best lasers tips", - "common lasers mistakes", - "pros and cons of lasers", - "what is magnetism", - "how does magnetism work", - "magnetism explained simply", - "beginner guide to magnetism", - "best magnetism tips", - "common magnetism mistakes", - "pros and cons of magnetism", - "what is electricity", - "how does electricity work", - "electricity explained simply", - "beginner guide to electricity", - "best electricity tips", - "common electricity mistakes", - "pros and cons of electricity", - "what is sound waves", - "how does sound waves work", - "sound waves explained simply", - "beginner guide to sound waves", - "best sound waves tips", - "common sound waves mistakes", - "pros and cons of sound waves", - "what is friction", - "how does friction work", - "friction explained simply", - "beginner guide to friction", - "best friction tips", - "common friction mistakes", - "pros and cons of friction", - "what is inertia", - "how does inertia work", - "inertia explained simply", - "beginner guide to inertia", - "best inertia tips", - "common inertia mistakes", - "pros and cons of inertia", - "what is chemical reactions", - "how does chemical reactions work", - "chemical reactions explained simply", - "beginner guide to chemical reactions", - "best chemical reactions tips", - "common chemical reactions mistakes", - "pros and cons of chemical reactions", - "what is periodic table", - "how does periodic table work", - "periodic table explained simply", - "beginner guide to periodic table", - "best periodic table tips", - "common periodic table mistakes", - "pros and cons of periodic table", - "what is atoms", - "how does atoms work", - "atoms explained simply", - "beginner guide to atoms", - "best atoms tips", - "common atoms mistakes", - "pros and cons of atoms", - "what is molecules", - "how does molecules work", - "molecules explained simply", - "beginner guide to molecules", - "best molecules tips", - "common molecules mistakes", - "pros and cons of molecules", - "what is acids and bases", - "how does acids and bases work", - "acids and bases explained simply", - "beginner guide to acids and bases", - "best acids and bases tips", - "common acids and bases mistakes", - "pros and cons of acids and bases", - "what is ancient Egypt", - "how does ancient Egypt work", - "ancient Egypt explained simply", - "beginner guide to ancient Egypt", - "best ancient Egypt tips", - "common ancient Egypt mistakes", - "pros and cons of ancient Egypt", - "what is ancient Greece", - "how does ancient Greece work", - "ancient Greece explained simply", - "beginner guide to ancient Greece", - "best ancient Greece tips", - "common ancient Greece mistakes", - "pros and cons of ancient Greece", - "what is ancient Rome", - "how does ancient Rome work", - "ancient Rome explained simply", - "beginner guide to ancient Rome", - "best ancient Rome tips", - "common ancient Rome mistakes", - "pros and cons of ancient Rome", - "what is the Vikings", - "how does the Vikings work", - "the Vikings explained simply", - "beginner guide to the Vikings", - "best the Vikings tips", - "common the Vikings mistakes", - "pros and cons of the Vikings", - "what is the Renaissance", - "how does the Renaissance work", - "the Renaissance explained simply", - "beginner guide to the Renaissance", - "best the Renaissance tips", - "common the Renaissance mistakes", - "pros and cons of the Renaissance", - "what is the Middle Ages", - "how does the Middle Ages work", - "the Middle Ages explained simply", - "beginner guide to the Middle Ages", - "best the Middle Ages tips", - "common the Middle Ages mistakes", - "pros and cons of the Middle Ages", - "what is the Silk Road", - "how does the Silk Road work", - "the Silk Road explained simply", - "beginner guide to the Silk Road", - "best the Silk Road tips", - "common the Silk Road mistakes", - "pros and cons of the Silk Road", - "what is the Ottoman Empire", - "how does the Ottoman Empire work", - "the Ottoman Empire explained simply", - "beginner guide to the Ottoman Empire", - "best the Ottoman Empire tips", - "common the Ottoman Empire mistakes", - "pros and cons of the Ottoman Empire", - "what is the Byzantine Empire", - "how does the Byzantine Empire work", - "the Byzantine Empire explained simply", - "beginner guide to the Byzantine Empire", - "best the Byzantine Empire tips", - "common the Byzantine Empire mistakes", - "pros and cons of the Byzantine Empire", - "what is the Roman Empire", - "how does the Roman Empire work", - "the Roman Empire explained simply", - "beginner guide to the Roman Empire", - "best the Roman Empire tips", - "common the Roman Empire mistakes", - "pros and cons of the Roman Empire", - "what is the French Revolution", - "how does the French Revolution work", - "the French Revolution explained simply", - "beginner guide to the French Revolution", - "best the French Revolution tips", - "common the French Revolution mistakes", - "pros and cons of the French Revolution", - "what is the Industrial Revolution", - "how does the Industrial Revolution work", - "the Industrial Revolution explained simply", - "beginner guide to the Industrial Revolution", - "best the Industrial Revolution tips", - "common the Industrial Revolution mistakes", - "pros and cons of the Industrial Revolution", - "what is World War I", - "how does World War I work", - "world War I explained simply", - "beginner guide to World War I", - "best World War I tips", - "common World War I mistakes", - "pros and cons of World War I", - "what is World War II", - "how does World War II work", - "world War II explained simply", - "beginner guide to World War II", - "best World War II tips", - "common World War II mistakes", - "pros and cons of World War II", - "what is the Cold War", - "how does the Cold War work", - "the Cold War explained simply", - "beginner guide to the Cold War", - "best the Cold War tips", - "common the Cold War mistakes", - "pros and cons of the Cold War", - "what is the moon landing", - "how does the moon landing work", - "the moon landing explained simply", - "beginner guide to the moon landing", - "best the moon landing tips", - "common the moon landing mistakes", - "pros and cons of the moon landing", - "what is the printing press", - "how does the printing press work", - "the printing press explained simply", - "beginner guide to the printing press", - "best the printing press tips", - "common the printing press mistakes", - "pros and cons of the printing press", - "what is the internet history", - "how does the internet history work", - "the internet history explained simply", - "beginner guide to the internet history", - "best the internet history tips", - "common the internet history mistakes", - "pros and cons of the internet history", - "what is Cleopatra", - "how does Cleopatra work", - "cleopatra explained simply", - "beginner guide to Cleopatra", - "best Cleopatra tips", - "common Cleopatra mistakes", - "pros and cons of Cleopatra", - "what is Julius Caesar", - "how does Julius Caesar work", - "julius Caesar explained simply", - "beginner guide to Julius Caesar", - "best Julius Caesar tips", - "common Julius Caesar mistakes", - "pros and cons of Julius Caesar", - "what is Napoleon Bonaparte", - "how does Napoleon Bonaparte work", - "napoleon Bonaparte explained simply", - "beginner guide to Napoleon Bonaparte", - "best Napoleon Bonaparte tips", - "common Napoleon Bonaparte mistakes", - "pros and cons of Napoleon Bonaparte", - "what is Albert Einstein", - "how does Albert Einstein work", - "albert Einstein explained simply", - "beginner guide to Albert Einstein", - "best Albert Einstein tips", - "common Albert Einstein mistakes", - "pros and cons of Albert Einstein", - "what is Marie Curie", - "how does Marie Curie work", - "marie Curie explained simply", - "beginner guide to Marie Curie", - "best Marie Curie tips", - "common Marie Curie mistakes", - "pros and cons of Marie Curie", - "what is Leonardo da Vinci", - "how does Leonardo da Vinci work", - "leonardo da Vinci explained simply", - "beginner guide to Leonardo da Vinci", - "best Leonardo da Vinci tips", - "common Leonardo da Vinci mistakes", - "pros and cons of Leonardo da Vinci", - "what is Vincent van Gogh", - "how does Vincent van Gogh work", - "vincent van Gogh explained simply", - "beginner guide to Vincent van Gogh", - "best Vincent van Gogh tips", - "common Vincent van Gogh mistakes", - "pros and cons of Vincent van Gogh", - "what is Pablo Picasso", - "how does Pablo Picasso work", - "pablo Picasso explained simply", - "beginner guide to Pablo Picasso", - "best Pablo Picasso tips", - "common Pablo Picasso mistakes", - "pros and cons of Pablo Picasso", - "what is Frida Kahlo", - "how does Frida Kahlo work", - "frida Kahlo explained simply", - "beginner guide to Frida Kahlo", - "best Frida Kahlo tips", - "common Frida Kahlo mistakes", - "pros and cons of Frida Kahlo", - "what is William Shakespeare", - "how does William Shakespeare work", - "william Shakespeare explained simply", - "beginner guide to William Shakespeare", - "best William Shakespeare tips", - "common William Shakespeare mistakes", - "pros and cons of William Shakespeare", - "what is Jane Austen", - "how does Jane Austen work", - "jane Austen explained simply", - "beginner guide to Jane Austen", - "best Jane Austen tips", - "common Jane Austen mistakes", - "pros and cons of Jane Austen", - "what is Charles Dickens", - "how does Charles Dickens work", - "charles Dickens explained simply", - "beginner guide to Charles Dickens", - "best Charles Dickens tips", - "common Charles Dickens mistakes", - "pros and cons of Charles Dickens", - "what is Greek mythology", - "how does Greek mythology work", - "greek mythology explained simply", - "beginner guide to Greek mythology", - "best Greek mythology tips", - "common Greek mythology mistakes", - "pros and cons of Greek mythology", - "what is Norse mythology", - "how does Norse mythology work", - "norse mythology explained simply", - "beginner guide to Norse mythology", - "best Norse mythology tips", - "common Norse mythology mistakes", - "pros and cons of Norse mythology", - "what is Egyptian mythology", - "how does Egyptian mythology work", - "egyptian mythology explained simply", - "beginner guide to Egyptian mythology", - "best Egyptian mythology tips", - "common Egyptian mythology mistakes", - "pros and cons of Egyptian mythology", - "what is world wonders", - "how does world wonders work", - "world wonders explained simply", - "beginner guide to world wonders", - "best world wonders tips", - "common world wonders mistakes", - "pros and cons of world wonders", - "what is famous castles", - "how does famous castles work", - "famous castles explained simply", - "beginner guide to famous castles", - "best famous castles tips", - "common famous castles mistakes", - "pros and cons of famous castles", - "what is famous museums", - "how does famous museums work", - "famous museums explained simply", - "beginner guide to famous museums", - "best famous museums tips", - "common famous museums mistakes", - "pros and cons of famous museums", - "what is classical music", - "how does classical music work", - "classical music explained simply", - "beginner guide to classical music", - "best classical music tips", - "common classical music mistakes", - "pros and cons of classical music", - "what is jazz music", - "how does jazz music work", - "jazz music explained simply", - "beginner guide to jazz music", - "best jazz music tips", - "common jazz music mistakes", - "pros and cons of jazz music", - "what is rock music", - "how does rock music work", - "rock music explained simply", - "beginner guide to rock music", - "best rock music tips", - "common rock music mistakes", - "pros and cons of rock music", - "what is hip hop history", - "how does hip hop history work", - "hip hop history explained simply", - "beginner guide to hip hop history", - "best hip hop history tips", - "common hip hop history mistakes", - "pros and cons of hip hop history", - "what is film history", - "how does film history work", - "film history explained simply", - "beginner guide to film history", - "best film history tips", - "common film history mistakes", - "pros and cons of film history", - "what is animation history", - "how does animation history work", - "animation history explained simply", - "beginner guide to animation history", - "best animation history tips", - "common animation history mistakes", - "pros and cons of animation history", - "what is comic books", - "how does comic books work", - "comic books explained simply", - "beginner guide to comic books", - "best comic books tips", - "common comic books mistakes", - "pros and cons of comic books", - "what is manga", - "how does manga work", - "manga explained simply", - "beginner guide to manga", - "best manga tips", - "common manga mistakes", - "pros and cons of manga", - "what is anime", - "how does anime work", - "anime explained simply", - "beginner guide to anime", - "best anime tips", - "common anime mistakes", - "pros and cons of anime", - "what is fantasy books", - "how does fantasy books work", - "fantasy books explained simply", - "beginner guide to fantasy books", - "best fantasy books tips", - "common fantasy books mistakes", - "pros and cons of fantasy books", - "what is science fiction books", - "how does science fiction books work", - "science fiction books explained simply", - "beginner guide to science fiction books", - "best science fiction books tips", - "common science fiction books mistakes", - "pros and cons of science fiction books", - "what is mystery novels", - "how does mystery novels work", - "mystery novels explained simply", - "beginner guide to mystery novels", - "best mystery novels tips", - "common mystery novels mistakes", - "pros and cons of mystery novels", - "what is poetry", - "how does poetry work", - "poetry explained simply", - "beginner guide to poetry", - "best poetry tips", - "common poetry mistakes", - "pros and cons of poetry", - "what is architecture", - "how does architecture work", - "architecture explained simply", - "beginner guide to architecture", - "best architecture tips", - "common architecture mistakes", - "pros and cons of architecture", - "what is street art", - "how does street art work", - "street art explained simply", - "beginner guide to street art", - "best street art tips", - "common street art mistakes", - "pros and cons of street art", - "what is photography", - "how does photography work", - "photography explained simply", - "beginner guide to photography", - "best photography tips", - "common photography mistakes", - "pros and cons of photography", - "what is modern art", - "how does modern art work", - "modern art explained simply", - "beginner guide to modern art", - "best modern art tips", - "common modern art mistakes", - "pros and cons of modern art", - "what is impressionism", - "how does impressionism work", - "impressionism explained simply", - "beginner guide to impressionism", - "best impressionism tips", - "common impressionism mistakes", - "pros and cons of impressionism", - "what is baroque art", - "how does baroque art work", - "baroque art explained simply", - "beginner guide to baroque art", - "best baroque art tips", - "common baroque art mistakes", - "pros and cons of baroque art", - "what is gothic architecture", - "how does gothic architecture work", - "gothic architecture explained simply", - "beginner guide to gothic architecture", - "best gothic architecture tips", - "common gothic architecture mistakes", - "pros and cons of gothic architecture", - "what is art deco", - "how does art deco work", - "art deco explained simply", - "beginner guide to art deco", - "best art deco tips", - "common art deco mistakes", - "pros and cons of art deco", - "what is minimalist design", - "how does minimalist design work", - "minimalist design explained simply", - "beginner guide to minimalist design", - "best minimalist design tips", - "common minimalist design mistakes", - "pros and cons of minimalist design", - "what is Dutch Golden Age", - "how does Dutch Golden Age work", - "dutch Golden Age explained simply", - "beginner guide to Dutch Golden Age", - "best Dutch Golden Age tips", - "common Dutch Golden Age mistakes", - "pros and cons of Dutch Golden Age", - "what is European history", - "how does European history work", - "european history explained simply", - "beginner guide to European history", - "best European history tips", - "common European history mistakes", - "pros and cons of European history", - "what is African history", - "how does African history work", - "african history explained simply", - "beginner guide to African history", - "best African history tips", - "common African history mistakes", - "pros and cons of African history", - "what is Asian history", - "how does Asian history work", - "asian history explained simply", - "beginner guide to Asian history", - "best Asian history tips", - "common Asian history mistakes", - "pros and cons of Asian history", - "what is American history", - "how does American history work", - "american history explained simply", - "beginner guide to American history", - "best American history tips", - "common American history mistakes", - "pros and cons of American history", - "what is indigenous cultures", - "how does indigenous cultures work", - "indigenous cultures explained simply", - "beginner guide to indigenous cultures", - "best indigenous cultures tips", - "common indigenous cultures mistakes", - "pros and cons of indigenous cultures", - "what is world religions overview", - "how does world religions overview work", - "world religions overview explained simply", - "beginner guide to world religions overview", - "best world religions overview tips", - "common world religions overview mistakes", - "pros and cons of world religions overview", - "what is language origins", - "how does language origins work", - "language origins explained simply", - "beginner guide to language origins", - "best language origins tips", - "common language origins mistakes", - "pros and cons of language origins", - "what is folklore", - "how does folklore work", - "folklore explained simply", - "beginner guide to folklore", - "best folklore tips", - "common folklore mistakes", - "pros and cons of folklore", - "what is famous explorers", - "how does famous explorers work", - "famous explorers explained simply", - "beginner guide to famous explorers", - "best famous explorers tips", - "common famous explorers mistakes", - "pros and cons of famous explorers", - "what is pirates history", - "how does pirates history work", - "pirates history explained simply", - "beginner guide to pirates history", - "best pirates history tips", - "common pirates history mistakes", - "pros and cons of pirates history", - "what is medieval weapons history", - "how does medieval weapons history work", - "medieval weapons history explained simply", - "beginner guide to medieval weapons history", - "best medieval weapons history tips", - "common medieval weapons history mistakes", - "pros and cons of medieval weapons history", - "what is ancient trade routes", - "how does ancient trade routes work", - "ancient trade routes explained simply", - "beginner guide to ancient trade routes", - "best ancient trade routes tips", - "common ancient trade routes mistakes", - "pros and cons of ancient trade routes", - "how to make spaghetti carbonara", - "easy spaghetti carbonara recipe", - "best spaghetti carbonara recipe", - "quick spaghetti carbonara recipe", - "healthy spaghetti carbonara recipe", - "homemade spaghetti carbonara", - "how long to cook spaghetti carbonara", - "how to make pasta sauce", - "easy pasta sauce recipe", - "best pasta sauce recipe", - "quick pasta sauce recipe", - "healthy pasta sauce recipe", - "homemade pasta sauce", - "how long to cook pasta sauce", - "how to make lasagna", - "easy lasagna recipe", - "best lasagna recipe", - "quick lasagna recipe", - "healthy lasagna recipe", - "homemade lasagna", - "how long to cook lasagna", - "easy risotto recipe", - "best risotto recipe", - "quick risotto recipe", - "healthy risotto recipe", - "homemade risotto", - "how long to cook risotto", - "how to make fried rice", - "easy fried rice recipe", - "best fried rice recipe", - "quick fried rice recipe", - "healthy fried rice recipe", - "homemade fried rice", - "how long to cook fried rice", - "how to make sushi rice", - "easy sushi rice recipe", - "best sushi rice recipe", - "quick sushi rice recipe", - "healthy sushi rice recipe", - "homemade sushi rice", - "how long to cook sushi rice", - "how to make ramen", - "easy ramen recipe", - "best ramen recipe", - "quick ramen recipe", - "healthy ramen recipe", - "homemade ramen", - "how long to cook ramen", - "how to make pho", - "easy pho recipe", - "best pho recipe", - "quick pho recipe", - "healthy pho recipe", - "homemade pho", - "how long to cook pho", - "how to make pad thai", - "easy pad thai recipe", - "best pad thai recipe", - "quick pad thai recipe", - "healthy pad thai recipe", - "homemade pad thai", - "how long to cook pad thai", - "how to make tacos", - "easy tacos recipe", - "best tacos recipe", - "quick tacos recipe", - "healthy tacos recipe", - "homemade tacos", - "how long to cook tacos", - "how to make burritos", - "easy burritos recipe", - "best burritos recipe", - "quick burritos recipe", - "healthy burritos recipe", - "homemade burritos", - "how long to cook burritos", - "how to make quesadillas", - "easy quesadillas recipe", - "best quesadillas recipe", - "quick quesadillas recipe", - "healthy quesadillas recipe", - "homemade quesadillas", - "how long to cook quesadillas", - "how to make homemade pizza", - "easy homemade pizza recipe", - "best homemade pizza recipe", - "quick homemade pizza recipe", - "healthy homemade pizza recipe", - "homemade homemade pizza", - "how long to cook homemade pizza", - "how to make pizza dough", - "easy pizza dough recipe", - "best pizza dough recipe", - "quick pizza dough recipe", - "healthy pizza dough recipe", - "homemade pizza dough", - "how long to cook pizza dough", - "how to make sourdough bread", - "easy sourdough bread recipe", - "best sourdough bread recipe", - "quick sourdough bread recipe", - "healthy sourdough bread recipe", - "homemade sourdough bread", - "how long to cook sourdough bread", - "how to make banana bread", - "easy banana bread recipe", - "best banana bread recipe", - "quick banana bread recipe", - "healthy banana bread recipe", - "homemade banana bread", - "how long to cook banana bread", - "easy pancakes recipe", - "best pancakes recipe", - "quick pancakes recipe", - "healthy pancakes recipe", - "homemade pancakes", - "how long to cook pancakes", - "how to make waffles", - "easy waffles recipe", - "best waffles recipe", - "quick waffles recipe", - "healthy waffles recipe", - "homemade waffles", - "how long to cook waffles", - "how to make french toast", - "easy french toast recipe", - "best french toast recipe", - "quick french toast recipe", - "healthy french toast recipe", - "homemade french toast", - "how long to cook french toast", - "how to make omelette", - "easy omelette recipe", - "best omelette recipe", - "quick omelette recipe", - "healthy omelette recipe", - "homemade omelette", - "how long to cook omelette", - "how to make scrambled eggs", - "easy scrambled eggs recipe", - "best scrambled eggs recipe", - "quick scrambled eggs recipe", - "healthy scrambled eggs recipe", - "homemade scrambled eggs", - "how long to cook scrambled eggs", - "how to make boiled eggs", - "easy boiled eggs recipe", - "best boiled eggs recipe", - "quick boiled eggs recipe", - "healthy boiled eggs recipe", - "homemade boiled eggs", - "how long to cook boiled eggs", - "how to make roast chicken", - "easy roast chicken recipe", - "best roast chicken recipe", - "quick roast chicken recipe", - "healthy roast chicken recipe", - "homemade roast chicken", - "how long to cook roast chicken", - "how to make grilled steak", - "easy grilled steak recipe", - "best grilled steak recipe", - "quick grilled steak recipe", - "healthy grilled steak recipe", - "homemade grilled steak", - "how long to cook grilled steak", - "how to make burgers", - "easy burgers recipe", - "best burgers recipe", - "quick burgers recipe", - "healthy burgers recipe", - "homemade burgers", - "how long to cook burgers", - "how to make meatballs", - "easy meatballs recipe", - "best meatballs recipe", - "quick meatballs recipe", - "healthy meatballs recipe", - "homemade meatballs", - "how long to cook meatballs", - "how to make salmon", - "easy salmon recipe", - "best salmon recipe", - "quick salmon recipe", - "healthy salmon recipe", - "homemade salmon", - "how long to cook salmon", - "how to make cod fillets", - "easy cod fillets recipe", - "best cod fillets recipe", - "quick cod fillets recipe", - "healthy cod fillets recipe", - "homemade cod fillets", - "how long to cook cod fillets", - "how to make shrimp pasta", - "easy shrimp pasta recipe", - "best shrimp pasta recipe", - "quick shrimp pasta recipe", - "healthy shrimp pasta recipe", - "homemade shrimp pasta", - "how long to cook shrimp pasta", - "how to make chicken curry", - "easy chicken curry recipe", - "best chicken curry recipe", - "quick chicken curry recipe", - "healthy chicken curry recipe", - "homemade chicken curry", - "how long to cook chicken curry", - "how to make butter chicken", - "easy butter chicken recipe", - "best butter chicken recipe", - "quick butter chicken recipe", - "healthy butter chicken recipe", - "homemade butter chicken", - "how long to cook butter chicken", - "how to make vegetable curry", - "easy vegetable curry recipe", - "best vegetable curry recipe", - "quick vegetable curry recipe", - "healthy vegetable curry recipe", - "homemade vegetable curry", - "how long to cook vegetable curry", - "how to make lentil soup", - "easy lentil soup recipe", - "best lentil soup recipe", - "quick lentil soup recipe", - "healthy lentil soup recipe", - "homemade lentil soup", - "how long to cook lentil soup", - "how to make tomato soup", - "easy tomato soup recipe", - "best tomato soup recipe", - "quick tomato soup recipe", - "healthy tomato soup recipe", - "homemade tomato soup", - "how long to cook tomato soup", - "how to make pumpkin soup", - "easy pumpkin soup recipe", - "best pumpkin soup recipe", - "quick pumpkin soup recipe", - "healthy pumpkin soup recipe", - "homemade pumpkin soup", - "how long to cook pumpkin soup", - "how to make chili con carne", - "easy chili con carne recipe", - "best chili con carne recipe", - "quick chili con carne recipe", - "healthy chili con carne recipe", - "homemade chili con carne", - "how long to cook chili con carne", - "how to make beef stew", - "easy beef stew recipe", - "best beef stew recipe", - "quick beef stew recipe", - "healthy beef stew recipe", - "homemade beef stew", - "how long to cook beef stew", - "how to make stir fry vegetables", - "easy stir fry vegetables recipe", - "best stir fry vegetables recipe", - "quick stir fry vegetables recipe", - "healthy stir fry vegetables recipe", - "homemade stir fry vegetables", - "how long to cook stir fry vegetables", - "how to make roasted potatoes", - "easy roasted potatoes recipe", - "best roasted potatoes recipe", - "quick roasted potatoes recipe", - "healthy roasted potatoes recipe", - "homemade roasted potatoes", - "how long to cook roasted potatoes", - "how to make mashed potatoes", - "easy mashed potatoes recipe", - "best mashed potatoes recipe", - "quick mashed potatoes recipe", - "healthy mashed potatoes recipe", - "homemade mashed potatoes", - "how long to cook mashed potatoes", - "how to make caesar salad", - "easy caesar salad recipe", - "best caesar salad recipe", - "quick caesar salad recipe", - "healthy caesar salad recipe", - "homemade caesar salad", - "how long to cook caesar salad", - "how to make greek salad", - "easy greek salad recipe", - "best greek salad recipe", - "quick greek salad recipe", - "healthy greek salad recipe", - "homemade greek salad", - "how long to cook greek salad", - "easy smoothies recipe", - "best smoothies recipe", - "quick smoothies recipe", - "healthy smoothies recipe", - "homemade smoothies", - "how long to cook smoothies", - "how to make protein shakes", - "easy protein shakes recipe", - "best protein shakes recipe", - "quick protein shakes recipe", - "healthy protein shakes recipe", - "homemade protein shakes", - "how long to cook protein shakes", - "how to make overnight oats", - "easy overnight oats recipe", - "best overnight oats recipe", - "quick overnight oats recipe", - "healthy overnight oats recipe", - "homemade overnight oats", - "how long to cook overnight oats", - "how to make granola", - "easy granola recipe", - "best granola recipe", - "quick granola recipe", - "healthy granola recipe", - "homemade granola", - "how long to cook granola", - "how to make chocolate cake", - "easy chocolate cake recipe", - "best chocolate cake recipe", - "quick chocolate cake recipe", - "healthy chocolate cake recipe", - "homemade chocolate cake", - "how long to cook chocolate cake", - "how to make cheesecake", - "easy cheesecake recipe", - "best cheesecake recipe", - "quick cheesecake recipe", - "healthy cheesecake recipe", - "homemade cheesecake", - "how long to cook cheesecake", - "how to make brownies", - "easy brownies recipe", - "best brownies recipe", - "quick brownies recipe", - "healthy brownies recipe", - "homemade brownies", - "how long to cook brownies", - "how to make cookies", - "easy cookies recipe", - "best cookies recipe", - "quick cookies recipe", - "healthy cookies recipe", - "homemade cookies", - "how long to cook cookies", - "how to make ice cream", - "easy ice cream recipe", - "best ice cream recipe", - "quick ice cream recipe", - "healthy ice cream recipe", - "homemade ice cream", - "how long to cook ice cream", - "how to make apple pie", - "easy apple pie recipe", - "best apple pie recipe", - "quick apple pie recipe", - "healthy apple pie recipe", - "homemade apple pie", - "how long to cook apple pie", - "how to make tiramisu", - "easy tiramisu recipe", - "best tiramisu recipe", - "quick tiramisu recipe", - "healthy tiramisu recipe", - "homemade tiramisu", - "how long to cook tiramisu", - "how to make crepes", - "easy crepes recipe", - "best crepes recipe", - "quick crepes recipe", - "healthy crepes recipe", - "homemade crepes", - "how long to cook crepes", - "how to make dumplings", - "easy dumplings recipe", - "best dumplings recipe", - "quick dumplings recipe", - "healthy dumplings recipe", - "homemade dumplings", - "how long to cook dumplings", - "how to make falafel", - "easy falafel recipe", - "best falafel recipe", - "quick falafel recipe", - "healthy falafel recipe", - "homemade falafel", - "how long to cook falafel", - "how to make hummus", - "easy hummus recipe", - "best hummus recipe", - "quick hummus recipe", - "healthy hummus recipe", - "homemade hummus", - "how long to cook hummus", - "how to make guacamole", - "easy guacamole recipe", - "best guacamole recipe", - "quick guacamole recipe", - "healthy guacamole recipe", - "homemade guacamole", - "how long to cook guacamole", - "how to make salsa", - "easy salsa recipe", - "best salsa recipe", - "quick salsa recipe", - "healthy salsa recipe", - "homemade salsa", - "how long to cook salsa", - "how to make bbq ribs", - "easy bbq ribs recipe", - "best bbq ribs recipe", - "quick bbq ribs recipe", - "healthy bbq ribs recipe", - "homemade bbq ribs", - "how long to cook bbq ribs", - "how to make pulled pork", - "easy pulled pork recipe", - "best pulled pork recipe", - "quick pulled pork recipe", - "healthy pulled pork recipe", - "homemade pulled pork", - "how long to cook pulled pork", - "how to make air fryer fries", - "easy air fryer fries recipe", - "best air fryer fries recipe", - "quick air fryer fries recipe", - "healthy air fryer fries recipe", - "homemade air fryer fries", - "how long to cook air fryer fries", - "how to make air fryer chicken", - "easy air fryer chicken recipe", - "best air fryer chicken recipe", - "quick air fryer chicken recipe", - "healthy air fryer chicken recipe", - "homemade air fryer chicken", - "how long to cook air fryer chicken", - "how to make meal prep bowls", - "easy meal prep bowls recipe", - "best meal prep bowls recipe", - "quick meal prep bowls recipe", - "healthy meal prep bowls recipe", - "homemade meal prep bowls", - "how long to cook meal prep bowls", - "how to make vegan burgers", - "easy vegan burgers recipe", - "best vegan burgers recipe", - "quick vegan burgers recipe", - "healthy vegan burgers recipe", - "homemade vegan burgers", - "how long to cook vegan burgers", - "how to make vegetarian chili", - "easy vegetarian chili recipe", - "best vegetarian chili recipe", - "quick vegetarian chili recipe", - "healthy vegetarian chili recipe", - "homemade vegetarian chili", - "how long to cook vegetarian chili", - "how to make gluten free bread", - "easy gluten free bread recipe", - "best gluten free bread recipe", - "quick gluten free bread recipe", - "healthy gluten free bread recipe", - "homemade gluten free bread", - "how long to cook gluten free bread", - "how to make low carb dinners", - "easy low carb dinners recipe", - "best low carb dinners recipe", - "quick low carb dinners recipe", - "healthy low carb dinners recipe", - "homemade low carb dinners", - "how long to cook low carb dinners", - "how to make healthy lunches", - "easy healthy lunches recipe", - "best healthy lunches recipe", - "quick healthy lunches recipe", - "healthy healthy lunches recipe", - "homemade healthy lunches", - "how long to cook healthy lunches", - "how to make quick breakfasts", - "easy quick breakfasts recipe", - "best quick breakfasts recipe", - "quick quick breakfasts recipe", - "healthy quick breakfasts recipe", - "homemade quick breakfasts", - "how long to cook quick breakfasts", - "easy coffee at home recipe", - "best coffee at home recipe", - "quick coffee at home recipe", - "healthy coffee at home recipe", - "homemade coffee at home", - "how long to cook coffee at home", - "how to make espresso", - "easy espresso recipe", - "best espresso recipe", - "quick espresso recipe", - "healthy espresso recipe", - "homemade espresso", - "how long to cook espresso", - "how to make cold brew", - "easy cold brew recipe", - "best cold brew recipe", - "quick cold brew recipe", - "healthy cold brew recipe", - "homemade cold brew", - "how long to cook cold brew", - "how to make matcha latte", - "easy matcha latte recipe", - "best matcha latte recipe", - "quick matcha latte recipe", - "healthy matcha latte recipe", - "homemade matcha latte", - "how long to cook matcha latte", - "how to make iced tea", - "easy iced tea recipe", - "best iced tea recipe", - "quick iced tea recipe", - "healthy iced tea recipe", - "homemade iced tea", - "how long to cook iced tea", - "how to make homemade lemonade", - "easy homemade lemonade recipe", - "best homemade lemonade recipe", - "quick homemade lemonade recipe", - "healthy homemade lemonade recipe", - "homemade homemade lemonade", - "how long to cook homemade lemonade", - "how to make mocktails", - "easy mocktails recipe", - "best mocktails recipe", - "quick mocktails recipe", - "healthy mocktails recipe", - "homemade mocktails", - "how long to cook mocktails", - "best things to do in Amsterdam", - "where to stay in Amsterdam", - "budget travel guide Amsterdam", - "amsterdam weekend itinerary", - "best time to visit Amsterdam", - "hidden gems in Amsterdam", - "best things to do in Rotterdam", - "where to stay in Rotterdam", - "budget travel guide Rotterdam", - "rotterdam weekend itinerary", - "best time to visit Rotterdam", - "hidden gems in Rotterdam", - "best things to do in Utrecht", - "where to stay in Utrecht", - "budget travel guide Utrecht", - "utrecht weekend itinerary", - "best time to visit Utrecht", - "hidden gems in Utrecht", - "best things to do in The Hague", - "where to stay in The Hague", - "budget travel guide The Hague", - "the Hague weekend itinerary", - "best time to visit The Hague", - "hidden gems in The Hague", - "best things to do in Brussels", - "where to stay in Brussels", - "budget travel guide Brussels", - "brussels weekend itinerary", - "best time to visit Brussels", - "hidden gems in Brussels", - "best things to do in Bruges", - "where to stay in Bruges", - "budget travel guide Bruges", - "bruges weekend itinerary", - "best time to visit Bruges", - "hidden gems in Bruges", - "best things to do in Antwerp", - "where to stay in Antwerp", - "budget travel guide Antwerp", - "antwerp weekend itinerary", - "best time to visit Antwerp", - "hidden gems in Antwerp", - "best things to do in Paris", - "where to stay in Paris", - "budget travel guide Paris", - "paris weekend itinerary", - "best time to visit Paris", - "hidden gems in Paris", - "best things to do in Lyon", - "where to stay in Lyon", - "budget travel guide Lyon", - "lyon weekend itinerary", - "best time to visit Lyon", - "hidden gems in Lyon", - "best things to do in Nice", - "where to stay in Nice", - "budget travel guide Nice", - "nice weekend itinerary", - "best time to visit Nice", - "hidden gems in Nice", - "best things to do in London", - "where to stay in London", - "budget travel guide London", - "london weekend itinerary", - "best time to visit London", - "hidden gems in London", - "best things to do in Edinburgh", - "where to stay in Edinburgh", - "budget travel guide Edinburgh", - "edinburgh weekend itinerary", - "best time to visit Edinburgh", - "hidden gems in Edinburgh", - "best things to do in Dublin", - "where to stay in Dublin", - "budget travel guide Dublin", - "dublin weekend itinerary", - "best time to visit Dublin", - "hidden gems in Dublin", - "best things to do in Berlin", - "where to stay in Berlin", - "budget travel guide Berlin", - "berlin weekend itinerary", - "best time to visit Berlin", - "hidden gems in Berlin", - "best things to do in Munich", - "where to stay in Munich", - "budget travel guide Munich", - "munich weekend itinerary", - "best time to visit Munich", - "hidden gems in Munich", - "best things to do in Hamburg", - "where to stay in Hamburg", - "budget travel guide Hamburg", - "hamburg weekend itinerary", - "best time to visit Hamburg", - "hidden gems in Hamburg", - "best things to do in Copenhagen", - "where to stay in Copenhagen", - "budget travel guide Copenhagen", - "copenhagen weekend itinerary", - "best time to visit Copenhagen", - "hidden gems in Copenhagen", - "best things to do in Stockholm", - "where to stay in Stockholm", - "budget travel guide Stockholm", - "stockholm weekend itinerary", - "best time to visit Stockholm", - "hidden gems in Stockholm", - "best things to do in Oslo", - "where to stay in Oslo", - "budget travel guide Oslo", - "oslo weekend itinerary", - "best time to visit Oslo", - "hidden gems in Oslo", - "best things to do in Helsinki", - "where to stay in Helsinki", - "budget travel guide Helsinki", - "helsinki weekend itinerary", - "best time to visit Helsinki", - "hidden gems in Helsinki", - "best things to do in Reykjavik", - "where to stay in Reykjavik", - "budget travel guide Reykjavik", - "reykjavik weekend itinerary", - "best time to visit Reykjavik", - "hidden gems in Reykjavik", - "best things to do in Zurich", - "where to stay in Zurich", - "budget travel guide Zurich", - "zurich weekend itinerary", - "best time to visit Zurich", - "hidden gems in Zurich", - "best things to do in Vienna", - "where to stay in Vienna", - "budget travel guide Vienna", - "vienna weekend itinerary", - "best time to visit Vienna", - "hidden gems in Vienna", - "best things to do in Prague", - "where to stay in Prague", - "budget travel guide Prague", - "prague weekend itinerary", - "best time to visit Prague", - "hidden gems in Prague", - "best things to do in Budapest", - "where to stay in Budapest", - "budget travel guide Budapest", - "budapest weekend itinerary", - "best time to visit Budapest", - "hidden gems in Budapest", - "best things to do in Warsaw", - "where to stay in Warsaw", - "budget travel guide Warsaw", - "warsaw weekend itinerary", - "best time to visit Warsaw", - "hidden gems in Warsaw", - "best things to do in Krakow", - "where to stay in Krakow", - "budget travel guide Krakow", - "krakow weekend itinerary", - "best time to visit Krakow", - "hidden gems in Krakow", - "best things to do in Rome", - "where to stay in Rome", - "budget travel guide Rome", - "rome weekend itinerary", - "best time to visit Rome", - "hidden gems in Rome", - "best things to do in Florence", - "where to stay in Florence", - "budget travel guide Florence", - "florence weekend itinerary", - "best time to visit Florence", - "hidden gems in Florence", - "best things to do in Venice", - "where to stay in Venice", - "budget travel guide Venice", - "venice weekend itinerary", - "best time to visit Venice", - "hidden gems in Venice", - "best things to do in Milan", - "where to stay in Milan", - "budget travel guide Milan", - "milan weekend itinerary", - "best time to visit Milan", - "hidden gems in Milan", - "best things to do in Naples", - "where to stay in Naples", - "budget travel guide Naples", - "naples weekend itinerary", - "best time to visit Naples", - "hidden gems in Naples", - "best things to do in Barcelona", - "where to stay in Barcelona", - "budget travel guide Barcelona", - "barcelona weekend itinerary", - "best time to visit Barcelona", - "hidden gems in Barcelona", - "best things to do in Madrid", - "where to stay in Madrid", - "budget travel guide Madrid", - "madrid weekend itinerary", - "best time to visit Madrid", - "hidden gems in Madrid", - "best things to do in Valencia", - "where to stay in Valencia", - "budget travel guide Valencia", - "valencia weekend itinerary", - "best time to visit Valencia", - "hidden gems in Valencia", - "best things to do in Lisbon", - "where to stay in Lisbon", - "budget travel guide Lisbon", - "lisbon weekend itinerary", - "best time to visit Lisbon", - "hidden gems in Lisbon", - "best things to do in Porto", - "where to stay in Porto", - "budget travel guide Porto", - "porto weekend itinerary", - "best time to visit Porto", - "hidden gems in Porto", - "best things to do in Athens", - "where to stay in Athens", - "budget travel guide Athens", - "athens weekend itinerary", - "best time to visit Athens", - "hidden gems in Athens", - "best things to do in Santorini", - "where to stay in Santorini", - "budget travel guide Santorini", - "santorini weekend itinerary", - "best time to visit Santorini", - "hidden gems in Santorini", - "best things to do in Istanbul", - "where to stay in Istanbul", - "budget travel guide Istanbul", - "istanbul weekend itinerary", - "best time to visit Istanbul", - "hidden gems in Istanbul", - "best things to do in Cappadocia", - "where to stay in Cappadocia", - "budget travel guide Cappadocia", - "cappadocia weekend itinerary", - "best time to visit Cappadocia", - "hidden gems in Cappadocia", - "best things to do in Marrakech", - "where to stay in Marrakech", - "budget travel guide Marrakech", - "marrakech weekend itinerary", - "best time to visit Marrakech", - "hidden gems in Marrakech", - "best things to do in Dubai", - "where to stay in Dubai", - "budget travel guide Dubai", - "dubai weekend itinerary", - "best time to visit Dubai", - "hidden gems in Dubai", - "best things to do in Tokyo", - "where to stay in Tokyo", - "budget travel guide Tokyo", - "tokyo weekend itinerary", - "best time to visit Tokyo", - "hidden gems in Tokyo", - "best things to do in Kyoto", - "where to stay in Kyoto", - "budget travel guide Kyoto", - "kyoto weekend itinerary", - "best time to visit Kyoto", - "hidden gems in Kyoto", - "best things to do in Osaka", - "where to stay in Osaka", - "budget travel guide Osaka", - "osaka weekend itinerary", - "best time to visit Osaka", - "hidden gems in Osaka", - "best things to do in Seoul", - "where to stay in Seoul", - "budget travel guide Seoul", - "seoul weekend itinerary", - "best time to visit Seoul", - "hidden gems in Seoul", - "best things to do in Bangkok", - "where to stay in Bangkok", - "budget travel guide Bangkok", - "bangkok weekend itinerary", - "best time to visit Bangkok", - "hidden gems in Bangkok", - "best things to do in Singapore", - "where to stay in Singapore", - "budget travel guide Singapore", - "singapore weekend itinerary", - "best time to visit Singapore", - "hidden gems in Singapore", - "best things to do in Bali", - "where to stay in Bali", - "budget travel guide Bali", - "bali weekend itinerary", - "best time to visit Bali", - "hidden gems in Bali", - "best things to do in Sydney", - "where to stay in Sydney", - "budget travel guide Sydney", - "sydney weekend itinerary", - "best time to visit Sydney", - "hidden gems in Sydney", - "best things to do in Melbourne", - "where to stay in Melbourne", - "budget travel guide Melbourne", - "melbourne weekend itinerary", - "best time to visit Melbourne", - "hidden gems in Melbourne", - "best things to do in Auckland", - "where to stay in Auckland", - "budget travel guide Auckland", - "auckland weekend itinerary", - "best time to visit Auckland", - "hidden gems in Auckland", - "best things to do in New York City", - "where to stay in New York City", - "budget travel guide New York City", - "new York City weekend itinerary", - "best time to visit New York City", - "hidden gems in New York City", - "best things to do in Los Angeles", - "where to stay in Los Angeles", - "budget travel guide Los Angeles", - "los Angeles weekend itinerary", - "best time to visit Los Angeles", - "hidden gems in Los Angeles", - "best things to do in San Francisco", - "where to stay in San Francisco", - "budget travel guide San Francisco", - "san Francisco weekend itinerary", - "best time to visit San Francisco", - "hidden gems in San Francisco", - "best things to do in Miami", - "where to stay in Miami", - "budget travel guide Miami", - "miami weekend itinerary", - "best time to visit Miami", - "hidden gems in Miami", - "best things to do in Chicago", - "where to stay in Chicago", - "budget travel guide Chicago", - "chicago weekend itinerary", - "best time to visit Chicago", - "hidden gems in Chicago", - "best things to do in Toronto", - "where to stay in Toronto", - "budget travel guide Toronto", - "toronto weekend itinerary", - "best time to visit Toronto", - "hidden gems in Toronto", - "best things to do in Vancouver", - "where to stay in Vancouver", - "budget travel guide Vancouver", - "vancouver weekend itinerary", - "best time to visit Vancouver", - "hidden gems in Vancouver", - "best things to do in Montreal", - "where to stay in Montreal", - "budget travel guide Montreal", - "montreal weekend itinerary", - "best time to visit Montreal", - "hidden gems in Montreal", - "best things to do in Mexico City", - "where to stay in Mexico City", - "budget travel guide Mexico City", - "mexico City weekend itinerary", - "best time to visit Mexico City", - "hidden gems in Mexico City", - "best things to do in Rio de Janeiro", - "where to stay in Rio de Janeiro", - "budget travel guide Rio de Janeiro", - "rio de Janeiro weekend itinerary", - "best time to visit Rio de Janeiro", - "hidden gems in Rio de Janeiro", - "best things to do in Buenos Aires", - "where to stay in Buenos Aires", - "budget travel guide Buenos Aires", - "buenos Aires weekend itinerary", - "best time to visit Buenos Aires", - "hidden gems in Buenos Aires", - "best things to do in Cape Town", - "where to stay in Cape Town", - "budget travel guide Cape Town", - "cape Town weekend itinerary", - "best time to visit Cape Town", - "hidden gems in Cape Town", - "best things to do in Nairobi", - "where to stay in Nairobi", - "budget travel guide Nairobi", - "nairobi weekend itinerary", - "best time to visit Nairobi", - "hidden gems in Nairobi", - "best things to do in Cairo", - "where to stay in Cairo", - "budget travel guide Cairo", - "cairo weekend itinerary", - "best time to visit Cairo", - "hidden gems in Cairo", - "best things to do in Petra", - "where to stay in Petra", - "budget travel guide Petra", - "petra weekend itinerary", - "best time to visit Petra", - "hidden gems in Petra", - "best things to do in Maldives", - "where to stay in Maldives", - "budget travel guide Maldives", - "maldives weekend itinerary", - "best time to visit Maldives", - "hidden gems in Maldives", - "best things to do in Canary Islands", - "where to stay in Canary Islands", - "budget travel guide Canary Islands", - "canary Islands weekend itinerary", - "best time to visit Canary Islands", - "hidden gems in Canary Islands", - "best things to do in Madeira", - "where to stay in Madeira", - "budget travel guide Madeira", - "madeira weekend itinerary", - "best time to visit Madeira", - "hidden gems in Madeira", - "best things to do in Mallorca", - "where to stay in Mallorca", - "budget travel guide Mallorca", - "mallorca weekend itinerary", - "best time to visit Mallorca", - "hidden gems in Mallorca", - "best things to do in Sicily", - "where to stay in Sicily", - "budget travel guide Sicily", - "sicily weekend itinerary", - "best time to visit Sicily", - "hidden gems in Sicily", - "best things to do in Tuscany", - "where to stay in Tuscany", - "budget travel guide Tuscany", - "tuscany weekend itinerary", - "best time to visit Tuscany", - "hidden gems in Tuscany", - "best things to do in Swiss Alps", - "where to stay in Swiss Alps", - "budget travel guide Swiss Alps", - "swiss Alps weekend itinerary", - "best time to visit Swiss Alps", - "hidden gems in Swiss Alps", - "best things to do in Norwegian fjords", - "where to stay in Norwegian fjords", - "budget travel guide Norwegian fjords", - "norwegian fjords weekend itinerary", - "best time to visit Norwegian fjords", - "hidden gems in Norwegian fjords", - "best things to do in Scottish Highlands", - "where to stay in Scottish Highlands", - "budget travel guide Scottish Highlands", - "scottish Highlands weekend itinerary", - "best time to visit Scottish Highlands", - "hidden gems in Scottish Highlands", - "best things to do in Lake Como", - "where to stay in Lake Como", - "budget travel guide Lake Como", - "lake Como weekend itinerary", - "best time to visit Lake Como", - "hidden gems in Lake Como", - "best things to do in Cinque Terre", - "where to stay in Cinque Terre", - "budget travel guide Cinque Terre", - "cinque Terre weekend itinerary", - "best time to visit Cinque Terre", - "hidden gems in Cinque Terre", - "best things to do in Algarve", - "where to stay in Algarve", - "budget travel guide Algarve", - "algarve weekend itinerary", - "best time to visit Algarve", - "hidden gems in Algarve", - "best things to do in French Riviera", - "where to stay in French Riviera", - "budget travel guide French Riviera", - "french Riviera weekend itinerary", - "best time to visit French Riviera", - "hidden gems in French Riviera", - "best things to do in Black Forest", - "where to stay in Black Forest", - "budget travel guide Black Forest", - "black Forest weekend itinerary", - "best time to visit Black Forest", - "hidden gems in Black Forest", - "best things to do in Dolomites", - "where to stay in Dolomites", - "budget travel guide Dolomites", - "dolomites weekend itinerary", - "best time to visit Dolomites", - "hidden gems in Dolomites", - "best things to do in Lapland", - "where to stay in Lapland", - "budget travel guide Lapland", - "lapland weekend itinerary", - "best time to visit Lapland", - "hidden gems in Lapland", - "best things to do in Faroe Islands", - "where to stay in Faroe Islands", - "budget travel guide Faroe Islands", - "faroe Islands weekend itinerary", - "best time to visit Faroe Islands", - "hidden gems in Faroe Islands", - "best way to clean a keyboard", - "tools for cleaning a keyboard", - "step by step cleaning a keyboard", - "cleaning a keyboard tips for beginners", - "best way to clean a monitor", - "tools for cleaning a monitor", - "step by step cleaning a monitor", - "cleaning a monitor tips for beginners", - "best way to clean white shoes", - "tools for cleaning white shoes", - "step by step cleaning white shoes", - "cleaning white shoes tips for beginners", - "how to remove stains", - "best way to remove stains", - "tools for removing stains", - "step by step removing stains", - "removing stains tips for beginners", - "how to organize a closet", - "best way to organize a closet", - "tools for organizing a closet", - "step by step organizing a closet", - "organizing a closet tips for beginners", - "how to declutter a bedroom", - "best way to declutter a bedroom", - "tools for decluttering a bedroom", - "step by step decluttering a bedroom", - "decluttering a bedroom tips for beginners", - "how to spring clean", - "best way to spring clean", - "tools for spring cleaning", - "step by step spring cleaning", - "spring cleaning tips for beginners", - "best way to paint a room", - "tools for painting a room", - "step by step painting a room", - "painting a room tips for beginners", - "how to hang shelves", - "best way to hang shelves", - "tools for hanging shelves", - "step by step hanging shelves", - "hanging shelves tips for beginners", - "how to fix a squeaky door", - "best way to fix a squeaky door", - "tools for fixing a squeaky door", - "step by step fixing a squeaky door", - "fixing a squeaky door tips for beginners", - "best way to unclog a drain", - "tools for unclogging a drain", - "step by step unclogging a drain", - "unclogging a drain tips for beginners", - "how to clean a washing machine", - "best way to clean a washing machine", - "tools for cleaning a washing machine", - "step by step cleaning a washing machine", - "cleaning a washing machine tips for beginners", - "how to clean an oven", - "best way to clean an oven", - "tools for cleaning an oven", - "step by step cleaning an oven", - "cleaning an oven tips for beginners", - "how to clean a dishwasher", - "best way to clean a dishwasher", - "tools for cleaning a dishwasher", - "step by step cleaning a dishwasher", - "cleaning a dishwasher tips for beginners", - "how to descale a kettle", - "best way to descale a kettle", - "tools for descaling a kettle", - "step by step descaling a kettle", - "descaling a kettle tips for beginners", - "how to save electricity", - "best way to save electricity", - "tools for saving electricity", - "step by step saving electricity", - "saving electricity tips for beginners", - "how to reduce water usage", - "best way to reduce water usage", - "tools for reducing water usage", - "step by step reducing water usage", - "reducing water usage tips for beginners", - "how to improve home insulation", - "best way to improve home insulation", - "tools for home insulation", - "step by step home insulation", - "home insulation tips for beginners", - "how to smart thermostats", - "best way to smart thermostats", - "tools for smart thermostats", - "step by step smart thermostats", - "smart thermostats tips for beginners", - "how to care for indoor plants", - "best way to care for indoor plants", - "tools for indoor plants", - "step by step indoor plants", - "indoor plants tips for beginners", - "best way to grow tomatoes", - "tools for growing tomatoes", - "step by step growing tomatoes", - "growing tomatoes tips for beginners", - "how to grow herbs indoors", - "best way to grow herbs indoors", - "tools for growing herbs indoors", - "step by step growing herbs indoors", - "growing herbs indoors tips for beginners", - "best way to compost at home", - "tools for composting at home", - "step by step composting at home", - "composting at home tips for beginners", - "how to start balcony gardening", - "best way to start balcony gardening", - "tools for balcony gardening", - "step by step balcony gardening", - "balcony gardening tips for beginners", - "how to decorate a small apartment", - "best way to decorate a small apartment", - "tools for small apartment decor", - "step by step small apartment decor", - "small apartment decor tips for beginners", - "how to decorate on a budget", - "best way to decorate on a budget", - "tools for budget home decor", - "step by step budget home decor", - "budget home decor tips for beginners", - "how to create warm modern interiors", - "best way to create warm modern interiors", - "tools for warm modern interiors", - "step by step warm modern interiors", - "warm modern interiors tips for beginners", - "how to design dark wood interiors", - "best way to design dark wood interiors", - "tools for dark wood interiors", - "step by step dark wood interiors", - "dark wood interiors tips for beginners", - "how to light a living room", - "best way to light a living room", - "tools for lighting a living room", - "step by step lighting a living room", - "lighting a living room tips for beginners", - "how to choose a mattress", - "best way to choose a mattress", - "tools for choosing a mattress", - "step by step choosing a mattress", - "choosing a mattress tips for beginners", - "how to organize cables", - "best way to organize cables", - "tools for organizing cables", - "step by step organizing cables", - "organizing cables tips for beginners", - "how to set up a desk", - "best way to set up a desk", - "tools for setting up a desk", - "step by step setting up a desk", - "setting up a desk tips for beginners", - "how to set up a home office", - "best way to set up a home office", - "tools for home office setup", - "step by step home office setup", - "home office setup tips for beginners", - "how to soundproof a room", - "best way to soundproof a room", - "tools for soundproofing a room", - "step by step soundproofing a room", - "soundproofing a room tips for beginners", - "how to organize a garage", - "best way to organize a garage", - "tools for garage organization", - "step by step garage organization", - "garage organization tips for beginners", - "how to do laundry better", - "best way to do laundry better", - "tools for laundry tips", - "step by step laundry tips", - "laundry tips tips for beginners", - "how to fold clothes", - "best way to fold clothes", - "tools for folding clothes", - "step by step folding clothes", - "folding clothes tips for beginners", - "how to pet proof a home", - "best way to pet proof a home", - "tools for pet proofing a home", - "step by step pet proofing a home", - "pet proofing a home tips for beginners", - "how to make a moving house checklist", - "best way to make a moving house checklist", - "tools for moving house checklist", - "step by step moving house checklist", - "moving house checklist tips for beginners", - "how to pack boxes", - "best way to pack boxes", - "tools for packing boxes", - "step by step packing boxes", - "packing boxes tips for beginners", - "how to build a basic tool kit", - "best way to build a basic tool kit", - "tools for basic tool kits", - "step by step basic tool kits", - "basic tool kits tips for beginners", - "how to drill into walls", - "best way to drill into walls", - "tools for drilling into walls", - "step by step drilling into walls", - "drilling into walls tips for beginners", - "how to fix drywall holes", - "best way to fix drywall holes", - "tools for fixing drywall holes", - "step by step fixing drywall holes", - "fixing drywall holes tips for beginners", - "how to replace light bulbs", - "best way to replace light bulbs", - "tools for replacing light bulbs", - "step by step replacing light bulbs", - "replacing light bulbs tips for beginners", - "how to install curtains", - "best way to install curtains", - "tools for installing curtains", - "step by step installing curtains", - "installing curtains tips for beginners", - "how to clean windows", - "best way to clean windows", - "tools for cleaning windows", - "step by step cleaning windows", - "cleaning windows tips for beginners", - "how to clean carpets", - "best way to clean carpets", - "tools for cleaning carpets", - "step by step cleaning carpets", - "cleaning carpets tips for beginners", - "how to remove mold safely", - "best way to remove mold safely", - "tools for removing mold safely", - "step by step removing mold safely", - "removing mold safely tips for beginners", - "how to choose air purifiers", - "best way to choose air purifiers", - "tools for air purifiers", - "step by step air purifiers", - "air purifiers tips for beginners", - "how to control humidity", - "best way to control humidity", - "tools for humidity control", - "step by step humidity control", - "humidity control tips for beginners", - "how to improve fire safety at home", - "best way to improve fire safety at home", - "tools for fire safety at home", - "step by step fire safety at home", - "fire safety at home tips for beginners", - "how to build a first aid kit", - "best way to build a first aid kit", - "tools for first aid kit", - "step by step first aid kit", - "first aid kit tips for beginners", - "how to prepare emergency supplies", - "best way to prepare emergency supplies", - "tools for emergency supplies", - "step by step emergency supplies", - "emergency supplies tips for beginners", - "how to start football", - "beginner tips for football", - "best gear for football", - "football rules explained", - "how to get better at football", - "common mistakes in football", - "how to start basketball", - "beginner tips for basketball", - "best gear for basketball", - "basketball rules explained", - "how to get better at basketball", - "common mistakes in basketball", - "how to start tennis", - "beginner tips for tennis", - "best gear for tennis", - "tennis rules explained", - "how to get better at tennis", - "common mistakes in tennis", - "how to start padel", - "beginner tips for padel", - "best gear for padel", - "padel rules explained", - "how to get better at padel", - "common mistakes in padel", - "how to start golf", - "beginner tips for golf", - "best gear for golf", - "golf rules explained", - "how to get better at golf", - "common mistakes in golf", - "beginner tips for running", - "best gear for running", - "running rules explained", - "how to get better at running", - "common mistakes in running", - "how to start cycling", - "beginner tips for cycling", - "best gear for cycling", - "cycling rules explained", - "how to get better at cycling", - "common mistakes in cycling", - "how to start swimming", - "beginner tips for swimming", - "best gear for swimming", - "swimming rules explained", - "how to get better at swimming", - "common mistakes in swimming", - "how to start boxing", - "beginner tips for boxing", - "best gear for boxing", - "boxing rules explained", - "how to get better at boxing", - "common mistakes in boxing", - "how to start kickboxing", - "beginner tips for kickboxing", - "best gear for kickboxing", - "kickboxing rules explained", - "how to get better at kickboxing", - "common mistakes in kickboxing", - "how to start mma", - "beginner tips for mma", - "best gear for mma", - "mma rules explained", - "how to get better at mma", - "common mistakes in mma", - "how to start yoga", - "beginner tips for yoga", - "best gear for yoga", - "yoga rules explained", - "how to get better at yoga", - "common mistakes in yoga", - "how to start pilates", - "beginner tips for pilates", - "best gear for pilates", - "pilates rules explained", - "how to get better at pilates", - "common mistakes in pilates", - "how to start weightlifting", - "beginner tips for weightlifting", - "best gear for weightlifting", - "weightlifting rules explained", - "how to get better at weightlifting", - "common mistakes in weightlifting", - "how to start calisthenics", - "beginner tips for calisthenics", - "best gear for calisthenics", - "calisthenics rules explained", - "how to get better at calisthenics", - "common mistakes in calisthenics", - "how to start skateboarding", - "beginner tips for skateboarding", - "best gear for skateboarding", - "skateboarding rules explained", - "how to get better at skateboarding", - "common mistakes in skateboarding", - "how to start skiing", - "beginner tips for skiing", - "best gear for skiing", - "skiing rules explained", - "how to get better at skiing", - "common mistakes in skiing", - "how to start snowboarding", - "beginner tips for snowboarding", - "best gear for snowboarding", - "snowboarding rules explained", - "how to get better at snowboarding", - "common mistakes in snowboarding", - "how to start ice skating", - "beginner tips for ice skating", - "best gear for ice skating", - "ice skating rules explained", - "how to get better at ice skating", - "common mistakes in ice skating", - "how to start surfing", - "beginner tips for surfing", - "best gear for surfing", - "surfing rules explained", - "how to get better at surfing", - "common mistakes in surfing", - "how to start climbing", - "beginner tips for climbing", - "best gear for climbing", - "climbing rules explained", - "how to get better at climbing", - "common mistakes in climbing", - "how to start bouldering", - "beginner tips for bouldering", - "best gear for bouldering", - "bouldering rules explained", - "how to get better at bouldering", - "common mistakes in bouldering", - "how to start hiking", - "beginner tips for hiking", - "best gear for hiking", - "hiking rules explained", - "how to get better at hiking", - "common mistakes in hiking", - "how to start camping", - "beginner tips for camping", - "best gear for camping", - "camping rules explained", - "how to get better at camping", - "common mistakes in camping", - "how to start fishing", - "beginner tips for fishing", - "best gear for fishing", - "fishing rules explained", - "how to get better at fishing", - "common mistakes in fishing", - "how to start chess", - "beginner tips for chess", - "best gear for chess", - "chess rules explained", - "how to get better at chess", - "common mistakes in chess", - "how to start poker rules", - "beginner tips for poker rules", - "best gear for poker rules", - "poker rules rules explained", - "how to get better at poker rules", - "common mistakes in poker rules", - "how to start board games", - "beginner tips for board games", - "best gear for board games", - "board games rules explained", - "how to get better at board games", - "common mistakes in board games", - "how to start card games", - "beginner tips for card games", - "best gear for card games", - "card games rules explained", - "how to get better at card games", - "common mistakes in card games", - "how to start dungeons and dragons", - "beginner tips for dungeons and dragons", - "best gear for dungeons and dragons", - "dungeons and dragons rules explained", - "how to get better at dungeons and dragons", - "common mistakes in dungeons and dragons", - "how to start rubiks cube", - "beginner tips for rubiks cube", - "best gear for rubiks cube", - "rubiks cube rules explained", - "how to get better at rubiks cube", - "common mistakes in rubiks cube", - "how to start guitar", - "beginner tips for guitar", - "best gear for guitar", - "guitar rules explained", - "how to get better at guitar", - "common mistakes in guitar", - "how to start piano", - "beginner tips for piano", - "best gear for piano", - "piano rules explained", - "how to get better at piano", - "common mistakes in piano", - "how to start singing", - "beginner tips for singing", - "best gear for singing", - "singing rules explained", - "how to get better at singing", - "common mistakes in singing", - "how to start music production", - "beginner tips for music production", - "best gear for music production", - "music production rules explained", - "how to get better at music production", - "common mistakes in music production", - "how to start dj mixing", - "beginner tips for dj mixing", - "best gear for dj mixing", - "dj mixing rules explained", - "how to get better at dj mixing", - "common mistakes in dj mixing", - "how to start drawing", - "beginner tips for drawing", - "best gear for drawing", - "drawing rules explained", - "how to get better at drawing", - "common mistakes in drawing", - "how to start watercolor painting", - "beginner tips for watercolor painting", - "best gear for watercolor painting", - "watercolor painting rules explained", - "how to get better at watercolor painting", - "common mistakes in watercolor painting", - "how to start oil painting", - "beginner tips for oil painting", - "best gear for oil painting", - "oil painting rules explained", - "how to get better at oil painting", - "common mistakes in oil painting", - "how to start digital art", - "beginner tips for digital art", - "best gear for digital art", - "digital art rules explained", - "how to get better at digital art", - "common mistakes in digital art", - "how to start graphic design", - "beginner tips for graphic design", - "best gear for graphic design", - "graphic design rules explained", - "how to get better at graphic design", - "common mistakes in graphic design", - "how to start photography", - "beginner tips for photography", - "best gear for photography", - "photography rules explained", - "how to get better at photography", - "common mistakes in photography", - "how to start portrait photography", - "beginner tips for portrait photography", - "best gear for portrait photography", - "portrait photography rules explained", - "how to get better at portrait photography", - "common mistakes in portrait photography", - "how to start video editing", - "beginner tips for video editing", - "best gear for video editing", - "video editing rules explained", - "how to get better at video editing", - "common mistakes in video editing", - "how to start filmmaking", - "beginner tips for filmmaking", - "best gear for filmmaking", - "filmmaking rules explained", - "how to get better at filmmaking", - "common mistakes in filmmaking", - "how to start podcasting", - "beginner tips for podcasting", - "best gear for podcasting", - "podcasting rules explained", - "how to get better at podcasting", - "common mistakes in podcasting", - "how to start blogging", - "beginner tips for blogging", - "best gear for blogging", - "blogging rules explained", - "how to get better at blogging", - "common mistakes in blogging", - "how to start creative writing", - "beginner tips for creative writing", - "best gear for creative writing", - "creative writing rules explained", - "how to get better at creative writing", - "common mistakes in creative writing", - "how to start novel writing", - "beginner tips for novel writing", - "best gear for novel writing", - "novel writing rules explained", - "how to get better at novel writing", - "common mistakes in novel writing", - "how to start screenwriting", - "beginner tips for screenwriting", - "best gear for screenwriting", - "screenwriting rules explained", - "how to get better at screenwriting", - "common mistakes in screenwriting", - "how to start woodworking", - "beginner tips for woodworking", - "best gear for woodworking", - "woodworking rules explained", - "how to get better at woodworking", - "common mistakes in woodworking", - "how to start 3d printing", - "beginner tips for 3d printing", - "best gear for 3d printing", - "3d printing rules explained", - "how to get better at 3d printing", - "common mistakes in 3d printing", - "how to start crochet", - "beginner tips for crochet", - "best gear for crochet", - "crochet rules explained", - "how to get better at crochet", - "common mistakes in crochet", - "how to start knitting", - "beginner tips for knitting", - "best gear for knitting", - "knitting rules explained", - "how to get better at knitting", - "common mistakes in knitting", - "how to start sewing", - "beginner tips for sewing", - "best gear for sewing", - "sewing rules explained", - "how to get better at sewing", - "common mistakes in sewing", - "how to start candle making", - "beginner tips for candle making", - "best gear for candle making", - "candle making rules explained", - "how to get better at candle making", - "common mistakes in candle making", - "how to start soap making", - "beginner tips for soap making", - "best gear for soap making", - "soap making rules explained", - "how to get better at soap making", - "common mistakes in soap making", - "how to start gardening", - "beginner tips for gardening", - "best gear for gardening", - "gardening rules explained", - "how to get better at gardening", - "common mistakes in gardening", - "how to start bird watching", - "beginner tips for bird watching", - "best gear for bird watching", - "bird watching rules explained", - "how to get better at bird watching", - "common mistakes in bird watching", - "how to start stargazing", - "beginner tips for stargazing", - "best gear for stargazing", - "stargazing rules explained", - "how to get better at stargazing", - "common mistakes in stargazing", - "how to start model trains", - "beginner tips for model trains", - "best gear for model trains", - "model trains rules explained", - "how to get better at model trains", - "common mistakes in model trains", - "how to start lego building", - "beginner tips for lego building", - "best gear for lego building", - "lego building rules explained", - "how to get better at lego building", - "common mistakes in lego building", - "how to start home brewing coffee", - "beginner tips for home brewing coffee", - "best gear for home brewing coffee", - "home brewing coffee rules explained", - "how to get better at home brewing coffee", - "common mistakes in home brewing coffee", - "how to start collecting vinyl records", - "beginner tips for collecting vinyl records", - "best gear for collecting vinyl records", - "collecting vinyl records rules explained", - "how to get better at collecting vinyl records", - "common mistakes in collecting vinyl records", - "how to start car detailing", - "beginner tips for car detailing", - "best gear for car detailing", - "car detailing rules explained", - "how to get better at car detailing", - "common mistakes in car detailing", - "how to start sim racing", - "beginner tips for sim racing", - "best gear for sim racing", - "sim racing rules explained", - "how to get better at sim racing", - "common mistakes in sim racing", - "how to start pc building", - "beginner tips for pc building", - "best gear for pc building", - "pc building rules explained", - "how to get better at pc building", - "common mistakes in pc building", - "how to start retro gaming", - "beginner tips for retro gaming", - "best gear for retro gaming", - "retro gaming rules explained", - "how to get better at retro gaming", - "common mistakes in retro gaming", - "how to start speedrunning", - "beginner tips for speedrunning", - "best gear for speedrunning", - "speedrunning rules explained", - "how to get better at speedrunning", - "common mistakes in speedrunning", - "how to start language exchange", - "beginner tips for language exchange", - "best gear for language exchange", - "language exchange rules explained", - "how to get better at language exchange", - "common mistakes in language exchange", - "how to start calligraphy", - "beginner tips for calligraphy", - "best gear for calligraphy", - "calligraphy rules explained", - "how to get better at calligraphy", - "common mistakes in calligraphy", - "how to start origami", - "beginner tips for origami", - "best gear for origami", - "origami rules explained", - "how to get better at origami", - "common mistakes in origami", - "how to start magic tricks", - "beginner tips for magic tricks", - "best gear for magic tricks", - "magic tricks rules explained", - "how to get better at magic tricks", - "common mistakes in magic tricks", - "how to start geocaching", - "beginner tips for geocaching", - "best gear for geocaching", - "geocaching rules explained", - "how to get better at geocaching", - "common mistakes in geocaching", - "how to check engine oil", - "when to replace engine oil", - "common engine oil problems", - "how much does engine oil cost", - "how to maintain engine oil", - "how to check tire pressure", - "when to replace tire pressure", - "common tire pressure problems", - "how much does tire pressure cost", - "how to maintain tire pressure", - "how to check winter tires", - "when to replace winter tires", - "common winter tires problems", - "how much does winter tires cost", - "how to maintain winter tires", - "how to check summer tires", - "when to replace summer tires", - "common summer tires problems", - "how much does summer tires cost", - "how to maintain summer tires", - "how to check brake pads", - "when to replace brake pads", - "common brake pads problems", - "how much does brake pads cost", - "how to maintain brake pads", - "how to check car batteries", - "when to replace car batteries", - "common car batteries problems", - "how much does car batteries cost", - "how to maintain car batteries", - "how to check spark plugs", - "when to replace spark plugs", - "common spark plugs problems", - "how much does spark plugs cost", - "how to maintain spark plugs", - "how to check air filters", - "when to replace air filters", - "common air filters problems", - "how much does air filters cost", - "how to maintain air filters", - "how to check cabin filters", - "when to replace cabin filters", - "common cabin filters problems", - "how much does cabin filters cost", - "how to maintain cabin filters", - "how to check wheel alignment", - "when to replace wheel alignment", - "common wheel alignment problems", - "how much does wheel alignment cost", - "how to maintain wheel alignment", - "how to check car detailing", - "when to replace car detailing", - "common car detailing problems", - "how much does car detailing cost", - "how to maintain car detailing", - "how to check waxing a car", - "when to replace waxing a car", - "common waxing a car problems", - "how much does waxing a car cost", - "how to maintain waxing a car", - "how to check washing a car", - "when to replace washing a car", - "common washing a car problems", - "how much does washing a car cost", - "how to maintain washing a car", - "how to check removing scratches", - "when to replace removing scratches", - "common removing scratches problems", - "how much does removing scratches cost", - "how to maintain removing scratches", - "how to check dashboard warning lights", - "when to replace dashboard warning lights", - "common dashboard warning lights problems", - "how much does dashboard warning lights cost", - "how to maintain dashboard warning lights", - "how to check check engine light", - "when to replace check engine light", - "common check engine light problems", - "how much does check engine light cost", - "how to maintain check engine light", - "how to check fuel economy", - "when to replace fuel economy", - "common fuel economy problems", - "how much does fuel economy cost", - "how to maintain fuel economy", - "how to check hybrid cars", - "when to replace hybrid cars", - "common hybrid cars problems", - "how much does hybrid cars cost", - "how to maintain hybrid cars", - "how to check electric cars", - "when to replace electric cars", - "common electric cars problems", - "how much does electric cars cost", - "how to maintain electric cars", - "how to check manual transmission", - "when to replace manual transmission", - "common manual transmission problems", - "how much does manual transmission cost", - "how to maintain manual transmission", - "how to check automatic transmission", - "when to replace automatic transmission", - "common automatic transmission problems", - "how much does automatic transmission cost", - "how to maintain automatic transmission", - "how to check turbo engines", - "when to replace turbo engines", - "common turbo engines problems", - "how much does turbo engines cost", - "how to maintain turbo engines", - "how to check stage 1 tuning", - "when to replace stage 1 tuning", - "common stage 1 tuning problems", - "how much does stage 1 tuning cost", - "how to maintain stage 1 tuning", - "how to check stage 2 tuning", - "when to replace stage 2 tuning", - "common stage 2 tuning problems", - "how much does stage 2 tuning cost", - "how to maintain stage 2 tuning", - "how to check bmw 330i", - "when to replace bmw 330i", - "common bmw 330i problems", - "how much does bmw 330i cost", - "how to maintain bmw 330i", - "how to check bmw m3", - "when to replace bmw m3", - "common bmw m3 problems", - "how much does bmw m3 cost", - "how to maintain bmw m3", - "how to check audi s3", - "when to replace audi s3", - "common audi s3 problems", - "how much does audi s3 cost", - "how to maintain audi s3", - "how to check volkswagen golf gti", - "when to replace volkswagen golf gti", - "common volkswagen golf gti problems", - "how much does volkswagen golf gti cost", - "how to maintain volkswagen golf gti", - "how to check mercedes c class", - "when to replace mercedes c class", - "common mercedes c class problems", - "how much does mercedes c class cost", - "how to maintain mercedes c class", - "how to check toyota corolla", - "when to replace toyota corolla", - "common toyota corolla problems", - "how much does toyota corolla cost", - "how to maintain toyota corolla", - "how to check honda civic", - "when to replace honda civic", - "common honda civic problems", - "how much does honda civic cost", - "how to maintain honda civic", - "how to check tesla model 3", - "when to replace tesla model 3", - "common tesla model 3 problems", - "how much does tesla model 3 cost", - "how to maintain tesla model 3", - "how to check road trips", - "when to replace road trips", - "common road trips problems", - "how much does road trips cost", - "how to maintain road trips", - "how to check car insurance", - "when to replace car insurance", - "common car insurance problems", - "how much does car insurance cost", - "how to maintain car insurance", - "how to check car tax", - "when to replace car tax", - "common car tax problems", - "how much does car tax cost", - "how to maintain car tax", - "how to check importing cars", - "when to replace importing cars", - "common importing cars problems", - "how much does importing cars cost", - "how to maintain importing cars", - "how to check used car checklist", - "when to replace used car checklist", - "common used car checklist problems", - "how much does used car checklist cost", - "how to maintain used car checklist", - "how to check test driving a car", - "when to replace test driving a car", - "common test driving a car problems", - "how much does test driving a car cost", - "how to maintain test driving a car", - "how to check leasing a car", - "when to replace leasing a car", - "common leasing a car problems", - "how much does leasing a car cost", - "how to maintain leasing a car", - "how to check buying used cars", - "when to replace buying used cars", - "common buying used cars problems", - "how much does buying used cars cost", - "how to maintain buying used cars", - "how to check selling a car", - "when to replace selling a car", - "common selling a car problems", - "how much does selling a car cost", - "how to maintain selling a car", - "how to check car depreciation", - "when to replace car depreciation", - "common car depreciation problems", - "how much does car depreciation cost", - "how to maintain car depreciation", - "how to check charging stations", - "when to replace charging stations", - "common charging stations problems", - "how much does charging stations cost", - "how to maintain charging stations", - "how to check dash cams", - "when to replace dash cams", - "common dash cams problems", - "how much does dash cams cost", - "how to maintain dash cams", - "how to check android auto", - "when to replace android auto", - "common android auto problems", - "how much does android auto cost", - "how to maintain android auto", - "how to check apple carplay", - "when to replace apple carplay", - "common apple carplay problems", - "how much does apple carplay cost", - "how to maintain apple carplay", - "how to check parking sensors", - "when to replace parking sensors", - "common parking sensors problems", - "how much does parking sensors cost", - "how to maintain parking sensors", - "how to check adaptive cruise control", - "when to replace adaptive cruise control", - "common adaptive cruise control problems", - "how much does adaptive cruise control cost", - "how to maintain adaptive cruise control", - "best budget laptops 2026", - "best budget budget laptops", - "budget laptops buying guide", - "budget laptops comparison", - "what to look for in budget laptops", - "best student laptops 2026", - "best budget student laptops", - "student laptops buying guide", - "student laptops comparison", - "what to look for in student laptops", - "best gaming laptops 2026", - "best budget gaming laptops", - "gaming laptops buying guide", - "gaming laptops comparison", - "what to look for in gaming laptops", - "best ultrabooks 2026", - "best budget ultrabooks", - "ultrabooks buying guide", - "ultrabooks comparison", - "what to look for in ultrabooks", - "best mini pcs 2026", - "best budget mini pcs", - "mini pcs buying guide", - "mini pcs comparison", - "what to look for in mini pcs", - "best gaming pcs 2026", - "best budget gaming pcs", - "gaming pcs buying guide", - "gaming pcs comparison", - "what to look for in gaming pcs", - "best budget graphics cards", - "graphics cards buying guide", - "graphics cards comparison", - "what to look for in graphics cards", - "best mechanical keyboards 2026", - "best budget mechanical keyboards", - "mechanical keyboards buying guide", - "mechanical keyboards comparison", - "what to look for in mechanical keyboards", - "best gaming mice 2026", - "best budget gaming mice", - "gaming mice buying guide", - "gaming mice comparison", - "what to look for in gaming mice", - "best office chairs 2026", - "best budget office chairs", - "office chairs buying guide", - "office chairs comparison", - "what to look for in office chairs", - "best standing desks 2026", - "best budget standing desks", - "standing desks buying guide", - "standing desks comparison", - "what to look for in standing desks", - "best monitors 2026", - "best budget monitors", - "monitors buying guide", - "monitors comparison", - "what to look for in monitors", - "best 4k monitors 2026", - "best budget 4k monitors", - "4k monitors buying guide", - "4k monitors comparison", - "what to look for in 4k monitors", - "best portable monitors 2026", - "best budget portable monitors", - "portable monitors buying guide", - "portable monitors comparison", - "what to look for in portable monitors", - "best webcams 2026", - "best budget webcams", - "webcams buying guide", - "webcams comparison", - "what to look for in webcams", - "best microphones 2026", - "best budget microphones", - "microphones buying guide", - "microphones comparison", - "what to look for in microphones", - "best headphones 2026", - "headphones buying guide", - "headphones comparison", - "what to look for in headphones", - "best wireless earbuds 2026", - "best budget wireless earbuds", - "wireless earbuds buying guide", - "wireless earbuds comparison", - "what to look for in wireless earbuds", - "best bluetooth speakers 2026", - "best budget bluetooth speakers", - "bluetooth speakers buying guide", - "bluetooth speakers comparison", - "what to look for in bluetooth speakers", - "best smartphones 2026", - "smartphones buying guide", - "smartphones comparison", - "what to look for in smartphones", - "best android phones 2026", - "best budget android phones", - "android phones buying guide", - "android phones comparison", - "what to look for in android phones", - "best iphones 2026", - "best budget iphones", - "iphones buying guide", - "iphones comparison", - "what to look for in iphones", - "best tablets 2026", - "best budget tablets", - "tablets buying guide", - "tablets comparison", - "what to look for in tablets", - "best e readers 2026", - "best budget e readers", - "e readers buying guide", - "e readers comparison", - "what to look for in e readers", - "best budget smartwatches", - "smartwatches buying guide", - "smartwatches comparison", - "what to look for in smartwatches", - "best fitness trackers 2026", - "best budget fitness trackers", - "fitness trackers buying guide", - "fitness trackers comparison", - "what to look for in fitness trackers", - "best budget routers", - "routers buying guide", - "routers comparison", - "what to look for in routers", - "best mesh wifi systems 2026", - "best budget mesh wifi systems", - "mesh wifi systems buying guide", - "mesh wifi systems comparison", - "what to look for in mesh wifi systems", - "best external hard drives 2026", - "best budget external hard drives", - "external hard drives buying guide", - "external hard drives comparison", - "what to look for in external hard drives", - "best portable ssds 2026", - "best budget portable ssds", - "portable ssds buying guide", - "portable ssds comparison", - "what to look for in portable ssds", - "best usb c hubs 2026", - "best budget usb c hubs", - "usb c hubs buying guide", - "usb c hubs comparison", - "what to look for in usb c hubs", - "best power banks 2026", - "best budget power banks", - "power banks buying guide", - "power banks comparison", - "what to look for in power banks", - "best phone chargers 2026", - "best budget phone chargers", - "phone chargers buying guide", - "phone chargers comparison", - "what to look for in phone chargers", - "best laptop backpacks 2026", - "best budget laptop backpacks", - "laptop backpacks buying guide", - "laptop backpacks comparison", - "what to look for in laptop backpacks", - "best camera bags 2026", - "best budget camera bags", - "camera bags buying guide", - "camera bags comparison", - "what to look for in camera bags", - "best mirrorless cameras 2026", - "best budget mirrorless cameras", - "mirrorless cameras buying guide", - "mirrorless cameras comparison", - "what to look for in mirrorless cameras", - "best action cameras 2026", - "best budget action cameras", - "action cameras buying guide", - "action cameras comparison", - "what to look for in action cameras", - "best drones 2026", - "best budget drones", - "drones buying guide", - "drones comparison", - "what to look for in drones", - "best air fryers 2026", - "best budget air fryers", - "air fryers buying guide", - "air fryers comparison", - "what to look for in air fryers", - "best coffee grinders 2026", - "best budget coffee grinders", - "coffee grinders buying guide", - "coffee grinders comparison", - "what to look for in coffee grinders", - "best espresso machines 2026", - "best budget espresso machines", - "espresso machines buying guide", - "espresso machines comparison", - "what to look for in espresso machines", - "best robot vacuums 2026", - "best budget robot vacuums", - "robot vacuums buying guide", - "robot vacuums comparison", - "what to look for in robot vacuums", - "best air purifiers 2026", - "best budget air purifiers", - "air purifiers buying guide", - "air purifiers comparison", - "what to look for in air purifiers", - "best electric toothbrushes 2026", - "best budget electric toothbrushes", - "electric toothbrushes buying guide", - "electric toothbrushes comparison", - "what to look for in electric toothbrushes", - "best water bottles 2026", - "best budget water bottles", - "water bottles buying guide", - "water bottles comparison", - "what to look for in water bottles", - "best running shoes 2026", - "best budget running shoes", - "running shoes buying guide", - "running shoes comparison", - "what to look for in running shoes", - "best hiking boots 2026", - "best budget hiking boots", - "hiking boots buying guide", - "hiking boots comparison", - "what to look for in hiking boots", - "best winter jackets 2026", - "best budget winter jackets", - "winter jackets buying guide", - "winter jackets comparison", - "what to look for in winter jackets", - "best travel backpacks 2026", - "best budget travel backpacks", - "travel backpacks buying guide", - "travel backpacks comparison", - "what to look for in travel backpacks", - "best carry on luggage 2026", - "best budget carry on luggage", - "carry on luggage buying guide", - "carry on luggage comparison", - "what to look for in carry on luggage", - "best camping tents 2026", - "best budget camping tents", - "camping tents buying guide", - "camping tents comparison", - "what to look for in camping tents", - "best sleeping bags 2026", - "best budget sleeping bags", - "sleeping bags buying guide", - "sleeping bags comparison", - "what to look for in sleeping bags", - "best bike lights 2026", - "best budget bike lights", - "bike lights buying guide", - "bike lights comparison", - "what to look for in bike lights", - "best dash cams 2026", - "best budget dash cams", - "dash cams buying guide", - "dash cams comparison", - "what to look for in dash cams", - "best baby monitors 2026", - "best budget baby monitors", - "baby monitors buying guide", - "baby monitors comparison", - "what to look for in baby monitors", - "best noise machines 2026", - "best budget noise machines", - "noise machines buying guide", - "noise machines comparison", - "what to look for in noise machines", - "best indoor plant lights 2026", - "best budget indoor plant lights", - "indoor plant lights buying guide", - "indoor plant lights comparison", - "what to look for in indoor plant lights", - "best tool kits 2026", - "best budget tool kits", - "tool kits buying guide", - "tool kits comparison", - "what to look for in tool kits", - "best cordless drills 2026", - "best budget cordless drills", - "cordless drills buying guide", - "cordless drills comparison", - "what to look for in cordless drills", - "best pressure washers 2026", - "best budget pressure washers", - "pressure washers buying guide", - "pressure washers comparison", - "what to look for in pressure washers", - "best security cameras 2026", - "best budget security cameras", - "security cameras buying guide", - "security cameras comparison", - "what to look for in security cameras", - "what is compound interest", - "compound interest explained simply", - "how does compound interest work", - "compound interest for beginners", - "common mistakes with compound interest", - "what is index funds", - "index funds explained simply", - "how does index funds work", - "index funds for beginners", - "common mistakes with index funds", - "what is etfs", - "etfs explained simply", - "how does etfs work", - "etfs for beginners", - "common mistakes with etfs", - "what is dividends", - "dividends explained simply", - "how does dividends work", - "dividends for beginners", - "common mistakes with dividends", - "what is bonds", - "bonds explained simply", - "how does bonds work", - "bonds for beginners", - "common mistakes with bonds", - "what is stocks", - "stocks explained simply", - "how does stocks work", - "stocks for beginners", - "common mistakes with stocks", - "what is emergency fund", - "emergency fund explained simply", - "how does emergency fund work", - "emergency fund for beginners", - "common mistakes with emergency fund", - "what is inflation", - "inflation explained simply", - "how does inflation work", - "inflation for beginners", - "common mistakes with inflation", - "what is interest rates", - "interest rates explained simply", - "how does interest rates work", - "interest rates for beginners", - "common mistakes with interest rates", - "mortgages for beginners", - "common mistakes with mortgages", - "what is rent vs buy", - "rent vs buy explained simply", - "how does rent vs buy work", - "rent vs buy for beginners", - "common mistakes with rent vs buy", - "what is credit cards", - "credit cards explained simply", - "how does credit cards work", - "credit cards for beginners", - "common mistakes with credit cards", - "what is credit score", - "credit score explained simply", - "how does credit score work", - "credit score for beginners", - "common mistakes with credit score", - "what is debit cards", - "debit cards explained simply", - "how does debit cards work", - "debit cards for beginners", - "common mistakes with debit cards", - "what is bank accounts", - "bank accounts explained simply", - "how does bank accounts work", - "bank accounts for beginners", - "common mistakes with bank accounts", - "what is savings accounts", - "savings accounts explained simply", - "how does savings accounts work", - "savings accounts for beginners", - "common mistakes with savings accounts", - "what is budget categories", - "budget categories explained simply", - "how does budget categories work", - "budget categories for beginners", - "common mistakes with budget categories", - "what is expense tracking", - "expense tracking explained simply", - "how does expense tracking work", - "expense tracking for beginners", - "common mistakes with expense tracking", - "what is net worth", - "net worth explained simply", - "how does net worth work", - "net worth for beginners", - "common mistakes with net worth", - "what is retirement planning", - "retirement planning explained simply", - "how does retirement planning work", - "retirement planning for beginners", - "common mistakes with retirement planning", - "what is pension plans", - "pension plans explained simply", - "how does pension plans work", - "pension plans for beginners", - "common mistakes with pension plans", - "what is tax deductions", - "tax deductions explained simply", - "how does tax deductions work", - "tax deductions for beginners", - "common mistakes with tax deductions", - "what is vat", - "vat explained simply", - "how does vat work", - "vat for beginners", - "common mistakes with vat", - "what is gross salary", - "gross salary explained simply", - "how does gross salary work", - "gross salary for beginners", - "common mistakes with gross salary", - "what is net salary", - "net salary explained simply", - "how does net salary work", - "net salary for beginners", - "common mistakes with net salary", - "what is overtime pay", - "overtime pay explained simply", - "how does overtime pay work", - "overtime pay for beginners", - "common mistakes with overtime pay", - "what is side income", - "side income explained simply", - "how does side income work", - "side income for beginners", - "common mistakes with side income", - "what is freelance taxes", - "freelance taxes explained simply", - "how does freelance taxes work", - "freelance taxes for beginners", - "common mistakes with freelance taxes", - "what is real estate investing", - "real estate investing explained simply", - "how does real estate investing work", - "real estate investing for beginners", - "common mistakes with real estate investing", - "what is dollar cost averaging", - "dollar cost averaging explained simply", - "how does dollar cost averaging work", - "dollar cost averaging for beginners", - "common mistakes with dollar cost averaging", - "what is risk tolerance", - "risk tolerance explained simply", - "how does risk tolerance work", - "risk tolerance for beginners", - "common mistakes with risk tolerance", - "what is asset allocation", - "asset allocation explained simply", - "how does asset allocation work", - "asset allocation for beginners", - "common mistakes with asset allocation", - "what is financial independence", - "financial independence explained simply", - "how does financial independence work", - "financial independence for beginners", - "common mistakes with financial independence", - "what is student loans", - "student loans explained simply", - "how does student loans work", - "student loans for beginners", - "common mistakes with student loans", - "what is car loans", - "car loans explained simply", - "how does car loans work", - "car loans for beginners", - "common mistakes with car loans", - "what is insurance premiums", - "insurance premiums explained simply", - "how does insurance premiums work", - "insurance premiums for beginners", - "common mistakes with insurance premiums", - "what is health insurance", - "health insurance explained simply", - "how does health insurance work", - "health insurance for beginners", - "common mistakes with health insurance", - "what is travel insurance", - "travel insurance explained simply", - "how does travel insurance work", - "travel insurance for beginners", - "common mistakes with travel insurance", - "what is currency exchange", - "currency exchange explained simply", - "how does currency exchange work", - "currency exchange for beginners", - "common mistakes with currency exchange", - "what is euro exchange rates", - "euro exchange rates explained simply", - "how does euro exchange rates work", - "euro exchange rates for beginners", - "common mistakes with euro exchange rates", - "what is cost of living", - "cost of living explained simply", - "how does cost of living work", - "cost of living for beginners", - "common mistakes with cost of living", - "what is salary negotiation", - "salary negotiation explained simply", - "how does salary negotiation work", - "salary negotiation for beginners", - "common mistakes with salary negotiation", - "what is business expenses", - "business expenses explained simply", - "how does business expenses work", - "business expenses for beginners", - "common mistakes with business expenses", - "what is profit margins", - "profit margins explained simply", - "how does profit margins work", - "profit margins for beginners", - "common mistakes with profit margins", - "what is cash flow", - "cash flow explained simply", - "how does cash flow work", - "cash flow for beginners", - "common mistakes with cash flow", - "what is invoices", - "invoices explained simply", - "how does invoices work", - "invoices for beginners", - "common mistakes with invoices", - "what is bookkeeping", - "bookkeeping explained simply", - "how does bookkeeping work", - "bookkeeping for beginners", - "common mistakes with bookkeeping", - "what is passive income", - "passive income explained simply", - "how does passive income work", - "passive income for beginners", - "common mistakes with passive income", - "what is subscription budgeting", - "subscription budgeting explained simply", - "how does subscription budgeting work", - "subscription budgeting for beginners", - "common mistakes with subscription budgeting", - "what is energy bills", - "energy bills explained simply", - "how does energy bills work", - "energy bills for beginners", - "common mistakes with energy bills", - "what is grocery budgeting", - "grocery budgeting explained simply", - "how does grocery budgeting work", - "grocery budgeting for beginners", - "common mistakes with grocery budgeting", - "why do the sky blue", - "how does the sky blue", - "why is cats purr", - "why do cats purr", - "how does cats purr", - "why is dogs wag their tails", - "why do dogs wag their tails", - "how does dogs wag their tails", - "why is leaves change color", - "why do leaves change color", - "how does leaves change color", - "why is bread rise", - "why do bread rise", - "how does bread rise", - "why is onions make you cry", - "why do onions make you cry", - "how does onions make you cry", - "why is coffee keep you awake", - "why do coffee keep you awake", - "how does coffee keep you awake", - "why is ice float", - "why do ice float", - "how does ice float", - "why is the ocean salty", - "why do the ocean salty", - "how does the ocean salty", - "why is bananas turn brown", - "why do bananas turn brown", - "how does bananas turn brown", - "why is people dream", - "why do people dream", - "how does people dream", - "why is we yawn", - "why do we yawn", - "how does we yawn", - "why is music feel emotional", - "why do music feel emotional", - "how does music feel emotional", - "why is spicy food burn", - "why do spicy food burn", - "how does spicy food burn", - "why is soap clean things", - "why do soap clean things", - "how does soap clean things", - "why is mirrors reflect", - "why do mirrors reflect", - "how does mirrors reflect", - "why is magnets attract metal", - "why do magnets attract metal", - "how does magnets attract metal", - "why is the moon change shape", - "why do the moon change shape", - "how does the moon change shape", - "why is stars twinkle", - "why do stars twinkle", - "how does stars twinkle", - "why is airplanes fly", - "why do airplanes fly", - "how does airplanes fly", - "why is boats float", - "why do boats float", - "how does boats float", - "why is cars need oil", - "why do cars need oil", - "how does cars need oil", - "why is batteries lose capacity", - "why do batteries lose capacity", - "how does batteries lose capacity", - "why is phones get hot", - "why do phones get hot", - "how does phones get hot", - "why is laptops slow down", - "why do laptops slow down", - "how does laptops slow down", - "why is wifi drop randomly", - "why do wifi drop randomly", - "how does wifi drop randomly", - "why is plants need sunlight", - "why do plants need sunlight", - "how does plants need sunlight", - "why is bees make honey", - "why do bees make honey", - "how does bees make honey", - "why is birds migrate", - "why do birds migrate", - "how does birds migrate", - "why is fish breathe underwater", - "why do fish breathe underwater", - "how does fish breathe underwater", - "why is rain smell fresh", - "why do rain smell fresh", - "how does rain smell fresh", - "why is snow look white", - "why do snow look white", - "how does snow look white", - "why is thunder happen after lightning", - "why do thunder happen after lightning", - "how does thunder happen after lightning", - "why is deserts get cold at night", - "why do deserts get cold at night", - "how does deserts get cold at night", - "why is mountains form", - "why do mountains form", - "how does mountains form", - "why is volcanoes erupt", - "why do volcanoes erupt", - "how does volcanoes erupt", - "why is earth rotate", - "why do earth rotate", - "how does earth rotate", - "why is seasons change", - "why do seasons change", - "how does seasons change", - "why is time zones exist", - "why do time zones exist", - "how does time zones exist", - "why is popcorn pop", - "why do popcorn pop", - "how does popcorn pop", - "why is cheese melt", - "why do cheese melt", - "how does cheese melt", - "why is eggs harden when cooked", - "why do eggs harden when cooked", - "how does eggs harden when cooked", - "why is cookies spread in the oven", - "why do cookies spread in the oven", - "how does cookies spread in the oven", - "why is hair turns gray", - "why do hair turns gray", - "how does hair turns gray", - "why is skin wrinkles", - "why do skin wrinkles", - "how does skin wrinkles", - "why is muscles get sore", - "why do muscles get sore", - "how does muscles get sore", - "why is exercise improve mood", - "why do exercise improve mood", - "how does exercise improve mood", - "why is sleep help memory", - "why do sleep help memory", - "how does sleep help memory", - "why is water boil faster with a lid", - "why do water boil faster with a lid", - "how does water boil faster with a lid", - "why is metal rust", - "why do metal rust", - "how does metal rust", - "why is wood burn", - "why do wood burn", - "how does wood burn", - "why is plastic take long to decompose", - "why do plastic take long to decompose", - "how does plastic take long to decompose", - "best action movies", - "best action movies 2026", - "underrated action movies", - "classic action movies", - "action movies recommendations", - "best comedy movies", - "best comedy movies 2026", - "underrated comedy movies", - "classic comedy movies", - "comedy movies recommendations", - "best horror movies", - "best horror movies 2026", - "underrated horror movies", - "classic horror movies", - "horror movies recommendations", - "best science fiction movies", - "best science fiction movies 2026", - "underrated science fiction movies", - "classic science fiction movies", - "science fiction movies recommendations", - "best fantasy movies", - "best fantasy movies 2026", - "underrated fantasy movies", - "classic fantasy movies", - "fantasy movies recommendations", - "best thriller movies", - "best thriller movies 2026", - "underrated thriller movies", - "classic thriller movies", - "thriller movies recommendations", - "best crime documentaries", - "best crime documentaries 2026", - "underrated crime documentaries", - "classic crime documentaries", - "crime documentaries recommendations", - "best nature documentaries", - "best nature documentaries 2026", - "underrated nature documentaries", - "classic nature documentaries", - "nature documentaries recommendations", - "best anime series", - "best anime series 2026", - "underrated anime series", - "classic anime series", - "anime series recommendations", - "best manga series", - "best manga series 2026", - "underrated manga series", - "classic manga series", - "manga series recommendations", - "best k drama", - "best k drama 2026", - "underrated k drama", - "classic k drama", - "k drama recommendations", - "best sitcoms", - "best sitcoms 2026", - "underrated sitcoms", - "classic sitcoms", - "sitcoms recommendations", - "best animated movies", - "best animated movies 2026", - "underrated animated movies", - "classic animated movies", - "animated movies recommendations", - "best superhero movies", - "best superhero movies 2026", - "underrated superhero movies", - "classic superhero movies", - "superhero movies recommendations", - "best alien movies", - "best alien movies 2026", - "underrated alien movies", - "classic alien movies", - "alien movies recommendations", - "best zombie movies", - "best zombie movies 2026", - "underrated zombie movies", - "classic zombie movies", - "zombie movies recommendations", - "best time travel movies", - "best time travel movies 2026", - "underrated time travel movies", - "classic time travel movies", - "time travel movies recommendations", - "best war movies", - "best war movies 2026", - "underrated war movies", - "classic war movies", - "war movies recommendations", - "best romantic comedies", - "best romantic comedies 2026", - "underrated romantic comedies", - "classic romantic comedies", - "romantic comedies recommendations", - "best mystery books", - "best mystery books 2026", - "underrated mystery books", - "classic mystery books", - "mystery books recommendations", - "best fantasy books 2026", - "underrated fantasy books", - "classic fantasy books", - "fantasy books recommendations", - "best science fiction books", - "best science fiction books 2026", - "underrated science fiction books", - "classic science fiction books", - "science fiction books recommendations", - "best young adult books", - "best young adult books 2026", - "underrated young adult books", - "classic young adult books", - "young adult books recommendations", - "best classic novels", - "best classic novels 2026", - "underrated classic novels", - "classic classic novels", - "classic novels recommendations", - "best true crime podcasts", - "best true crime podcasts 2026", - "underrated true crime podcasts", - "classic true crime podcasts", - "true crime podcasts recommendations", - "best tech podcasts", - "best tech podcasts 2026", - "underrated tech podcasts", - "classic tech podcasts", - "tech podcasts recommendations", - "best history podcasts", - "best history podcasts 2026", - "underrated history podcasts", - "classic history podcasts", - "history podcasts recommendations", - "best music documentaries", - "best music documentaries 2026", - "underrated music documentaries", - "classic music documentaries", - "music documentaries recommendations", - "best stand up comedy specials", - "best stand up comedy specials 2026", - "underrated stand up comedy specials", - "classic stand up comedy specials", - "stand up comedy specials recommendations", - "best video game soundtracks", - "best video game soundtracks 2026", - "underrated video game soundtracks", - "classic video game soundtracks", - "video game soundtracks recommendations", - "best open world games", - "best open world games 2026", - "underrated open world games", - "classic open world games", - "open world games recommendations", - "best fps games", - "best fps games 2026", - "underrated fps games", - "classic fps games", - "fps games recommendations", - "best rpg games", - "best rpg games 2026", - "underrated rpg games", - "classic rpg games", - "rpg games recommendations", - "best indie games", - "best indie games 2026", - "underrated indie games", - "classic indie games", - "indie games recommendations", - "best cozy games", - "best cozy games 2026", - "underrated cozy games", - "classic cozy games", - "cozy games recommendations", - "best strategy games", - "best strategy games 2026", - "underrated strategy games", - "classic strategy games", - "strategy games recommendations", - "best survival games", - "best survival games 2026", - "underrated survival games", - "classic survival games", - "survival games recommendations", - "best racing games", - "best racing games 2026", - "underrated racing games", - "classic racing games", - "racing games recommendations", - "best sports games", - "best sports games 2026", - "underrated sports games", - "classic sports games", - "sports games recommendations", - "best mobile games", - "best mobile games 2026", - "underrated mobile games", - "classic mobile games", - "mobile games recommendations", - "best board games", - "best board games 2026", - "underrated board games", - "classic board games", - "board games recommendations", - "how to study math", - "math tips for students", - "math explained for beginners", - "best resources for math", - "common mistakes in math", - "how to study algebra", - "algebra tips for students", - "algebra explained for beginners", - "best resources for algebra", - "common mistakes in algebra", - "how to study geometry", - "geometry tips for students", - "geometry explained for beginners", - "best resources for geometry", - "common mistakes in geometry", - "how to study calculus", - "calculus tips for students", - "calculus explained for beginners", - "best resources for calculus", - "common mistakes in calculus", - "how to study statistics", - "statistics tips for students", - "statistics explained for beginners", - "best resources for statistics", - "common mistakes in statistics", - "how to study physics", - "physics tips for students", - "physics explained for beginners", - "best resources for physics", - "common mistakes in physics", - "how to study chemistry", - "chemistry tips for students", - "chemistry explained for beginners", - "best resources for chemistry", - "common mistakes in chemistry", - "how to study biology", - "biology tips for students", - "biology explained for beginners", - "best resources for biology", - "common mistakes in biology", - "how to study geography", - "geography tips for students", - "geography explained for beginners", - "best resources for geography", - "common mistakes in geography", - "how to study history", - "history tips for students", - "history explained for beginners", - "best resources for history", - "common mistakes in history", - "how to study economics", - "economics tips for students", - "economics explained for beginners", - "best resources for economics", - "common mistakes in economics", - "how to study english grammar", - "english grammar tips for students", - "english grammar explained for beginners", - "best resources for english grammar", - "common mistakes in english grammar", - "how to study essay writing", - "essay writing tips for students", - "essay writing explained for beginners", - "best resources for essay writing", - "common mistakes in essay writing", - "how to study computer science", - "computer science tips for students", - "computer science explained for beginners", - "best resources for computer science", - "common mistakes in computer science", - "how to study coding", - "coding tips for students", - "coding explained for beginners", - "best resources for coding", - "common mistakes in coding", - "how to study web development", - "web development tips for students", - "web development explained for beginners", - "best resources for web development", - "common mistakes in web development", - "how to study data analysis", - "data analysis tips for students", - "data analysis explained for beginners", - "best resources for data analysis", - "common mistakes in data analysis", - "how to study art history", - "art history tips for students", - "art history explained for beginners", - "best resources for art history", - "common mistakes in art history", - "how to study music theory", - "music theory tips for students", - "music theory explained for beginners", - "best resources for music theory", - "common mistakes in music theory", - "how to study philosophy", - "philosophy tips for students", - "philosophy explained for beginners", - "best resources for philosophy", - "common mistakes in philosophy", - "how to study psychology", - "psychology tips for students", - "psychology explained for beginners", - "best resources for psychology", - "common mistakes in psychology", - "how to study sociology", - "sociology tips for students", - "sociology explained for beginners", - "best resources for sociology", - "common mistakes in sociology", - "how to study political science", - "political science tips for students", - "political science explained for beginners", - "best resources for political science", - "common mistakes in political science", - "how to study business studies", - "business studies tips for students", - "business studies explained for beginners", - "best resources for business studies", - "common mistakes in business studies", - "how to study accounting", - "accounting tips for students", - "accounting explained for beginners", - "best resources for accounting", - "common mistakes in accounting", - "how to study marketing", - "marketing tips for students", - "marketing explained for beginners", - "best resources for marketing", - "common mistakes in marketing", - "how to study public speaking", - "public speaking tips for students", - "public speaking explained for beginners", - "best resources for public speaking", - "common mistakes in public speaking", - "how to study research methods", - "research methods tips for students", - "research methods explained for beginners", - "best resources for research methods", - "common mistakes in research methods", - "how to study exam revision", - "exam revision tips for students", - "exam revision explained for beginners", - "best resources for exam revision", - "common mistakes in exam revision", - "how to study homework planning", - "homework planning tips for students", - "homework planning explained for beginners", - "best resources for homework planning", - "common mistakes in homework planning", - "weather this weekend", - "weather next week", - "rain forecast today", - "sunset time today", - "sunrise time tomorrow", - "moon phase tonight", - "public holidays Netherlands", - "school holidays Netherlands", - "current week number", - "how many days until christmas", - "how many days until new year", - "calculate percentage increase", - "convert cups to grams", - "convert miles to kilometers", - "convert fahrenheit to celsius", - "convert pounds to kilograms", - "euro to dollar conversion", - "bitcoin price today", - "stock market basics", - "gas prices near me", - "cheap flights to europe", - "train timetable near me", - "restaurants open now", - "pharmacy open now", - "dentist emergency near me", - "nearest supermarket opening hours", - "package tracking explained", - "how to renew passport", - "how to register a business", - "how to write an email professionally", - "formal email examples", - "thank you message examples", - "birthday message ideas", - "wedding speech examples", - "apology message examples", - "condolence message examples", - "meeting agenda template", - "invoice template", - "project plan template", - "daily planner template", - "weekly meal planner", - "grocery list ideas", - "moving checklist printable", - "travel packing checklist", - "camping checklist", - "car maintenance checklist", - "new apartment checklist", - "baby hospital bag checklist", - "pet adoption checklist", - "job application checklist", - "interview follow up email", - "salary increase letter", - "resignation letter example", - "complaint letter example", - "motivation letter example", - "linkedin profile tips", - "portfolio website ideas", - "github profile readme ideas", - "discord server rules template", - "privacy policy template", - "terms of service basics", - "funny shower thoughts", - "funny shower thoughts explained", - "best funny shower thoughts", - "weird facts about animals", - "weird facts about animals explained", - "best weird facts about animals", - "strange laws around the world", - "strange laws around the world explained", - "best strange laws around the world", - "fun facts about space", - "fun facts about space explained", - "best fun facts about space", - "random trivia questions", - "random trivia questions explained", - "best random trivia questions", - "best dad jokes", - "best dad jokes explained", - "best best dad jokes", - "funny animal videos", - "funny animal videos explained", - "best funny animal videos", - "oddly satisfying videos", - "oddly satisfying videos explained", - "best oddly satisfying videos", - "brain teasers with answers", - "brain teasers with answers explained", - "best brain teasers with answers", - "would you rather questions", - "would you rather questions explained", - "best would you rather questions", - "impossible riddles", - "impossible riddles explained", - "best impossible riddles", - "fun party questions", - "fun party questions explained", - "best fun party questions", - "icebreaker questions", - "icebreaker questions explained", - "best icebreaker questions", - "random facts for kids", - "random facts for kids explained", - "best random facts for kids", - "interesting facts about history", - "interesting facts about history explained", - "best interesting facts about history", - "weird food combinations", - "weird food combinations explained", - "best weird food combinations", - "famous movie quotes", - "famous movie quotes explained", - "best famous movie quotes", - "best plot twists", - "best plot twists explained", - "best best plot twists", - "mystery unsolved cases", - "mystery unsolved cases explained", - "best mystery unsolved cases", - "urban legends explained", - "urban legends explained explained", - "best urban legends explained", - "internet memes explained", - "internet memes explained explained", - "best internet memes explained", - "why is the internet weird", - "why is the internet weird explained", - "best why is the internet weird", - "old viral videos", - "old viral videos explained", - "best old viral videos", - "nostalgic games from the 2000s", - "nostalgic games from the 2000s explained", - "best nostalgic games from the 2000s", - "cartoon dogs names", - "cartoon dogs names explained", - "best cartoon dogs names", - "fictional robots list", - "fictional robots list explained", - "best fictional robots list", - "alien movie recommendations", - "alien movie recommendations explained", - "best alien movie recommendations", - "time travel movies", - "time travel movies explained", - "best zombie movies explained", - "best best zombie movies", - "superhero origins", - "superhero origins explained", - "best superhero origins", - "villain backstories", - "villain backstories explained", - "best villain backstories", - "movie endings explained", - "movie endings explained explained", - "best movie endings explained", - "video game easter eggs", - "video game easter eggs explained", - "best video game easter eggs", - "hidden google tricks", - "hidden google tricks explained", - "best hidden google tricks", - "cool websites to visit", - "cool websites to visit explained", - "best cool websites to visit", - "random wikipedia articles", - "random wikipedia articles explained", - "best random wikipedia articles", - "learn something new today", - "learn something new today explained", - "best learn something new today", - "facts that sound fake", - "facts that sound fake explained", - "best facts that sound fake", - "common myths debunked", - "common myths debunked explained", - "best common myths debunked", - "mandela effect examples", - "mandela effect examples explained", - "best mandela effect examples", - "most confusing words in english", - "most confusing words in english explained", - "best most confusing words in english", - "palindrome examples", - "palindrome examples explained", - "best palindrome examples", - "tongue twisters", - "tongue twisters explained", - "best tongue twisters", - "optical illusions explained", - "optical illusions explained explained", - "best optical illusions explained", - "dream meanings", - "dream meanings explained", - "best dream meanings", - "personality quiz ideas", - "personality quiz ideas explained", - "best personality quiz ideas", - "fun facts about the Netherlands", - "fun facts about the Netherlands explained", - "best fun facts about the Netherlands", - "fun facts about Japan", - "fun facts about Japan explained", - "best fun facts about Japan", - "fun facts about Sweden", - "fun facts about Sweden explained", - "best fun facts about Sweden", - "why do people say bless you", - "why do people say bless you explained", - "best why do people say bless you", - "where did pizza come from", - "where did pizza come from explained", - "best where did pizza come from", - "who invented the sandwich", - "who invented the sandwich explained", - "best who invented the sandwich", - "easy coding projects", - "best easy coding projects", - "how to find easy coding projects", - "easy coding projects for beginners", - "easy coding projects at home", - "easy coding projects near me", - "easy coding projects 2026", - "easy dinner ideas", - "best easy dinner ideas", - "how to find easy dinner ideas", - "easy dinner ideas for beginners", - "easy dinner ideas at home", - "easy dinner ideas near me", - "easy dinner ideas 2026", - "easy breakfast ideas", - "best easy breakfast ideas", - "how to find easy breakfast ideas", - "easy breakfast ideas for beginners", - "easy breakfast ideas at home", - "easy breakfast ideas near me", - "easy breakfast ideas 2026", - "best easy lunch ideas", - "how to find easy lunch ideas", - "easy lunch ideas for beginners", - "easy lunch ideas at home", - "easy lunch ideas near me", - "easy lunch ideas 2026", - "easy workout plans", - "best easy workout plans", - "how to find easy workout plans", - "easy workout plans for beginners", - "easy workout plans at home", - "easy workout plans near me", - "easy workout plans 2026", - "easy study plans", - "best easy study plans", - "how to find easy study plans", - "easy study plans for beginners", - "easy study plans at home", - "easy study plans near me", - "easy study plans 2026", - "easy travel tips", - "best easy travel tips", - "how to find easy travel tips", - "easy travel tips for beginners", - "easy travel tips at home", - "easy travel tips near me", - "easy travel tips 2026", - "easy saving tips", - "best easy saving tips", - "how to find easy saving tips", - "easy saving tips for beginners", - "easy saving tips at home", - "easy saving tips near me", - "easy saving tips 2026", - "easy cleaning tips", - "best easy cleaning tips", - "how to find easy cleaning tips", - "easy cleaning tips for beginners", - "easy cleaning tips at home", - "easy cleaning tips near me", - "easy cleaning tips 2026", - "easy gardening ideas", - "best easy gardening ideas", - "how to find easy gardening ideas", - "easy gardening ideas for beginners", - "easy gardening ideas at home", - "easy gardening ideas near me", - "easy gardening ideas 2026", - "easy photo ideas", - "best easy photo ideas", - "how to find easy photo ideas", - "easy photo ideas for beginners", - "easy photo ideas at home", - "easy photo ideas near me", - "easy photo ideas 2026", - "easy video ideas", - "best easy video ideas", - "how to find easy video ideas", - "easy video ideas for beginners", - "easy video ideas at home", - "easy video ideas near me", - "easy video ideas 2026", - "easy desk setup ideas", - "best easy desk setup ideas", - "how to find easy desk setup ideas", - "easy desk setup ideas for beginners", - "easy desk setup ideas at home", - "easy desk setup ideas near me", - "easy desk setup ideas 2026", - "easy small room ideas", - "best easy small room ideas", - "how to find easy small room ideas", - "easy small room ideas for beginners", - "easy small room ideas at home", - "easy small room ideas near me", - "easy small room ideas 2026", - "easy date ideas", - "best easy date ideas", - "how to find easy date ideas", - "easy date ideas for beginners", - "easy date ideas at home", - "easy date ideas near me", - "easy date ideas 2026", - "easy party ideas", - "best easy party ideas", - "how to find easy party ideas", - "easy party ideas for beginners", - "easy party ideas at home", - "easy party ideas near me", - "easy party ideas 2026", - "easy gift ideas", - "best easy gift ideas", - "how to find easy gift ideas", - "easy gift ideas for beginners", - "easy gift ideas at home", - "easy gift ideas near me", - "easy gift ideas 2026", - "easy business ideas", - "best easy business ideas", - "how to find easy business ideas", - "easy business ideas for beginners", - "easy business ideas at home", - "easy business ideas near me", - "easy business ideas 2026", - "easy blog ideas", - "best easy blog ideas", - "how to find easy blog ideas", - "easy blog ideas for beginners", - "easy blog ideas at home", - "easy blog ideas near me", - "easy blog ideas 2026", - "easy youtube ideas", - "best easy youtube ideas", - "how to find easy youtube ideas", - "easy youtube ideas for beginners", - "easy youtube ideas at home", - "easy youtube ideas near me", - "easy youtube ideas 2026", - "easy instagram captions", - "best easy instagram captions", - "how to find easy instagram captions", - "easy instagram captions for beginners", - "easy instagram captions at home", - "easy instagram captions near me", - "easy instagram captions 2026", - "easy productivity systems", - "best easy productivity systems", - "how to find easy productivity systems", - "easy productivity systems for beginners", - "easy productivity systems at home", - "easy productivity systems near me", - "easy productivity systems 2026", - "easy habit trackers", - "best easy habit trackers", - "how to find easy habit trackers", - "easy habit trackers for beginners", - "easy habit trackers at home", - "easy habit trackers near me", - "easy habit trackers 2026", - "easy learning resources", - "best easy learning resources", - "how to find easy learning resources", - "easy learning resources for beginners", - "easy learning resources at home", - "easy learning resources near me", - "easy learning resources 2026", - "easy science facts", - "best easy science facts", - "how to find easy science facts", - "easy science facts for beginners", - "easy science facts at home", - "easy science facts near me", - "easy science facts 2026", - "easy history facts", - "best easy history facts", - "how to find easy history facts", - "easy history facts for beginners", - "easy history facts at home", - "easy history facts near me", - "easy history facts 2026", - "easy movie recommendations", - "best easy movie recommendations", - "how to find easy movie recommendations", - "easy movie recommendations for beginners", - "easy movie recommendations at home", - "easy movie recommendations near me", - "easy movie recommendations 2026", - "easy book recommendations", - "best easy book recommendations", - "how to find easy book recommendations", - "easy book recommendations for beginners", - "easy book recommendations at home", - "easy book recommendations near me", - "easy book recommendations 2026", - "easy game recommendations", - "best easy game recommendations", - "how to find easy game recommendations", - "easy game recommendations for beginners", - "easy game recommendations at home", - "easy game recommendations near me", - "easy game recommendations 2026", - "easy music playlists", - "best easy music playlists", - "how to find easy music playlists", - "easy music playlists for beginners", - "easy music playlists at home", - "easy music playlists near me", - "easy music playlists 2026", - "easy pc upgrades", - "best easy pc upgrades", - "how to find easy pc upgrades", - "easy pc upgrades for beginners", - "easy pc upgrades at home", - "easy pc upgrades near me", - "easy pc upgrades 2026", - "easy phone tips", - "best easy phone tips", - "how to find easy phone tips", - "easy phone tips for beginners", - "easy phone tips at home", - "easy phone tips near me", - "easy phone tips 2026", - "easy internet tips", - "best easy internet tips", - "how to find easy internet tips", - "easy internet tips for beginners", - "easy internet tips at home", - "easy internet tips near me", - "easy internet tips 2026", - "easy security tips", - "best easy security tips", - "how to find easy security tips", - "easy security tips for beginners", - "easy security tips at home", - "easy security tips near me", - "easy security tips 2026", - "easy linux commands", - "best easy linux commands", - "how to find easy linux commands", - "easy linux commands for beginners", - "easy linux commands at home", - "easy linux commands near me", - "easy linux commands 2026", - "easy windows shortcuts", - "best easy windows shortcuts", - "how to find easy windows shortcuts", - "easy windows shortcuts for beginners", - "easy windows shortcuts at home", - "easy windows shortcuts near me", - "easy windows shortcuts 2026", - "easy excel formulas", - "best easy excel formulas", - "how to find easy excel formulas", - "easy excel formulas for beginners", - "easy excel formulas at home", - "easy excel formulas near me", - "easy excel formulas 2026", - "easy notion templates", - "best easy notion templates", - "how to find easy notion templates", - "easy notion templates for beginners", - "easy notion templates at home", - "easy notion templates near me", - "easy notion templates 2026", - "easy resume tips", - "best easy resume tips", - "how to find easy resume tips", - "easy resume tips for beginners", - "easy resume tips at home", - "easy resume tips near me", - "easy resume tips 2026", - "easy interview tips", - "best easy interview tips", - "how to find easy interview tips", - "easy interview tips for beginners", - "easy interview tips at home", - "easy interview tips near me", - "easy interview tips 2026", - "easy freelance tips", - "best easy freelance tips", - "how to find easy freelance tips", - "easy freelance tips for beginners", - "easy freelance tips at home", - "easy freelance tips near me", - "easy freelance tips 2026", - "easy home office tips", - "best easy home office tips", - "how to find easy home office tips", - "easy home office tips for beginners", - "easy home office tips at home", - "easy home office tips near me", - "easy home office tips 2026", - "easy pet care tips", - "best easy pet care tips", - "how to find easy pet care tips", - "easy pet care tips for beginners", - "easy pet care tips at home", - "easy pet care tips near me", - "easy pet care tips 2026", - "easy car care tips", - "best easy car care tips", - "how to find easy car care tips", - "easy car care tips for beginners", - "easy car care tips at home", - "easy car care tips near me", - "easy car care tips 2026", - "easy coffee recipes", - "best easy coffee recipes", - "how to find easy coffee recipes", - "easy coffee recipes for beginners", - "easy coffee recipes at home", - "easy coffee recipes near me", - "easy coffee recipes 2026", - "easy air fryer recipes", - "best easy air fryer recipes", - "how to find easy air fryer recipes", - "easy air fryer recipes for beginners", - "easy air fryer recipes at home", - "easy air fryer recipes near me", - "easy air fryer recipes 2026", - "easy vegan meals", - "best easy vegan meals", - "how to find easy vegan meals", - "easy vegan meals for beginners", - "easy vegan meals at home", - "easy vegan meals near me", - "easy vegan meals 2026", - "easy vegetarian meals", - "best easy vegetarian meals", - "how to find easy vegetarian meals", - "easy vegetarian meals for beginners", - "easy vegetarian meals at home", - "easy vegetarian meals near me", - "easy vegetarian meals 2026", - "easy high protein meals", - "best easy high protein meals", - "how to find easy high protein meals", - "easy high protein meals for beginners", - "easy high protein meals at home", - "easy high protein meals near me", - "easy high protein meals 2026", - "easy low calorie snacks", - "best easy low calorie snacks", - "how to find easy low calorie snacks", - "easy low calorie snacks for beginners", - "easy low calorie snacks at home", - "easy low calorie snacks near me", - "easy low calorie snacks 2026", - "easy road trip ideas", - "best easy road trip ideas", - "how to find easy road trip ideas", - "easy road trip ideas for beginners", - "easy road trip ideas at home", - "easy road trip ideas near me", - "easy road trip ideas 2026", - "easy city break ideas", - "best easy city break ideas", - "how to find easy city break ideas", - "easy city break ideas for beginners", - "easy city break ideas at home", - "easy city break ideas near me", - "easy city break ideas 2026", - "easy weekend plans", - "best easy weekend plans", - "how to find easy weekend plans", - "easy weekend plans for beginners", - "easy weekend plans at home", - "easy weekend plans near me", - "easy weekend plans 2026", - "easy family activities", - "best easy family activities", - "how to find easy family activities", - "easy family activities for beginners", - "easy family activities at home", - "easy family activities near me", - "easy family activities 2026", - "easy rainy day activities", - "best easy rainy day activities", - "how to find easy rainy day activities", - "easy rainy day activities for beginners", - "easy rainy day activities at home", - "easy rainy day activities near me", - "easy rainy day activities 2026", - "easy outdoor hobbies", - "best easy outdoor hobbies", - "how to find easy outdoor hobbies", - "easy outdoor hobbies for beginners", - "easy outdoor hobbies at home", - "easy outdoor hobbies near me", - "easy outdoor hobbies 2026", - "easy indoor hobbies", - "best easy indoor hobbies", - "how to find easy indoor hobbies", - "easy indoor hobbies for beginners", - "easy indoor hobbies at home", - "easy indoor hobbies near me", - "easy indoor hobbies 2026", - "easy mindfulness exercises", - "best easy mindfulness exercises", - "how to find easy mindfulness exercises", - "easy mindfulness exercises for beginners", - "easy mindfulness exercises at home", - "easy mindfulness exercises near me", - "easy mindfulness exercises 2026", - "easy breathing exercises", - "best easy breathing exercises", - "how to find easy breathing exercises", - "easy breathing exercises for beginners", - "easy breathing exercises at home", - "easy breathing exercises near me", - "easy breathing exercises 2026", - "easy stretching routines", - "best easy stretching routines", - "how to find easy stretching routines", - "easy stretching routines for beginners", - "easy stretching routines at home", - "easy stretching routines near me", - "easy stretching routines 2026", - "easy running workouts", - "best easy running workouts", - "how to find easy running workouts", - "easy running workouts for beginners", - "easy running workouts at home", - "easy running workouts near me", - "easy running workouts 2026", - "easy cycling routes", - "best easy cycling routes", - "how to find easy cycling routes", - "easy cycling routes for beginners", - "easy cycling routes at home", - "easy cycling routes near me", - "easy cycling routes 2026", - "easy hiking routes", - "best easy hiking routes", - "how to find easy hiking routes", - "easy hiking routes for beginners", - "easy hiking routes at home", - "easy hiking routes near me", - "easy hiking routes 2026", - "easy language practice", - "best easy language practice", - "how to find easy language practice", - "easy language practice for beginners", - "easy language practice at home", - "easy language practice near me", - "easy language practice 2026", - "easy english phrases", - "best easy english phrases", - "how to find easy english phrases", - "easy english phrases for beginners", - "easy english phrases at home", - "easy english phrases near me", - "easy english phrases 2026", - "easy dutch phrases", - "best easy dutch phrases", - "how to find easy dutch phrases", - "easy dutch phrases for beginners", - "easy dutch phrases at home", - "easy dutch phrases near me", - "easy dutch phrases 2026", - "easy turkish phrases", - "best easy turkish phrases", - "how to find easy turkish phrases", - "easy turkish phrases for beginners", - "easy turkish phrases at home", - "easy turkish phrases near me", - "easy turkish phrases 2026", - "easy spanish phrases", - "best easy spanish phrases", - "how to find easy spanish phrases", - "easy spanish phrases for beginners", - "easy spanish phrases at home", - "easy spanish phrases near me", - "easy spanish phrases 2026", - "easy french phrases", - "best easy french phrases", - "how to find easy french phrases", - "easy french phrases for beginners", - "easy french phrases at home", - "easy french phrases near me", - "easy french phrases 2026", - "easy german phrases", - "best easy german phrases", - "how to find easy german phrases", - "easy german phrases for beginners", - "easy german phrases at home", - "easy german phrases near me", - "easy german phrases 2026", - "quick coding projects", - "best quick coding projects", - "how to find quick coding projects", - "quick coding projects for beginners", - "quick coding projects at home", - "quick coding projects near me", - "quick coding projects 2026", - "quick dinner ideas", - "best quick dinner ideas", - "how to find quick dinner ideas", - "quick dinner ideas for beginners", - "quick dinner ideas at home", - "quick dinner ideas near me", - "quick dinner ideas 2026", - "quick breakfast ideas", - "best quick breakfast ideas", - "how to find quick breakfast ideas", - "quick breakfast ideas for beginners", - "quick breakfast ideas at home", - "quick breakfast ideas near me", - "quick breakfast ideas 2026", - "quick lunch ideas", - "best quick lunch ideas", - "how to find quick lunch ideas", - "quick lunch ideas for beginners", - "quick lunch ideas at home", - "quick lunch ideas near me", - "quick lunch ideas 2026", - "quick workout plans", - "best quick workout plans", - "how to find quick workout plans", - "quick workout plans for beginners", - "quick workout plans at home", - "quick workout plans near me", - "quick workout plans 2026", - "quick study plans", - "best quick study plans", - "how to find quick study plans", - "quick study plans for beginners", - "quick study plans at home", - "quick study plans near me", - "quick study plans 2026", - "quick travel tips", - "best quick travel tips", - "how to find quick travel tips", - "quick travel tips for beginners", - "quick travel tips at home", - "quick travel tips near me", - "quick travel tips 2026", - "quick saving tips", - "best quick saving tips", - "how to find quick saving tips", - "quick saving tips for beginners", - "quick saving tips at home", - "quick saving tips near me", - "quick saving tips 2026", - "quick cleaning tips", - "best quick cleaning tips", - "how to find quick cleaning tips", - "quick cleaning tips for beginners", - "quick cleaning tips at home", - "quick cleaning tips near me", - "quick cleaning tips 2026", - "quick gardening ideas", - "best quick gardening ideas", - "how to find quick gardening ideas", - "quick gardening ideas for beginners", - "quick gardening ideas at home", - "quick gardening ideas near me", - "quick gardening ideas 2026", - "quick photo ideas", - "best quick photo ideas", - "how to find quick photo ideas", - "quick photo ideas for beginners", - "quick photo ideas at home", - "quick photo ideas near me", - "quick photo ideas 2026", - "quick video ideas", - "best quick video ideas", - "how to find quick video ideas", - "quick video ideas for beginners", - "quick video ideas at home", - "quick video ideas near me", - "quick video ideas 2026", - "quick desk setup ideas", - "best quick desk setup ideas", - "how to find quick desk setup ideas", - "quick desk setup ideas for beginners", - "quick desk setup ideas at home", - "quick desk setup ideas near me", - "quick desk setup ideas 2026", - "quick small room ideas", - "best quick small room ideas", - "how to find quick small room ideas", - "quick small room ideas for beginners", - "quick small room ideas at home", - "quick small room ideas near me", - "quick small room ideas 2026", - "quick date ideas", - "best quick date ideas", - "how to find quick date ideas", - "quick date ideas for beginners", - "quick date ideas at home", - "quick date ideas near me", - "quick date ideas 2026", - "quick party ideas", - "best quick party ideas", - "how to find quick party ideas", - "quick party ideas for beginners", - "quick party ideas at home", - "quick party ideas near me", - "quick party ideas 2026", - "quick gift ideas", - "best quick gift ideas", - "how to find quick gift ideas", - "quick gift ideas for beginners", - "quick gift ideas at home", - "quick gift ideas near me", - "quick gift ideas 2026", - "quick business ideas", - "best quick business ideas", - "how to find quick business ideas", - "quick business ideas for beginners", - "quick business ideas at home", - "quick business ideas near me", - "quick business ideas 2026", - "quick blog ideas", - "best quick blog ideas", - "how to find quick blog ideas", - "quick blog ideas for beginners", - "quick blog ideas at home", - "quick blog ideas near me", - "quick blog ideas 2026", - "quick youtube ideas", - "best quick youtube ideas", - "how to find quick youtube ideas", - "quick youtube ideas for beginners", - "quick youtube ideas at home", - "quick youtube ideas near me", - "quick youtube ideas 2026", - "quick instagram captions", - "best quick instagram captions", - "how to find quick instagram captions", - "quick instagram captions for beginners", - "quick instagram captions at home", - "quick instagram captions near me", - "quick instagram captions 2026", - "quick productivity systems", - "best quick productivity systems", - "how to find quick productivity systems", - "quick productivity systems for beginners", - "quick productivity systems at home", - "quick productivity systems near me", - "quick productivity systems 2026", - "quick habit trackers", - "best quick habit trackers", - "how to find quick habit trackers", - "quick habit trackers for beginners", - "quick habit trackers at home", - "quick habit trackers near me", - "quick habit trackers 2026", - "quick learning resources", - "best quick learning resources", - "how to find quick learning resources", - "quick learning resources for beginners", - "quick learning resources at home", - "quick learning resources near me", - "quick learning resources 2026", - "quick science facts", - "best quick science facts", - "how to find quick science facts", - "quick science facts for beginners", - "quick science facts at home", - "quick science facts near me", - "quick science facts 2026", - "quick history facts", - "best quick history facts", - "how to find quick history facts", - "quick history facts for beginners", - "quick history facts at home", - "quick history facts near me", - "quick history facts 2026", - "quick movie recommendations", - "best quick movie recommendations", - "how to find quick movie recommendations", - "quick movie recommendations for beginners", - "quick movie recommendations at home", - "quick movie recommendations near me", - "quick movie recommendations 2026", - "quick book recommendations", - "best quick book recommendations", - "how to find quick book recommendations", - "quick book recommendations for beginners", - "quick book recommendations at home", - "quick book recommendations near me", - "quick book recommendations 2026", - "quick game recommendations", - "best quick game recommendations", - "how to find quick game recommendations", - "quick game recommendations for beginners", - "quick game recommendations at home", - "quick game recommendations near me", - "quick game recommendations 2026", - "quick music playlists", - "best quick music playlists", - "how to find quick music playlists", - "quick music playlists for beginners", - "quick music playlists at home", - "quick music playlists near me", - "quick music playlists 2026", - "quick pc upgrades", - "best quick pc upgrades", - "how to find quick pc upgrades", - "quick pc upgrades for beginners", - "quick pc upgrades at home", - "quick pc upgrades near me", - "quick pc upgrades 2026", - "quick phone tips", - "best quick phone tips", - "how to find quick phone tips", - "quick phone tips for beginners", - "quick phone tips at home", - "quick phone tips near me", - "quick phone tips 2026", - "quick internet tips", - "best quick internet tips", - "how to find quick internet tips", - "quick internet tips for beginners", - "quick internet tips at home", - "quick internet tips near me", - "quick internet tips 2026", - "quick security tips", - "best quick security tips", - "how to find quick security tips", - "quick security tips for beginners", - "quick security tips at home", - "quick security tips near me", - "quick security tips 2026", - "quick linux commands", - "best quick linux commands", - "how to find quick linux commands", - "quick linux commands for beginners", - "quick linux commands at home", - "quick linux commands near me", - "quick linux commands 2026", - "quick windows shortcuts", - "best quick windows shortcuts", - "how to find quick windows shortcuts", - "quick windows shortcuts for beginners", - "quick windows shortcuts at home", - "quick windows shortcuts near me", - "quick windows shortcuts 2026", - "quick excel formulas", - "best quick excel formulas", - "how to find quick excel formulas", - "quick excel formulas for beginners", - "quick excel formulas at home", - "quick excel formulas near me", - "quick excel formulas 2026", - "quick notion templates", - "best quick notion templates", - "how to find quick notion templates", - "quick notion templates for beginners", - "quick notion templates at home", - "quick notion templates near me", - "quick notion templates 2026", - "quick resume tips", - "best quick resume tips", - "how to find quick resume tips", - "quick resume tips for beginners", - "quick resume tips at home", - "quick resume tips near me", - "quick resume tips 2026", - "quick interview tips", - "best quick interview tips", - "how to find quick interview tips", - "quick interview tips for beginners", - "quick interview tips at home", - "quick interview tips near me", - "quick interview tips 2026", - "quick freelance tips", - "best quick freelance tips", - "how to find quick freelance tips", - "quick freelance tips for beginners", - "quick freelance tips at home", - "quick freelance tips near me", - "quick freelance tips 2026", - "quick home office tips", - "best quick home office tips", - "how to find quick home office tips", - "quick home office tips for beginners", - "quick home office tips at home", - "quick home office tips near me", - "quick home office tips 2026", - "quick pet care tips" + "明天天气", + "如何煮意面", + "2024年最佳电影", + "最新科技新闻", + "埃菲尔铁塔有多高", + "简单晚餐食谱", + "日本现在几点", + "光合作用是如何运作的", + "最佳性价比智能手机", + "咖啡与浓缩咖啡的区别", + "如何改善wifi信号", + "热门网飞剧集", + "一个苹果有多少卡路里", + "当今世界人口", + "最佳免费电脑游戏", + "如何清洁键盘", + "什么是人工智能", + "简单居家锻炼", + "猫能活多久", + "著名博物馆画作", + "如何煮鸡蛋", + "最新windows更新", + "如何在windows上截图", + "欧洲最佳旅游目的地", + "什么是云计算", + "如何每月省钱", + "最佳youtube频道", + "光速有多快", + "如何学习编程", + "热门桌游", + "如何制作煎饼", + "欧洲国家首都", + "vpn是如何运作的", + "最佳效率提升应用", + "如何在室内种植植物", + "机械硬盘和固态硬盘的区别", + "如何解决电脑卡顿", + "最热门歌曲排行", + "如何打领带", + "下雨是什么原因", + "学生最佳笔记本电脑", + "如何重置路由器", + "健康早餐创意", + "世界上有多少个大洲", + "最新智能手机功能", + "冥想入门指南", + "什么是可再生能源", + "最佳电脑配件", + "如何清洁眼镜", + "世界著名地标", + "如何在家做咖啡", + "什么是机器学习", + "最佳编程语言", + "如何备份文件", + "蓝牙是如何工作的", + "当下热门电子游戏", + "如何提高睡眠质量", + "什么是加密货币", + "简单午餐创意", + "如何测试网速", + "最佳降噪耳机", + "如何在mac上截图", + "什么是银河系", + "如何整理文件", + "热门手机应用", + "如何更快速地学习", + "gps是如何工作的", + "最佳免费杀毒软件", + "如何清洁显示器", + "什么是电动汽车", + "简单数学技巧", + "如何更新驱动程序", + "著名科学家", + "一系列管道!", + "如何煮米饭", + "最高的山是什么", + "史上最佳电视剧", + "如何提高打字速度", + "太阳能是如何发电的", + "简单甜点食谱", + "如何解决蓝牙问题", + "什么是互联网", + "最佳电脑键盘", + "如何保持专注", + "流行科学常识", + "如何将文件转换为pdf", + "入睡需要多长时间", + "最佳旅行建议", + "如何清洁耳机", + "什么是开源软件", + "如何更好地管理时间", + "最新游戏新闻", + "如何查看笔记本温度", + "什么是防火墙", + "简单备餐创意", + "如何减轻眼睛疲劳", + "最佳性价比耳机", + "电子邮件是如何工作的", + "什么是虚拟现实", + "如何压缩文件", + "热门编程工具", + "如何提高注意力", + "如何制作冰沙", + "最佳桌面布置创意", + "最佳外星人电影", + "如何屏蔽广告", + "什么是5g技术", + "如何清洁鼠标", + "世界著名奇观", + "如何延长电池寿命", + "最佳云存储服务", + "如何学习新语言", + "什么是深色模式", + "如何清除浏览器缓存", + "热门科技播客", + "如何保持动力", + "如何加速电脑", + "最佳游戏鼠标", + "如何清理手机存储", + "如何修改wifi密码", + "2026年最佳安卓应用", + "如何使用chatgpt", + "什么是区块链", + "如何学习javascript", + "最佳免费vpn", + "如何剪辑视频", + "如何清除电脑病毒", + "2026年最佳浏览器", + "如何提高网速", + "如何安装windows 11", + "最佳编程学习网站", + "谁创造了天网?", + "如何使用excel公式", + "如何搭建网站", + "什么是html", + "第一个终结者是谁", + "什么是css", + "什么是javascript", + "如何组装台式电脑", + "2026年最佳显卡", + "如何清洁电脑风扇", + "如何升级内存", + "如何安装固态硬盘", + "什么是内存", + "什么是处理器", + "网天能飞吗?", + "如何查看显卡温度", + "2026年最佳电脑游戏", + "最佳多人游戏", + "如何在twitch上直播", + "如何开通youtube频道", + "最佳视频编辑软件", + "如何录屏", + "如何下载视频", + "如何转换视频格式", + "最佳文件压缩工具", + "如何解压文件", + "如何使用winrar", + "如何给文件设置密码", + "最佳密码管理器", + "如何创建强密码", + "如何开启双重认证", + "什么是网络钓鱼", + "如何安全上网", + "先有鸡还是先有蛋?", + "如何屏蔽垃圾邮件", + "如何使用谷歌云盘", + "最佳笔记应用", + "如何同步手机和电脑", + "如何在手机和电脑间传输文件", + "最佳无线耳机", + "2026年最佳智能手表", + "如何节省手机电量", + "如何给手机降温", + "如何释放安卓手机存储空间", + "如何释放苹果手机存储空间", + "如何恢复手机出厂设置", + "如何定位手机", + "如何找回丢失的手机", + "最佳导航应用", + "如何离线使用谷歌地图", + "如何共享位置", + "如何扫描二维码", + "什么是nfc", + "如何使用蓝牙耳机", + "如何将手柄连接到电脑", + "最佳游戏手柄", + "如何解决手柄漂移", + "如何提升游戏性能", + "最佳帧率设置", + "如何减少卡顿", + "如何降低延迟", + "如何设置路由器端口转发", + "如何组网mesh wifi", + "2026年最佳路由器", + "如何修改dns", + "为什么netsky这么酷?", + "游戏最佳dns", + "如何隐藏ip地址", + "如何使用代理", + "如何安装linux", + "最佳linux发行版", + "如何使用终端", + "基础linux命令", + "如何使用git", + "什么是github", + "如何推送到github", + "如何克隆仓库", + "如何学习python", + "最佳python库", + "如何自动化任务", + "什么是api", + "如何使用rest api", + "如何解析json", + "如何使用nodejs", + "最佳npm包", + "如何调试代码", + "如何修复漏洞", + "什么是软件开发", + "如何成为程序员", + "最佳在线课程", + "如何高效学习", + "如何快速记忆", + "如何保持高效", + "晨间routine创意", + "如何克服拖延", + "如何设定目标", + "如何规划一天", + "如何追踪习惯", + "最佳习惯应用", + "如何多喝水", + "如何吃得更健康", + "简单健身建议", + "如何安全减肥", + "如何增肌", + "家庭健身装备", + "最佳跑步应用", + "如何改善姿势", + "如何减轻压力", + "如何快速放松", + "如何快速入睡", + "如何早起", + "如何建立自信", + "如何自信地说话", + "如何提高沟通能力", + "如何有效社交", + "如何准备面试", + "常见面试问题", + "如何写简历", + "如何写求职信", + "如何快速找到工作", + "如何开展副业", + "在线创业创意", + "如何在线赚钱", + "最佳自由职业网站", + "如何在网上销售", + "如何做电商", + "什么是代发货", + "如何理财投资", + "如何开始投资", + "最佳储蓄建议", + "如何预算开支", + "如何追踪支出", + "最佳理财应用", + "附近最佳徒步路线", + "如何备战5公里跑", + "简单素食晚餐", + "如何烤酸面包", + "意大利最佳海滩", + "如何开始园艺", + "如何修剪玫瑰", + "简单汤品食谱", + "如何完美煎牛排", + "最佳浓缩咖啡豆", + "如何自制披萨面团", + "浪漫约会创意", + "如何整理家居", + "极简生活建议", + "如何筹备婚礼", + "最佳蜜月目的地", + "如何拍出更好的照片", + "人像摄影技巧", + "如何开始写日记", + "每日感恩主题", + "如何粉刷房间", + "最佳室内植物", + "如何在家堆肥", + "如何种番茄", + "如何制作蜡烛", + "钩针编织入门", + "简单编织花样", + "如何提高棋艺", + "国际象棋开局解析", + "如何解魔方", + "最佳家庭桌游", + "如何训练幼犬", + "狗为什么会叫", + "如何训练猫咪用猫砂", + "如何搭建树屋", + "如何写小说", + "创意写作提示", + "如何克服写作瓶颈", + "最佳奇幻小说", + "如何多读书", + "如何背诵演讲稿", + "公开演讲技巧", + "如何谈薪水", + "如何攒钱买房", + "首次购房建议", + "如何再融资房贷", + "如何投资房地产", + "欧洲最佳居住城市", + "如何穷游", + "独自旅行建议", + "如何轻装出行", + "最佳自驾游路线", + "如何规划周末短途旅行", + "成年人如何交朋友", + "如何改善人际关系", + "健康关系的标志", + "如何解决冲突", + "如何练习正念", + "缓解焦虑的呼吸练习", + "如何增强免疫力", + "喝水的好处", + "健康饮食计划", + "如何一周备餐", + "简单素食食谱", + "如何煎三文鱼", + "如何做意大利烩饭", + "如何烤巧克力蛋糕", + "快手早餐食谱", + "如何自制冰淇淋", + "如何开始跑步", + "瑜伽入门动作", + "如何正确拉伸", + "每天步行的好处", + "如何备战马拉松", + "如何保持水分", + "如何清洁白鞋", + "如何去除衣物污渍", + "如何整理衣柜", + "春季清洁清单", + "如何装饰小公寓", + "平价家居装饰创意", + "如何解决门吱呀响", + "如何疏通下水道", + "如何在家省电", + "如何减少浪费", + "环保生活建议", + "如何正确回收垃圾", + "如何开始冥想", + "每日肯定语示例", + "如何改善字迹", + "如何学吉他", + "钢琴入门课程", + "如何唱得更好听", + "著名历史事件", + "埃及艳后是谁", + "珠穆朗玛峰有多高", + "天空为什么是蓝色的", + "飞机如何飞行", + "地震是什么原因", + "如何制作水晶泥", + "在家做有趣科学实验", + "如何筹办生日派对", + "成人派对游戏", + "如何筹办迎婴派对", + "如何给宝宝取名", + "如何迎接宝宝到来", + "如何训练幼儿如厕", + "青少年教育建议", + "如何备考", + "学生时间管理建议", + "如何写论文", + "如何在学校学得更快", + "如何开办播客", + "如何开始写博客", + "如何在instagram上涨粉", + "如何培养自律", + "如何改掉坏习惯", + "如何建立晨间习惯", + "如何保持积极", + "如何原谅别人", + "如何真诚道歉", + "如何养成好习惯", + "如何面对失败", + "如何应对批评", + "如何提高创造力", + "如何找到自己的热爱", + "如何设定现实目标", + "如何保持坚持" ] diff --git a/src/index.ts b/src/index.ts index ae19c491..2bcd3fb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import BrowserUtils from './browser/BrowserUtils' import ReactFunc from './browser/ReactFunc' import type { PageSnapshot } from './browser/ReactFunc' -import { IpcLog, Logger } from './logging/Logger' +import { IpcLog, Logger, formatLocalTimestamp } from './logging/Logger' import Utils, { isBrowserClosedError } from './util/Utils' import { loadAccounts, loadConfig } from './util/Load' import { closeSessionStore, loadResolvedRegion, saveResolvedRegion } from './util/SessionStore' @@ -28,6 +28,8 @@ import HttpClient from './util/Http' import { sendDiscord, flushDiscordQueue } from './logging/Discord' import { sendNtfy, flushNtfyQueue } from './logging/Ntfy' import { sendTelegram, flushTelegramQueue } from './logging/Telegram' +import { sendPushPlus, flushPushPlusQueue } from './logging/PushPlus' +import { sendClawBot, flushClawBotQueue, ensureClawBotReady } from './logging/ClawBot' import type { DashboardData } from './interface/DashboardData' import type { AppDashboardData } from './interface/AppDashBoardData' import type { AppEarnablePoints } from './interface/Points' @@ -69,7 +71,13 @@ export function getCurrentContext(): ExecutionContext { } async function flushAllWebhooks(timeoutMs = 5000): Promise { - await Promise.allSettled([flushDiscordQueue(timeoutMs), flushNtfyQueue(timeoutMs), flushTelegramQueue(timeoutMs)]) + await Promise.allSettled([ + flushDiscordQueue(timeoutMs), + flushNtfyQueue(timeoutMs), + flushTelegramQueue(timeoutMs), + flushPushPlusQueue(timeoutMs), + flushClawBotQueue(timeoutMs) + ]) closeSessionStore() } @@ -169,7 +177,7 @@ export class MicrosoftRewardsBot { this.logger.debug( this.isMobile, 'CONTEXT-REFRESH', - `Cannot refresh rewards context | reason=${reason} | account=unavailable` + `无法刷新奖励上下文 | 原因=${reason} | 账户=不可用` ) return false } @@ -178,7 +186,7 @@ export class MicrosoftRewardsBot { this.logger.warn( this.isMobile, 'CONTEXT-REFRESH', - `Refreshing rewards browser context after request failure | reason=${reason}` + `请求失败后正在刷新奖励浏览器上下文 | 原因=${reason}` ) if (!page || page.isClosed()) { @@ -211,7 +219,7 @@ export class MicrosoftRewardsBot { this.logger.info( this.isMobile, 'CONTEXT-REFRESH', - `Rewards context refreshed successfully | cookies=${refreshedCookies.length}`, + `奖励上下文刷新成功 | Cookie数=${refreshedCookies.length}`, 'green' ) refreshSucceeded = true @@ -220,7 +228,7 @@ export class MicrosoftRewardsBot { this.logger.error( this.isMobile, 'CONTEXT-REFRESH', - `Rewards context refresh failed | reason=${reason} | message=${error instanceof Error ? error.message : String(error)}` + `奖励上下文刷新失败 | 原因=${reason} | 信息=${error instanceof Error ? error.message : String(error)}` ) return false } finally { @@ -245,9 +253,9 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'EXPERIMENTAL', - `${searchFeatures.join(' + ')} enabled - these perform searches over HTTP with no real browser. ` + - `This path is EXPERIMENTAL and UNSAFE and may get your account flagged or banned. ` + - `Disable it under config.experimental if you are unsure.`, + `${searchFeatures.join(' + ')} 已启用 - 这些功能通过 HTTP 执行搜索,不使用真实浏览器。` + + `此路径属于实验性且不安全,可能导致你的账户被标记或封禁。` + + `如不确定,请在 config.experimental 下禁用。`, 'redBright' ) } @@ -256,8 +264,8 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'EXPERIMENTAL', - 'edgeBrowsing enabled - the Edge browsing activity will be reported over HTTP in the background. ' + - 'This integration is experimental; disable it under config.experimental if it behaves unexpectedly.' + 'edgeBrowsing 已启用 - Edge 浏览活动将在后台通过 HTTP 上报。' + + '此集成为实验性功能;如表现异常,请在 config.experimental 下禁用。' ) } } @@ -269,9 +277,14 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'RUN-START', - `Starting Microsoft Rewards Script | v${pkg.version} | Accounts: ${totalAccounts} | Clusters: ${this.config.clusters}` + `启动微软奖励脚本 | v${pkg.version} | 账户数: ${totalAccounts} | 集群数: ${this.config.clusters}` ) + // 主进程启动时检查 ClawBot 凭证:缺失则弹出扫码登录(worker 不重复触发) + if (cluster.isPrimary) { + await ensureClawBotReady(this.config.webhook?.clawbot) + } + if (this.config.clusters > 1) { if (cluster.isPrimary) { await this.runMaster(runStartTime) @@ -284,7 +297,7 @@ export class MicrosoftRewardsBot { } private async runMaster(runStartTime: number): Promise { - void this.logger.info('main', 'CLUSTER-PRIMARY', `Primary process started | PID: ${process.pid}`) + void this.logger.info('main', 'CLUSTER-PRIMARY', `主进程已启动 | PID: ${process.pid}`) const rawChunks = this.utils.chunkArray(this.accounts, this.config.clusters) const accountChunks = rawChunks.filter(c => c && c.length > 0) @@ -342,7 +355,7 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'CLUSTER-WORKER-EXIT', - `Worker ${pid} exit | Code: ${code ?? 'n/a'} | Signal: ${signal ?? 'n/a'} | Active workers: ${this.activeWorkers}` + `worker ${pid} 退出 | 代码: ${code ?? 'n/a'} | 信号: ${signal ?? 'n/a'} | 活跃worker数: ${this.activeWorkers}` ) if (this.activeWorkers <= 0) { @@ -354,10 +367,12 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'RUN-END', - `Completed all accounts | accountsProcessed=${allAccountStats.length} | pointsGained=${totalCollectedPoints} | previousBalance=${totalInitialPoints} | currentBalance=${totalFinalPoints} | runtimeMinutes=${totalDurationMinutes}`, + `全部账户完成 | 处理账户数=${allAccountStats.length} | 获得积分=${totalCollectedPoints} | 原余额=${totalInitialPoints} | 现余额=${totalFinalPoints} | 运行分钟数=${totalDurationMinutes}`, 'green' ) + await this.sendPushPlusSummary(allAccountStats, runStartTime, hadWorkerFailure) + await this.sendClawBotSummary(allAccountStats, runStartTime, hadWorkerFailure) await flushAllWebhooks() process.exit(hadWorkerFailure ? 1 : 0) @@ -370,18 +385,18 @@ export class MicrosoftRewardsBot { cluster.on('disconnect', worker => { const pid = worker.process?.pid - this.logger.warn('main', 'CLUSTER-WORKER-DISCONNECT', `Worker ${pid ?? '?'} disconnected`) + this.logger.warn('main', 'CLUSTER-WORKER-DISCONNECT', `worker ${pid ?? '?'} 已断开连接`) }) } private runWorker(runStartTimeFromMaster?: number): void { - void this.logger.info('main', 'CLUSTER-WORKER-START', `Worker spawned | PID: ${process.pid}`) + void this.logger.info('main', 'CLUSTER-WORKER-START', `worker 已生成 | PID: ${process.pid}`) process.on('message', async ({ chunk, runStartTime }: { chunk: Account[]; runStartTime: number }) => { void this.logger.info( 'main', 'CLUSTER-WORKER-TASK', - `Worker ${process.pid} received ${chunk.length} accounts.` + `worker ${process.pid} 接收到 ${chunk.length} 个账户。` ) try { @@ -397,7 +412,7 @@ export class MicrosoftRewardsBot { this.logger.error( 'main', 'CLUSTER-WORKER-ERROR', - `Worker task crash: ${error instanceof Error ? error.message : String(error)}` + `worker 任务崩溃: ${error instanceof Error ? error.message : String(error)}` ) await flushAllWebhooks() @@ -406,6 +421,66 @@ export class MicrosoftRewardsBot { }) } + private buildSummaryMessage(accountStats: AccountStats[], runStartTime: number, hadWorkerFailure: boolean): string { + const totalCollectedPoints = accountStats.reduce((sum, s) => sum + s.collectedPoints, 0) + const totalInitialPoints = accountStats.reduce((sum, s) => sum + s.initialPoints, 0) + const totalFinalPoints = accountStats.reduce((sum, s) => sum + s.finalPoints, 0) + const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1) + const timestamp = formatLocalTimestamp(new Date()) + + const lines: string[] = [ + `每日积分摘要 | ${timestamp}`, + `状态: ${hadWorkerFailure ? '异常' : '完成'}`, + `账户数: ${accountStats.length}`, + `总收集积分: +${totalCollectedPoints}`, + `原始总计: ${totalInitialPoints} → 新总计: ${totalFinalPoints}`, + `总运行时间: ${totalDurationMinutes}分钟` + ] + + if (accountStats.length > 0) { + lines.push('') + lines.push('账户明细:') + for (const stat of accountStats) { + const status = stat.success ? '成功' : '失败' + const duration = Number.isFinite(stat.duration) ? stat.duration.toFixed(1) : String(stat.duration) + const error = stat.error ? ` | ${stat.error}` : '' + lines.push( + `${stat.email} | +${stat.collectedPoints} | ${stat.initialPoints}→${stat.finalPoints} | ${duration}秒 | ${status}${error}` + ) + } + } + + return lines.join('\n') + } + + private async sendPushPlusSummary( + accountStats: AccountStats[], + runStartTime: number, + hadWorkerFailure: boolean + ): Promise { + const pushplus = this.config?.webhook?.pushplus + if (!pushplus?.enabled || !pushplus.token) { + return + } + + const content = this.buildSummaryMessage(accountStats, runStartTime, hadWorkerFailure) + await sendPushPlus(pushplus, content) + } + + private async sendClawBotSummary( + accountStats: AccountStats[], + runStartTime: number, + hadWorkerFailure: boolean + ): Promise { + const clawbot = this.config?.webhook?.clawbot + if (!clawbot?.enabled) { + return + } + + const content = this.buildSummaryMessage(accountStats, runStartTime, hadWorkerFailure) + await sendClawBot(clawbot, content) + } + private async runTasks(accounts: Account[], runStartTime: number): Promise { const accountStats: AccountStats[] = [] @@ -429,8 +504,8 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'ACCOUNT-START', - `Starting account: ${accountEmail} | geoLocale: ${account.geoLocale} | locale: ${this.accountLocale.locale}${ - cachedRegion ? ` | cachedRegion: ${cachedRegion}` : '' + `开始处理账户: ${accountEmail} | geoLocale: ${account.geoLocale} | locale: ${this.accountLocale.locale}${ + cachedRegion ? ` | 缓存区域: ${cachedRegion}` : '' }` ) @@ -438,12 +513,10 @@ export class MicrosoftRewardsBot { 'Accept-Language': this.accountLocale.acceptLanguage }) + let flowError: string | undefined const result: AccountRunResult | undefined = await this.Main(account).catch(error => { - void this.logger.error( - true, - 'FLOW', - `Mobile flow failed for ${accountEmail}: ${error instanceof Error ? error.message : String(error)}` - ) + flowError = error instanceof Error ? error.message : String(error) + void this.logger.error(true, 'FLOW', `${accountEmail} 的移动端流程失败: ${flowError}`) return undefined }) @@ -468,7 +541,7 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'ACCOUNT-SKIP', - `Skipped account: ${accountEmail} | reason=Fraud_UserWarning_BotScore_UX | durationSeconds=${durationSeconds}` + `跳过账户: ${accountEmail} | 原因=Fraud_UserWarning_BotScore_UX | 持续秒数=${durationSeconds}` ) } else { accountStats.push({ @@ -483,11 +556,15 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'ACCOUNT-END', - `Completed account: ${accountEmail} | pointsGained=${collectedPoints} | previousBalance=${accountInitialPoints} | currentBalance=${accountFinalPoints} | durationSeconds=${durationSeconds}`, + `账户完成: ${accountEmail} | 获得积分=${collectedPoints} | 原余额=${accountInitialPoints} | 现余额=${accountFinalPoints} | 持续秒数=${durationSeconds}`, 'green' ) } } else { + const errorDetail = flowError && flowError !== 'undefined' + ? flowError.replace(/\r?\n/g, ' ').slice(0, 120) + : '流程失败(未捕获到错误详情)' + accountStats.push({ email: accountEmail, initialPoints: 0, @@ -495,7 +572,7 @@ export class MicrosoftRewardsBot { collectedPoints: 0, duration: parseFloat(durationSeconds), success: false, - error: 'Flow failed' + error: errorDetail }) } } catch (error) { @@ -503,7 +580,7 @@ export class MicrosoftRewardsBot { this.logger.error( 'main', 'ACCOUNT-ERROR', - `${accountEmail}: ${error instanceof Error ? error.message : String(error)}` + `${accountEmail} | 错误=${error instanceof Error ? error.message : String(error)}` ) accountStats.push({ @@ -527,10 +604,13 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'RUN-END', - `Completed all accounts | accountsProcessed=${accountStats.length} | pointsGained=${totalCollectedPoints} | previousBalance=${totalInitialPoints} | currentBalance=${totalFinalPoints} | runtimeMinutes=${totalDurationMinutes}`, + `全部账户完成 | 处理账户数=${accountStats.length} | 获得积分=${totalCollectedPoints} | 原余额=${totalInitialPoints} | 现余额=${totalFinalPoints} | 运行分钟数=${totalDurationMinutes}`, 'green' ) + const hadFailure = accountStats.some(s => !s.success) + await this.sendPushPlusSummary(accountStats, runStartTime, hadFailure) + await this.sendClawBotSummary(accountStats, runStartTime, hadFailure) await flushAllWebhooks() process.exit(0) } @@ -551,7 +631,7 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'ACCOUNT-DELAY', - `Waiting ${(delayMs / 1000).toFixed(1)} seconds before starting the next account${ + `等待 ${(delayMs / 1000).toFixed(1)} 秒后开始下一个账户${ nextEmail ? ` (${nextEmail})` : '' }` ) @@ -563,7 +643,7 @@ export class MicrosoftRewardsBot { this.mainDesktopPage = await session.context.newPage() this.fingerprintDesktop = session.fingerprint - this.logger.info(this.isMobile, 'BROWSER', `Desktop Browser started | ${account.email}`) + this.logger.info(this.isMobile, 'BROWSER', `桌面浏览器已启动 | ${account.email}`) await this.login.login(this.mainDesktopPage, account) await this.browser.func.checkpointActiveSession('LOGIN-CHECKPOINT') @@ -574,7 +654,7 @@ export class MicrosoftRewardsBot { async Main(account: Account): Promise { const accountEmail = account.email - this.logger.info('main', 'FLOW', `Starting session for ${accountEmail}`) + this.logger.info('main', 'FLOW', `开始为 ${accountEmail} 创建会话`) this.accessToken = '' this.cookies = { mobile: [], desktop: [] } @@ -627,7 +707,7 @@ export class MicrosoftRewardsBot { const initialContext: BrowserContext = mobileSession.context this.mainMobilePage = await initialContext.newPage() - this.logger.info('main', 'BROWSER', `Mobile Browser started | ${accountEmail}`) + this.logger.info('main', 'BROWSER', `移动浏览器已启动 | ${accountEmail}`) await this.login.login(this.mainMobilePage, account) @@ -638,7 +718,7 @@ export class MicrosoftRewardsBot { this.logger.error( 'main', 'FLOW', - `Failed to get mobile access token: ${error instanceof Error ? error.message : String(error)}` + `获取移动端访问令牌失败: ${error instanceof Error ? error.message : String(error)}` ) this.accessToken = '' } @@ -653,7 +733,7 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'FLOW', - 'Mobile login browser closed; continuing with the saved session and HTTP requests' + '移动端登录浏览器已关闭;继续使用已保存的会话和 HTTP 请求' ) } @@ -669,9 +749,9 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'BOT-WARNING', - `Microsoft Rewards reported Fraud_UserWarning_BotScore_UX for ${accountEmail}. ` + - 'This account will be skipped for safety. The preferred action is to stop automation for this account and wait a few days. ' + - 'To continue anyway (not recommended), set "contintueOnBotWarning": true.' + `Microsoft Rewards 报告 ${accountEmail} 存在机器人评分警告(Fraud_UserWarning_BotScore_UX)。` + + '为安全起见将跳过该账户。建议暂停该账户的自动化并等待几天。' + + '如仍要继续(不推荐),可设置 "contintueOnBotWarning": true。' ) return { @@ -684,8 +764,8 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'BOT-WARNING', - `Microsoft Rewards reported Fraud_UserWarning_BotScore_UX for ${accountEmail}, but contintueOnBotWarning=true. ` + - 'Continuing as configured is not recommended; waiting a few days is the preferred action.' + `Microsoft Rewards 报告 ${accountEmail} 存在机器人评分警告(Fraud_UserWarning_BotScore_UX),但 contintueOnBotWarning=true。` + + '按配置继续运行(不推荐);等待几天才是首选做法。' ) } @@ -698,7 +778,7 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'GEO-LOCALE', - `Microsoft profile returned an invalid country; retaining ${ + `Microsoft 个人资料返回了无效的国家/地区;保留 ${ this.accountLocale.country ?? 'US fallback' }` ) @@ -721,7 +801,7 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'LOGIN-APP', - `App dashboard unavailable - app activities will be skipped this run | message=${error instanceof Error ? error.message : String(error)}` + `App 仪表板不可用 - 本次运行将跳过 App 活动 | 信息=${error instanceof Error ? error.message : String(error)}` ) this.accessToken = '' } @@ -741,7 +821,7 @@ export class MicrosoftRewardsBot { this.logger.warn( 'main', 'LOGIN-APP', - `App earnable-points lookup failed - app activities will be skipped this run | message=${error instanceof Error ? error.message : String(error)}` + `App 可赚积分查询失败 - 本次运行将跳过 App 活动 | 信息=${error instanceof Error ? error.message : String(error)}` ) this.accessToken = '' appData = null @@ -753,7 +833,7 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'POINTS', - `Earnable today | Mobile: ${browserEarnable.mobileSearchPoints} | Browser: ${ + `今日可赚取 | 移动端: ${browserEarnable.mobileSearchPoints} | 浏览器: ${ browserEarnable.desktopSearchPoints } | App: ${appEarnable?.totalEarnablePoints ?? 0} | ${accountEmail} | locale: ${this.accountLocale.locale}` ) @@ -773,7 +853,7 @@ export class MicrosoftRewardsBot { this.logger.error( this.isMobile, 'EDGE-BROWSING', - `Unexpected background task failure | message=${ + `意外的后台任务失败 | 信息=${ error instanceof Error ? error.message : String(error) }` ) @@ -899,7 +979,7 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'SEARCH-MANAGER', - `Search summary | mobile=${mobilePoints} | desktop=${desktopPoints} | bonus=${bonusPoints} | total=${ + `搜索汇总 | 移动端=${mobilePoints} | 桌面端=${desktopPoints} | 额外=${bonusPoints} | 总计=${ mobilePoints + desktopPoints + bonusPoints }` ) @@ -911,7 +991,7 @@ export class MicrosoftRewardsBot { this.logger.info( this.isMobile, 'EDGE-BROWSING', - 'Foreground activities finished; waiting for the background Edge browsing activity' + '前台活动已完成;正在等待后台 Edge 浏览活动' ) } await edgeBrowsingTask @@ -924,7 +1004,7 @@ export class MicrosoftRewardsBot { this.logger.info( 'main', 'FLOW', - `Points collected | pointsGained=${collectedPoints} | currentBalance=${finalPoints} | account=${accountEmail}` + `积分已收集 | 获得积分=${collectedPoints} | 现余额=${finalPoints} | 账户=${accountEmail}` ) return { @@ -946,7 +1026,7 @@ export class MicrosoftRewardsBot { this.logger.debug( 'main', 'CLEANUP', - `Mobile context close failed | ${error instanceof Error ? error.message : String(error)}` + `移动端上下文关闭失败 | ${error instanceof Error ? error.message : String(error)}` ) } } @@ -958,7 +1038,7 @@ export class MicrosoftRewardsBot { this.logger.debug( 'main', 'CLEANUP', - `Desktop context close failed | ${error instanceof Error ? error.message : String(error)}` + `桌面端上下文关闭失败 | ${error instanceof Error ? error.message : String(error)}` ) } } @@ -976,12 +1056,12 @@ async function main(): Promise { void flushAllWebhooks() }) process.on('SIGINT', async () => { - rewardsBot.logger.warn('main', 'PROCESS', 'SIGINT received, flushing and exiting...') + rewardsBot.logger.warn('main', 'PROCESS', '收到 SIGINT 信号,正在刷新并退出...') await flushAllWebhooks() process.exit(130) }) process.on('SIGTERM', async () => { - rewardsBot.logger.warn('main', 'PROCESS', 'SIGTERM received, flushing and exiting...') + rewardsBot.logger.warn('main', 'PROCESS', '收到 SIGTERM 信号,正在刷新并退出...') await flushAllWebhooks() process.exit(143) }) @@ -990,7 +1070,7 @@ async function main(): Promise { rewardsBot.logger.debug( 'main', 'UNCAUGHT-EXCEPTION', - `Ignoring benign browser-closed error during teardown | ${error instanceof Error ? error.message : String(error)}` + `忽略清理阶段良性的浏览器已关闭错误 | ${error instanceof Error ? error.message : String(error)}` ) return } @@ -1003,7 +1083,7 @@ async function main(): Promise { rewardsBot.logger.debug( 'main', 'UNHANDLED-REJECTION', - `Ignoring benign browser-closed rejection during teardown | ${reason instanceof Error ? reason.message : String(reason)}` + `忽略清理阶段良性的浏览器已关闭拒绝 | ${reason instanceof Error ? reason.message : String(reason)}` ) return } diff --git a/src/interface/Config.ts b/src/interface/Config.ts index d8261f32..6cf9f28e 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -20,7 +20,7 @@ export interface Config { webhook: ConfigWebhook } -export type QueryEngine = 'google' | 'wikipedia' | 'wikirandom' | 'hackernews' | 'reddit' | 'local' +export type QueryEngine = 'china' | 'google' | 'wikipedia' | 'wikirandom' | 'hackernews' | 'reddit' | 'local' export type RssFeedSelector = 'rss' | `rss.${string}` export type QueryEngineEntry = QueryEngine | RssFeedSelector @@ -36,6 +36,13 @@ export interface ConfigSearchSettings { searchResultVisitTime: number | string searchDelay: ConfigDelay readDelay: ConfigDelay + /** + * 中国热搜源(gmya.net)配置。 + * appkey 留空走免费档(有频率限制);填入则带 appkey 请求以解除限流。 + */ + chinaApi?: { + appkey?: string + } } export interface ConfigDelay { @@ -79,6 +86,8 @@ export interface ConfigWebhook { discord?: WebhookDiscordConfig ntfy?: WebhookNtfyConfig telegram?: WebhookTelegramConfig + pushplus?: WebhookPushPlusConfig + clawbot?: WebhookClawBotConfig webhookLogFilter: LogFilter } @@ -110,3 +119,16 @@ export interface WebhookTelegramConfig { botToken: string chatId: string | number } + +export interface WebhookPushPlusConfig { + enabled?: boolean + token: string + title?: string + template?: 'txt' | 'html' | 'markdown' + channel?: string +} + +export interface WebhookClawBotConfig { + enabled?: boolean + authFile?: string +} diff --git a/src/interface/OAuth.ts b/src/interface/OAuth.ts deleted file mode 100644 index d9d5408d..00000000 --- a/src/interface/OAuth.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface OAuth { - access_token: string - refresh_token: string - scope: string - expires_in: number - ext_expires_in: number - foci: string - token_type: string -} diff --git a/src/logging/ClawBot.ts b/src/logging/ClawBot.ts new file mode 100644 index 00000000..067149d7 --- /dev/null +++ b/src/logging/ClawBot.ts @@ -0,0 +1,85 @@ +import PQueue from 'p-queue' +import type { WebhookClawBotConfig } from '../interface/Config' +import { flushQueue } from './Queue' +import { + loadClawBotAuth, + saveClawBotAuth, + sendClawBotText, + clearClawBotAuth, + loginClawBotInteractive, + waitForClawBotActivation +} from '../util/ClawBotClient' + +// iLink 服务端限频约 7 条/5 分钟,保守按 10 秒 1 条放行 +const clawBotQueue = new PQueue({ + interval: 10000, + intervalCap: 1, + carryoverConcurrencyCount: true +}) + +export async function sendClawBot(config: WebhookClawBotConfig, content: string): Promise { + if (!config?.enabled) return + + const auth = loadClawBotAuth(config.authFile) + if (!auth) { + console.warn('[ClawBot] 未找到凭证(clawbot-auth.json),本次跳过推送') + return + } + + await clawBotQueue.add(async () => { + try { + const result = await sendClawBotText(auth, content) + if (result === 'expired') { + // 过期凭证直接清除,下次运行启动时会自动重新弹出扫码 + clearClawBotAuth(config.authFile) + console.warn('[ClawBot] 凭证已过期并清除,下次运行时将重新弹出扫码登录') + } else if (result === 'needs-activation') { + // context_token 缺失或失效(ret=-2):清除后等待下次启动时重新激活 + auth.contextToken = undefined + saveClawBotAuth(auth, config.authFile) + console.warn('[ClawBot] 推送上下文失效,本次跳过;下次运行时请在微信给「微信 ClawBot」发一条消息完成激活') + } else if (result === 'error') { + console.warn('[ClawBot] 发送失败,已跳过(不影响任务运行)') + } + } catch { + // 通知失败不影响主流程 + } + }) +} + +export function flushClawBotQueue(timeoutMs = 5000): Promise { + return flushQueue(clawBotQueue, timeoutMs) +} + +/** + * 启动钩子:配置开启 ClawBot 时确保推送通道就绪。 + * - 无凭证:弹出扫码登录(5 分钟超时),登录后自动进入激活等待 + * - 有凭证但无 contextToken:提示在微信给「微信 ClawBot」发条消息,长轮询抓取(2 分钟超时) + * 超时或失败仅跳过推送,不阻塞任务。 + */ +export async function ensureClawBotReady(config?: WebhookClawBotConfig): Promise { + if (!config?.enabled) return false + let auth = loadClawBotAuth(config.authFile) + + if (!auth) { + console.warn('[ClawBot] 已启用微信 ClawBot 推送但尚未登录,开始扫码连接(等待 5 分钟)…') + const result = await loginClawBotInteractive(config.authFile) + if (!result.ok || !result.auth) { + console.warn(`[ClawBot] ${result.message}`) + return false + } + auth = result.auth + } + + if (!auth.contextToken) { + console.warn('[ClawBot] 需要激活:请现在在手机微信中给「微信 ClawBot」发送一条任意消息(等待 2 分钟)…') + const activated = await waitForClawBotActivation(auth, config.authFile, 2 * 60 * 1000) + if (!activated) { + console.warn('[ClawBot] 激活超时,本次跳过推送;下次运行时会继续等待激活消息') + return false + } + await sendClawBot(config, '✅ ClawBot 通知已接通:Microsoft Rewards 脚本将在此推送运行摘要') + } + + return true +} diff --git a/src/logging/Logger.ts b/src/logging/Logger.ts index 93900e1e..e5ddabbd 100644 --- a/src/logging/Logger.ts +++ b/src/logging/Logger.ts @@ -1,10 +1,13 @@ import chalk from 'chalk' import cluster from 'cluster' +import fs from 'fs' +import path from 'path' import { sendDiscord } from './Discord' import { sendNtfy } from './Ntfy' import { sendTelegram } from './Telegram' import type { MicrosoftRewardsBot } from '../index' import { errorDiagnostic } from '../util/ErrorDiagnostic' +import { getProjectRoot } from '../util/Load' import type { LogFilter } from '../interface/Config' export type Platform = boolean | 'main' @@ -54,6 +57,77 @@ function formatMessage(message: string | Error): string { return stackFrames ? `${message.message} | stack=${stackFrames}` : message.message } +/** + * 日志保留天数,超期的日志文件在进程首次写日志时清理 + */ +const LOG_RETENTION_DAYS = 90 + +/** + * 日志文件目录(首次写入时解析,避免每条日志都做磁盘检查) + */ +let logFileDir: string | null = null + +function cleanOldLogFiles(logDir: string): void { + const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000 + let entries: string[] + try { + entries = fs.readdirSync(logDir) + } catch { + return + } + for (const name of entries) { + const match = /^(\d{4})-(\d{2})-(\d{2})\.log$/.exec(name) + if (!match) continue + const fileDate = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])).getTime() + if (Number.isNaN(fileDate) || fileDate >= cutoff) continue + try { + fs.rmSync(path.join(logDir, name), { force: true }) + } catch { + // 单个文件清理失败不影响运行 + } + } +} + +function getLogFilePath(now: Date): string | null { + try { + if (!logFileDir) { + logFileDir = path.join(getProjectRoot(), 'logs') + fs.mkdirSync(logFileDir, { recursive: true }) + cleanOldLogFiles(logFileDir) + } + const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + return path.join(logFileDir, `${date}.log`) + } catch (error) { + console.error('[Logger] 创建日志目录失败:', error) + logFileDir = null + return null + } +} + +/** + * 本地时区的 YYYY-MM-DD HH:mm:ss(.sss) 时间戳,用于日志与推送展示 + */ +export function formatLocalTimestamp(date: Date, withMilliseconds = false): string { + const pad = (n: number) => String(n).padStart(2, '0') + const base = + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` + return withMilliseconds ? `${base}.${String(date.getMilliseconds()).padStart(3, '0')}` : base +} + +/** + * 将日志追加写入 logs/YYYY-MM-DD.log(按本地日期分文件) + */ +function writeLogToFile(logContent: string): void { + try { + const logFilePath = getLogFilePath(new Date()) + if (!logFilePath) return + fs.appendFileSync(logFilePath, `${formatLocalTimestamp(new Date(), true)} ${logContent}\n`, 'utf8') + } catch (error) { + console.error('[Logger] 写入日志文件失败:', error) + } +} + export class Logger { constructor(private bot: MicrosoftRewardsBot) {} @@ -89,6 +163,9 @@ export class Logger { const levelTag = level.toUpperCase() const cleanMsg = `[${now}] [${userName}] [${levelTag}] ${platformText(isMobile)} [${title}] ${formatted}` + // 保存日志到本地文件 + writeLogToFile(cleanMsg) + if (level === 'error' && config.errorDiagnostics) { const page = this.bot.isMobile ? this.bot.mainMobilePage : this.bot.mainDesktopPage const error = message instanceof Error ? message : new Error(String(message)) diff --git a/src/logging/PushPlus.ts b/src/logging/PushPlus.ts new file mode 100644 index 00000000..0f9508b6 --- /dev/null +++ b/src/logging/PushPlus.ts @@ -0,0 +1,42 @@ +import { httpRequest } from '../util/Http' +import type { HttpRequestConfig } from '../util/Http' +import PQueue from 'p-queue' +import type { WebhookPushPlusConfig } from '../interface/Config' +import { flushQueue } from './Queue' + +const pushPlusQueue = new PQueue({ + interval: 1000, + intervalCap: 2, + carryoverConcurrencyCount: true +}) + +export async function sendPushPlus(config: WebhookPushPlusConfig, content: string): Promise { + if (!config?.token) return + + const request: HttpRequestConfig = { + method: 'POST', + url: 'https://www.pushplus.plus/send', + headers: { 'Content-Type': 'application/json' }, + data: { + token: config.token, + title: config.title, + content, + template: config.template, + channel: config.channel + }, + timeout: 10000 + } + + await pushPlusQueue.add(async () => { + try { + await httpRequest(request) + } catch (err) { + const status = (err as { response?: { status?: number } })?.response?.status + if (status === 429) return + } + }) +} + +export function flushPushPlusQueue(timeoutMs = 5000): Promise { + return flushQueue(pushPlusQueue, timeoutMs) +} diff --git a/src/util/ClawBotClient.ts b/src/util/ClawBotClient.ts new file mode 100644 index 00000000..e93007d3 --- /dev/null +++ b/src/util/ClawBotClient.ts @@ -0,0 +1,382 @@ +import fs from 'fs' +import path from 'path' +import { randomBytes } from 'crypto' +import { httpRequest } from './Http' +import { getProjectRoot } from './Load' + +// 微信 ClawBot 推送:直连腾讯 iLink 官方灰度接口。 +// 请求结构与登录流程对照 @tencent-weixin/openclaw-weixin 2.4.x 插件源码实现(MIT)。 +const BASE_URL = 'https://ilinkai.weixin.qq.com' +const ILINK_APP_ID = 'bot' +const CHANNEL_VERSION = '2.4.3' +// iLink-App-ClientVersion 编码: 0x00MMNNPP(major<<16 | minor<<8 | patch) +const ILINK_APP_CLIENT_VERSION = (2 << 16) | (4 << 8) | 3 +const BOT_AGENT = 'microsoft-rewards-script/4.3.0' + +const QR_LONG_POLL_TIMEOUT_MS = 35_000 +const API_TIMEOUT_MS = 15_000 +const MAX_QR_REFRESH_COUNT = 3 +const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000 + +export interface ClawBotAuth { + token: string + accountId: string + userId: string + // 主动发送必须携带的上下文凭证,来自用户发给 bot 的消息(getupdates 抓取) + contextToken?: string + // getupdates 游标,持久化以便增量拉取 + getUpdatesBuf?: string + savedAt: string +} + +export type ClawBotSendResult = 'ok' | 'expired' | 'needs-activation' | 'error' + +export function resolveClawBotAuthFile(customPath?: string): string { + if (customPath) return path.resolve(customPath) + return path.join(getProjectRoot(), 'clawbot-auth.json') +} + +export function loadClawBotAuth(customPath?: string): ClawBotAuth | null { + const file = resolveClawBotAuthFile(customPath) + try { + if (!fs.existsSync(file)) return null + const raw = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record + if (typeof raw.token === 'string' && raw.token && typeof raw.userId === 'string' && raw.userId) { + return { + token: raw.token, + accountId: typeof raw.accountId === 'string' ? raw.accountId : '', + userId: raw.userId, + contextToken: typeof raw.contextToken === 'string' && raw.contextToken ? raw.contextToken : undefined, + getUpdatesBuf: typeof raw.getUpdatesBuf === 'string' && raw.getUpdatesBuf ? raw.getUpdatesBuf : undefined, + savedAt: typeof raw.savedAt === 'string' ? raw.savedAt : new Date().toISOString() + } + } + return null + } catch { + return null + } +} + +export function saveClawBotAuth(auth: ClawBotAuth, customPath?: string): void { + const file = resolveClawBotAuthFile(customPath) + fs.writeFileSync(file, JSON.stringify(auth, null, 4)) +} + +export function clearClawBotAuth(customPath?: string): void { + try { + fs.unlinkSync(resolveClawBotAuthFile(customPath)) + } catch { + // ignore missing file + } +} + +// X-WECHAT-UIN: 每次请求随机生成(uint32 -> 十进制字符串 -> base64) +function randomWechatUin(): string { + const uint32 = randomBytes(4).readUInt32BE(0) + return Buffer.from(String(uint32), 'utf-8').toString('base64') +} + +function buildHeaders(token?: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + AuthorizationType: 'ilink_bot_token', + 'X-WECHAT-UIN': randomWechatUin(), + 'iLink-App-Id': ILINK_APP_ID, + 'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION) + } + if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}` + return headers +} + +async function iLinkPost(url: string, data: unknown, opts: { token?: string; timeout?: number } = {}): Promise { + const response = await httpRequest({ + method: 'POST', + url, + headers: buildHeaders(opts.token), + data, + timeout: opts.timeout ?? API_TIMEOUT_MS + }) + return response.data +} + +async function iLinkGet( + url: string, + params: Record, + opts: { timeout?: number } = {} +): Promise { + const response = await httpRequest({ + method: 'GET', + url, + params, + headers: buildHeaders(), + timeout: opts.timeout ?? API_TIMEOUT_MS + }) + return response.data +} + +interface SendMessageResponse { + ret?: number + errmsg?: string +} + +export async function sendClawBotText(auth: ClawBotAuth, text: string): Promise { + const clientId = `openclaw-weixin-${randomBytes(16).toString('hex')}` + const body = { + msg: { + from_user_id: '', + to_user_id: auth.userId, + client_id: clientId, + message_type: 2, + message_state: 2, + ...(auth.contextToken ? { context_token: auth.contextToken } : {}), + item_list: [{ type: 1, text_item: { text } }] + }, + base_info: { + channel_version: CHANNEL_VERSION, + bot_agent: BOT_AGENT + } + } + + try { + const resp = await iLinkPost(`${BASE_URL}/ilink/bot/sendmessage`, body, { + token: auth.token + }) + const ret = resp?.ret ?? 0 + if (ret === 0) return 'ok' + if (ret === -14) return 'expired' + // -2: prepare failed —— context_token 缺失或已失效(也与限频共用错误码) + if (ret === -2) return 'needs-activation' + return 'error' + } catch (err) { + const status = (err as { status?: number })?.status + if (status === 401 || status === 403) return 'expired' + return 'error' + } +} + +// --------------------------------------------------------------------------- +// 交互式扫码登录 +// --------------------------------------------------------------------------- + +interface QrCodeResponse { + qrcode?: string + qrcode_img_content?: string +} + +interface QrStatusResponse { + status?: string + redirect_host?: string + bot_token?: string + ilink_bot_id?: string + ilink_user_id?: string +} + +function log(msg: string): void { + process.stdout.write(`[ClawBot] ${msg}\n`) +} + +async function displayQrCode(qrUrl: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const qrterm = require('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }) => void } + qrterm.generate(qrUrl, { small: true }) + } catch { + // 二维码渲染失败时仅打印链接 + } + process.stdout.write('若二维码无法显示,可在浏览器打开以下链接后用微信扫描:\n') + process.stdout.write(`${qrUrl}\n`) +} + +async function fetchQrCode(pollBaseUrl: string, localTokenList: string[]): Promise { + return iLinkPost(`${pollBaseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`, { + local_token_list: localTokenList + }) +} + +function readVerifyCodeFromStdin(prompt: string): Promise { + process.stdout.write(prompt) + return new Promise(resolve => { + let input = '' + const onData = (chunk: Buffer) => { + input += chunk.toString() + if (input.includes('\n')) { + process.stdin.removeListener('data', onData) + process.stdin.pause() + resolve(input.trim()) + } + } + process.stdin.resume() + process.stdin.setEncoding('utf-8') + process.stdin.on('data', onData) + }) +} + +export interface ClawBotLoginResult { + ok: boolean + auth?: ClawBotAuth + message: string +} + +/** + * 交互式扫码登录:终端展示二维码,等待手机微信确认。 + * 总超时默认 5 分钟;期间处理数字配对码、二维码过期刷新与 IDC 重定向。 + */ +export async function loginClawBotInteractive(customAuthPath?: string, timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS): Promise { + const existing = loadClawBotAuth(customAuthPath) + const localTokenList = existing?.token ? [existing.token] : [] + + let pollBaseUrl = BASE_URL + let qrRefreshCount = 0 + let pendingVerifyCode: string | undefined + let scannedNotified = false + + const refreshQr = async (): Promise => { + const resp = await fetchQrCode(pollBaseUrl, localTokenList) + if (!resp.qrcode) return null + scannedNotified = false + if (resp.qrcode_img_content) await displayQrCode(resp.qrcode_img_content) + return resp.qrcode + } + + let qrcode: string + try { + log('请使用手机微信扫描以下二维码,以连接微信 ClawBot:') + qrcode = (await refreshQr()) ?? '' + } catch (err) { + return { ok: false, message: `获取二维码失败: ${String(err)}` } + } + if (!qrcode) { + return { ok: false, message: '获取二维码失败(接口可能未放量或网络异常)' } + } + + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + let status: QrStatusResponse + try { + const params: Record = { qrcode } + if (pendingVerifyCode) params.verify_code = pendingVerifyCode + status = await iLinkGet(`${pollBaseUrl}/ilink/bot/get_qrcode_status`, params, { + timeout: QR_LONG_POLL_TIMEOUT_MS + }) + } catch { + // 长轮询超时或网络抖动,视为等待状态继续轮询 + await new Promise(resolve => setTimeout(resolve, 1000)) + continue + } + + switch (status.status) { + case 'wait': + break + case 'scaned': + pendingVerifyCode = undefined + if (!scannedNotified) { + log('已扫码,等待手机确认…') + scannedNotified = true + } + break + case 'need_verifycode': { + const prompt = pendingVerifyCode ? '❌ 数字不匹配,请重新输入手机微信显示的数字:' : '请输入手机微信上显示的数字以继续:' + pendingVerifyCode = await readVerifyCodeFromStdin(prompt) + continue + } + case 'expired': + case 'verify_code_blocked': { + if (status.status === 'verify_code_blocked') log('⛔ 多次输入错误,正在刷新二维码…') + else log('⏳ 二维码已过期,正在刷新…') + pendingVerifyCode = undefined + qrRefreshCount++ + if (qrRefreshCount > MAX_QR_REFRESH_COUNT) { + return { ok: false, message: '二维码多次失效,登录已停止,请稍后重试' } + } + try { + const renewed = await refreshQr() + if (!renewed) return { ok: false, message: '刷新二维码失败' } + qrcode = renewed + } catch { + return { ok: false, message: '刷新二维码失败' } + } + break + } + case 'scaned_but_redirect': + if (status.redirect_host) { + pollBaseUrl = `https://${status.redirect_host}` + log(`服务节点切换: ${status.redirect_host}`) + } + break + case 'binded_redirect': + return { ok: false, message: '该微信已连接过其他 ClawBot 客户端,请先在原客户端解绑后重试' } + case 'confirmed': { + if (!status.ilink_bot_id) { + return { ok: false, message: '登录失败:服务器未返回账号标识' } + } + const auth: ClawBotAuth = { + token: status.bot_token ?? '', + accountId: status.ilink_bot_id, + userId: status.ilink_user_id ?? '', + savedAt: new Date().toISOString() + } + if (!auth.token || !auth.userId) { + return { ok: false, message: '登录失败:服务器未返回完整凭证' } + } + saveClawBotAuth(auth, customAuthPath) + log('✅ 微信 ClawBot 连接成功,凭证已保存') + return { ok: true, auth, message: '连接成功' } + } + default: + break + } + + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + return { ok: false, message: '等待扫码超时(5 分钟),本次跳过 ClawBot 推送' } +} + +// --------------------------------------------------------------------------- +// 激活:通过 getupdates 抓取 context_token +// --------------------------------------------------------------------------- + +interface GetUpdatesResponse { + ret?: number + msgs?: Array<{ context_token?: string; from_user_id?: string }> + sync_buf?: string + get_updates_buf?: string +} + +/** + * 激活等待:提示用户给「微信 ClawBot」发一条消息,通过 getupdates 长轮询抓取 + * 消息中的 context_token 并持久化。iLink 要求主动发送必须携带该 token + * (无 token 时 sendmessage 返回 ret=-2 "prepare failed")。 + */ +export async function waitForClawBotActivation( + auth: ClawBotAuth, + customAuthPath?: string, + timeoutMs = 2 * 60 * 1000 +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const resp = await iLinkPost( + `${BASE_URL}/ilink/bot/getupdates`, + { + get_updates_buf: auth.getUpdatesBuf ?? '', + base_info: { channel_version: CHANNEL_VERSION, bot_agent: BOT_AGENT } + }, + { token: auth.token, timeout: QR_LONG_POLL_TIMEOUT_MS } + ) + + if (resp?.get_updates_buf) auth.getUpdatesBuf = resp.get_updates_buf + const msg = resp?.msgs?.find(m => m?.context_token) + if (msg?.context_token) { + auth.contextToken = msg.context_token + if (msg.from_user_id) auth.userId = msg.from_user_id + saveClawBotAuth(auth, customAuthPath) + log('✅ 已收到激活消息,ClawBot 推送通道就绪') + return true + } + } catch { + // 长轮询超时或网络抖动,继续等待 + } + } + return false +} diff --git a/src/util/ConfigEnvOverrides.ts b/src/util/ConfigEnvOverrides.ts index 10dc65e3..342557ad 100644 --- a/src/util/ConfigEnvOverrides.ts +++ b/src/util/ConfigEnvOverrides.ts @@ -49,6 +49,7 @@ export const ENV_OVERRIDES: EnvOverrideEntry[] = [ { env: 'CONFIG_SEARCH_MAX_BONUS_SEARCHES', path: 'searchSettings.maxBonusSearches', type: 'number' }, { env: 'CONFIG_SEARCH_QUERY_ENGINES', path: 'searchSettings.queryEngines', type: 'array' }, { env: 'CONFIG_SEARCH_ON_BING_LOCAL', path: 'searchOnBingLocalQueries', type: 'bool' }, + { env: 'CONFIG_CHINA_API_APPKEY', path: 'searchSettings.chinaApi.appkey', type: 'string' }, // Activities { env: 'CONFIG_ACTIVITY_URL_REWARD', path: 'activities.urlReward', type: 'bool' }, @@ -79,6 +80,17 @@ export const ENV_OVERRIDES: EnvOverrideEntry[] = [ { env: 'CONFIG_TELEGRAM_BOTTOKEN', path: 'webhook.telegram.botToken', type: 'string' }, { env: 'CONFIG_TELEGRAM_CHATID', path: 'webhook.telegram.chatId', type: 'string' }, + // PushPlus webhook(国内微信推送) + { env: 'CONFIG_PUSHPLUS_ENABLED', path: 'webhook.pushplus.enabled', type: 'bool' }, + { env: 'CONFIG_PUSHPLUS_TOKEN', path: 'webhook.pushplus.token', type: 'string' }, + { env: 'CONFIG_PUSHPLUS_TITLE', path: 'webhook.pushplus.title', type: 'string' }, + { env: 'CONFIG_PUSHPLUS_TEMPLATE', path: 'webhook.pushplus.template', type: 'string' }, + { env: 'CONFIG_PUSHPLUS_CHANNEL', path: 'webhook.pushplus.channel', type: 'string' }, + + // ClawBot webhook(微信 ClawBot 直连推送,凭证由扫码生成) + { env: 'CONFIG_CLAWBOT_ENABLED', path: 'webhook.clawbot.enabled', type: 'bool' }, + { env: 'CONFIG_CLAWBOT_AUTHFILE', path: 'webhook.clawbot.authFile', type: 'string' }, + // ntfy webhook (tags are comma-separated e.g. "bot,notify") { env: 'CONFIG_NTFY_ENABLED', path: 'webhook.ntfy.enabled', type: 'bool' }, { env: 'CONFIG_NTFY_URL', path: 'webhook.ntfy.url', type: 'string' }, diff --git a/src/util/ErrorDiagnostic.ts b/src/util/ErrorDiagnostic.ts index 40b178c5..375410dd 100644 --- a/src/util/ErrorDiagnostic.ts +++ b/src/util/ErrorDiagnostic.ts @@ -2,6 +2,7 @@ import fs from 'fs/promises' import path from 'path' import { createHash } from 'crypto' import type { Page } from 'patchright' +import { formatLocalTimestamp } from '../logging/Logger' interface UnknownPageDiagnosticOptions { platform: 'mobile' | 'desktop' @@ -38,7 +39,7 @@ function unknownPageOutputDir(rawUrl: string, capturedAt: string, platform: stri export async function errorDiagnostic(page: Page, error: Error): Promise { try { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const timestamp = formatLocalTimestamp(new Date(), true).replace(/[:. ]/g, '-') const folderName = `error-${timestamp}` const outputDir = path.join(process.cwd(), 'diagnostics', folderName) @@ -53,7 +54,7 @@ export async function errorDiagnostic(page: Page, error: Error): Promise { const errorLog = ` Name: ${error.name} Message: ${error.message} -Timestamp: ${new Date().toISOString()} +Timestamp: ${formatLocalTimestamp(new Date(), true)} --------------------------------------------------- Stack Trace: ${error.stack || 'No stack trace available'} diff --git a/src/util/Load.ts b/src/util/Load.ts index 71b4400e..c69204da 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -8,7 +8,7 @@ import { validateAccounts, validateConfig } from './Validator' let configCache: Config let envLoaded = false -function getProjectRoot(): string { +export function getProjectRoot(): string { const cwd = process.cwd() if (fs.existsSync(path.join(cwd, 'package.json'))) return cwd @@ -119,6 +119,34 @@ function getAccountIndexes(): string[] { .sort((a, b) => Number(a) - Number(b)) } +// Dashboard-managed dynamic accounts (config/accounts.extra.json), picked up by +// every new run process without a container restart. Env accounts keep priority. +function loadExtraAccounts(): Account[] { + const filePath = path.join(getProjectRoot(), 'config', 'accounts.extra.json') + try { + if (!fs.existsSync(filePath)) return [] + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf-8')) + if (!Array.isArray(parsed)) return [] + return parsed + .filter( + (entry): entry is Record => + Boolean(entry) && typeof (entry as { email?: unknown }).email === 'string' + ) + .map(entry => ({ + email: String(entry.email).trim(), + password: typeof entry.password === 'string' ? entry.password : '', + totpSecret: typeof entry.totpSecret === 'string' ? entry.totpSecret : undefined, + recoveryEmail: typeof entry.recoveryEmail === 'string' ? entry.recoveryEmail : '', + geoLocale: typeof entry.geoLocale === 'string' ? entry.geoLocale : 'auto', + langCode: typeof entry.langCode === 'string' ? entry.langCode : 'en', + proxy: { proxyHttp: false, url: '', port: 0, username: '', password: '' }, + saveFingerprint: { mobile: false, desktop: false } + })) + } catch { + return [] + } +} + export function loadAccounts(): Account[] { try { ensureEnvLoaded() @@ -143,6 +171,8 @@ export function loadAccounts(): Account[] { }) } + accounts.push(...loadExtraAccounts()) + if (!accounts.length) { throw new Error('No accounts found in environment. Set at least one ACCOUNT_N_EMAIL (see env.example).') } diff --git a/src/util/SessionStore.ts b/src/util/SessionStore.ts index a8f3b936..b7833e0a 100644 --- a/src/util/SessionStore.ts +++ b/src/util/SessionStore.ts @@ -133,16 +133,6 @@ export function saveStorageState( .run(email, platformOf(isMobile), JSON.stringify(sanitized), Date.now()) } -export function clearStorageState(sessionPath: string, email: string, isMobile: boolean): void { - getDb(sessionPath) - .prepare( - `UPDATE sessions - SET storage_state = NULL, updated_at = ? - WHERE email = ? AND platform = ?` - ) - .run(Date.now(), email, platformOf(isMobile)) -} - export function saveFingerprint( sessionPath: string, email: string, diff --git a/src/util/Validator.ts b/src/util/Validator.ts index 907cc998..4e26db5f 100644 --- a/src/util/Validator.ts +++ b/src/util/Validator.ts @@ -44,7 +44,7 @@ const DelaySchema = z }) const QueryEngineSchema = z.union([ - z.enum(['google', 'wikipedia', 'wikirandom', 'hackernews', 'reddit', 'local']), + z.enum(['china', 'google', 'wikipedia', 'wikirandom', 'hackernews', 'reddit', 'local']), z .string() .regex(/^rss(\.[A-Za-z0-9_-]+){0,2}$/, 'Invalid rss selector (use rss, rss., or rss..)') @@ -98,6 +98,21 @@ const WebhookSchema = z.object({ chatId: z.string() }) .optional(), + pushplus: z + .object({ + enabled: z.boolean().optional(), + token: z.string(), + title: z.string().optional(), + template: z.enum(['txt', 'html', 'markdown']).optional(), + channel: z.string().optional() + }) + .optional(), + clawbot: z + .object({ + enabled: z.boolean().optional(), + authFile: z.string().optional() + }) + .optional(), webhookLogFilter: LogFilterSchema }) @@ -143,7 +158,12 @@ export const ConfigSchema = z.object({ queryEngines: z.array(QueryEngineSchema), searchResultVisitTime: NumberOrString, searchDelay: DelaySchema, - readDelay: DelaySchema + readDelay: DelaySchema, + chinaApi: z + .object({ + appkey: z.string().optional() + }) + .optional() }), experimental: z .object({ @@ -285,7 +305,8 @@ const defaultConfig: Config = { queryEngines: ['google', 'wikipedia', 'wikirandom', 'hackernews', 'reddit', 'local'], searchResultVisitTime: '10sec', searchDelay: { min: '30sec', max: '1min' }, - readDelay: { min: '30sec', max: '1min' } + readDelay: { min: '30sec', max: '1min' }, + chinaApi: { appkey: '' } }, experimental: { apiSearch: false,