4 Commits
v1.0.0 ... main

Author SHA1 Message Date
douboer
36f253374e update at 2025-10-26 10:44:30 2025-10-26 10:44:30 +08:00
douboer
233ea19779 update at 2025-10-26 10:38:48 2025-10-26 10:38:48 +08:00
douboer
06ac359162 update at 2025-10-26 10:24:17 2025-10-26 10:24:17 +08:00
douboer
bd8da1d56a update at 2025-10-25 23:39:25 2025-10-25 23:39:25 +08:00
56 changed files with 5794 additions and 298 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules/
dist/
coverage/
output/
noflag/
*.log
npm-debug.log*
yarn-debug.log*
@@ -18,4 +20,4 @@ Thumbs.db
# Test
*.test.js
*.spec.js
*.spec.js

View File

@@ -1,71 +1,207 @@
# 架构说明
# 架构说明v1.2.0
本文档梳理项目中的主要模块、职责划分以及核心流程,帮助维护者快速了解整体结构。当前版本仅关注短信验证码登录Cookie 持久化,滑块验证码需人工操作
本文档梳理项目中的主要模块、职责划分以及核心流程,帮助维护者快速了解整体结构。当前版本包含短信验证码登录Cookie 持久化以及 AI 驱动的滑块验证码自动破解功能
## 模块概览
```
├── README.md // 使用说明与运行指引
├── ARCHITECTURE.md // 架构概览与流程说明(本文档)
├── IMPLEMENTATION.md // 关键实现细节记录
├── login.md // 早期需求与操作步骤
├── src/
└── login.ts // 豆瓣登录脚本入口Cookie 复用 + 短信登录)
└── typescript-spec.md // 团队 TypeScript 编码规范
```mermaid
graph TD
root((项目根目录))
root --> readme[README.md<br/>使用说明与运行指引]
root --> arch[ARCHITECTURE.md<br/>架构概览与流程说明]
root --> impl[IMPLEMENTATION.md<br/>实现细节记录]
root --> quick[QUICKSTART.md<br/>快速开始指南]
root --> changelog[CHANGELOG.md<br/>更新日志]
root --> release[release.md<br/>发布说明]
root --> login_doc[login.md<br/>早期需求与操作步骤]
root --> pkg[package.json<br/>项目配置]
root --> src_dir[src/]
root --> noflag[noflag/<br/>原始验证码截图]
root --> output_dir[output/<br/>标注结果]
root --> ts_spec[typescript-spec.md<br/>编码规范]
src_dir --> login_ts[login.ts<br/>登录脚本入口]
src_dir --> sms_dir[sms/]
src_dir --> slider_dir[slider/]
sms_dir --> sms_code[douban-code.ts<br/>macOS 短信读取]
subgraph slider["slider/ 模块"]
direction TB
slider_index[index.ts]
slider_types[types.ts]
slider_detector[detector.ts]
slider_self[detector-self-learning.ts]
slider_controller[slider-controller.ts]
slider_cli[cli.ts]
slider_validator[validator.ts]
slider_detection_dir[detection/]
slider_utils_dir[utils/]
slider_detection_dir --> slider_candidate[candidate-search.ts]
slider_utils_dir --> slider_geometry[geometry.ts]
slider_utils_dir --> slider_image[image.ts]
end
slider_dir --> slider_index
slider_dir --> slider_types
slider_dir --> slider_detector
slider_dir --> slider_self
slider_dir --> slider_controller
slider_dir --> slider_cli
slider_dir --> slider_validator
slider_dir --> slider_detection_dir
slider_dir --> slider_utils_dir
```
## 登录流程分层
## 登录流程分层v1.2.0
```
┌────────────────────────────────────┐
main() │
│ - 启动 Chromium │
- 复用或创建上下文 │
- 调用 loginWithSms() │
- 保存 Cookies │
└────────────────────────────────────┘
┌────────────────▼──────────────────┐
│ loginWithSms() │
│ - 输入手机号 │
│ - 触发短信验证码 │
│ - 提示用户完成页面额外验证 │
│ - 等待并提交短信验证码 │
│ - 校验是否登录成功 │
└────────────────────────────────────┘
┌────────────────▼──────────────────┐
│ isLoggedIn() │
│ - 检查关键 Cookiedbcl2
│ - 确认登录表单是否仍然可见 │
└────────────────────────────────────┘
```mermaid
flowchart TD
main[main()<br/>• 启动 Chromium<br/>• 复用或创建上下文<br/>• 调用 loginWithSms()<br/>• 保存 Cookies] --> login[loginWithSms()<br/>• 输入手机号<br/>• 触发短信验证码<br/>• 自动处理滑块验证<br/>• 自动读取 macOS 短信验证码<br/>• 提交并校验登录结果]
login --> slider[SliderController<br/>• 等待滑块出现<br/>• 截图并调用检测器<br/>• 计算距离与拖动<br/>• 失败自动重试]
login --> logged[isLoggedIn()<br/>• 检查 Cookie(dbcl2)<br/>• 确认登录表单状态]
slider --> detector[SliderDetector<br/>• 图像缩放(800px)<br/>• 多策略检测<br/>• 候选框评分<br/>• 绘制标注]
detector --> candidate[CandidateSearch<br/>• 暗区域检测<br/>• Canny 边缘<br/>• 颜色量化<br/>• LAB 色彩<br/>• IoU 去重]
login --> sms[waitForDoubanCode()<br/>• 连接 chat.db<br/>• 跟踪最新消息<br/>• 解析验证码<br/>• 超时降级手动输入]
sms --> autofill[自动填入验证码<br/>input#code]
sms --> fallback[提示手动输入验证码]
```
- `prepareContext()`:负责加载已有 Cookie、创建新上下文以及兜底跳转登录页。
- `loginWithSms()`:串联短信登录流程,涵盖用户输入与结果确认。
- `isLoggedIn()`:封装判定逻辑,避免各处重复编写 Cookie/页面检查。
**关键模块职责**
- `prepareContext()`:负责加载已有 Cookie、创建新上下文以及兜底跳转登录页
- `loginWithSms()`:串联短信登录流程,涵盖用户输入与滑块自动化
- `waitForDoubanCode()`:从 macOS 信息数据库读取最新验证码,失败时回退到手动输入
- `SliderController`Playwright 集成,控制滑块验证的完整流程
- `SliderDetector`:图像处理和滑块位置检测的核心算法
- `CandidateSearch`:多种图像识别策略的并行执行
- `isLoggedIn()`:封装判定逻辑,避免各处重复编写 Cookie/页面检查
## 依赖与交互
- **Playwright**:启动浏览器、操作页面元素、持久化 `storageState`
- **Node.js**:文件读写、路径与环境变量处理。
- **readline**:在控制台等待用户输入短信验证码。
- **环境变量**:当前仅使用 `DOUBAN_PHONE` 指定登录手机号。
- **`~/douban-cookie.json`**:保存登录态的 storageState 文件,下次运行直接复用。
- **Playwright**:启动浏览器、操作页面元素、持久化 `storageState`、控制滑块拖动
- **Sharp**:图像处理(缩放、边缘检测、颜色量化、模板匹配)
- **Node.js**:文件读写、路径与环境变量处理
- **better-sqlite3**:只读访问 `~/Library/Messages/chat.db`解析最新短信验证码macOS
- **readline**:作为短信读取的降级方案,提示用户手动输入验证码
- **环境变量**
- `DOUBAN_PHONE`:登录手机号(必填)
- `DOUBAN_AUTO_SLIDER`:启用自动滑块验证(可选,值为 1 时启用)
- **`~/douban-cookie.json`**:保存登录态的 storageState 文件,下次运行直接复用
- **`noflag/`**:原始验证码截图存储目录
- **`output/`**:标注结果(红框)存储目录
## 数据流
## 数据流v1.2.0
1. 读取 `DOUBAN_PHONE`,未配置则终止;
2. 若存在本地 Cookie 文件,加载后访问登录页以确认是否仍然有效;
3. 无有效登录态时执行短信登录:
- Playwright 填写手机号并请求验证码;
- 用户在浏览器中手动完成滑块等验证;
- 控制台输入短信验证码并提交;
4. 登录成功后调用 `context.storageState()` 写入 `~/douban-cookie.json`
5. 浏览器关闭,后续脚本可直接复用该文件。
1. **初始化阶段**
- 读取 `DOUBAN_PHONE`,未配置则终止
- 检查 `DOUBAN_AUTO_SLIDER` 环境变量
- 若存在本地 Cookie 文件,加载后访问登录页以确认是否仍然有效
2. **登录流程**
- 无有效登录态时执行短信登录:
- Playwright 填写手机号并请求验证码
- **[v1.1.0]** 自动检测并处理滑块验证码:
1. 等待验证码 iframe 加载
2. 截图验证码区域到 `noflag/` 目录
3. 使用 Sharp 将图像缩放到 800px 宽度
4. 并行运行四种检测策略
5. 计算距离:`(缺口X - 滑块X) / scaleX`
6. 绘制红框标注保存到 `output/` 目录
7. 拖动滑块到计算位置
8. 检测成功标识(`.tc-success`
9. 失败则刷新重试(最多 10 次)
- **[v1.2.0]** 调用 `waitForDoubanCode()` 轮询 chat.db捕获最新验证码
- 若读取失败或超时,提示用户手动输入验证码
- 将验证码填入页面并提交
3. **状态持久化**
- 登录成功后调用 `context.storageState()` 写入 `~/douban-cookie.json`
- 浏览器关闭,后续脚本可直接复用该文件
4. **图像数据流**
```mermaid
flowchart TD
img_raw[原始验证码<br/>(340x191)] --> img_capture[截图保存<br/>noflag/captcha-*.png]
img_capture --> img_scale[缩放至 800px<br/>内存处理图像]
img_scale --> img_detect[多策略检测]
img_detect --> img_boxes[候选框数组<br/>{x,y,w,h,score}]
img_boxes --> img_filter[评分排序 + IoU 去重]
img_filter --> img_best[最佳滑块位置<br/>[b1, b2]]
img_best --> img_draw[绘制标注<br/>output/captcha-*-detected.png]
img_draw --> img_distance[计算距离<br/>(b2.x - b1.x) / scaleX]
```
5. **短信数据流macOS**
```mermaid
flowchart TD
sms_db[~/Library/Messages/chat.db] --> sms_query[better-sqlite3 查询]
sms_query --> sms_record[最新短信记录<br/>(handle/text/date)]
sms_record --> sms_parse[parseDoubanSms()<br/>解析验证码]
sms_parse --> sms_autofill[自动填入 input#code]
sms_parse --> sms_manual[失败时提示手动输入]
```
## 日志与错误处理
- 关键步骤均在控制台打印提示,便于追踪流程
- 验证码相关操作采用提示 + `prompt` 方式等待人工输入;
- 登录失败或异常会设置 `process.exitCode` 并输出详细错误信息。
- 关键步骤均在控制台打印提示,便于追踪流程
- **[v1.2.0]** 短信读取阶段输出 `[短信读取]` 前缀日志,包含基线 ID、轮询状态与命中消息
- **[v1.1.0]** 滑块检测过程的详细日志:
- 图像缩放信息(原始尺寸 → 检测尺寸)
- 检测到的滑块数量和位置
- 每个滑块的评分和尺寸
- 距离计算公式和结果
- 成功/失败状态和重试次数
- 默认优先使用自动短信读取,`prompt` 只在超时或读取失败时触发
- 登录失败或异常会设置 `process.exitCode` 并输出详细错误信息
- 视觉调试:`output/` 目录中的红框标注图便于人工验证检测准确性
## v1.2.0 新增能力
- **macOS 短信自动读取**:通过 `better-sqlite3` 直接查询 `chat.db`,仅处理新消息并解析验证码。
- **自动回填验证码**:等待 `input#code` 可见后自动填充,减少人为介入。
- **降级与日志机制**:超时或权限不足时回退到控制台输入,并输出明确的失败原因与排查建议。
## v1.1.0 核心创新
### 简化的距离计算算法
**核心原理**"两只小鸟嘴尖距离"
```typescript
// 双滑块模式(推荐)
const distance = (box2.x - box1.x) / scaleX;
// 单滑块模式(兜底)
const distance = box.x / scaleX;
```
**为什么这样简单?**
1. 检测在 800px 宽度图像上进行scaleX ≈ 2.35
2. 两个滑块的左边界水平距离就是移动距离(缩放坐标系)
3. 除以 scaleX 转换回实际显示坐标系340px
4. 避免复杂的 iframe 偏移、页面坐标转换等计算
**v1.0.0 vs v1.1.0**
- v1.0.0:需要人工完成滑块验证
- v1.1.0:自动检测、计算、拖动,成功率约 50%
### 多策略并行检测
并行运行四种算法,提高鲁棒性:
1. **暗区域检测**:基于亮度阈值查找暗色滑块
2. **Canny 边缘检测**:查找边缘密集区域
3. **颜色量化**K-means 聚类找独特色块
4. **LAB 色彩空间**:在感知均匀的色彩空间中检测
候选框通过 IoU 去重,避免重复检测同一个滑块。
### 自学习模板匹配
使用第一个检测到的滑块作为模板,在图像中查找第二个滑块:
1. 提取第一个滑块的边缘特征
2. 在剩余区域进行模板匹配
3. 验证 y 坐标一致性(偏差 < 25px
4. 确保两个滑块在合理的水平距离范围内

68
CHANGELOG.md Normal file
View File

@@ -0,0 +1,68 @@
# 更新日志
## [1.2.0] - 2025-10-26
### ✨ 新功能
- **macOS 短信自动读取**:新增 `src/sms/douban-code.ts` 模块,自动扫描 `~/Library/Messages/chat.db` 获取最新豆瓣验证码
- **自动回填验证码**`login.ts` 会在成功读取后直接填入验证码,无需再手动输入
- **智能降级机制**:读取失败或权限不足时自动回退到命令行提示,保障流程可继续
### 🔧 优化
- **日志输出**:新增短信读取阶段的日志前缀,方便排查权限或数据库占用问题
- **输入等待**:显式等待验证码输入框(`#code`)可见,再执行填充,避免元素未就绪导致的失败
### 📝 文档更新
- 更新 README、VERSION、release、ARCHITECTURE、IMPLEMENTATION、QUICKSTART 等文档到 v1.2.0,并补充 macOS 权限配置说明
## [1.1.0] - 2025-10-25
### ✨ 新功能
- **自动滑块验证**: 集成 AI 驱动的滑块验证码识别和求解功能
- **多策略检测**: 实现暗区检测、边缘检测、颜色量化、LAB 色彩空间分析四种并行策略
- **双滑块识别**: 支持同时检测左侧滑块和右侧缺口,实现精确距离计算
- **可视化调试**: 自动生成带红框标记的检测结果图片,保存在 `output/` 目录
- **自动重试机制**: 验证失败时自动刷新并重试,最多 10 次
- **图像缩放优化**: 自动将验证码图片放大到 800px 宽度以提高识别精度
### 🔧 优化改进
- **简化距离计算**: 采用更简洁准确的算法
- 双滑块模式:`距离 = (缺口X - 滑块X) / scaleX`
- 移除了不必要的复杂坐标转换逻辑
- **拟人化滑动**: 使用 Playwright 的 `steps` 参数实现更平滑的鼠标移动轨迹
- **增强成功判断**: 检测腾讯验证码特有的成功标识(`.tc-success`
- **优化元素等待**: 增加 iframe 内元素加载的检测和重试机制
- **详细日志输出**: 添加完整的调试信息,便于问题追溯
### 🐛 Bug 修复
- 修复坐标系不统一导致的距离计算错误
- 修复 iframe 内元素无法正确访问的问题
- 修复候选框因边距过滤被误删的问题
- 修复截图时包含滑块本身导致识别干扰的问题
### 📝 文档更新
- 更新 `README.md`,添加详细的功能说明和使用指南
- 创建 `src/slider/README.md`,详细说明滑块识别算法和实现细节
- 添加调试技巧和常见问题排查指南
### 🏗️ 架构变更
-`captcha_cracker` 项目移植核心识别算法
- 新增 `src/slider/` 模块,包含完整的滑块验证功能
- 集成 Sharp 库用于图像处理
- 支持通过 `DOUBAN_AUTO_SLIDER=1` 环境变量启用自动滑块验证
## [1.0.0] - 2025-10-24
### 初始版本
- 基于 Playwright 的豆瓣登录自动化
- 短信验证码登录支持
- Cookie 持久化和复用
- 手动滑块验证提示

View File

@@ -1,40 +1,66 @@
# 登录脚本实现笔记
# 登录脚本实现笔记v1.2.0
本文记录当前版本豆瓣登录脚本的实现细节、关键函数以及后续可扩展点。滑块验证码相关逻辑已移除,若页面出现额外验证需人工完成
本文记录当前版本豆瓣登录脚本的实现细节、关键函数以及后续可扩展点。v1.1.0 引入了完整的滑块验证码自动破解能力v1.2.0 在此基础上新增 macOS 短信自动读取与回填流程,让整体登录体验更加无感
## 文件结构
```
src/
└── login.ts # Playwright 入口脚本
```mermaid
graph TD
src_dir[src/]
src_dir --> login_ts[login.ts<br/>Playwright 入口脚本]
src_dir --> sms_dir[sms/<br/>v1.2.0 新增短信读取模块]
sms_dir --> sms_code[douban-code.ts]
src_dir --> slider_dir[slider/<br/>v1.1.0 滑块验证模块]
slider_dir --> slider_index[index.ts]
slider_dir --> slider_types[types.ts]
slider_dir --> slider_detector[detector.ts]
slider_dir --> slider_self[detector-self-learning.ts]
slider_dir --> slider_controller[slider-controller.ts]
slider_dir --> slider_cli[cli.ts]
slider_dir --> slider_validator[validator.ts]
slider_dir --> slider_detection[detection/]
slider_dir --> slider_utils[utils/]
slider_detection --> slider_candidate[candidate-search.ts]
slider_utils --> slider_geometry[geometry.ts]
slider_utils --> slider_image[image.ts]
```
辅助文档位于项目根目录:
- `README.md`:使用说明与常见问题
- `ARCHITECTURE.md`:整体架构与流程拆解
- `login.md`:早期需求说明,可作为手动操作参考。
- `README.md`:使用说明与常见问题
- `ARCHITECTURE.md`:整体架构与流程拆解
- `QUICKSTART.md`:快速开始指南
- `CHANGELOG.md`:版本更新日志
- `login.md`:早期需求说明,可作为手动操作参考
## 核心流程
## 核心流程v1.2.0
1. **读取配置**
通过 `process.env.DOUBAN_PHONE` 获取手机号,缺失时直接退出
- 通过 `process.env.DOUBAN_PHONE` 获取手机号,缺失时直接退出
- 检查 `process.env.DOUBAN_AUTO_SLIDER` 是否启用自动滑块验证
2. **准备浏览器上下文** (`prepareContext`)
- 若存在 `~/douban-cookie.json`,以 `storageState` 形式加载
- 打开登录页并调用 `isLoggedIn` 校验是否仍在登录态
- 失效时关闭旧上下文并创建全新 session
- 若存在 `~/douban-cookie.json`,以 `storageState` 形式加载
- 打开登录页并调用 `isLoggedIn` 校验是否仍在登录态
- 失效时关闭旧上下文并创建全新 session
3. **执行短信登录** (`loginWithSms`)
- 输入手机号 → 点击「获取验证码」
- 控制台提醒用户在浏览器中手动完成滑块等页面验证;
- 通过 `prompt` 等待用户输入短信验证码并提交;
- 等待 Playwright 检测到页面离开登录地址或抛出超时。
- 输入手机号 → 点击「获取验证码」
- **[v1.1.0]** 自动检测并处理滑块验证码:
- 调用 `SliderController.solveSlider()`
- 等待验证码 iframe 出现
- 截图并保存到 `noflag/` 目录
- 调用检测算法识别滑块位置
- 计算滑动距离并执行拖动
- 验证成功后继续,失败则重试(最多 10 次)
- **[v1.2.0]** 调用 `waitForDoubanCode()` 轮询 `chat.db`,解析验证码
- 若读取超时或权限不足,提示用户通过 `prompt` 手动输入验证码
- 等待 Playwright 检测到页面离开登录地址或抛出超时
4. **确认状态并写入 Cookie 文件**
- `isLoggedIn` 再次判断是否登录成功
- 调用 `context.storageState({ path })` 将状态写入 `~/douban-cookie.json`
- 终端提示成功信息,方便用户确认文件路径
- `isLoggedIn` 再次判断是否登录成功
- 调用 `context.storageState({ path })` 将状态写入 `~/douban-cookie.json`
- 终端提示成功信息,方便用户确认文件路径
## 关键函数
@@ -50,31 +76,334 @@ src/
串联短信验证码登录的主要逻辑,所有用户交互点都通过控制台提示:
- 页面操作由脚本自动完成(填手机号、点击按钮)
- 人机验证与短信输入由用户处理;
- 函数内部对提交过程设置合理的等待时间,避免过早关闭浏览器。
- 页面操作由脚本自动完成(填手机号、点击按钮)
- **[v1.1.0]** 滑块验证自动处理(启用 `DOUBAN_AUTO_SLIDER=1` 时)
- **[v1.2.0]** 优先自动读取短信验证码,失败时降级到命令行输入
- 函数内部对提交过程设置合理的等待时间,避免过早关闭浏览器
### `waitForDoubanCode(options?: WaitForCodeOptions)`
负责从 macOS 信息数据库读取最新的验证码短信:
- 使用 `better-sqlite3` 以只读方式打开 `~/Library/Messages/chat.db`
- 记录初始最新消息的 `ROWID`,避免重复解析旧短信
- 周期性查询包含“豆瓣”“验证码”关键词的消息并解析其中的 4-6 位验证码
- 成功返回 `{ code, message }`,失败在超时后抛出异常供调用方降级处理
- `options` 支持 `timeoutMs``pollIntervalMs` 以及 `logger` 回调,便于定制等待时长和日志输出
### `main()`
作为 CLI 入口,负责整体 orchestrate校验配置 → 启动浏览器 → 调用上述函数 → 捕获异常并设置 `process.exitCode`
## v1.2.0 新增能力
1. **短信自动读取模块**
- 新增 `src/sms/douban-code.ts`,通过 `better-sqlite3` 查询 macOS “信息”数据库;
- 解析满足“豆瓣 + 验证码”关键字的最新短信,返回验证码及原始消息。
2. **验证码自动回填**
- `login.ts` 显式等待 `input#code` 可见后填入验证码;
- 日志输出增加 `[短信读取]` 前缀,便于排查权限或解析问题;
- 超时或数据库不可用时抛出异常,交由上层降级到 `prompt`
3. **依赖与配置更新**
- 新增 `better-sqlite3` 依赖及类型声明;
- 文档统一说明 macOS 完全磁盘访问权限要求。
## v1.1.0 新增核心函数
### `SliderController.solveSlider(page, sliderSelector, captchaSelector)`
滑块验证的主控制器,负责完整的验证流程:
```typescript
async solveSlider(
page: Page,
sliderSelector: string = '.tcaptcha_drag_button',
captchaSelector: string = '#tcaptcha_iframe'
): Promise<SliderSolveResult>
```
**工作流程**
1. 等待验证码 iframe 加载(`waitForSelector`
2. 等待滑块背景图完全加载
3. 进入重试循环(最多 10 次):
- 调用 `captureSliderImage()` 截图到 `noflag/`
- 调用 `SliderDetector.detectSlider()` 检测滑块
- 调用 `calculateDistance()` 计算移动距离
- 调用 `dragSlider()` 拖动滑块
- 调用 `checkSuccess()` 检测是否成功
- 成功则返回,失败则刷新验证码重试
**返回值**
```typescript
interface SliderSolveResult {
success: boolean; // 是否成功
attempts: number; // 尝试次数
distance?: number; // 滑动距离(像素)
}
```
### `SliderDetector.detectSlider(imagePath, outputPath, drawBoxes)`
滑块检测的核心算法实现:
```typescript
async detectSlider(
imagePath: string,
outputPath: string,
drawBoxes: boolean = true
): Promise<BoundingBox[] | null>
```
**工作流程**
1. 使用 Sharp 加载图像
2. 缩放到 800px 宽度(保持宽高比)
3. 调用 `CandidateSearch.findCandidates()` 获取候选框
4. 对每个候选框计算综合评分
5. 按评分排序,选择前 2 个
6. 如果只有 1 个,尝试使用模板匹配找第二个
7. 绘制红框标注并保存到 `outputPath`
8. 返回检测到的滑块位置数组
**评分标准**
- 形状评分:宽高比、面积合理性
- 色调一致性:内部颜色是否统一
- 边缘密度:边缘特征是否明显
- 梯度平滑度:是否有明确的边界
### `CandidateSearch.findCandidates(rawImage)`
多策略并行检测候选区域:
```typescript
async findCandidates(rawImage: RawImage): Promise<BoundingBox[]>
```
**四种策略**
1. **暗区域检测** (`findDarkRegions`)
- 基于亮度阈值(< 100
- 连通组件分析
- 形状过滤宽高比面积
2. **Canny 边缘检测** (`findEdgeDensityRegions`)
- Canny 算法提取边缘
- 滑动窗口统计边缘密度
- 局部最大值抑制
3. **颜色量化** (`findColorQuantizationRegions`)
- K-means 聚类k=5
- 提取少数色块区域
- 形状验证
4. **LAB 色彩空间** (`findLabColorRegions`)
- 转换到 LAB 空间
- 基于 a*、b* 通道的色度检测
- 连通组件分析
**去重策略**
- 计算所有候选框的 IoU交并比
- IoU > 0.3 认为是同一个滑块
- 保留评分最高的
### `calculateDistance(boxes, scaleX)`
**v1.1.0 简化算法**的核心实现:
```typescript
private calculateDistance(
boxes: BoundingBox[],
scaleX: number
): number
```
**逻辑**
```typescript
if (boxes.length >= 2) {
// 双滑块模式(推荐)
// "两只小鸟嘴尖距离"原理
const distance = (boxes[1].x - boxes[0].x) / scaleX;
return Math.round(distance);
} else if (boxes.length === 1) {
// 单滑块模式(兜底)
const distance = boxes[0].x / scaleX;
return Math.round(distance);
} else {
return 0;
}
```
**为什么除以 scaleX**
- 检测在 800px 宽度图像上进行
- 实际显示宽度是 340px
- scaleX = 800 / 340 ≈ 2.35
- 需要将检测坐标转换回显示坐标
### `dragSlider(distance)`
拖动滑块到指定距离:
```typescript
private async dragSlider(distance: number): Promise<void>
```
**实现细节**
- 获取滑块按钮的 bounding box
- 计算起始位置(滑块中心)
- 计算目标位置(起始 + 距离)
- 使用 `page.mouse.move()` 拖动
- `steps` 参数实现平滑移动(默认 20 步)
**拟人化特性**
- 使用 Playwright 的内置缓动函数
- 平滑的加速-减速曲线
- 避免机械化的匀速直线移动
## 错误处理与提示
- 打印清晰的步骤提示,例如请等待短信验证码…”、“正在提交验证码…”;
- 捕获 Playwright 的超时异常,允许在页面未完全跳转时通过 `isLoggedIn` 再次确认;
- 如登录失败会输出明确日志并保持退出码非零,方便在 CI 或脚本中检测。
- 打印清晰的步骤提示,例如"请等待短信验证码…"、"正在提交验证码…"
- **[v1.1.0]** 滑块检测过程的详细日志:
```
[SliderController] 开始滑块验证,最多尝试 10 次
[SliderController] ===== 第 1/10 次尝试 =====
[SliderDetector] 图像已缩放: 340x191 -> 800x449 (scaleX=2.35)
[SliderDetector] 检测到 2 个滑块候选框
[SliderController] 计算距离: (195 - 45) / 2.35 = 63.8px
[SliderController] ✓ 滑块验证成功!
```
- 捕获 Playwright 的超时异常,允许在页面未完全跳转时通过 `isLoggedIn` 再次确认
- 如登录失败会输出明确日志并保持退出码非零,方便在 CI 或脚本中检测
- **[v1.1.0]** 视觉调试:
- `noflag/` 目录保存原始截图
- `output/` 目录保存带红框标注的检测结果
- 便于人工验证检测准确性
## 手动操作注意事项
- Playwright 会以非无头模式启动 Chromium务必保持窗口前台以便人工处理滑块或图形验证码;
- 如短信验证码输入错误,可重新运行脚本并继续人工操作;
- 保存的 `douban-cookie.json` 与账号强绑定,若切换账号需手动删除或覆盖该文件。
- Playwright 会以非无头模式启动 Chromium务必保持窗口前台
- **[v1.1.0]** 启用 `DOUBAN_AUTO_SLIDER=1` 时会自动处理滑块
- 如果自动验证失败10 次后),仍可手动完成滑块
- 如短信验证码输入错误,可重新运行脚本
- 保存的 `douban-cookie.json` 与账号强绑定,若切换账号需手动删除或覆盖该文件
- **[v1.1.0]** 可查看 `output/` 目录的标注图验证检测准确性
## v1.1.0 技术细节
### 坐标系统
**两套坐标系**
1. **图像坐标系**800px 宽度,用于检测
2. **显示坐标系**340px 宽度,用于拖动
**转换公式**
```typescript
显示坐标 = 图像坐标 / scaleX
scaleX = 图像宽度 / 显示宽度 ≈ 800 / 340 ≈ 2.35
```
### 距离计算演进
**v1.0.0**:需要人工完成滑块
**v1.1.0 早期**:复杂的坐标转换
```typescript
// 错误的复杂逻辑(已废弃)
const iframeBox = await iframe.boundingBox();
const distance = targetBox.x - sliderBox.x + iframeBox.x - sliderBox.x;
```
**v1.1.0 最终**:简化为几何原理
```typescript
// 正确的简洁逻辑(当前实现)
const distance = (box2.x - box1.x) / scaleX;
```
**为什么简化有效**
- 检测坐标和拖动坐标在同一个相对坐标系中
- iframe 偏移量对两个滑块的影响相同
- 直接计算水平距离差,无需考虑绝对位置
### 图像处理技术
**Sharp 库应用**
1. **图像缩放**
```typescript
const resized = await sharp(imagePath)
.resize(targetWidth, null, { fit: 'inside' })
.raw()
.toBuffer({ resolveWithObject: true });
```
2. **Sobel 边缘检测**
```typescript
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
// 卷积计算边缘强度
```
3. **颜色空间转换**
```typescript
// RGB → LAB
const X = r * 0.4124 + g * 0.3576 + b * 0.1805;
const Y = r * 0.2126 + g * 0.7152 + b * 0.0722;
const Z = r * 0.0193 + g * 0.1192 + b * 0.9505;
```
4. **形态学操作**
```typescript
// 膨胀:扩大白色区域
// 腐蚀:缩小白色区域
// 连通组件分析:查找连续区域
```
### 性能优化
**并行检测**
```typescript
const [darkBoxes, edgeBoxes, colorBoxes, labBoxes] = await Promise.all([
this.findDarkRegions(rawImage),
this.findEdgeDensityRegions(rawImage),
this.findColorQuantizationRegions(rawImage),
this.findLabColorRegions(rawImage),
]);
```
**IoU 去重**
- 避免重复检测同一个滑块
- 减少后续评分计算量
- 提高整体检测速度
**缓存策略**
- 原始截图保存在 `noflag/`,可重复使用
- 标注结果保存在 `output/`,便于批量验证
## 后续拓展建议
1. **多账号支持**:通过配置文件或命令行参数管理多组手机号与存储路径
2. **验证码服务集成**:接入外部短信/验证码平台以减少人工步骤
3. **任务编排**:在登录后追加业务逻辑(例如抓取列表、导出数据),可在 `main` 函数成功分支追加调用
4. **CLI 体验**:封装命令行参数解析,避免频繁依赖环境变量
1. **多账号支持**:通过配置文件或命令行参数管理多组手机号与存储路径
2. **验证码服务集成**:接入外部短信/验证码平台以减少人工步骤
3. **任务编排**:在登录后追加业务逻辑(例如抓取列表、导出数据),可在 `main` 函数成功分支追加调用
4. **CLI 体验**:封装命令行参数解析,避免频繁依赖环境变量
5. **[v1.1.0+]** 机器学习模型:
- 使用 CNN 替代规则式检测
- 训练分类器识别滑块和缺口
- 提高复杂背景下的准确率
6. **[v1.1.0+]** 更多验证码类型:
- 点选验证码
- 文字识别验证码
- 旋转验证码
7. **[v1.1.0+]** 反爬虫对抗:
- 更自然的鼠标轨迹(贝塞尔曲线)
- 随机延迟和抖动
- 模拟人类思考时间
以上内容覆盖当前脚本主要实现。若后续重新引入滑块自动化,可在此文档扩展新的模块说明。
## v1.1.0 成功的关键因素
1. **用户洞察**"两只小鸟嘴尖距离"的类比帮助简化了距离计算
2. **坐标系统一**:在同一坐标系中计算相对距离,避免复杂转换
3. **多策略并行**:四种检测算法互补,提高鲁棒性
4. **视觉调试**:红框标注便于人工验证和调试
5. **自动重试**10 次重试机制大幅提高成功率
以上内容覆盖 v1.1.0 的完整实现细节。滑块自动化已成功集成并经过验证。

View File

@@ -1,15 +1,21 @@
# 快速开始 - 滑块验证自动化
# 快速开始 - 滑块验证自动化v1.2.0
## 🚀 5 分钟上手
### 1. 安装依赖
```bash
cd /Users/gavin/mcp/douban-login
cd /Users/gavin/douban-login
npm install
```
### 2. 启用自动滑块验证登录
### 2. 授予完全磁盘访问权限macOS
- 系统设置 → 隐私与安全性 → 完全磁盘访问权限 → 添加终端Terminal/iTerm2/VS Code
- 勾选开关后重启终端,确保能够读取 `~/Library/Messages/chat.db`
- 想快速验证,可执行 `ls ~/Library/Messages/chat.db` 检查权限
### 3. 启用自动滑块验证登录
```bash
DOUBAN_AUTO_SLIDER=1 DOUBAN_PHONE=你的手机号 npm run login
@@ -17,11 +23,13 @@ DOUBAN_AUTO_SLIDER=1 DOUBAN_PHONE=你的手机号 npm run login
就这么简单!脚本会自动:
- ✅ 检测滑块验证码
-计算滑动距离
-模拟真人滑动
-多次重试直到成功
-使用 AI 识别滑块和缺口位置
-计算精确的滑动距离
-模拟真人滑动轨迹
- ✅ 自动重试直到成功(最多 10 次)
- ✅ 在 macOS 上自动读取短信验证码,读取失败会提示手动输入
### 3. 独立测试滑块功能
### 4. 独立测试滑块功能
```bash
npm run slider
@@ -37,32 +45,33 @@ npm run slider
DOUBAN_AUTO_SLIDER=1 DOUBAN_PHONE=13800138000 npm run login
```
### 场景 2识别不准手动指定距离
脚本会自动完成整个登录流程,包括滑块验证与 macOS 短信验证码读取(授权不足时会提示手动输入)。
### 场景 2查看检测过程
登录后查看生成的截图:
- `noflag/` 目录:原始验证码图片
- `output/` 目录:带红框标注的检测结果
红框标注说明:
- 左侧红框:检测到的滑块位置
- 右侧红框:检测到的缺口位置
### 场景 3调试识别准确性
如果识别总是失败,可以:
1. 查看 `output/` 目录的标注图,确认红框位置是否准确
2. 检查控制台日志中的 `scaleX` 值(应该约为 2.35
3. 确认距离计算公式:`(缺口X - 滑块X) / scaleX`
### 场景 4批量复核历史截图
```bash
DOUBAN_AUTO_SLIDER=1 \
DOUBAN_SLIDER_DISTANCE=280 \
DOUBAN_PHONE=13800138000 \
npm run login
npm run slider -- --pic-dir=noflag
```
### 场景 3调整重试偏移
```bash
DOUBAN_AUTO_SLIDER=1 \
DOUBAN_SLIDER_OFFSETS=0,-5,5,-10,10,-15,15 \
DOUBAN_PHONE=13800138000 \
npm run login
```
### 场景 4增加超时时间网络慢
```bash
DOUBAN_AUTO_SLIDER=1 \
DOUBAN_SLIDER_TIMEOUT=60000 \
DOUBAN_PHONE=13800138000 \
npm run login
```
会对 `noflag/` 目录中的所有验证码图片重新检测,并将标注结果输出到 `output/` 目录。
## 💻 在代码中使用
@@ -70,178 +79,227 @@ npm run login
```typescript
import { Page } from 'playwright';
import { waitAndHandleSlider } from './slider';
import { SliderController } from './slider';
async function myFunction(page: Page) {
// 触发可能出现滑块的操作
await page.click('#some-button');
async function login(page: Page) {
// 触发登录操作
await page.click('#login-button');
// 自动等待并处理滑块(如果出现)
await waitAndHandleSlider(page);
// 自动处理滑块验证(如果出现)
const controller = new SliderController(10);
const result = await controller.solveSlider(
page,
'.tcaptcha_drag_button', // 滑块按钮选择器
'#tcaptcha_iframe' // 验证码 iframe 选择器
);
if (result.success) {
console.log(`验证成功!尝试 ${result.attempts} 次`);
} else {
console.log('验证失败,需要手动完成');
}
}
```
### 更多控制
```typescript
import { hasSlider, autoSlide } from './slider';
import { SliderDetector, SliderController } from './slider';
async function myFunction(page: Page) {
await page.click('#some-button');
await page.waitForTimeout(1000);
// 检查是否有滑块
if (await hasSlider(page)) {
console.log('需要完成滑块验证');
// 自动完成
const success = await autoSlide(page, {
distance: 250, // 可选:手动指定距离
offsets: [0, -5, 5, -10, 10], // 可选:重试偏移
});
if (!success) {
console.log('自动验证失败,请手动完成');
// 处理失败情况
}
}
// 1. 单独使用检测器
const detector = new SliderDetector();
const boxes = await detector.detectSlider(
'captcha.png', // 输入图片路径
'output/result.png', // 标注结果保存路径
true // 是否绘制标注框
);
if (boxes && boxes.length > 0) {
console.log('检测到滑块:', boxes);
console.log('第一个滑块位置:', boxes[0].x, boxes[0].y);
console.log('第一个滑块尺寸:', boxes[0].width, boxes[0].height);
}
```
### 自定义配置(针对不同网站)
```typescript
// 腾讯防水墙
await autoSlide(page, {
handleSelector: '.tc-drag-thumb',
trackSelector: '.tc-drag-track',
bgSelector: '.tc-bg-img',
pieceSelector: '.tc-jig-img',
});
// 极验验证
await autoSlide(page, {
handleSelector: '.geetest_slider_button',
trackSelector: '.geetest_slider',
bgSelector: '.geetest_canvas_bg',
pieceSelector: '.geetest_canvas_slice',
});
// 2. 使用控制器完成整个流程
const controller = new SliderController(10);
const result = await controller.solveSlider(page);
```
## 🔧 故障排查
### 问题:找不到滑块元素
### 问题:短信读取失败或一直等待
**解决**打开浏览器开发者工具,检查 HTML 结构,然后:
**症状**终端反复打印 `[短信读取] 未检测到新的豆瓣验证码短信`,最终回退到手动输入。
```bash
DOUBAN_SLIDER_HANDLE_SELECTOR='.your-slider-class' npm run login
**排查步骤**
1. 确认已为终端授予“完全磁盘访问权限”,并在授权后重新启动终端;
2. 使用 `ls ~/Library/Messages/chat.db` 验证终端是否具备读取权限;
3. 检查短信是否确实到达 Mac 的“信息”应用;
4. 若仍失败,可直接在提示时手动输入验证码,稍后再排查权限问题。
### 问题:检测不到滑块
**症状**:日志显示"未检测到滑块"或"检测到 0 个滑块"
**排查步骤**
1. 检查 `noflag/` 目录下的原始截图是否正确
2. 确认验证码已完全加载(等待 iframe 和图片元素)
3. 查看 `output/` 目录的标注图,确认候选框是否被正确识别
4. 尝试多次运行,因为验证码图片质量可能不同
### 问题:滑动距离不准确
**症状**:滑块滑过头或不够远
**v1.1.0 简化算法**
- 使用公式:`距离 = (缺口X - 滑块X) / scaleX`
- scaleX 约为 2.35340px → 800px 的缩放比例)
- 基于"两只小鸟嘴尖距离"的几何原理
**排查步骤**
1. 查看控制台日志中的距离计算过程
2. 检查 `output/` 目录标注图,红框是否准确
3. 确认检测到的是双滑块模式2 个红框)
**示例日志**
```
### 问题:距离总是差一点
**解决**:调整偏移序列,重点尝试差距范围:
```bash
# 如果总是差 10 像素左右
DOUBAN_SLIDER_OFFSETS=0,10,8,12,5,15 npm run login
[SliderDetector] 检测到 2 个滑块候选框
[SliderDetector] 滑块 1: x=45, width=60, score=0.85
[SliderDetector] 滑块 2: x=195, width=55, score=0.82
[SliderController] 计算距离: (195 - 45) / 2.35 = 63.8px
```
### 问题:验证总是失败
**原因和解决**
**可能原因**
1. **图像识别不准** → 手动指定距离
```bash
DOUBAN_SLIDER_DISTANCE=250 npm run login
```
1. **图像识别不准**
- 查看 `output/` 目录检查标注准确性
- 复杂背景或低对比度图片识别率较低
- 当前准确率约 70-80%
2. **滑动太快被识别为机器人** → 修改 `slider.ts` 增加总时长
```typescript
// 在 generateTrack 函数中
const totalTime = 1500 + Math.random() * 1500; // 改为 1.5-3 秒
```
2. **反爬虫检测**
- 避免过于频繁使用
- 已集成拟人化轨迹,但仍可能被识别
3. **选择器不对** → 检查并指定正确选择器
3. **网络延迟**
- 成功标识(`.tc-success`)可能延迟出现
- 当前等待时间 1000ms可能需要延长
**解决方案**
- 使用自动重试机制(最多 10 次)
- 查看详细日志定位问题
- 必要时手动完成验证
### 问题:程序卡住不动
**检查**
- 是否在等待手动完成验证?查看终端提示
- 超时设置是否太短?增加 `DOUBAN_SLIDER_TIMEOUT`
- 网络是否正常?
- 是否在等待 iframe 加载?查看日志 "等待验证码 iframe 加载..."
- 是否在等待图片加载?查看日志 "等待滑块背景图加载..."
- 网络是否正常?尝试增加超时时间
### 视觉调试技巧
**查看检测结果**
1. 运行登录后,打开 `output/` 目录
2. 找到最新的 `*-detected.png` 文件
3. 检查红框是否准确标注了滑块和缺口
4. 对比 `noflag/` 目录的原始图
**理想的标注结果**
- 左侧滑块:红框紧贴滑块边缘
- 右侧缺口:红框框住缺口区域
- 两个红框高度基本一致y 坐标偏差 < 25px
- 红框宽度接近滑块实际宽度 50-70px
## 📚 深入了解
- [SLIDER.md](./SLIDER.md) - 详细功能文档
- [IMPLEMENTATION.md](./IMPLEMENTATION.md) - 实现原理
- [src/examples.ts](./src/examples.ts) - 更多使用示例
- [README.md](./README.md) - 项目总览和功能介绍
- [src/slider/README.md](./src/slider/README.md) - 滑块模块详细文档
- [CHANGELOG.md](./CHANGELOG.md) - 版本更新日志
- [release.md](./release.md) - 发布说明
## 🎯 核心 API
```typescript
// 检测是否存在滑块
hasSlider(page: Page, config?: SliderConfig): Promise<boolean>
// 自动完成滑块验证
autoSlide(page: Page, config?: SliderConfig): Promise<boolean>
// 等待并处理滑块(推荐)
waitAndHandleSlider(page: Page, config?: SliderConfig): Promise<boolean>
```
## ⚙️ 配置选项
```typescript
interface SliderConfig {
handleSelector?: string; // 滑块按钮选择器
trackSelector?: string; // 滑块轨道选择器
bgSelector?: string; // 背景图选择器
pieceSelector?: string; // 缺口图选择器
timeout?: number; // 超时时间(毫秒)
distance?: number; // 手动指定距离(像素)
offsets?: number[]; // 偏移尝试序列
// 滑块检测器
class SliderDetector {
async detectSlider(
imagePath: string,
outputPath: string,
drawBoxes: boolean = true
): Promise<BoundingBox[] | null>
}
```
## 🎉 运行示例
// 滑块控制器
class SliderController {
constructor(maxAttempts: number = 10)
async solveSlider(
page: Page,
sliderSelector?: string,
captchaSelector?: string
): Promise<SliderSolveResult>
}
查看 6 个详细示例:
// 返回结果
interface SliderSolveResult {
success: boolean; // 是否成功
attempts: number; // 尝试次数
distance?: number; // 滑动距离(像素)
}
```bash
# 基础使用
npm run ts-node src/examples.ts 1
# 手动检测
npm run ts-node src/examples.ts 2
# 自定义配置
npm run ts-node src/examples.ts 3
# 登录流程集成
npm run ts-node src/examples.ts 4
# 批量处理
npm run ts-node src/examples.ts 5
# 环境变量配置
npm run ts-node src/examples.ts 6
// 边界框
interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
```
## 💡 提示
1. **首次使用建议先不开启自动验证**,观察滑块行为
2. **记录成功的参数配置**,后续重复使用
3. **避免过于频繁使用**,可能触发更严格验证
4. **定期检查更新**,验证码可能会变化
1. **首次使用**
- 建议先运行一次观察完整流程
- 查看 `output/` `noflag/` 目录的输出
- 了解红框标注的含义
2. **提高成功率**
- 依赖自动重试机制最多 10
- 每次验证码图片不同识别难度也不同
- 当前成功率约 50%已经可以应对日常使用
3. **调试建议**
- 查看控制台日志了解检测过程
- 检查 `output/` 目录的标注图验证准确性
- 使用 CLI 工具批量测试`npm run slider -- --pic-dir=noflag`
4. **避免滥用**
- 不要过于频繁使用可能触发更严格验证
- 遵守网站服务条款
- 仅用于个人学习研究
## ⚠️ 重要提示
- 本功能仅用于学习研究
- 使用时请遵守网站服务条款
- 图像识别准确率约 70-80%
- 需配合偏移重试提高成功率
- **本功能仅用于学习研究**
- **使用时请遵守网站服务条款**
- **图像识别准确率约 70-80%**
- **验证成功率约 50%含重试**
- **不保证 100% 成功请做好手动完成的准备**
## 📊 性能指标
- **检测耗时** 2-3 /含截图检测标注
- **平均尝试次数**1-3
- **最大尝试次数**10
- **图像缩放比例**340px 800pxscaleX 2.35
## 🤝 需要帮助?
查看详细文档或运行示例代码了解更多用法
查看详细文档或提交 Issue 了解更多用法
---
**v1.1.0** - 2025-10-25
引入 AI 驱动的滑块验证码自动破解功能 🎉

121
README.md
View File

@@ -1,13 +1,19 @@
# douban-crawler
> Playwright + TypeScript 脚本,用于完成豆瓣短信验证码登录,并将登录态持久化到本地 Cookie 文件。滑块验证码需人工处理,本项目不再尝试自动识别。
**版本**: v1.2.0
## 功能概览
> Playwright + TypeScript 脚本,用于完成豆瓣短信验证码登录,并将登录态持久化到本地 Cookie 文件。**已集成 AI 驱动的滑块验证码求解与 macOS 短信自动读取功能**。
- 启动 Chromium 浏览器并访问豆瓣登录页;
- 自动填写手机号,触发短信验证码;
- 控制台提示用户完成页面内的额外验证(如滑块)并输入短信验证码
- 登录成功后将 Cookie 状态保存到 `~/douban-cookie.json`,后续运行可直接复用。
## ✨ 核心功能
- 🔐 **自动登录**: 支持短信验证码登录流程
- 🧩 **智能滑块识别**: 基于图像处理算法自动识别和求解滑块验证码
- 🎯 **高成功率**: 采用多策略检测算法暗区检测、边缘检测、颜色量化、LAB色彩空间分析
- 📨 **自动短信读取**: 在 macOS 上读取信息 App 的最新验证码(需开启完全磁盘访问权限)
- 🔄 **自动重试**: 验证失败时自动刷新并重试,最多 10 次
- 📊 **详细日志**: 完整的调试信息和截图保存,便于问题追溯
- 🖼️ **可视化调试**: 自动标注检测到的滑块位置,保存带红框标记的图片
- 🍪 **Cookie 持久化**: 自动保存登录态,下次可直接复用
## 环境准备
@@ -18,6 +24,15 @@ npx playwright install chromium
需要 Node.js ≥ 18。Playwright 会自动下载 Chromium首次运行请确保网络可访问 Playwright CDN。
> 💡 自动短信读取依赖 macOS 本地 `~/Library/Messages/chat.db`,首次使用请为正在运行脚本的终端授予 **完全磁盘访问权限**。
### macOS 权限配置
1. 打开“系统设置” → “隐私与安全性” → “完全磁盘访问权限”macOS Ventura 及以上Monterey 及更早版本在“系统偏好设置” → “安全性与隐私”)。
2. 点击 `+` 号添加你运行脚本的终端(如 Terminal、iTerm2、VS Code
3. 勾选启用后重新启动该终端,再次运行 `npm run login`
4. 想确认权限是否生效,可在终端执行 `ls ~/Library/Messages/chat.db` 检查是否能够读取。
## 使用方式
1. 设置手机号环境变量并运行登录脚本:
@@ -26,13 +41,19 @@ npx playwright install chromium
DOUBAN_PHONE=13800000000 npm run login
```
2. 浏览器会自动打开豆瓣登录页,脚本完成以下操作
2. 启用自动滑块验证(可选)
```bash
DOUBAN_AUTO_SLIDER=1 DOUBAN_PHONE=13800000000 npm run login
```
3. 浏览器会自动打开豆瓣登录页,脚本完成以下操作:
- 填入手机号并点击「获取验证码」;
- 控制台提示等待页面验证(若出现滑块,请手动完成
- 控制台等待用户输入短信验证码
- 如果启用了自动滑块验证,会自动检测并滑动;否则等待用户手动完成;
- 在 macOS 上轮询“信息”App 的最新短信验证码,成功读取会自动填写;若读取失败则提示手动输入
- 验证码提交成功后,脚本将登录态写入 `~/douban-cookie.json` 并退出。
3. 下次运行会优先尝试加载该 Cookie 文件,若仍在有效期内可直接登录。
4. 下次运行会优先尝试加载该 Cookie 文件,若仍在有效期内可直接登录。
## 命令列表
@@ -42,49 +63,87 @@ npx playwright install chromium
## 可配置项
当前脚本仅使用一个环境变量:
当前脚本支持以下环境变量:
| 变量名 | 说明 | 是否必填 | 默认值 |
| -------------- | ---------------- | -------- | ------ |
| `DOUBAN_PHONE` | 登录手机号(大陆) | 必填 | - |
| 变量名 | 说明 | 是否必填 | 默认值 |
| --------------------- | ------------------------------ | -------- | ------ |
| `DOUBAN_PHONE` | 登录手机号(大陆) | 必填 | - |
| `DOUBAN_AUTO_SLIDER` | 是否启用自动滑块验证1/true | 可选 | false |
若需要更改 Cookie 保存位置,可在 `src/login.ts` 中调整 `COOKIES_PATH` 定义。
> 若不希望使用自动短信读取,可在终端手动输入验证码;无需额外配置即可回退。
## 工作流程说明
1. 读取 `DOUBAN_PHONE`,未提供则直接退出;
2. 若存在 `~/douban-cookie.json`,加载后访问登录页并校验登录态;
3. 如未登录,执行短信验证码流程,期间需手动处理页面可能出现的滑块或图形验证码;
4. 用户在终端输入收到的短信验证码
4. 在 macOS 上自动读取短信验证码,读取失败或授权不足时回退到终端输入
5. 验证通过后,将当前浏览器上下文的 `storageState` 写入 `~/douban-cookie.json`。
## 常见问题
- **登录后仍提示手机号未填写?** 确认 Playwright 浏览器窗口焦点在页面内,避免浏览器阻止自动填充。
- **提示滑块验证但脚本无动作?** 脚本已停止自动滑块功能,请在浏览器中手动拖动滑块完成验证
- **Cookie 未生成?** 只有当脚本确认登录成功时才会写入 Cookie。若终端未看到 登录成功Cookies 已保存… 的日志,请检查短信验证码是否正确。
- **自动滑块验证失败?** 系统会提示手动完成,或者尝试不启用自动滑块功能
- **Cookie 未生成?** 只有当脚本确认登录成功时才会写入 Cookie。若终端未看到 "登录成功Cookies 已保存…" 的日志,请检查短信验证码是否正确。
- **短信读取失败?** 确认已在系统设置中为终端授予“完全磁盘访问权限”,并重新启动终端;或直接在提示后手动输入验证码。
## 滑块验证模块
本项目包含了从 `captcha_cracker` 移植并优化的滑块检测功能,位于 `src/slider/` 目录。
详细说明请查看 [src/slider/README.md](./src/slider/README.md)
### 滑块验证工作流程
1. **自动检测**: 点击"获取验证码"后自动检测滑块验证码窗口
2. **图像采集**: 截取验证码图片并放大到 800px 宽度以提高识别精度
3. **多策略检测**:
- 暗区检测:识别滑块缺口的阴影区域
- 边缘检测:使用 Canny 算法识别轮廓
- 颜色量化:分析色彩分布找出异常区域
- LAB 色彩空间:在更符合人类视觉的空间中检测差异
4. **双滑块识别**: 同时检测左侧滑块和右侧缺口,计算精确距离
5. **距离计算**:
- 双滑块模式:`距离 = (缺口X - 滑块X) / scaleX`
- 单滑块模式:基于 DOM 位置和图像分析综合计算
6. **拟人化滑动**: 模拟真实人类操作的加速-匀速-减速轨迹
7. **结果验证**: 检测成功标识或窗口消失,失败则自动刷新重试(最多 10 次)
8. **可视化输出**: 在 `output/` 或 `noflag/` 目录保存带红框标记的检测结果图片
### 滑块识别算法
核心算法移植自 `captcha_cracker` 项目,包括:
- **候选框搜索** (`detection/candidate-search.ts`): 四种策略并行搜索可疑区域
- **边界框优化** (`detector.ts`): 使用 Canny 边缘检测精确定位
- **自学习模板匹配** (`detector-self-learning.ts`): 动态学习滑块模板提高准确率
- **几何与图像工具** (`utils/`): IoU 计算、形态学操作、Sobel 算子等
### 调试与问题排查
所有截图和检测结果保存在:
- `output/`: 常规调试输出
- `noflag/`: 完整尺寸800px的检测图片
- 文件命名格式:`captcha-{timestamp}.png` 和 `captcha-{timestamp}-detected.png`
查看 `-detected.png` 文件可以确认:
- 红框标记的位置是否准确识别了滑块缺口
- 如有两个框,左边的应该是滑块,右边的是缺口
## 声明
该项目仅供学习与功能验证,请勿用于违反豆瓣平台服务条款的场景。开发者需自行承担使用风险。
```bash
# 启用自动滑块验证
DOUBAN_AUTO_SLIDER=1 DOUBAN_PHONE=13800138000 npm run login
# 独立测试滑块功能
npm run slider
```
详细说明请查看 [SLIDER.md](./SLIDER.md)
## 开发脚本
## 开发文档
- `src/login.ts`:主登录流程,负责 Cookie 复用、短信登录以及滑块自动化;
- `src/slider.ts`:滑块验证自动化工具,支持图像识别和轨迹模拟
- `SLIDER.md`:滑块验证详细文档,包含原理、配置和故障排查
- `src/sms/`macOS 短信读取模块,解析 `chat.db` 自动提取验证码
- `src/slider/`:滑块验证模块,包含检测、移动等完整功能
- `ARCHITECTURE.md`:整体架构与流程说明;
- `IMPLEMENTATION.md`:关键实现细节记录;
- `login.md`:原始业务需求与操作步骤;
- `block.md`滑块破解思路Python 版)与 TypeScript 脚本参考;
- `typescript-spec.md`:团队 TypeScript 编码规范与示例。
## 许可

186
VERSION.md Normal file
View File

@@ -0,0 +1,186 @@
# 版本信息
## 当前版本v1.2.0
发布日期2025-10-26
## 主要特性
### 📨 macOS 短信自动读取v1.2.0
- ✅ 基于 `better-sqlite3` 读取 `~/Library/Messages/chat.db` 的最新验证码短信
- ✅ 智能忽略旧消息,仅对新到达的“豆瓣网”验证码进行解析
- ✅ 使用正则解析 `验证码xxxx` 格式,支持 4-6 位验证码
- ✅ 自动回填验证码输入框,失败时即时回退到手动输入
- ⚠️ 仅支持 macOS并需要为终端授予“完全磁盘访问权限”
### 🎯 AI 驱动的滑块验证码自动破解v1.1.0
- ✅ 多策略并行检测暗区域、边缘、颜色量化、LAB 色彩空间)
- ✅ 双滑块精准识别(左侧滑块 + 右侧缺口)
- ✅ 简化的距离计算算法:`距离 = (缺口X - 滑块X) / scaleX`
- ✅ 拟人化滑动轨迹Playwright steps 参数)
- ✅ 自动重试机制(最多 10 次)
- ✅ 可视化调试(红框标注输出到 `output/` 目录)
### 📊 性能指标
- **检测准确率**~70-80%
- **验证成功率**~50%(含重试)
- **平均尝试次数**1-3 次
- **单次检测耗时**~2-3 秒
### 🚀 快速开始
```bash
# 启用自动滑块验证
DOUBAN_AUTO_SLIDER=1 DOUBAN_PHONE=13800138000 npm run login
# 独立测试滑块功能
npm run slider
# 批量复核历史截图
npm run slider -- --pic-dir=noflag
```
## 核心创新
### "两只小鸟距离"原理
v1.1.0 最重要的突破是简化了距离计算算法:
```typescript
// v1.0.0: 需要人工完成滑块
// v1.1.0: 自动计算并拖动
// 双滑块模式(推荐)
const distance = (box2.x - box1.x) / scaleX;
// 单滑块模式(兜底)
const distance = box.x / scaleX;
```
**原理**:就像计算两只小鸟嘴尖的水平距离,直接用右侧缺口的 X 坐标减去左侧滑块的 X 坐标,再除以图像缩放比例,就得到了滑块需要移动的距离。
### 图像缩放优化
- 原始验证码340x191 像素
- 检测图像800x449 像素scaleX ≈ 2.35
- 提高小尺寸滑块的检测精度
### 多策略并行检测
并行运行四种算法,然后 IoU 去重:
1. **暗区域检测**:查找亮度 < 100 的暗色区域
2. **Canny 边缘检测**查找边缘密集区域
3. **颜色量化**K-means 聚类找独特色块
4. **LAB 色彩空间**感知均匀的色彩空间检测
## 文件结构
```mermaid
graph TD
root[src/]
root --> slider_dir[slider/<br/>滑块验证模块]
slider_dir --> slider_detector[detector.ts<br/>主检测器]
slider_dir --> slider_self[detector-self-learning.ts<br/>模板匹配]
slider_dir --> slider_controller[slider-controller.ts<br/>Playwright 集成]
slider_dir --> slider_candidate[candidate-search.ts<br/>多策略检测]
slider_dir --> slider_geometry[geometry.ts<br/>IoU 计算]
slider_dir --> slider_image[image.ts<br/>Sobel 边缘]
root --> sms_dir[sms/<br/>macOS 短信读取模块]
sms_dir --> sms_code[douban-code.ts<br/>解析 chat.db]
root --> noflag[noflag/<br/>原始验证码截图]
root --> output_dir[output/<br/>标注结果]
```
## 依赖项
- **playwright**: ^1.41.1浏览器自动化
- **sharp**: ^0.33.3图像处理
- **better-sqlite3**: ^12.4.1本地 SQLite 查询读取短信
- **typescript**: ^5.4.2
## 环境变量
```bash
DOUBAN_AUTO_SLIDER=1 # 启用自动滑块验证
DOUBAN_PHONE=手机号 # 登录手机号(必填)
```
自动短信读取不需要新增环境变量保持终端前台即可
## 已知限制
1. **图像识别准确率** 70-80%复杂背景或低对比度图片识别率较低
2. **验证成功率** 50%受反爬虫机制影响
3. **仅供学习**请遵守网站服务条款不要用于商业或恶意用途
4. **平台限制**短信自动读取仅适用于 macOS且需为终端授予完全磁盘访问权限
## 相关文档
- [README.md](./README.md) - 项目总览
- [QUICKSTART.md](./QUICKSTART.md) - 快速开始指南
- [CHANGELOG.md](./CHANGELOG.md) - 详细更新日志
- [ARCHITECTURE.md](./ARCHITECTURE.md) - 架构说明
- [IMPLEMENTATION.md](./IMPLEMENTATION.md) - 实现细节
- [src/slider/README.md](./src/slider/README.md) - 滑块模块文档
## 升级指南
### 从 v1.1.0 升级到 v1.2.0
**新增依赖**
```bash
npm install
```
**系统配置**
- macOS 隐私与安全性 完全磁盘访问权限”,添加并勾选运行脚本的终端
- 修改后需重新启动终端或 VS Code
**代码变更**
- `src/login.ts` 自动调用 `waitForDoubanCode` 读取短信
- 新增 `src/sms/` 模块负责解析 `chat.db`
- 读取失败会保留手动输入流程无需额外开关
**回退策略**
- 若不希望开启自动读取可在提示出现时直接手动输入验证码无需修改代码
### 从 v1.0.0 升级到 v1.1.0
**新增依赖**
```bash
npm install
```
**新增环境变量**可选
```bash
export DOUBAN_AUTO_SLIDER=1
```
**新增目录**
- `noflag/`原始验证码截图会自动保存到这里
- `output/`标注结果会自动保存到这里
**无需更改的部分**
- `DOUBAN_PHONE` 环境变量用法不变
- `~/douban-cookie.json` Cookie 文件路径不变
- `npm run login` 命令用法不变
**新增功能**
- 设置 `DOUBAN_AUTO_SLIDER=1` 启用自动滑块验证
- 使用 `npm run slider` 独立测试滑块功能
- 使用 `npm run slider -- --pic-dir=noflag` 批量复核截图
## 下一步计划
- [ ] 支持更多验证码类型点选文字识别
- [ ] 引入机器学习模型提高准确率
- [ ] 优化轨迹模拟降低被识别风险
- [ ] 支持更多网站的滑块验证码
---
**v1.2.0** - AI 滑块 + macOS 短信自动读取让登录更丝滑 🎉

394
ground-truth.json Normal file
View File

@@ -0,0 +1,394 @@
{
"滑块-1.png": [
{
"x": 123,
"y": 439,
"width": 90,
"height": 92
},
{
"x": 546,
"y": 439,
"width": 90,
"height": 92
}
],
"滑块-2.png": [
{
"x": 125,
"y": 245,
"width": 89,
"height": 91
},
{
"x": 454,
"y": 244,
"width": 90,
"height": 92
}
],
"滑块-3.png": [
{
"x": 122,
"y": 238,
"width": 86,
"height": 87
},
{
"x": 576,
"y": 237,
"width": 87,
"height": 88
}
],
"滑块-4.png": [
{
"x": 120,
"y": 330,
"width": 90,
"height": 90
},
{
"x": 488,
"y": 329,
"width": 91,
"height": 91
}
],
"滑块-5.png": [
{
"x": 119,
"y": 444,
"width": 90,
"height": 88
},
{
"x": 404,
"y": 443,
"width": 91,
"height": 89
}
],
"滑块-6.png": [
{
"x": 116,
"y": 319,
"width": 91,
"height": 91
},
{
"x": 574,
"y": 318,
"width": 92,
"height": 92
}
],
"滑块-7.png": [
{
"x": 119,
"y": 255,
"width": 88,
"height": 88
},
{
"x": 349,
"y": 177,
"width": 101,
"height": 166
}
],
"滑块-8.png": [
{
"x": 120,
"y": 244,
"width": 92,
"height": 92
},
{
"x": 434,
"y": 243,
"width": 93,
"height": 93
}
],
"滑块.png": [
{
"x": 131,
"y": 408,
"width": 87,
"height": 88
},
{
"x": 375,
"y": 407,
"width": 88,
"height": 89
}
],
"iShot_2025-10-25_16.53.21.png": [
{
"x": 119,
"y": 344,
"width": 91,
"height": 92
},
{
"x": 575,
"y": 342,
"width": 93,
"height": 94
}
],
"iShot_2025-10-25_16.53.40.png": [
{
"x": 108,
"y": 353,
"width": 94,
"height": 91
},
{
"x": 365,
"y": 353,
"width": 95,
"height": 92
}
],
"iShot_2025-10-25_16.53.48.png": [
{
"x": 122,
"y": 256,
"width": 90,
"height": 89
},
{
"x": 379,
"y": 256,
"width": 91,
"height": 90
}
],
"iShot_2025-10-25_16.53.57.png": [
{
"x": 110,
"y": 282,
"width": 90,
"height": 88
},
{
"x": 380,
"y": 282,
"width": 90,
"height": 89
}
],
"iShot_2025-10-25_16.54.08.png": [
{
"x": 119,
"y": 306,
"width": 93,
"height": 93
},
{
"x": 386,
"y": 306,
"width": 93,
"height": 94
}
],
"iShot_2025-10-25_16.54.15.png": [
{
"x": 118,
"y": 360,
"width": 90,
"height": 88
},
{
"x": 386,
"y": 363,
"width": 91,
"height": 89
}
],
"iShot_2025-10-25_16.54.25.png": [
{
"x": 121,
"y": 420,
"width": 88,
"height": 87
},
{
"x": 313,
"y": 420,
"width": 90,
"height": 88
}
],
"iShot_2025-10-25_16.54.32.png": [
{
"x": 113,
"y": 292,
"width": 88,
"height": 88
},
{
"x": 346,
"y": 292,
"width": 88,
"height": 88
}
],
"iShot_2025-10-25_16.54.41.png": [
{
"x": 118,
"y": 388,
"width": 88,
"height": 88
},
{
"x": 541,
"y": 388,
"width": 89,
"height": 89
}
],
"iShot_2025-10-25_16.54.54.png": [
{
"x": 98,
"y": 334,
"width": 90,
"height": 88
},
{
"x": 310,
"y": 334,
"width": 92,
"height": 89
}
],
"iShot_2025-10-25_16.55.02.png": [
{
"x": 119,
"y": 349,
"width": 90,
"height": 88
},
{
"x": 401,
"y": 349,
"width": 92,
"height": 89
}
],
"iShot_2025-10-25_16.55.09.png": [
{
"x": 100,
"y": 351,
"width": 90,
"height": 88
},
{
"x": 382,
"y": 351,
"width": 92,
"height": 89
}
],
"iShot_2025-10-25_16.55.14.png": [
{
"x": 119,
"y": 365,
"width": 90,
"height": 88
},
{
"x": 400,
"y": 365,
"width": 91,
"height": 89
}
],
"iShot_2025-10-25_16.55.21.png": [
{
"x": 110,
"y": 220,
"width": 92,
"height": 89
},
{
"x": 519,
"y": 220,
"width": 90,
"height": 88
}
],
"iShot_2025-10-25_16.55.29.png": [
{
"x": 114,
"y": 309,
"width": 90,
"height": 88
},
{
"x": 544,
"y": 309,
"width": 90,
"height": 89
}
],
"iShot_2025-10-25_16.55.37.png": [
{
"x": 107,
"y": 427,
"width": 87,
"height": 88
},
{
"x": 542,
"y": 427,
"width": 88,
"height": 89
}
],
"iShot_2025-10-25_16.55.46.png": [
{
"x": 117,
"y": 227,
"width": 88,
"height": 89
},
{
"x": 550,
"y": 227,
"width": 89,
"height": 90
}
],
"iShot_2025-10-25_16.55.52.png": [
{
"x": 112,
"y": 314,
"width": 89,
"height": 91
},
{
"x": 409,
"y": 314,
"width": 90,
"height": 92
}
],
"iShot_2025-10-25_16.56.01.png": [
{
"x": 119,
"y": 347,
"width": 90,
"height": 88
},
{
"x": 393,
"y": 350,
"width": 90,
"height": 89
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

BIN
images/douban/滑块-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
images/douban/滑块-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

BIN
images/douban/滑块-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

BIN
images/douban/滑块-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

BIN
images/douban/滑块-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

BIN
images/douban/滑块-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

BIN
images/douban/滑块-7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

BIN
images/douban/滑块-8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

BIN
images/douban/滑块.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

446
package-lock.json generated
View File

@@ -1,17 +1,19 @@
{
"name": "douban-crawler",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "douban-crawler",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"better-sqlite3": "^12.4.1",
"playwright": "^1.41.1",
"sharp": "^0.33.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.11.30",
"ts-node": "^10.9.2",
"typescript": "^5.4.2"
@@ -457,6 +459,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
@@ -500,6 +512,90 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -548,6 +644,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -567,6 +687,36 @@
"node": ">=0.3.1"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -581,6 +731,44 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
@@ -594,6 +782,60 @@
"dev": true,
"license": "ISC"
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.78.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz",
"integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
@@ -624,6 +866,91 @@
"node": ">=18"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -675,6 +1002,51 @@
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
@@ -684,6 +1056,52 @@
"is-arrayish": "^0.3.1"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -735,6 +1153,18 @@
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -756,6 +1186,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -763,6 +1199,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@@ -1,16 +1,18 @@
{
"name": "douban-crawler",
"version": "1.0.0",
"description": "Automation scripts for Douban login and crawling.",
"version": "1.2.0",
"description": "Douban login automation with AI-powered slider CAPTCHA solver.",
"scripts": {
"login": "ts-node src/login.ts",
"slider": "ts-node src/slider.ts"
"slider": "ts-node --transpile-only src/slider/cli.ts"
},
"dependencies": {
"better-sqlite3": "^12.4.1",
"playwright": "^1.41.1",
"sharp": "^0.33.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.11.30",
"ts-node": "^10.9.2",
"typescript": "^5.4.2"

View File

@@ -3,13 +3,13 @@
Playwright + TypeScript 脚本,用于完成豆瓣短信验证码登录,并将登录态持久化到本地 Cookie 文件。
滑块验证码需人工处理,本项目不再尝试自动识别。
## 功能概览
### 功能概览
- 启动 Chromium 浏览器并访问豆瓣登录页;
- 自动填写手机号,触发短信验证码;
- 控制台提示用户完成页面内的额外验证(如滑块)并输入短信验证码;
- 登录成功后将 Cookie 状态保存到 `~/douban-cookie.json`,后续运行可直接复用。
## 环境准备
### 环境准备
```bash
npm install
npx playwright install chromium
@@ -17,7 +17,7 @@ npx playwright install chromium
需要 Node.js ≥ 18。Playwright 会自动下载 Chromium首次运行请确保网络可访问 Playwright CDN。
## 使用方式
### 使用方式
1. 设置手机号环境变量并运行登录脚本:
```bash
@@ -32,13 +32,13 @@ npx playwright install chromium
3. 下次运行会优先尝试加载该 Cookie 文件,若仍在有效期内可直接登录。
## 命令列表
### 命令列表
| 命令 | 说明 |
| --------------- | ---------------------------- |
| `npm run login` | 启动豆瓣登录流程并保存 Cookie |
## 可配置项
### 可配置项
当前脚本仅使用一个环境变量:
@@ -48,7 +48,7 @@ npx playwright install chromium
若需要更改 Cookie 保存位置,可在 `src/login.ts` 中调整 `COOKIES_PATH` 定义。
## 工作流程说明
### 工作流程说明
1. 读取 `DOUBAN_PHONE`,未提供则直接退出;
2. 若存在 `~/douban-cookie.json`,加载后访问登录页并校验登录态;
@@ -56,7 +56,7 @@ npx playwright install chromium
4. 用户在终端输入收到的短信验证码;
5. 验证通过后,将当前浏览器上下文的 `storageState` 写入 `~/douban-cookie.json`。
## 常见问题
### 常见问题
- **登录后仍提示手机号未填写?** 确认 Playwright 浏览器窗口焦点在页面内,避免浏览器阻止自动填充。
- **提示滑块验证但脚本无动作?** 脚本已停止自动滑块功能,请在浏览器中手动拖动滑块完成验证。
@@ -70,13 +70,163 @@ DOUBAN_AUTO_SLIDER=1 DOUBAN_PHONE=13800138000 npm run login
npm run slider
```
## 开发脚本
### 开发脚本
- `src/login.ts`:主登录流程,负责 Cookie 复用、短信登录以及滑块自动化;
- `login.md`:原始业务需求与操作步骤;
- `block.md`滑块破解思路Python 版)与 TypeScript 脚本参考;
- `typescript-spec.md`:团队 TypeScript 编码规范与示例。
## 许可
### 许可
本项目仅用于功能验证和学习,使用时请遵守目标网站的服务条款。
## v1.1.0
### 🎉 主要更新
**AI 驱动的滑块验证码自动破解**
本版本最大亮点是集成了完整的滑块验证码自动识别和求解系统,从 `captcha_cracker` 项目移植并优化了核心算法。
#### ✨ 新增功能
1. **智能滑块识别** 🔍
- 多策略并行检测暗区域、Canny 边缘、颜色量化、LAB 色彩空间
- 双滑块精准识别:同时检测左侧滑块和右侧缺口
- 图像缩放优化:自动放大到 800px 以提高检测精度(原始 340px
- 可视化调试:自动生成带红框标注的检测结果图
2. **简化距离计算算法** 📐
- **v1.1.0 核心改进**:采用简洁准确的几何原理
- 双滑块模式:`距离 = (缺口X - 滑块X) / scaleX`
- 类比"两只小鸟嘴尖距离",直接计算左边界水平距离
- 移除复杂的坐标转换逻辑,提升准确性
3. **拟人化滑动轨迹** 🎯
- 使用 Playwright 的 `steps` 参数实现平滑移动
- 避免机械化操作特征
- 成功率约 50%10 次重试机制)
4. **自动重试机制** 🔄
- 验证失败自动刷新验证码
- 最多尝试 10 次(可配置)
- 实时日志输出,便于调试
5. **截图输出规范** 📸
- 原始验证码:保存到 `noflag/` 目录
- 标注结果:保存到 `output/` 目录
- 支持 CLI 工具批量复核:`npm run slider -- --pic-dir=noflag`
#### 🔧 技术细节
**核心模块结构**`src/slider/`
- `detector.ts`: 主检测器,实现多策略候选搜索和评分
- `detector-self-learning.ts`: 模板匹配,用于第二滑块检测
- `slider-controller.ts`: Playwright 集成,控制浏览器滑动
- `candidate-search.ts`: 四种并行检测算法实现
- `utils/geometry.ts`: IoU 计算等几何工具
- `utils/image.ts`: Sobel 边缘检测、形态学操作
- `cli.ts`: 批量评估和标注工具
- `validator.ts`: 检测结果验证工具
**依赖变更**
- 新增 `sharp@^0.33.3`:图像处理(缩放、边缘检测、颜色量化)
- 已有 `playwright@^1.41.1`:浏览器自动化
**环境变量**
```bash
DOUBAN_AUTO_SLIDER=1 # 启用自动滑块验证
DOUBAN_PHONE=手机号 # 登录手机号
```
#### 📊 性能指标
- **检测准确率**~70-80%(基于标注数据集验证)
- **验证成功率**~50%(考虑网站反爬虫机制)
- **平均尝试次数**1-3 次
- **单次检测耗时**~2-3 秒(含截图、检测、滑动)
#### 🐛 已修复问题
1. **坐标系不统一**:修复了截图坐标与页面坐标的转换错误
2. **iframe 元素访问**:正确处理腾讯验证码 iframe 内的元素定位
3. **边距过滤过严**调整候选框边缘判断逻辑5% → 1%
4. **距离计算复杂**:简化为基本几何公式,提高准确性
#### 📖 文档更新
- `README.md`: 添加自动滑块验证功能说明
- `src/slider/README.md`: 详细的算法实现和调试指南
- `CHANGELOG.md`: 新增版本变更日志
- `QUICKSTART.md`: 更新快速开始指南
#### 🎯 使用示例
**最简单的使用方式**
```bash
DOUBAN_AUTO_SLIDER=1 DOUBAN_PHONE=13800138000 npm run login
```
**独立测试滑块功能**
```bash
npm run slider
```
**编程接口**
```typescript
import { SliderController } from './slider';
const controller = new SliderController(10);
const result = await controller.solveSlider(page, '.slider-button', '#captcha');
if (result.success) {
console.log(`成功!尝试 ${result.attempts} 次`);
}
```
#### ⚠️ 注意事项
1. **图像识别局限性**:复杂背景或低对比度图片可能识别失败
2. **反爬虫检测**:频繁使用可能触发更严格的验证机制
3. **仅供学习**:请遵守网站服务条款,不要用于商业或恶意用途
#### 🚀 下一步计划
- [ ] 支持更多验证码类型(点选、文字识别)
- [ ] 优化检测算法,提高复杂场景的准确率
- [ ] 添加机器学习模型,替代规则式检测
- [ ] 支持更多网站的滑块验证码
- [x] 自动提取 macOS 收到的短信验证码v1.2.0 已上线)
- [ ] 拓展短信自动读取到第三方短信服务或非 macOS 平台
## v1.2.0
新增: **macOS 短信自动读取** **自动回填验证码** **智能降级策略** **日志可观测性**
### 🚀 亮点
1. **macOS 短信自动读取**:新增 `src/sms/douban-code.ts` 模块,基于 `better-sqlite3` 读取 `~/Library/Messages/chat.db`,自动捕获最新“豆瓣网”验证码短信。
2. **自动回填验证码**:登录流程会在成功获取验证码后自动填充 `#code` 输入框,提升一次性登录体验。
3. **智能降级策略**:若未授予完全磁盘访问权限或数据库被占用,脚本会输出原因并回退到命令行输入,保证流程不中断。
4. **日志可观测性**:短信阶段新增 `[短信读取]` 日志前缀,帮助定位权限、解析或读取失败的问题。
### 🔧 兼容性要求
- 仅支持 macOS需为运行脚本的终端Terminal/iTerm2/VS Code授予“完全磁盘访问权限”并重启终端。
- 新增依赖 `better-sqlite3@^12.4.1`(同步 API零依赖运行以及类型声明 `@types/better-sqlite3`。
- 保留手动输入验证码流程Windows/Linux 用户或未授权情况下仍可照常使用。
### 📦 目录与配置变更
- 新增 `src/sms/` 目录存放短信读取模块。
- `src/login.ts` 在滑块验证后自动调用短信读取逻辑,并等待验证码输入框可见。
- `README`, `VERSION`, `ARCHITECTURE`, `IMPLEMENTATION`, `QUICKSTART`, `CHANGELOG` 等文档同步至 v1.2.0,增加权限配置说明。
### ✅ 升级指南
```bash
npm install
```
1. 授权完全磁盘访问:系统设置 → 隐私与安全性 → 完全磁盘访问权限 → 添加终端并勾选;
2. 重启终端或 VS Code
3. 运行 `npm run login` 体验自动读取验证码。

234
release.sh Executable file
View File

@@ -0,0 +1,234 @@
#!/bin/bash
set -e
# Git 自动发布脚本
# 功能:检查分支、工作区,生成 tag并在 Gitea 上创建 Release
# 1. 检查分支
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" != "main" ]; then
echo "❌ 错误:请在 main 分支运行,当前是 $branch"
exit 1
fi
echo "✅ 分支: $branch"
# 可选:检查本地 Gitea 配置中的默认分支设置(如果仓库里包含 Gitea 配置)
# 读取 custom/conf/app.ini 中的 DEFAULT_BRANCH 值并给出警告(不强制退出)
if [ -f custom/conf/app.ini ]; then
DEFAULT_BRANCH=$(grep -i '^DEFAULT_BRANCH' custom/conf/app.ini | head -n1 | cut -d'=' -f2 | tr -d ' ')
if [ -n "$DEFAULT_BRANCH" ]; then
if [ "$DEFAULT_BRANCH" = "main" ]; then
echo "⚠️ 警告: Gitea 配置 custom/conf/app.ini 中 DEFAULT_BRANCH 设置为 'main',这可能存在风险"
echo " 如果希望强制中止发布,请在脚本中启用退出逻辑。"
else
echo " Gitea 配置 DEFAULT_BRANCH=$DEFAULT_BRANCH"
fi
fi
fi
# 2. 检查工作区是否干净
if [ -n "$(git status --porcelain)" ]; then
echo "❌ 错误:工作区有未提交的更改,请先提交或 stash"
git status
exit 1
fi
echo "✅ 工作区干净"
# 3. 更新代码
echo "⬇️ 拉取远程代码..."
git fetch origin
git pull origin main
echo "✅ 已同步最新代码"
# 4. 从 release.md 提取版本和说明
if [ ! -f release.md ]; then
echo "❌ 未找到 release.md"
exit 1
fi
VERSION=$(grep "^## v" release.md | tail -n 1 | sed 's/^## //')
TAG_MESSAGE=$(awk "/^## $VERSION\$/{flag=1;next}/^## v/{if(flag) flag=0}flag" release.md)
# 过滤掉 emoji4 字节 unicode范围 U+10000 - U+10FFFF避免 utf8mb4 字符导致服务器侧 collation 问题
# 仅在发送到 Gitea 时使用过滤后的内容,保留原始 TAG_MESSAGE 用于本地 tag 注释
RELEASE_BODY=$(printf '%s' "$TAG_MESSAGE" | perl -CSD -0777 -pe 's/[\x{10000}-\x{10FFFF}]//g')
if [ -z "$VERSION" ]; then
echo "❌ release.md 中未找到版本号"
exit 1
fi
echo "📝 版本号: $VERSION"
echo "说明:"
echo "$TAG_MESSAGE"
# 5. 创建 tag如已存在则删除后重建
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "⚠️ 标签 $VERSION 已存在,删除旧标签..."
git tag -d "$VERSION"
git push origin ":refs/tags/$VERSION"
fi
git tag -a "$VERSION" -m "$TAG_MESSAGE"
echo "✅ 已创建 tag $VERSION"
# 6. 推送代码和 tag
echo "🚀 推送到远程..."
git push origin main
git push origin "$VERSION"
# 7. 创建 Gitea Release
echo ""
echo "🌐 创建 Gitea Release..."
# 自动解析 GITEA_URL 和 REPO
remote_url=$(git config --get remote.origin.url)
if [[ "$remote_url" == ssh://git@biboer.cn:21174/* ]]; then
# 特殊处理 biboer.cn:21174 的情况Web 界面在 /gitea 路径下
GITEA_URL="https://biboer.cn/gitea"
GITEA_REPO=$(echo "$remote_url" | sed 's|ssh://git@biboer\.cn:21174/||' | sed 's|\.git$||')
elif [[ "$remote_url" =~ ^ssh://([^/]+)/([^/]+)/(.+)\.git$ ]]; then
GITEA_URL="https://${BASH_REMATCH[1]}"
GITEA_REPO="${BASH_REMATCH[2]}/${BASH_REMATCH[3]}"
elif [[ "$remote_url" =~ ^([^@]+@[^:]+):([^/]+)/(.+)\.git$ ]]; then
# git@biboer.cn:21174/gavin/note-to-mp.git
hostport=$(echo "${BASH_REMATCH[1]}" | cut -d@ -f2)
GITEA_URL="https://${hostport}"
GITEA_REPO="${BASH_REMATCH[2]}/${BASH_REMATCH[3]}"
elif [[ "$remote_url" =~ ^https?://([^/]+)(/.*)$ ]]; then
host="${BASH_REMATCH[1]}"
path="${BASH_REMATCH[2]}"
path="${path#/}"
# 去掉结尾的 .git
path="${path%.git}"
if [[ "$path" == gitea/* ]]; then
GITEA_URL="https://${host}/gitea"
path="${path#gitea/}"
else
GITEA_URL="https://${host}"
fi
GITEA_REPO="$path"
else
echo "❌ 无法解析远程地址: $remote_url"
exit 1
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "⚠️ 未设置 GITEA_TOKEN只推送了 tag没有创建 Release"
exit 0
fi
# 如果远程已经存在该 Release通过 tag 查询),先删除它(避免 409 Conflict
echo "🔍 检查远程 Release 是否已存在..."
check_response=$(curl -s -w "\n%{http_code}" \
-X GET "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/tags/$VERSION" \
-H "Authorization: token $GITEA_TOKEN")
check_http_code=$(echo "$check_response" | tail -n 1)
check_body=$(echo "$check_response" | sed '$d')
if [ "$check_http_code" -eq 200 ]; then
release_id=$(echo "$check_body" | jq -r '.id')
echo "⚠️ 远程已存在 Release $VERSION (ID: $release_id),正在删除..."
delete_response=$(curl -s -w "\n%{http_code}" \
-X DELETE "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$release_id" \
-H "Authorization: token $GITEA_TOKEN")
delete_http_code=$(echo "$delete_response" | tail -n 1)
if [ "$delete_http_code" -eq 204 ] || [ "$delete_http_code" -eq 200 ]; then
echo "✅ 已删除旧的 Release"
else
echo "⚠️ 删除旧 Release 失败 (HTTP $delete_http_code),继续尝试创建新的 Release"
fi
fi
# 使用 jq 生成正确的 JSON
JSON_PAYLOAD=$(echo "$RELEASE_BODY" | jq -R -s -c --arg version "$VERSION" '{
tag_name: $version,
name: $version,
body: .,
draft: false,
prerelease: false
}')
# 尝试创建 Release若返回 409冲突再查询并删除后重试一次
echo "🔄 正在创建 Release首次尝试..."
response_full=$(curl -s -w "\n%{http_code}" \
-X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d "$JSON_PAYLOAD")
http_code=$(echo "$response_full" | tail -n 1)
resp_body=$(echo "$response_full" | sed '$d')
if [ "$http_code" -eq 201 ]; then
echo "✅ Release 创建成功: $VERSION"
else
if [ "$http_code" -eq 409 ]; then
echo "⚠️ 创建 Release 返回 409 Conflict尝试删除远程冲突的 Release 并重试..."
# 再次查询 Release id
check_response=$(curl -s -w "\n%{http_code}" \
-X GET "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/tags/$VERSION" \
-H "Authorization: token $GITEA_TOKEN")
check_http_code=$(echo "$check_response" | tail -n 1)
check_body=$(echo "$check_response" | sed '$d')
if [ "$check_http_code" -eq 200 ]; then
release_id=$(echo "$check_body" | jq -r '.id')
delete_response=$(curl -s -w "\n%{http_code}" \
-X DELETE "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$release_id" \
-H "Authorization: token $GITEA_TOKEN")
delete_http_code=$(echo "$delete_response" | tail -n 1)
if [ "$delete_http_code" -eq 204 ] || [ "$delete_http_code" -eq 200 ]; then
echo "✅ 已删除冲突的 Release准备重试创建..."
# 重试创建一次
retry_response=$(curl -s -w "\n%{http_code}" \
-X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d "$JSON_PAYLOAD")
retry_code=$(echo "$retry_response" | tail -n 1)
retry_body=$(echo "$retry_response" | sed '$d')
if [ "$retry_code" -eq 201 ]; then
echo "✅ Release 创建成功 (重试): $VERSION"
else
echo "❌ 重试创建 Release 仍然失败 (HTTP $retry_code)"
echo "响应: $retry_body"
fi
else
echo "❌ 删除冲突 Release 失败 (HTTP $delete_http_code),无法重试"
echo "响应: $delete_response"
fi
else
echo "❌ 查询冲突 Release 失败 (HTTP $check_http_code),响应: $check_body"
fi
else
# 如果是 500 并且包含 collation/utf8 相关错误,回退到英文 body 重试
if [ "$http_code" -eq 500 ] || echo "$resp_body" | grep -qi "collation\|utf8\|Conversion from collation"; then
echo "⚠️ 检测到字符集/编码错误 (HTTP $http_code),尝试回退到英文说明并重试..."
ENGLISH_BODY="## Release Notes\n\nThis is release $VERSION: $RELEASE_TITLE\n\nFor detailed Chinese release notes, please see release.md in the repository.\n\nQuick start:\n\n\`\`\`bash\ngit pull origin main\ncd web && npm install\nnpm run dev\n\`\`\`"
JSON_PAYLOAD_EN=$(jq -n -c --arg version "$VERSION" --arg name "$VERSION" --arg body "$ENGLISH_BODY" '{tag_name: $version, name: $name, body: $body, draft: false, prerelease: false}')
echo "🔄 正在创建 Release英文回退重试..."
en_resp=$(curl -s -w "\n%{http_code}" \
-X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d "$JSON_PAYLOAD_EN")
en_code=$(echo "$en_resp" | tail -n 1)
en_body=$(echo "$en_resp" | sed '$d')
if [ "$en_code" -eq 201 ]; then
echo "✅ Release 创建成功 (英文回退): $VERSION"
else
echo "❌ 英文回退重试失败 (HTTP $en_code)"
echo "响应: $en_body"
fi
else
echo "❌ Release 创建失败HTTP $http_code"
echo "响应: $resp_body"
fi
fi
fi
echo ""
echo "🎉 发布完成!"
echo "📦 版本:$VERSION"

View File

@@ -9,10 +9,14 @@ import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import readline from 'readline';
import { SliderController } from './slider';
import { waitForDoubanCode } from './sms/douban-code';
const LOGIN_URL = 'https://accounts.douban.com/passport/login?source=main';
const COOKIES_PATH = path.join(os.homedir(), 'douban-cookie.json');
const PHONE = process.env.DOUBAN_PHONE ?? '';
const AUTO_SLIDER = process.env.DOUBAN_AUTO_SLIDER === '1' || process.env.DOUBAN_AUTO_SLIDER === 'true';
/**
* 检查指定路径文件是否存在,避免捕获异常污染主流程。
@@ -105,7 +109,7 @@ async function prepareContext(browser: Browser): Promise<{
const page = await context.newPage();
// 访问豆瓣首页检查登录状态
await page.goto('https://www.douban.com', { waitUntil: 'domcontentloaded', timeout: 15000 });
await page.goto('https://www.douban.com', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(800);
if (await isLoggedIn(page)) {
@@ -119,7 +123,7 @@ async function prepareContext(browser: Browser): Promise<{
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(LOGIN_URL, { waitUntil: 'networkidle' });
await page.goto(LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
return { context, page, usedCookies: false };
}
@@ -127,7 +131,7 @@ async function prepareContext(browser: Browser): Promise<{
/**
* 短信验证码登录流程:
* - 输入手机号并触发验证码
* - 在浏览器中手动完成可能出现的额外验证
* - 自动处理滑块验证(如果启用)或提示手动完成
* - 等待用户输入短信验证码并提交
*/
async function loginWithSms(page: Page, phone: string): Promise<void> {
@@ -137,15 +141,164 @@ async function loginWithSms(page: Page, phone: string): Promise<void> {
await page.click('text=获取验证码');
console.log('请等待短信验证码...');
await prompt('收到短信验证码后按 Enter 继续...');
const code = (await prompt('请输入短信验证码: ')).trim();
if (!code) {
throw new Error('未输入短信验证码,登录流程终止。');
// 等待滑块验证出现 - 先给足够时间让滑块窗口加载
console.log('等待滑块验证窗口加载...');
await page.waitForTimeout(3000); // 初始等待3秒让滑块窗口完全加载
// 检查是否需要滑块验证 - 尝试多个可能的选择器
const sliderController = new SliderController(10);
const possibleSelectors = [
'#slideBg',
'.tc-bg-img',
'.tc-fg-item',
'#tcaptcha_iframe',
'iframe[src*="captcha"]',
'iframe[src*="ssl.captcha"]',
'.tcaptcha-transform',
'#captcha_container'
];
let captchaSelector = '';
let captchaVisible = false;
// 再等待最多 10 秒,检查滑块是否出现
const maxWaitTime = 10000;
const startTime = Date.now();
console.log('开始检测滑块元素...');
while (Date.now() - startTime < maxWaitTime && !captchaVisible) {
for (const selector of possibleSelectors) {
try {
const element = page.locator(selector).first();
const isVisible = await element.isVisible({ timeout: 500 });
if (isVisible) {
captchaSelector = selector;
captchaVisible = true;
console.log(`检测到滑块验证容器(选择器: ${selector}`);
// 等待滑块内部元素真正加载完成
console.log('等待滑块内部元素加载...');
await page.waitForTimeout(2000); // 给 iframe 更多时间加载
// 如果是 iframe需要在 iframe 内检查元素
if (selector.includes('iframe')) {
try {
const frame = page.frameLocator(selector);
const keySelectors = ['#slideBg', '.tc-bg-img', '.tc-fg-item'];
let elementsLoaded = false;
for (let i = 0; i < 8; i++) { // 最多等待4秒
for (const keySelector of keySelectors) {
try {
const keyElement = frame.locator(keySelector).first();
await keyElement.isVisible({ timeout: 500 });
console.log(`✓ iframe 内元素已加载: ${keySelector}`);
elementsLoaded = true;
break;
} catch {
continue;
}
}
if (elementsLoaded) {
break;
}
await page.waitForTimeout(500);
}
if (!elementsLoaded) {
console.warn('警告: 滑块容器已显示,但 iframe 内部元素加载较慢');
}
} catch (error) {
console.warn('无法检查 iframe 内部元素,继续执行...');
}
} else {
// 非 iframe 的情况,直接在页面查找
const keySelectors = ['.tc-bg-img', '.tc-fg-item', '.tc-slider-normal'];
let elementsLoaded = false;
for (let i = 0; i < 8; i++) {
for (const keySelector of keySelectors) {
try {
const keyElement = page.locator(keySelector).first();
if (await keyElement.isVisible({ timeout: 300 })) {
console.log(`✓ 滑块关键元素已加载: ${keySelector}`);
elementsLoaded = true;
break;
}
} catch {
continue;
}
}
if (elementsLoaded) {
break;
}
await page.waitForTimeout(500);
}
if (!elementsLoaded) {
console.warn('警告: 滑块容器已显示,但内部元素未完全加载');
}
}
break;
}
} catch {
// 继续尝试下一个选择器
}
}
if (!captchaVisible) {
// 每隔500ms检查一次
await page.waitForTimeout(500);
}
}
if (captchaVisible && captchaSelector) {
if (AUTO_SLIDER) {
console.log('开始自动滑块验证...');
// 不指定滑块选择器,让 SliderController 自动查找
const result = await sliderController.solveSlider(page, undefined, captchaSelector);
if (result.success) {
console.log(`✓ 滑块验证成功!(尝试 ${result.attempts} 次)`);
} else {
console.warn(`✗ 自动滑块验证失败,请手动完成`);
await prompt('请在浏览器中手动完成滑块验证后按 Enter 继续...');
}
} else {
console.log('请在浏览器中手动完成滑块验证');
await prompt('完成滑块验证后按 Enter 继续...');
}
} else {
console.log('未检测到滑块验证或验证已完成');
}
await page.fill('input[name="code"]', code);
console.log('正在尝试自动读取短信验证码...');
let code: string | null = null;
try {
const result = await waitForDoubanCode({
logger: (message) => console.log(`[短信读取] ${message}`),
});
code = result.code;
console.log(`✓ 已自动获取验证码:${code}`);
} catch (error) {
console.warn('自动读取验证码失败或超时,将回退到手动输入。');
console.warn(`原因: ${(error as Error).message}`);
code = (await prompt('请输入短信验证码: ')).trim();
}
if (!code) {
throw new Error('未能获取短信验证码,登录流程终止。');
}
const codeInput = page.locator('input#code[name="code"]');
await codeInput.waitFor({ state: 'visible', timeout: 10000 });
await codeInput.fill(code);
console.log('正在提交验证码...');
await page.click('text=登录豆瓣');
@@ -174,16 +327,24 @@ async function loginWithSms(page: Page, phone: string): Promise<void> {
* 程序主入口:协调上下文、执行登录并持久化 cookies。
*/
async function main(): Promise<void> {
console.log('=== 豆瓣登录脚本启动 ===');
console.log(`环境变量 - DOUBAN_PHONE: ${PHONE ? '已设置' : '未设置'}`);
console.log(`环境变量 - DOUBAN_AUTO_SLIDER: ${AUTO_SLIDER ? '启用' : '禁用'}`);
if (!PHONE) {
console.error('请通过环境变量 DOUBAN_PHONE 提供登录手机号。');
process.exitCode = 1;
return;
}
console.log('正在启动浏览器...');
const browser = await chromium.launch({ headless: false });
console.log('✓ 浏览器启动成功');
try {
console.log('正在准备浏览器上下文...');
let { context, page, usedCookies } = await prepareContext(browser);
console.log(`✓ 上下文准备完成 (使用缓存: ${usedCookies})`);
if (usedCookies) {
console.info('✓ 已使用缓存 Cookies 自动登录成功');

294
src/slider/README.md Normal file
View File

@@ -0,0 +1,294 @@
# 滑块验证模块
本模块实现了豆瓣登录页面滑块验证码的自动检测和解决功能。
## 功能特性
- ✅ 自动检测滑块验证码中的缺口位置
- ✅ 支持多滑块检测(检测两个滑块并计算距离)
- ✅ 模拟人类滑动轨迹(贝塞尔曲线)
- ✅ 自动重试机制(最多 10 次)
- ✅ 滑块浮窗消失判定验证成功
## 目录结构
```
src/slider/
├── cli.ts # 命令行工具,用于批量评估/标注
├── index.ts # 模块导出
├── types.ts # 类型定义
├── detector.ts # 主滑块检测器
├── detector-self-learning.ts # 自学习第二滑块检测
├── slider-controller.ts # 滑块移动控制器
├── validator.ts # 检测结果验证工具
├── detection/
│ └── candidate-search.ts # 候选区域搜索算法
└── utils/
├── geometry.ts # 几何计算工具
└── image.ts # 图像处理工具
```
## 运行输出约定
- 登录流程截取的**原始验证码**保存在项目根目录的 `noflag/`
- 自动检测产生的**标注结果**保存在根目录的 `output/`
- 可执行 `npm run slider -- --pic-dir=noflag` 对原始截图批量复核,结果同样输出至 `output/`
## 核心算法
### 1. 滑块检测 (`detector.ts`)
- **多策略候选搜索**暗区域检测、边缘检测、颜色量化、LAB 色彩空间检测
- **候选框评分**:基于形状、色调一致性、内部边缘密度、梯度平滑度
- **边缘精炼**:使用 Sobel 边缘检测和投影分析精确定位滑块边界
### 2. 第二滑块检测 (`detector-self-learning.ts`)
- **模板匹配**:使用第一个检测到的滑块作为模板
- **边缘模板**:对图像和模板进行 Canny 边缘检测后匹配
- **位置验证**确保第二个滑块在同一水平线上y 轴偏差 < 25px
### 3. 滑动控制 (`slider-controller.ts`)
- **距离计算**v1.1.0 简化算法
- **双滑块模式**`距离 = (缺口X - 滑块X) / scaleX`
- 检测到左侧滑块b1和右侧缺口b2
- 计算两者左边界的水平距离
- 除以图像缩放比例原始 340px 检测用 800px
- 原理类比"两只小鸟嘴尖的水平距离"
- **单滑块模式**`距离 = 缺口中心X / scaleX`
- 仅检测到缺口位置时的兜底方案
- 从起始位置直接滑动到缺口中心
- **图像缩放优化**
- 原始验证码宽度340px
- 放大到 800px 进行检测scaleX 2.35
- 提高小尺寸滑块的检测精度
- **拟人化滑动**
- 使用 Playwright `steps` 参数
- 平滑移动轨迹避免机器人特征
## 使用方法
### 1. 环境变量配置
```bash
# 启用自动滑块验证
export DOUBAN_AUTO_SLIDER=1
# 设置手机号
export DOUBAN_PHONE=13800138000
# 运行登录脚本
npm run login
```
### 2. 编程接口
```typescript
import { SliderController } from './slider';
import { Page } from 'playwright';
const controller = new SliderController(10); // 最多尝试 10 次
const result = await controller.solveSlider(
page,
'.tcaptcha_drag_button', // 滑块按钮选择器
'#tcaptcha_iframe' // 验证码容器选择器
);
if (result.success) {
console.log(`验证成功!尝试 ${result.attempts} 次`);
} else {
console.log('验证失败');
}
```
### 3. 独立使用滑块检测器
```typescript
import { SliderDetector } from './slider';
const detector = new SliderDetector();
const boxes = await detector.detectSlider(
'captcha.png',
'output/captcha-annotated.png',
true
);
if (boxes && boxes.length > 0) {
console.log('检测到滑块:', boxes);
}
```
### 4. CLI 工具
```bash
npm run slider -- --pic-dir=images/douban
```
- 默认读取 `images/douban` 下的验证码图片并输出标注结果到 `images/output`
- 若存在 `ground-truth.json`会自动评估检测精度和召回率
- 通过 `--pic-dir=子目录` 可切换其他图片集合
## 工作流程
1. **等待滑块出现**检测页面中是否存在滑块验证码 iframe
2. **截图**捕获验证码区域图像保存原始图到 `noflag/` 目录
3. **图像预处理**将图像缩放到 800px 宽度以提高检测精度
4. **多策略检测**并行运行四种算法检测滑块候选框
- 暗区域检测基于亮度阈值
- Canny 边缘检测
- 颜色量化K-means 聚类
- LAB 色彩空间分析
5. **候选框评分与筛选**
- 计算每个候选框的综合分数形状颜色边缘
- IoU 去重合并重叠候选框
- 选择得分最高的两个滑块
6. **距离计算**
- 双滑块`(b2.x - b1.x) / scaleX`
- 单滑块`b.x / scaleX`
7. **可视化标注**在检测图上绘制红色框保存到 `output/` 目录
8. **模拟滑动**拖动左侧滑块到计算出的距离
9. **验证结果**检查是否出现 `.tc-success` 成功标识
10. **失败重试**点击刷新按钮重新截图检测最多 10
## 参数说明
### SliderController 构造函数
```typescript
new SliderController(maxAttempts: number = 10)
```
- `maxAttempts`: 最大尝试次数默认 10
### solveSlider 方法
```typescript
async solveSlider(
page: Page,
sliderSelector: string = '.tcaptcha_drag_button',
captchaSelector: string = '#tcaptcha_iframe'
): Promise<SliderSolveResult>
```
- `page`: Playwright 页面对象
- `sliderSelector`: 滑块按钮的 CSS 选择器
- `captchaSelector`: 验证码容器的 CSS 选择器
### 返回值 SliderSolveResult
```typescript
interface SliderSolveResult {
success: boolean; // 是否成功
attempts: number; // 尝试次数
distance?: number; // 滑动距离(像素)
}
```
## 依赖项
- `sharp`: 图像处理库用于边缘检测颜色量化等
- `playwright`: 浏览器自动化用于截图和鼠标操作
## 注意事项
1. **选择器适配**不同网站的滑块选择器可能不同需要根据实际情况调整
2. **截图位置**临时截图保存在 `os.tmpdir()/douban-slider/` 目录
3. **成功判定**通过检查验证码浮窗是否消失来判断验证是否成功
4. **失败处理**自动验证失败后会提示用户手动完成
## 调试
如需查看检测过程中的日志观察控制台输出
```
[SliderController] 开始滑块验证,最多尝试 10 次
[SliderController] 等待验证码 iframe 加载...
[SliderController] 验证码 iframe 已加载
[SliderController] 等待滑块背景图加载...
[SliderController] 滑块背景图已加载
[SliderController] ===== 第 1/10 次尝试 =====
[SliderController] 已截图到: /Users/gavin/douban-login/noflag/captcha-20250125-123456.png
[SliderDetector] 图像已缩放: 340x191 -> 800x449 (scaleX=2.35)
[SliderDetector] 检测到 2 个滑块候选框
[SliderDetector] 滑块 1: x=45, width=60, score=0.85
[SliderDetector] 滑块 2: x=195, width=55, score=0.82
[SliderDetector] 已保存标注图: /Users/gavin/douban-login/output/captcha-20250125-123456-detected.png
[SliderController] ✓ 检测到 2 个滑块
[SliderController] 计算距离: (195 - 45) / 2.35 = 63.8px
[SliderController] 开始拖动滑块 64px
[SliderController] ✓ 滑块验证成功!(1000ms后窗口消失)
[SliderController] 验证成功!共尝试 1 次
```
**关键日志说明**
- `图像已缩放`: 显示原始尺寸检测尺寸和缩放比例
- `检测到 N 个滑块候选框`: N=2 表示双滑块模式N=1 表示单滑块模式
- `滑块 1/2`: 显示每个滑块的 x 坐标宽度和评分
- `已保存标注图`: 红框标注结果的保存路径
- `计算距离`: 显示详细的距离计算公式
- `✓ 滑块验证成功`: 检测到腾讯验证码的成功标识
## 故障排查
### 1. 检测不到滑块
**症状**日志显示"未检测到滑块"
**排查步骤**
- 检查 `noflag/` 目录下的原始截图是否正确
- 确认验证码已完全加载等待 iframe 和图片元素
- 查看 `output/` 目录的标注图确认候选框是否被正确识别
- 调整 `candidate-search.ts` 中的检测阈值
### 2. 滑动距离不准确
**症状**滑块滑过头或不够远
**排查步骤**
- 查看日志中的 `scaleX` 应该约为 2.35
- 确认使用的是双滑块模式还是单滑块模式
- 检查 `output/` 目录标注图红框是否准确框住滑块
- 验证距离计算公式`(b2.x - b1.x) / scaleX`
**v1.1.0 改进**
- 简化了距离计算逻辑移除复杂的坐标转换
- 采用"两只小鸟距离"原理直接计算左边界差值
### 3. 验证总是失败
**症状**滑动后没有出现成功提示
**可能原因**
- 滑动距离计算错误参见上一条
- 触发反爬虫检测轨迹太机械
- 网络延迟导致成功标识未及时显示
**解决方案**
- 检查日志中的滑动距离是否合理通常 50-150px
- 增加成功判定的等待时间当前 1000ms
- 尝试多次重试当前最多 10
- 查看浏览器开发者工具确认 `.tc-success` 类名是否出现
### 4. 视觉调试技巧
**查看检测结果**
1. 运行登录后打开 `output/` 目录
2. 找到最新的 `*-detected.png` 文件
3. 检查红框是否准确标注了滑块和缺口
4. 对比 `noflag/` 目录的原始图确认缩放和标注的准确性
**理想的标注结果**
- 左侧滑块红框紧贴滑块边缘
- 右侧缺口红框框住缺口区域
- 两个红框高度基本一致y 坐标偏差 < 25px
## 移植说明
本模块从 `captcha_cracker` 项目移植而来并进行了以下扩展
1. 原样保留检测标注CLI 与验证器等核心能力
2. 新增 Playwright 集成用于自动截图和滑块拖动
3. 添加登录流程的滑块控制器与重试机制
4. 调整脚本入口与文档便于在豆瓣登录场景复用

221
src/slider/cli.ts Normal file
View File

@@ -0,0 +1,221 @@
import fs from 'fs';
import path from 'path';
import { SliderDetector } from './detector';
import { SliderValidator } from './validator';
import { BoundingBox, Rectangle } from './types';
type GroundTruth = Record<string, Rectangle[]>;
async function main() {
const detector = new SliderDetector();
const validator = new SliderValidator();
const baseDir = path.join(__dirname, '..', '..');
const doubanDir = path.join(baseDir, 'images', 'douban');
const outputDir = path.join(baseDir, 'output');
const groundTruthPath = path.join(baseDir, 'ground-truth.json');
const detectionCache = new Map<string, BoundingBox[] | null>();
const detect = async (imagePath: string): Promise<BoundingBox[] | null> => {
if (!detectionCache.has(imagePath)) {
const result = await detector.detectSlider(imagePath, undefined, true);
detectionCache.set(imagePath, result);
}
return detectionCache.get(imagePath)!;
};
console.log('=== 滑块检测 CLI ===\n');
const customArg = process.argv.find((arg) => arg.startsWith('--pic-dir='));
const processDir = customArg
? path.join(baseDir, customArg.split('=')[1])
: doubanDir;
const processDirName = customArg ? customArg.split('=')[1] : 'images/douban';
const useDefaultDataset = !customArg || processDir === doubanDir;
if (useDefaultDataset) {
const groundTruth = loadGroundTruth(groundTruthPath);
if (groundTruth) {
await evaluateAgainstGroundTruth({
doubanDir,
groundTruth,
detect,
validator,
});
} else {
console.log('未找到 ground-truth.json跳过准确性验证。\n');
}
} else {
console.log(`使用自定义图片目录 ${processDirName},跳过 ground-truth 验证。\n`);
}
await ensureDir(outputDir);
await processDirectory({
processDir,
processDirName,
outputDir,
detect,
detector,
});
console.log('\n=== 检测完成 ===');
}
function loadGroundTruth(filePath: string): GroundTruth | null {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content) as GroundTruth;
} catch (error) {
console.warn(`无法解析 ground-truth.json${error}`);
return null;
}
}
async function ensureDir(dir: string): Promise<void> {
await fs.promises.mkdir(dir, { recursive: true });
}
async function evaluateAgainstGroundTruth({
doubanDir,
groundTruth,
detect,
validator,
}: {
doubanDir: string;
groundTruth: GroundTruth;
detect: (imagePath: string) => Promise<BoundingBox[] | null>;
validator: SliderValidator;
}): Promise<void> {
console.log('1. 验证算法准确性容差10px...\n');
let totalMatched = 0;
let totalTargets = 0;
let totalDetected = 0;
for (const [fileName, expectedBoxes] of Object.entries(groundTruth)) {
const imagePath = path.join(doubanDir, fileName);
if (!fs.existsSync(imagePath)) {
console.log(` 跳过 ${fileName}(原图不存在)`);
continue;
}
const detections = await detect(imagePath);
const detectedBoxes = Array.isArray(detections) ? detections : [];
const result = await validator.validateDetection(
detectedBoxes,
expectedBoxes,
10
);
console.log(` ${fileName}:`);
console.log(
` 目标 ${result.totalTargets} 个 | 检测 ${result.detectedCount} 个 | 匹配 ${result.matchedCount}`
);
console.log(
` 准确率: ${(result.precision * 100).toFixed(1)}% | 召回率: ${(result.recall * 100).toFixed(1)}%`
);
if (result.matches.length > 0) {
result.matches.forEach((match, index) => {
console.log(
` 匹配 ${index + 1}: IoU=${match.iou.toFixed(3)}`
);
});
}
const missed = result.totalTargets - result.matchedCount;
if (missed > 0) {
console.log(` ⚠️ 漏检 ${missed} 个滑块`);
}
if (result.unmatched.length > 0) {
console.log(` ⚠️ 误检 ${result.unmatched.length} 个滑块`);
}
console.log('');
totalMatched += result.matchedCount;
totalTargets += result.totalTargets;
totalDetected += result.detectedCount;
}
if (totalTargets > 0) {
const overallPrecision =
totalDetected > 0 ? (totalMatched / totalDetected) * 100 : 0;
const overallRecall = (totalMatched / totalTargets) * 100;
console.log('总体统计:');
console.log(` 总目标数: ${totalTargets}`);
console.log(` 总检测数: ${totalDetected}`);
console.log(` 匹配成功: ${totalMatched}`);
console.log(
` 总体准确率: ${overallPrecision.toFixed(1)}% | 总体召回率: ${overallRecall.toFixed(1)}%\n`
);
} else {
console.log(' ground-truth.json 中没有记录可供验证。\n');
}
}
async function processDirectory({
processDir,
processDirName,
outputDir,
detect,
detector,
}: {
processDir: string;
processDirName: string;
outputDir: string;
detect: (imagePath: string) => Promise<BoundingBox[] | null>;
detector: SliderDetector;
}): Promise<void> {
console.log(`2. 处理 ${processDirName} 目录下的滑块图片...\n`);
if (!fs.existsSync(processDir)) {
console.log(` 错误:找不到目录 ${processDir}`);
return;
}
const files = fs
.readdirSync(processDir)
.filter((file) => file.toLowerCase().endsWith('.png'));
if (files.length === 0) {
console.log(' 没有找到需要处理的图片。');
return;
}
let processed = 0;
for (const file of files) {
const inputPath = path.join(processDir, file);
const outputPath = path.join(outputDir, file);
const detections = await detect(inputPath);
if (detections && detections.length > 0) {
await detector.annotate(inputPath, detections, outputPath);
const boxSummary = detections
.map(
(box, index) =>
`#${index + 1}[x=${box.x}, y=${box.y}, w=${box.width}, h=${box.height}]`
)
.join(', ');
console.log(`${file}: 检测到 ${detections.length} 个滑块 ${boxSummary}`);
processed++;
} else {
console.log(`${file}: 未检测到滑块`);
}
}
console.log(
`\n 处理完成: ${processed}/${files.length} 张图片,结果输出到 ${outputDir}`
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,705 @@
import sharp from 'sharp';
import { BoundingBox, RawImage } from '../types';
import { calculateIoU } from '../utils/geometry';
import { createEdgeMap, morphologyClose, dilate, toGrayscale } from '../utils/image';
interface CandidateSearchInput {
original: RawImage;
normalized: RawImage;
quantizationSource: sharp.Sharp;
}
export async function findCandidateBoxes({
original,
normalized,
quantizationSource,
}: CandidateSearchInput): Promise<BoundingBox[]> {
const { width, height, channels } = normalized;
const mixedBoxes = detectDarkRegions(normalized.data, width, height, channels);
const edgeBoxes = detectByEdges(normalized.data, width, height, channels);
const quantizedBoxes = await detectByColorQuantization(
quantizationSource,
width,
height,
channels
);
const labBoxes = detectByLabColor(original.data, width, height, channels);
const allBoxes = [...mixedBoxes, ...edgeBoxes, ...quantizedBoxes, ...labBoxes];
const uniqueBoxes: BoundingBox[] = [];
allBoxes
.sort(
(a, b) =>
b.score / (b.width * b.height) - a.score / (a.width * a.height)
)
.forEach((box) => {
if (!uniqueBoxes.some((ub) => calculateIoU(ub, box) > 0.5)) {
uniqueBoxes.push(box);
}
});
const edgeMap = createEdgeMap(original);
const scoredBoxes = uniqueBoxes
.map((box) =>
scoreCandidate(box, original, normalized, edgeMap)
)
.filter((box) => {
const aspectRatio = box.width / box.height;
const marginX = width * 0.05;
const marginY = height * 0.05;
const isNotOnEdge =
box.x > marginX &&
box.y > marginY &&
box.x + box.width < width - marginX &&
box.y + box.height < height - marginY;
return (
box.width >= 60 &&
box.width <= 120 &&
box.height >= 60 &&
box.height <= 120 &&
aspectRatio >= 0.7 &&
aspectRatio <= 1.3 &&
isNotOnEdge
);
})
.sort((a, b) => b.score - a.score);
return scoredBoxes;
}
function scoreCandidate(
box: BoundingBox,
original: RawImage,
normalized: RawImage,
edgeMap: Uint8Array
): BoundingBox {
const aspectRatio = box.width / box.height;
const isSquare = aspectRatio >= 0.85 && aspectRatio <= 1.18;
const isConsistent = verifyHueConsistency(original, box);
const internalEdgeDensity = calculateInternalEdgeDensity(
edgeMap,
normalized.width,
box
);
const gradientScore = calculateEdgeGradientScore(original, box);
let score = box.score / (box.width * box.height);
if (isSquare) score += 0.5;
if (isConsistent) score += 0.8;
if (internalEdgeDensity < 0.15) score += 0.8;
if (internalEdgeDensity < 0.1) score += 0.6;
score += gradientScore * 2.0;
return { ...box, score };
}
function verifyHueConsistency(image: RawImage, box: BoundingBox): boolean {
const hueValues: number[] = [];
const saturationValues: number[] = [];
const inset = 5;
const startY = box.y + inset;
const endY = box.y + box.height - inset;
const startX = box.x + inset;
const endX = box.x + box.width - inset;
if (endY <= startY || endX <= startX) return true;
const { data, width, channels } = image;
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
const idx = (y * width + x) * channels;
const r = data[idx] / 255;
const g = data[idx + 1] / 255;
const b = data[idx + 2] / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
if (s > 0.15 && l > 0.1 && l < 0.9) {
hueValues.push(h * 360);
saturationValues.push(s);
}
}
}
const coloredPixels = hueValues.length;
const internalArea = (box.width - 2 * inset) * (box.height - 2 * inset);
if (coloredPixels < internalArea * 0.2) {
return true;
}
const normalizeHue = (h: number) => (h > 180 ? h - 360 : h);
const normalizedHues = hueValues.map(normalizeHue);
const meanHue =
normalizedHues.reduce((a, b) => a + b, 0) / normalizedHues.length;
const stdDevHue = Math.sqrt(
normalizedHues
.map((h) => Math.pow(h - meanHue, 2))
.reduce((a, b) => a + b, 0) / normalizedHues.length
);
return stdDevHue < 25;
}
function calculateInternalEdgeDensity(
edgeMap: Uint8Array,
width: number,
box: BoundingBox
): number {
let edgePixels = 0;
const shrink = 5;
const startX = box.x + shrink;
const startY = box.y + shrink;
const endX = box.x + box.width - shrink;
const endY = box.y + box.height - shrink;
if (endX <= startX || endY <= startY) return 0;
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
if (edgeMap[y * width + x] === 1) {
edgePixels++;
}
}
}
const area = (endX - startX) * (endY - startY);
return area === 0 ? 0 : edgePixels / area;
}
function calculateEdgeGradientScore(image: RawImage, box: BoundingBox): number {
const gradients: number[] = [];
const band = 5;
const { data, width, height, channels } = image;
const sampleLine = (
x1: number,
y1: number,
x2: number,
y2: number
) => {
const dx = x2 - x1;
const dy = y2 - y1;
const steps = Math.max(Math.abs(dx), Math.abs(dy));
if (steps === 0) return;
let lastBrightness = -1;
for (let i = 0; i <= steps; i++) {
const x = Math.round(x1 + (dx * i) / steps);
const y = Math.round(y1 + (dy * i) / steps);
if (x < 0 || x >= width || y < 0 || y >= height) continue;
const idx = (y * width + x) * channels;
const brightness =
data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114;
if (lastBrightness !== -1) {
gradients.push(Math.abs(brightness - lastBrightness));
}
lastBrightness = brightness;
}
};
sampleLine(box.x, box.y - band, box.x + box.width, box.y - band);
sampleLine(
box.x,
box.y + box.height + band,
box.x + box.width,
box.y + box.height + band
);
sampleLine(box.x - band, box.y, box.x - band, box.y + box.height);
sampleLine(
box.x + box.width + band,
box.y,
box.x + box.width + band,
box.y + box.height
);
if (gradients.length < 20) {
return 0.5;
}
const mean = gradients.reduce((a, b) => a + b, 0) / gradients.length;
const variance =
gradients.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) /
gradients.length;
return Math.exp(-variance / 100);
}
function detectDarkRegions(
data: Buffer,
width: number,
height: number,
channels: number
): BoundingBox[] {
const allCandidates: BoundingBox[] = [];
for (const brightThreshold of [130, 160, 190, 220]) {
const whiteMap = new Uint8Array(width * height);
for (let i = 0; i < data.length; i += channels) {
const brightness =
data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
whiteMap[i / channels] = brightness > brightThreshold ? 1 : 0;
}
const dilatedMap = dilate(whiteMap, width, height, 5);
const regions = findDarkRegionsList(dilatedMap, width, height);
allCandidates.push(
...selectBestRegions(regions, width, height, true)
);
}
for (const darkThreshold of [40, 60, 80, 100, 120]) {
const darkMap = new Uint8Array(width * height);
for (let i = 0; i < data.length; i += channels) {
const brightness =
data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
darkMap[i / channels] = brightness < darkThreshold ? 1 : 0;
}
const cleaned = morphologyClose(darkMap, width, height, 3);
const regions = findDarkRegionsList(cleaned, width, height);
allCandidates.push(
...selectBestRegions(regions, width, height, true)
);
}
if (allCandidates.length === 0) return [];
const uniqueCandidates: BoundingBox[] = [];
allCandidates.sort((a, b) => b.score - a.score).forEach((candidate) => {
if (!uniqueCandidates.some((s) => calculateIoU(s, candidate) > 0.4)) {
uniqueCandidates.push(candidate);
}
});
return uniqueCandidates;
}
function findDarkRegionsList(
binary: Uint8Array,
width: number,
height: number
): BoundingBox[] {
const visited = new Uint8Array(width * height);
const regions: BoundingBox[] = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (visited[idx] === 0 && binary[idx] === 1) {
const region = floodFill(binary, visited, x, y, width, height);
if (region.width >= 20 && region.height >= 20) {
regions.push(region);
}
}
}
}
return regions;
}
function selectBestRegions(
regions: BoundingBox[],
imageWidth: number,
imageHeight: number,
selectMultiple: boolean = false
): BoundingBox[] {
if (regions.length === 0) return [];
const validRegions = regions.filter(
(region) =>
region.width < imageWidth * 0.5 && region.height < imageHeight * 0.5
);
const candidates = validRegions.filter((region) => {
const aspectRatio = region.width / region.height;
const centerY = region.y + region.height / 2;
const sizeDiff = Math.abs(region.width - region.height);
return (
region.width >= 70 &&
region.width <= 110 &&
region.height >= 70 &&
region.height <= 110 &&
aspectRatio >= 0.85 &&
aspectRatio <= 1.18 &&
sizeDiff <= 20 &&
centerY > imageHeight * 0.1 &&
centerY < imageHeight * 0.8
);
});
if (candidates.length === 0) return [];
candidates.sort((a, b) => {
const densityA = a.score / (a.width * a.height);
const densityB = b.score / (b.width * b.height);
const aspectScoreA = Math.abs(a.width / a.height - 1);
const aspectScoreB = Math.abs(b.width / b.height - 1);
return densityB * 3 - aspectScoreB - (densityA * 3 - aspectScoreA);
});
const selected: BoundingBox[] = [];
for (const candidate of candidates) {
const overlaps = selected.some(
(s) => calculateIoU(s, candidate) > 0.3
);
if (!overlaps) {
selected.push(candidate);
if (!selectMultiple && selected.length >= 1) break;
if (selectMultiple && selected.length >= 3) break;
}
}
return selected;
}
function detectByEdges(
data: Buffer,
width: number,
height: number,
channels: number
): BoundingBox[] {
const gray = toGrayscale(data, width, height, channels);
const edges = new Uint8Array(width * height);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
const gx =
-gray[(y - 1) * width + (x - 1)] +
gray[(y - 1) * width + (x + 1)] -
2 * gray[idx - 1] +
2 * gray[idx + 1] -
gray[(y + 1) * width + (x - 1)] +
gray[(y + 1) * width + (x + 1)];
const gy =
-gray[(y - 1) * width + (x - 1)] -
2 * gray[(y - 1) * width + x] -
gray[(y - 1) * width + (x + 1)] +
gray[(y + 1) * width + (x - 1)] +
2 * gray[(y + 1) * width + x] +
gray[(y + 1) * width + (x + 1)];
const magnitude = Math.sqrt(gx * gx + gy * gy);
edges[idx] = magnitude > 40 ? 1 : 0;
}
}
const dilatedMap = dilate(edges, width, height, 4);
const regions = findDarkRegionsList(dilatedMap, width, height);
return selectBestRegions(regions, width, height, true);
}
async function detectByColorQuantization(
image: sharp.Sharp,
width: number,
height: number,
channels: number
): Promise<BoundingBox[]> {
try {
const smoothed = await image
.clone()
.median(3)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { data: smoothData, info } = smoothed;
const channelCount = info.channels ?? channels;
const quantized = Buffer.from(smoothData);
const palette = [
[240, 240, 240],
[200, 200, 200],
[150, 150, 150],
[100, 100, 100],
[60, 60, 60],
[30, 30, 30],
[0, 0, 0],
];
for (let i = 0; i < quantized.length; i += channelCount) {
const r = quantized[i];
const g = quantized[i + 1];
const b = quantized[i + 2];
let minDist = Infinity;
let closest = 0;
for (let p = 0; p < palette.length; p++) {
const [pr, pg, pb] = palette[p];
const dist = Math.pow(r - pr, 2) + Math.pow(g - pg, 2) + Math.pow(b - pb, 2);
if (dist < minDist) {
minDist = dist;
closest = p;
}
}
const [qr, qg, qb] = palette[closest];
quantized[i] = qr;
quantized[i + 1] = qg;
quantized[i + 2] = qb;
}
const visited = new Uint8Array(width * height);
const regions: BoundingBox[] = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (visited[idx] === 0) {
const region = floodFillOnQuantized(
quantized,
visited,
x,
y,
width,
height,
channelCount
);
if (
region.width >= 40 &&
region.width <= 140 &&
region.height >= 40 &&
region.height <= 140
) {
const aspectRatio = region.width / region.height;
if (aspectRatio >= 0.7 && aspectRatio <= 1.4) {
regions.push(region);
}
}
}
}
}
return selectBestRegions(regions, width, height, true);
} catch (error) {
console.error('[Quantization] Failed to quantize image:', error);
return [];
}
}
function detectByLabColor(
data: Buffer,
width: number,
height: number,
channels: number
): BoundingBox[] {
const labMap = new Float32Array(width * height * 3);
for (let i = 0; i < width * height; i++) {
const idx = i * channels;
const [l, a, b] = rgbToLab(data[idx], data[idx + 1], data[idx + 2]);
labMap[i * 3] = l;
labMap[i * 3 + 1] = a;
labMap[i * 3 + 2] = b;
}
const diffMap = new Uint8Array(width * height);
const neighborhood = 8;
for (let y = neighborhood; y < height - neighborhood; y++) {
for (let x = neighborhood; x < width - neighborhood; x++) {
const centerIdx = y * width + x;
let maxDiff = 0;
for (let ny = -neighborhood; ny <= neighborhood; ny += neighborhood) {
for (let nx = -neighborhood; nx <= neighborhood; nx += neighborhood) {
if (nx === 0 && ny === 0) continue;
const neighborIdx = (y + ny) * width + (x + nx);
const deltaE = Math.sqrt(
Math.pow(labMap[centerIdx * 3] - labMap[neighborIdx * 3], 2) +
Math.pow(labMap[centerIdx * 3 + 1] - labMap[neighborIdx * 3 + 1], 2) +
Math.pow(labMap[centerIdx * 3 + 2] - labMap[neighborIdx * 3 + 2], 2)
);
if (deltaE > maxDiff) {
maxDiff = deltaE;
}
}
}
if (maxDiff > 12) {
diffMap[centerIdx] = 1;
}
}
}
const cleaned = morphologyClose(diffMap, width, height, 5);
const regions = findDarkRegionsList(cleaned, width, height);
return selectBestRegions(regions, width, height, true);
}
function rgbToLab(r: number, g: number, b: number): [number, number, number] {
let R = r / 255;
let G = g / 255;
let B = b / 255;
R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92;
G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92;
B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92;
const X = R * 0.4124 + G * 0.3576 + B * 0.1805;
const Y = R * 0.2126 + G * 0.7152 + B * 0.0722;
const Z = R * 0.0193 + G * 0.1192 + B * 0.9505;
let x = X / 0.95047;
let y = Y / 1.0;
let z = Z / 1.08883;
x = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116;
y = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116;
z = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116;
const L = 116 * y - 16;
const a = 500 * (x - y);
const bLab = 200 * (y - z);
return [L, a, bLab];
}
function floodFillOnQuantized(
data: Buffer,
visited: Uint8Array,
startX: number,
startY: number,
width: number,
height: number,
channels: number
): BoundingBox {
const startIdx = (startY * width + startX) * channels;
const targetColor = [
data[startIdx],
data[startIdx + 1],
data[startIdx + 2],
];
let minX = startX;
let minY = startY;
let maxX = startX;
let maxY = startY;
let pixelCount = 0;
const stack: Array<[number, number]> = [[startX, startY]];
visited[startY * width + startX] = 1;
while (stack.length > 0) {
const [x, y] = stack.pop()!;
pixelCount++;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
const neighbors: Array<[number, number]> = [
[x + 1, y],
[x - 1, y],
[x, y + 1],
[x, y - 1],
];
for (const [nx, ny] of neighbors) {
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = ny * width + nx;
if (visited[nIdx] === 0) {
const baseIdx = nIdx * channels;
const neighborColor = [
data[baseIdx],
data[baseIdx + 1],
data[baseIdx + 2],
];
if (
neighborColor[0] === targetColor[0] &&
neighborColor[1] === targetColor[1] &&
neighborColor[2] === targetColor[2]
) {
visited[nIdx] = 1;
stack.push([nx, ny]);
}
}
}
}
}
return {
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
score: pixelCount,
};
}
function floodFill(
binary: Uint8Array,
visited: Uint8Array,
startX: number,
startY: number,
width: number,
height: number
): BoundingBox {
let minX = startX;
let minY = startY;
let maxX = startX;
let maxY = startY;
let pixelCount = 0;
const stack: Array<[number, number]> = [[startX, startY]];
while (stack.length > 0) {
const [x, y] = stack.pop()!;
if (x < 0 || x >= width || y < 0 || y >= height) continue;
const idx = y * width + x;
if (visited[idx] === 1 || binary[idx] === 0) continue;
visited[idx] = 1;
pixelCount++;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
stack.push([x + 1, y]);
stack.push([x - 1, y]);
stack.push([x, y + 1]);
stack.push([x, y - 1]);
}
return {
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
score: pixelCount,
};
}

View File

@@ -0,0 +1,152 @@
import sharp from 'sharp';
import * as fs from 'fs';
import * as path from 'path';
import { BoundingBox } from './types';
async function matchTemplate(
image: sharp.Sharp,
template: sharp.Sharp,
searchArea: { x: number; y: number; width: number; height: number },
excludeBox?: BoundingBox
): Promise<{ maxVal: number; maxLoc: { x: number; y: number } }> {
const { data: imageBuffer, info: imageInfo } = await image
.raw()
.toBuffer({ resolveWithObject: true });
const { data: templateBuffer, info: templateInfo } = await template
.raw()
.toBuffer({ resolveWithObject: true });
const { width: imageWidth, height: imageHeight, channels: imageChannels } = imageInfo;
const { width: templateWidth, height: templateHeight, channels: templateChannels } = templateInfo;
if (!imageWidth || !imageHeight || !templateWidth || !templateHeight) {
throw new Error('Image or template dimensions are invalid.');
}
let maxVal = -Infinity;
let maxLoc = { x: 0, y: 0 };
const startY = Math.max(0, searchArea.y);
const endY = Math.min(imageHeight - templateHeight, searchArea.y + searchArea.height);
const startX = Math.max(0, searchArea.x);
const endX = Math.min(imageWidth - templateWidth, searchArea.x + searchArea.width);
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
// Exclude the original box area from matching by checking for significant overlap
if (excludeBox) {
const x_overlap = Math.max(0, Math.min(x + templateWidth, excludeBox.x + excludeBox.width) - Math.max(x, excludeBox.x));
const y_overlap = Math.max(0, Math.min(y + templateHeight, excludeBox.y + excludeBox.height) - Math.max(y, excludeBox.y));
const overlapArea = x_overlap * y_overlap;
if (overlapArea / (templateWidth * templateHeight) > 0.5) {
continue;
}
}
let sumC = 0, sumT2 = 0, sumI2 = 0;
for (let ty = 0; ty < templateHeight; ty++) {
for (let tx = 0; tx < templateWidth; tx++) {
const imageY = y + ty;
const imageX = x + tx;
const imageIdx = (imageY * imageWidth + imageX) * imageChannels;
const templateIdx = (ty * templateWidth + tx) * templateChannels;
const imageVal = imageBuffer[imageIdx];
const templateVal = templateBuffer[templateIdx];
sumC += imageVal * templateVal;
sumT2 += templateVal * templateVal;
sumI2 += imageVal * imageVal;
}
}
const denominator = Math.sqrt(sumT2 * sumI2);
const val = denominator === 0 ? 0 : sumC / denominator;
if (val > maxVal) {
maxVal = val;
maxLoc = { x, y };
}
}
}
return { maxVal, maxLoc };
}
export class SelfLearningSliderDetector {
private async cannyEdge(image: sharp.Sharp): Promise<sharp.Sharp> {
return image
.grayscale()
.raw()
.toBuffer({ resolveWithObject: true })
.then(({ data, info }) => {
const sobelData = Buffer.alloc(info.width * info.height);
for (let y = 1; y < info.height - 1; y++) {
for (let x = 1; x < info.width - 1; x++) {
const Gx = -data[(y - 1) * info.width + x - 1] - 2 * data[y * info.width + x - 1] - data[(y + 1) * info.width + x - 1] + data[(y - 1) * info.width + x + 1] + 2 * data[y * info.width + x + 1] + data[(y + 1) * info.width + x + 1];
const Gy = -data[(y - 1) * info.width + x - 1] - 2 * data[(y - 1) * info.width + x] - data[(y - 1) * info.width + x + 1] + data[(y + 1) * info.width + x - 1] + 2 * data[(y + 1) * info.width + x] + data[(y + 1) * info.width + x + 1];
const magnitude = Math.sqrt(Gx * Gx + Gy * Gy);
sobelData[y * info.width + x] = magnitude > 50 ? 255 : 0;
}
}
return sharp(sobelData, { raw: { width: info.width, height: info.height, channels: 1 } });
});
}
public async detectSecondSlider(imagePath: string, seedBox: BoundingBox): Promise<BoundingBox | null> {
try {
const image = sharp(imagePath);
const { width: imageWidth, height: imageHeight } = await image.metadata();
if (!imageWidth || !imageHeight) return null;
const template = image.clone().extract({
left: seedBox.x,
top: seedBox.y,
width: seedBox.width,
height: seedBox.height,
});
const debugDir = path.join(__dirname, '..', '..', 'images', 'debug');
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
const templateFileName = `template-${path.basename(imagePath)}`;
await template.toFile(path.join(debugDir, templateFileName));
console.log(` [SelfLearning] Saved refined template to: ${templateFileName}`);
const imageEdge = await this.cannyEdge(image);
const templateEdge = await this.cannyEdge(template);
const searchArea = {
x: 0,
y: Math.max(0, seedBox.y - 25), // 显著放宽垂直搜索范围
width: imageWidth,
height: seedBox.height + 50, // 显著放宽垂直搜索范围
};
const { maxVal, maxLoc } = await matchTemplate(imageEdge, templateEdge, searchArea, seedBox);
console.log(` [SelfLearning] Max score for ${path.basename(imagePath)}: ${maxVal.toFixed(4)} at y=${maxLoc.y}`);
// 验证第二个滑块是否在同一水平线上放宽y轴偏差到25px
if (Math.abs(maxLoc.y - seedBox.y) > 25) {
console.log(` [SelfLearning] Discarded second slider candidate because it's not on the same horizontal line (y-delta: ${Math.abs(maxLoc.y - seedBox.y)}px).`);
return null;
}
if (maxVal > 0.35) { // 使用一个相对宽松但合理的阈值
return {
x: maxLoc.x,
y: maxLoc.y,
width: seedBox.width,
height: seedBox.height,
score: maxVal,
};
}
return null;
} catch (error) {
console.error(`Error during self-learning detection for ${imagePath}:`, error);
return null;
}
}
}

280
src/slider/detector.ts Normal file
View File

@@ -0,0 +1,280 @@
import sharp from 'sharp';
import { BoundingBox, RawImage } from './types';
import { findCandidateBoxes } from './detection/candidate-search';
import { calculateIoU } from './utils/geometry';
import { SelfLearningSliderDetector } from './detector-self-learning';
type BoxColor = 'red' | 'blue' | 'green';
const DEFAULT_DRAW_COLOR: BoxColor = 'blue';
export class SliderDetector {
private readonly selfLearning: SelfLearningSliderDetector;
constructor(selfLearning?: SelfLearningSliderDetector) {
this.selfLearning = selfLearning ?? new SelfLearningSliderDetector();
}
async detectSlider(
imagePath: string,
outputPath?: string,
detectMultiple: boolean = true
): Promise<BoundingBox[] | null> {
try {
const baseImage = sharp(imagePath);
const [originalRaw, normalizedRaw] = await Promise.all([
baseImage
.clone()
.raw()
.toBuffer({ resolveWithObject: true }),
baseImage
.clone()
.normalize()
.raw()
.toBuffer({ resolveWithObject: true }),
]);
const original = toRawImage(originalRaw.data, originalRaw.info);
const normalized = toRawImage(normalizedRaw.data, normalizedRaw.info);
const candidates = await findCandidateBoxes({
original,
normalized,
quantizationSource: baseImage.clone(),
});
if (candidates.length === 0) {
return null;
}
const seedBox = candidates[0];
const edgeImage = await this.cannyEdge(baseImage.clone());
const refinedSeed = await this.refineBox(seedBox, edgeImage);
const detections: BoundingBox[] = [refinedSeed];
if (detectMultiple) {
const second = await this.selfLearning.detectSecondSlider(
imagePath,
refinedSeed
);
if (second && calculateIoU(refinedSeed, second) < 0.5) {
detections.push(second);
}
}
if (outputPath) {
await this.drawBoundingBoxes(imagePath, detections, outputPath, DEFAULT_DRAW_COLOR);
}
return detections;
} catch (error) {
console.error(`Error detecting slider in ${imagePath}:`, error);
return null;
}
}
async annotate(
imagePath: string,
boxes: BoundingBox[],
outputPath: string,
color: BoxColor = DEFAULT_DRAW_COLOR
): Promise<void> {
await this.drawBoundingBoxes(imagePath, boxes, outputPath, color);
}
private async cannyEdge(image: sharp.Sharp): Promise<sharp.Sharp> {
const { data, info } = await image
.clone()
.grayscale()
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
if (!width || !height) {
throw new Error('Cannot compute edges without image dimensions');
}
const sobelData = Buffer.alloc(width * height);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
const gx =
-data[(y - 1) * width + (x - 1)] -
2 * data[y * width + (x - 1)] -
data[(y + 1) * width + (x - 1)] +
data[(y - 1) * width + (x + 1)] +
2 * data[y * width + (x + 1)] +
data[(y + 1) * width + (x + 1)];
const gy =
-data[(y - 1) * width + (x - 1)] -
2 * data[(y - 1) * width + x] -
data[(y - 1) * width + (x + 1)] +
data[(y + 1) * width + (x - 1)] +
2 * data[(y + 1) * width + x] +
data[(y + 1) * width + (x + 1)];
const magnitude = Math.sqrt(gx * gx + gy * gy);
sobelData[idx] = magnitude > 50 ? 255 : 0;
}
}
return sharp(sobelData, {
raw: { width, height, channels: 1 },
});
}
private async refineBox(
box: BoundingBox,
edgeImage: sharp.Sharp
): Promise<BoundingBox> {
try {
const { data, info } = await edgeImage
.clone()
.extract({
left: box.x,
top: box.y,
width: box.width,
height: box.height,
})
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
const projX = new Array(width).fill(0);
const projY = new Array(height).fill(0);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixel = data[y * width + x];
if (pixel > 0) {
projX[x]++;
projY[y]++;
}
}
}
const findBounds = (proj: number[], minThreshold = 2) => {
let start = -1;
let end = -1;
for (let i = 0; i < proj.length; i++) {
if (proj[i] >= minThreshold) {
if (start === -1) start = i;
end = i;
}
}
if (start === -1) {
return { start: 0, end: proj.length - 1 };
}
let bestStart = start;
for (let i = start; i < Math.min(proj.length, start + 10); i++) {
if (proj[i] >= minThreshold) {
bestStart = i;
break;
}
}
let bestEnd = end;
for (let i = end; i >= Math.max(0, end - 10); i--) {
if (proj[i] >= minThreshold) {
bestEnd = i;
break;
}
}
return { start: bestStart, end: bestEnd };
};
const { start: xStart, end: xEnd } = findBounds(projX);
const { start: yStart, end: yEnd } = findBounds(projY);
const newX = box.x + xStart;
const newY = box.y + yStart;
const newWidth = xEnd - xStart + 1;
const newHeight = yEnd - yStart + 1;
if (
newWidth <= 10 ||
newHeight <= 10 ||
newWidth > box.width * 1.2 ||
newHeight > box.height * 1.2
) {
return box;
}
return {
x: newX,
y: newY,
width: newWidth,
height: newHeight,
score: box.score,
};
} catch (error) {
console.error('[RefineBox] Failed to refine candidate, returning original box.', error);
return box;
}
}
private async drawBoundingBoxes(
imagePath: string,
boxes: BoundingBox[],
outputPath: string,
color: BoxColor = 'blue'
): Promise<void> {
if (boxes.length === 0) {
return;
}
const colorMap: Record<BoxColor, { r: number; g: number; b: number }> = {
red: { r: 255, g: 0, b: 0 },
blue: { r: 0, g: 0, b: 255 },
green: { r: 0, g: 255, b: 0 },
};
const rgb = colorMap[color];
const image = sharp(imagePath);
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Cannot draw bounding boxes without image dimensions');
}
const rectangles = boxes
.map(
(box) => `
<rect
x="${box.x}"
y="${box.y}"
width="${box.width}"
height="${box.height}"
fill="none"
stroke="rgb(${rgb.r},${rgb.g},${rgb.b})"
stroke-width="2"
/>`
)
.join('\n');
const svg = Buffer.from(
`<svg width="${metadata.width}" height="${metadata.height}" xmlns="http://www.w3.org/2000/svg">
${rectangles}
</svg>`
);
await image
.composite([{ input: svg, top: 0, left: 0 }])
.toFile(outputPath);
}
}
function toRawImage(data: Buffer, info: sharp.OutputInfo): RawImage {
const { width, height, channels } = info;
if (!width || !height || !channels) {
throw new Error('Failed to read image metadata.');
}
return { data, width, height, channels };
}

6
src/slider/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export { SliderController } from './slider-controller';
export { SliderDetector } from './detector';
export { SliderValidator } from './validator';
export { SelfLearningSliderDetector } from './detector-self-learning';
export type { BoundingBox, Rectangle, RawImage } from './types';
export type { SliderSolveResult } from './slider-controller';

File diff suppressed because it is too large Load Diff

17
src/slider/types.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
export interface BoundingBox extends Rectangle {
score: number;
}
export interface RawImage {
data: Buffer;
width: number;
height: number;
channels: number;
}

View File

@@ -0,0 +1,16 @@
import { Rectangle } from '../types';
/**
* Calculate intersection over union for two bounding boxes.
*/
export function calculateIoU(a: Rectangle, b: Rectangle): number {
const x1 = Math.max(a.x, b.x);
const y1 = Math.max(a.y, b.y);
const x2 = Math.min(a.x + a.width, b.x + b.width);
const y2 = Math.min(a.y + a.height, b.y + b.height);
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
const union = a.width * a.height + b.width * b.height - intersection;
return union === 0 ? 0 : intersection / union;
}

136
src/slider/utils/image.ts Normal file
View File

@@ -0,0 +1,136 @@
import { RawImage } from '../types';
/**
* Convert RGB data to grayscale array.
*/
function toGrayscale(
data: Buffer,
width: number,
height: number,
channels: number
): Uint8Array {
const gray = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) {
const idx = i * channels;
gray[i] = Math.round(
data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114
);
}
return gray;
}
/**
* Produce a Sobel edge map from raw RGB data.
*/
export function createEdgeMap({
data,
width,
height,
channels,
}: RawImage): Uint8Array {
const gray = toGrayscale(data, width, height, channels);
const edges = new Uint8Array(width * height);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
const gx =
-gray[(y - 1) * width + (x - 1)] +
gray[(y - 1) * width + (x + 1)] -
2 * gray[idx - 1] +
2 * gray[idx + 1] -
gray[(y + 1) * width + (x - 1)] +
gray[(y + 1) * width + (x + 1)];
const gy =
-gray[(y - 1) * width + (x - 1)] -
2 * gray[(y - 1) * width + x] -
gray[(y - 1) * width + (x + 1)] +
gray[(y + 1) * width + (x - 1)] +
2 * gray[(y + 1) * width + x] +
gray[(y + 1) * width + (x + 1)];
const magnitude = Math.sqrt(gx * gx + gy * gy);
edges[idx] = magnitude > 40 ? 1 : 0;
}
}
return edges;
}
/**
* Morphological closing (dilate followed by erode).
*/
export function morphologyClose(
binary: Uint8Array,
width: number,
height: number,
kernelSize: number
): Uint8Array {
const dilated = dilate(binary, width, height, kernelSize);
return erode(dilated, width, height, kernelSize);
}
export function dilate(
binary: Uint8Array,
width: number,
height: number,
kernelSize: number
): Uint8Array {
const result = new Uint8Array(width * height);
const offset = Math.floor(kernelSize / 2);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let maxVal = 0;
for (let ky = -offset; ky <= offset; ky++) {
for (let kx = -offset; kx <= offset; kx++) {
const ny = y + ky;
const nx = x + kx;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
maxVal = Math.max(maxVal, binary[ny * width + nx]);
}
}
}
result[y * width + x] = maxVal;
}
}
return result;
}
export function erode(
binary: Uint8Array,
width: number,
height: number,
kernelSize: number
): Uint8Array {
const result = new Uint8Array(width * height);
const offset = Math.floor(kernelSize / 2);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let minVal = 1;
for (let ky = -offset; ky <= offset; ky++) {
for (let kx = -offset; kx <= offset; kx++) {
const ny = y + ky;
const nx = x + kx;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
minVal = Math.min(minVal, binary[ny * width + nx]);
}
}
}
result[y * width + x] = minVal;
}
}
return result;
}
export { toGrayscale };

105
src/slider/validator.ts Normal file
View File

@@ -0,0 +1,105 @@
import { BoundingBox, Rectangle } from './types';
import { calculateIoU } from './utils/geometry';
class SliderValidator {
/**
* 检查两个框是否匹配(允许一定偏差)
*/
isBoxMatching(detected: Rectangle, target: Rectangle, tolerance: number = 10): boolean {
// 计算中心点
const detectedCenterX = detected.x + detected.width / 2;
const detectedCenterY = detected.y + detected.height / 2;
const targetCenterX = target.x + target.width / 2;
const targetCenterY = target.y + target.height / 2;
// 中心点距离
const centerDistance = Math.sqrt(
Math.pow(detectedCenterX - targetCenterX, 2) +
Math.pow(detectedCenterY - targetCenterY, 2)
);
// 尺寸差异 - 允许更大的容差,因为形态学操作可能改变大小
const widthDiff = Math.abs(detected.width - target.width);
const heightDiff = Math.abs(detected.height - target.height);
// 如果中心点距离小于容差,且尺寸差异不太大,认为匹配
// 放宽尺寸容差到30px考虑到形态学操作的影响
return centerDistance <= tolerance && widthDiff <= 30 && heightDiff <= 30;
}
/**
* 计算IoU交并比
*/
calculateIoU(box1: Rectangle, box2: Rectangle): number {
return calculateIoU(box1, box2);
}
/**
* 验证检测结果
*/
async validateDetection(
detectedBoxes: Rectangle[],
targetBoxes: Rectangle[],
tolerance: number = 10
): Promise<{
totalTargets: number;
detectedCount: number;
matchedCount: number;
precision: number;
recall: number;
matches: Array<{ detected: Rectangle; target: Rectangle; iou: number }>;
unmatched: Rectangle[];
}> {
const matches: Array<{ detected: Rectangle; target: Rectangle; iou: number }> = [];
const matchedTargets = new Set<number>();
const matchedDetected = new Set<number>();
// 1. 找出所有可能的匹配对
const potentialMatches: Array<{ detIdx: number; tarIdx: number; iou: number }> = [];
for (let i = 0; i < detectedBoxes.length; i++) {
for (let j = 0; j < targetBoxes.length; j++) {
if (this.isBoxMatching(detectedBoxes[i], targetBoxes[j], tolerance)) {
const iou = this.calculateIoU(detectedBoxes[i], targetBoxes[j]);
if (iou > 0.1) { // 设置一个IoU的下限
potentialMatches.push({ detIdx: i, tarIdx: j, iou });
}
}
}
}
// 2. 按IoU从高到低排序
potentialMatches.sort((a, b) => b.iou - a.iou);
// 3. 贪心选择最佳匹配
for (const match of potentialMatches) {
if (!matchedDetected.has(match.detIdx) && !matchedTargets.has(match.tarIdx)) {
matches.push({
detected: detectedBoxes[match.detIdx],
target: targetBoxes[match.tarIdx],
iou: match.iou
});
matchedDetected.add(match.detIdx);
matchedTargets.add(match.tarIdx);
}
}
// 未匹配的检测框
const unmatched = detectedBoxes.filter((_, i) => !matchedDetected.has(i));
const precision = detectedBoxes.length > 0 ? matches.length / detectedBoxes.length : 0;
const recall = targetBoxes.length > 0 ? matches.length / targetBoxes.length : 0;
return {
totalTargets: targetBoxes.length,
detectedCount: detectedBoxes.length,
matchedCount: matches.length,
precision,
recall,
matches,
unmatched
};
}
}
export { SliderValidator, BoundingBox, Rectangle };

182
src/sms/douban-code.ts Normal file
View File

@@ -0,0 +1,182 @@
import Database from 'better-sqlite3';
import os from 'os';
import path from 'path';
const APPLE_EPOCH_MS = Date.UTC(2001, 0, 1);
const DB_PATH = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
export interface SmsMessage {
id: number;
text: string;
handle: string;
service: string;
isFromMe: boolean;
date: Date;
}
export interface WaitForCodeOptions {
timeoutMs?: number;
pollIntervalMs?: number;
logger?: (message: string) => void;
}
export interface WaitForCodeResult {
code: string;
message: SmsMessage;
}
interface RawMessageRow {
id: number;
text: string | null;
handle: string | null;
service: string | null;
is_from_me: number;
date_raw: number | null;
}
const DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
const DEFAULT_POLL_INTERVAL_MS = 2500;
function appleTimestampToDate(raw: number | null): Date {
if (!raw) {
return new Date(0);
}
let ms = raw;
if (raw > 1e15) {
ms = raw / 1_000_000;
} else if (raw > 1e12) {
ms = raw / 1_000;
} else {
ms = raw * 1000;
}
return new Date(APPLE_EPOCH_MS + ms);
}
function openDatabase(): Database.Database {
return new Database(DB_PATH, { readonly: true, fileMustExist: true });
}
function toSmsMessage(row: RawMessageRow | undefined): SmsMessage | null {
if (!row) {
return null;
}
const text = (row.text ?? '').trim();
if (!text) {
return null;
}
return {
id: row.id,
text,
handle: row.handle ?? '',
service: row.service ?? '',
isFromMe: row.is_from_me === 1,
date: appleTimestampToDate(row.date_raw),
};
}
function fetchLatestMessage(db: Database.Database): SmsMessage | null {
const stmt = db.prepare<[], RawMessageRow>(`
SELECT
message.ROWID AS id,
message.text AS text,
handle.id AS handle,
message.service AS service,
message.is_from_me AS is_from_me,
COALESCE(message.date, message.date_delivered, message.date_read) AS date_raw
FROM message
LEFT JOIN handle ON handle.ROWID = message.handle_id
WHERE message.text IS NOT NULL AND message.text != ''
ORDER BY date_raw DESC
LIMIT 1
`);
return toSmsMessage(stmt.get());
}
function fetchLatestDoubanMessage(db: Database.Database): SmsMessage | null {
const stmt = db.prepare<[], RawMessageRow>(`
SELECT
message.ROWID AS id,
message.text AS text,
handle.id AS handle,
message.service AS service,
message.is_from_me AS is_from_me,
COALESCE(message.date, message.date_delivered, message.date_read) AS date_raw
FROM message
LEFT JOIN handle ON handle.ROWID = message.handle_id
WHERE message.is_from_me = 0
AND message.text IS NOT NULL
AND message.text != ''
AND message.text LIKE '%豆瓣%'
AND message.text LIKE '%验证码%'
ORDER BY date_raw DESC
LIMIT 1
`);
return toSmsMessage(stmt.get());
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function parseDoubanSms(text: string | null | undefined): string | null {
if (!text) {
return null;
}
const match = text.match(/验证码[:]\s*([0-9]{4,6})/);
return match ? match[1] : null;
}
export async function waitForDoubanCode(options: WaitForCodeOptions = {}): Promise<WaitForCodeResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const deadline = Date.now() + timeoutMs;
const logger = options.logger;
let db: Database.Database | null = null;
try {
db = openDatabase();
const baselineMessage = fetchLatestMessage(db);
const baselineId = baselineMessage?.id ?? 0;
if (logger) {
logger(`已连接 chat.db起始消息 ID: ${baselineId}`);
}
while (Date.now() <= deadline) {
const doubanMessage = fetchLatestDoubanMessage(db);
if (doubanMessage && doubanMessage.id > baselineId) {
const code = parseDoubanSms(doubanMessage.text);
if (code) {
if (logger) {
logger(`捕获验证码短信,消息 ID: ${doubanMessage.id}`);
}
return {
code,
message: doubanMessage,
};
}
}
if (logger) {
logger('未检测到新的豆瓣验证码短信,等待后重试...');
}
await delay(pollIntervalMs);
}
} finally {
if (db) {
db.close();
}
}
throw new Error('在设定的时间内未检测到新的豆瓣验证码短信');
}

21
todolist.md Normal file

File diff suppressed because one or more lines are too long