VVAI 3D 数据采集与清洗平台 — 搭建全流程展示。
目标岗位: Meshy — AI 数据工程实习生(3D 训练数据采集/清洗/分析)
独立开发者: 周誉
本手册结构: 由宏观到微观、由浅入深,覆盖平台架构、技术实现、问题应对。
📖 目录
- 第一章:整体架构与工作流总览
- 1.1 系统全景图
- 1.2 三阶段管线(Pipeline)
- 1.3 数据流转全过程
- 1.4 技术选型与理由
- 1.5 与 Meshy 岗位要求的对标
- 第二章:平台搭建过程详解
- 2.1 项目初始化
- 2.2 数据库层搭建(db.js)
- 2.3 服务器层搭建(server.js)
- 2.4 前端搭建(public/index.html)
- 2.5 目录结构最终形态
- 2.6 搭建过程中的开发技巧
- 第三章:MCP 协议与浏览器自动化技术
- 3.1 什么是 MCP?
- 3.2 JSON-RPC 通信协议
- 3.3 双 MCP 架构
- 3.4 MCP 工具调用详解
- 3.5 反检测技术
- 3.6 MCP 在项目中的实际应用场景
- 第四章:爬虫实现深度剖析
- 4.1 爬虫调度总览
- 4.2 API 模式爬虫
- 4.3 Meshy AI 逆向爬虫(核心重点)
- 4.4 Sketchfab WebGL Hook 逆向爬虫
- 4.5 通用逆向爬虫(crawlReverse)
- 第五章:数据清洗与格式转换管线
- 5.1 管线调度逻辑
- 5.2 3D 格式解析器
- 5.3 清洗阶段(Clean)
- 5.4 格式转换阶段(Convert)
- 5.5 转换路径总结
- 第六章:问题与应对措施全记录
- 6.1 问题总览
- 6.2 问题 1:Cloudflare 验证码拦截
- 6.3 问题 2:Meshy 模型重复下载
- 6.4 问题 3:MakerWorld 搜索页 404
- 6.5 问题 4:SPA 动态渲染数据获取
- 6.6 问题 5:MCP 响应格式不统一
- 6.7 问题 6:浏览器指纹检测
- 6.8 问题 7:大文件传输限制
- 6.9 问题 8:WebGL stride 推断不准
- 6.10 问题 9:Cloudflare 成功标识误判
- 6.11 问题 10:MCP 属性名大小写错误
- 6.12 问题应对总结矩阵
- 第七章:面试高频 Q&A 与话术
- 7.1 自我介绍
- 7.2 岗位要求逐条对标
- 7.3 技术深层次问题
- 7.4 困境与突破
- 7.5 关心的问题
- 7.6 关键数字解释
- 7.7 术语表
- 第八章:迭代更新 — 三层捕获策略与工程化改进
- 8.1 初版方案的致命缺陷
- 8.2 三层递进捕获架构
- 8.3 GLB 格式校验与 glTF 打包
- 8.4 去重增强
- 8.5 通用逆向 WebGL 截取
- 8.6 开发环境自动化
- 8.7 问题排查与调试经验
- 8.8 补充说明
💡核心搭建流程
Angent全栈技术独立开发者——周誉,搭建了一个 全栈 3D 模型数据采集与清洗平台(VVAI Platform)。
前端用 Vue 3 + Three.js 实现 3D 预览和任务管理;
后端用 Node.js + Express + WebSocket 驱动三阶段管线(爬取→清洗→转换);
爬虫层通过 MCP(Model Context Protocol) 协议控制 Chrome 浏览器,
实现了对 Meshy.ai(加密 .meshy 文件逆向解密)、Sketchfab(WebGL bufferData 原型链 Hook)等平台的零 API-Key 逆向爬取;
清洗层支持 GLB/GLTF/OBJ/STL 四种 3D 格式的解析、质量校验(顶点/面数/水密性)和互转;
全程处理了 Cloudflare 验证码、SPA 动态渲染、批内数据去重(SHA-1 哈希) 等工程难题。该平台直接对标 Meshy 数据团队的核心工作——大规模互联网 3D 数据归档与质量校验。
第一章:整体架构与工作流总览
1.1 系统全景图
用户浏览器 (Vue 3 + Three.js)
│
│ HTTP REST API + WebSocket 实时推送
▼
┌──────────────────────────────────────────────────────────┐
│ Express 服务器 (server.js) │
│ 端口 3200 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ 数据源API │ │ 任务 API │ │ 模型 API │ │上传/统计│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬────┘ │
│ └──────────────┼────────────┘ │ │
│ ▼ │ │
│ ┌───────────────┐ │ │
│ │ pipeline.js │ ← 三阶段管线 │ │
│ │ crawl→clean │ │ │
│ │ →convert │ │ │
│ └───────┬───────┘ │ │
│ │ │ │
│ ┌──────────────┼──────────────┐ │ │
│ ▼ ▼ ▼ │ │
│ ┌─────────┐ ┌───────────┐ ┌──────────┐ │ │
│ │API 爬虫 │ │逆向爬虫 │ │ HTML回退 │ │ │
│ │PolyHaven│ │Meshy/Skfab│ │ 无MCP时 │ │ │
│ │Sketchfab│ │通用MCP │ │ │ │ │
│ └────┬────┘ └─────┬─────┘ └────┬─────┘ │ │
│ │ │ │ │ │
│ │ ┌──────▼──────┐ │ │ │
│ │ │ McpManager │ │ │ │
│ │ │ (单例模式) │ │ │ │
│ │ ├─────────────┤ │ │ │
│ │ │DevTools MCP │ │ │ │
│ │ │JSReverser │ │ │ │
│ │ └──────┬──────┘ │ │ │
│ │ │ │ │ │
│ │ ┌──────▼──────┐ │ │ │
│ │ │ Chrome │ │ │ │
│ │ │ 调试端口9222│ │ │ │
│ │ └─────────────┘ │ │ │
│ │ │ │ │
│ └────────────┬───────────────┘ │ │
│ ▼ │ │
│ ┌────────────────┐ │ │
│ │ db.js │◄────────────────────┘ │
│ │ data/vvai.json │ │
│ └────────────────┘ │
│ │ │
│ ▼ │
│ storage/models/ │
│ (GLB/OBJ/STL 文件) │
└──────────────────────────────────────────────────────────┘
名词解释
| 术语 | 全称/含义 | 通俗理解 |
|---|---|---|
| Express | Node.js 的 Web 框架 | 像一个"服务员",接收浏览器请求,返回数据 |
| REST API | Representational State Transfer | 用 HTTP 方法(GET/POST/PUT/DELETE)对资源做增删改查的约定 |
| WebSocket | 全双工通信协议 | HTTP 是"你问我答",WebSocket 是"随时互传",用于实时推送进度 |
| Vue 3 | 前端 UI 框架 | 帮你把数据自动映射到页面上,数据变了页面自动更新 |
| Three.js | WebGL 3D 渲染库 | 在浏览器里显示 3D 模型用的工具 |
| MCP | Model Context Protocol | Anthropic 提出的协议,让 AI/程序通过统一接口控制外部工具(这里控制浏览器) |
| JSON-RPC | JSON Remote Procedure Call | 用 JSON 格式远程调用函数,MCP 底层用的通信格式 |
| GLB/GLTF | GL Transmission Format | 3D 模型的"JPEG"——最通用的 3D 交换格式 |
| OBJ | Wavefront OBJ | 最古老最简单的 3D 格式,纯文本,只有几何没有材质动画 |
| STL | Stereolithography | 3D 打印常用格式,只存三角面片 |
| SPA | Single Page Application | 整个网站只有一个 HTML,页面切换靠 JavaScript 动态渲染 |
| 单例模式 | Singleton Pattern | 全程序只创建一个实例,避免重复创建浪费资源 |
1.2 三阶段管线(Pipeline)
管线是平台的核心工作流,像工厂流水线一样把原始数据加工成成品。
0% 40% 70% 100%
┌───────┤───────────────┤────────────────┤────────────────┤
│ │ │ │ │
│ 阶段1 │ 阶段2 │ 阶段3 │ 完成 │
│ CRAWL │ CLEAN │ CONVERT │ │
│ 爬取 │ 清洗 │ 转换 │ │
└───────┴───────────────┴────────────────┴────────────────┘
阶段 1:爬取(Crawl) — 0%~40%
做什么: 从互联网上的 3D 模型平台下载模型文件。
两种模式:
- API 模式: 直接调用平台公开的 API(如 Poly Haven 免费 API)
- 逆向模式: 平台没有公开 API 或需要付费,通过控制浏览器模拟真人操作来获取数据
类比: 就像去超市买菜(API 模式)vs 自己去田里摘菜(逆向模式)
阶段 2:清洗(Clean) — 40%~70%
做什么: 检查下载到的模型文件是否"健康"。
检查项目:
- 文件是否太小(< 100 字节可能是下载失败的空文件)
- 是否有顶点和面(3D 模型的基本组成)
- 记录模型的详细指标(顶点数、面数、文件大小)
结果: 通过→标记为 cleaned,不通过→标记为 rejected
类比: 就像质检员检查产品是否合格
阶段 3:转换(Convert) — 70%~100%
做什么: 把模型转换成目标格式。
支持的转换路径:
GLB ←→ GLTF (二进制 ←→ 文本+外部资源)
GLB → STL (提取几何,丢弃材质纹理)
GLB → OBJ (提取几何,文本格式输出)
GLTF → STL
GLTF → OBJ
类比: 就像把 Word 文档转换成 PDF
1.3 数据流转全过程
互联网 3D 平台 (Meshy/Sketchfab/PolyHaven/...)
│
│ ① 爬虫下载原始文件
▼
storage/models/task_{id}/ ← 原始文件存储目录
│
│ ② 解析+质检 → 记录元数据
▼
data/vvai.json ← 数据库(模型元数据)
models[i].status = 'raw' → 'cleaned' / 'rejected'
models[i].vertex_count, face_count, file_size ...
│
│ ③ 格式转换
▼
storage/models/task_{id}/ ← 转换后文件也在这里
models[i].converted_files.stl = 路径
models[i].status = 'ready'
│
│ ④ 前端展示
▼
浏览器 Three.js 3D 预览 + 统计仪表盘
1.4 技术选型与理由
| 技术选择 | 理由 | 面试话术 |
|---|---|---|
| Node.js 而非 Python | 爬虫需要与浏览器 MCP 交互,MCP SDK 原生支持 JS;3D 格式解析用二进制 Buffer 操作方便 | "Node.js 与 MCP 生态无缝集成,且处理二进制 ArrayBuffer 的性能优于 Python" |
| Express 而非 Koa/Fastify | 最成熟的 Node.js Web 框架,中间件生态丰富,学习成本低 | "Express 社区生态最完善,适合快速搭建原型" |
| JSON 文件而非 MongoDB/SQLite | 项目规模小(百~千级模型),JSON 文件零依赖启动,开发效率最高 | "在原型阶段选择零配置的 JSON 存储,如果数据量增长可以平滑迁移到 MongoDB" |
| Vue 3 CDN 而非 React+Vite | 无需构建工具,单 HTML 文件即可运行,降低部署复杂度 | "采用 CDN 引入避免构建步骤,加快开发迭代速度" |
| Three.js | 业界最流行的 WebGL 库,原生支持 GLB/GLTF 加载 | "Three.js 内置 GLTFLoader,是 3D 模型预览的事实标准" |
| WebSocket 而非轮询 | 爬虫任务长达数分钟,需要实时推送进度,轮询浪费带宽 | "WebSocket 提供低延迟双向通信,适合长时间任务的进度推送" |
| MCP 协议 | Anthropic 标准,两个 MCP Server 提供 107 个浏览器控制工具 | "MCP 是新兴的 AI Agent 标准协议,比 Puppeteer 直接调用更解耦" |
| 双 MCP 架构 | DevTools MCP 擅长标准操作;JSReverser MCP 擅长逆向(Hook/反检测/反混淆) | "职责分离:DevTools 处理常规交互,JSReverser 处理逆向分析" |
1.5 与 Meshy 岗位要求的对标
| 岗位要求 | 平台对应能力 | 章节 |
|---|---|---|
| 大规模互联网 3D 数据收集与归档 | 多平台爬虫 + JSON 数据库 + 文件存储 | 第二章、第四章 |
| 数据质量验证与筛选 | Clean 阶段:解析→校验→标记 | 第五章 |
| 数据管线优化 | 三阶段 Pipeline + 实时进度 | 第二章 |
| JavaScript / WebGL | Sketchfab WebGL Hook、Three.js 预览 | 第四章 |
| MCP / AI Agent 工具 | 双 MCP 架构 (107 工具) | 第三章 |
| 3D 格式 (glTF/OBJ/FBX) | GLB/GLTF/OBJ/STL 解析与互转 | 第五章 |
| Producer-Consumer 模式 | 管线阶段间的数据传递 | 第二章 |
| Cloudflare 经验 | CAPTCHA 检测与处理 | 第六章 |
| 网络爬虫与反检测 | Stealth 注入、CDP 伪装 | 第三章、第四章 |
| # 第二章:平台搭建过程详解 |
2.1 项目初始化
2.1.1 创建项目骨架
# 创建项目目录
mkdir vvai-platform
cd vvai-platform
# 初始化 Node.js 项目
npm init -y
# 关键配置:在 package.json 中启用 ESM 模块系统
# "type": "module" → 可以使用 import/export 语法(而非旧的 require)
ESM vs CommonJS — 名词解释:
| 对比项 | CommonJS (旧) | ESM (新,我们用的) |
|---|---|---|
| 导入语法 | const x = require('x') |
import x from 'x' |
| 导出语法 | module.exports = x |
export default x |
| 加载时机 | 运行时加载(动态) | 编译时确定(静态分析) |
| 适用场景 | 老 Node.js 项目 | 现代项目,可做 Tree Shaking |
为什么选 ESM: 现代标准,与浏览器端 JS 写法一致,AI 工具(Copilot/MCP)生成的代码默认也用 ESM。
2.1.2 安装核心依赖
npm install express cors ws multer uuid
| 依赖 | 作用 | 为什么需要 |
|---|---|---|
express |
Web 框架 | 处理 HTTP 请求路由 |
cors |
跨域中间件 | 允许前端(不同端口)访问后端 API |
ws |
WebSocket 库 | 实时推送任务进度到前端 |
multer |
文件上传中间件 | 处理缩略图/模型文件上传 |
uuid |
生成唯一 ID | 为上传文件生成不重复的文件名 |
跨域(CORS)— 名词解释:
浏览器出于安全考虑,禁止网页 JS 访问不同域名/端口的 API。比如前端在 localhost:3000 想请求 localhost:3200 的数据,浏览器会拦截。cors 中间件在响应头加 Access-Control-Allow-Origin: *,告诉浏览器"允许任何来源访问"。
2.2 数据库层搭建(db.js)
2.2.1 为什么用 JSON 文件而不用真正的数据库?
传统数据库: 安装 MongoDB → 配置 → 启动服务 → 连接 → 建表 → CRUD
JSON 文件: 一个 .json 文件 → 读写即可,零配置
项目处于原型阶段,数据量小(几百个模型),JSON 文件完全够用。
2.2.2 核心数据结构
// data/vvai.json 的结构
{
"sources": [], // 数据源列表(从哪里爬)
"tasks": [], // 爬取任务列表(爬什么、怎么爬)
"models": [], // 模型记录(爬到了什么)
"logs": [], // 操作日志
"_counters": { // 自增 ID 计数器
"sources": 0,
"tasks": 0,
"models": 0,
"logs": 0
}
}
2.2.3 自增 ID 机制详解
传统数据库有自增主键功能,JSON 文件需要手动实现:
addModel(model) {
this.data._counters.models++; // 计数器 +1
const newModel = {
id: this.data._counters.models, // 用计数器当 ID
...model, // 展开用户传入的字段
created_at: new Date().toISOString() // 自动加创建时间
};
this.data.models.push(newModel); // 加入数组
this.save(); // 写回文件
return newModel;
}
展开运算符 ... 解释: { id: 5, ...{name: 'cat', size: 100} } 等于 { id: 5, name: 'cat', size: 100 },把对象的所有属性"摊开"放进去。
2.2.4 数据源(Source)的预设机制
平台内置了 8 个常见 3D 平台作为预设数据源,用户不需要手动添加:
const PRESETS = [
{
name: '🏔️ Poly Haven (免费高质量)',
preset_key: 'polyhaven', // 内部标识,爬虫调度用
api_url: 'https://api.polyhaven.com',
api_key: '', // 不需要 API Key
config: { description: '...' }
},
// ... 另外 7 个平台
];
为什么要预设: 用户不需要知道每个平台的 API 地址,选一个就能用。代码里靠 preset_key 来决定用哪个爬虫函数。
2.2.5 模型(Model)的状态流转
┌──────────┐
│ 爬虫下载 │
└────┬─────┘
▼
┌────────┐
│ raw │ ← 原始状态,刚下载
└───┬────┘
│ Clean 阶段
┌────┴────┐
▼ ▼
┌────────┐ ┌──────────┐
│cleaned │ │ rejected │ ← 质检不合格
└───┬────┘ └──────────┘
│ Convert 阶段
▼
┌────────┐
│ ready │ ← 最终可用状态
└────────┘
2.3 服务器层搭建(server.js)
2.3.1 Express 应用创建
import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
const app = express();
// 中间件 = 请求到达路由之前的"过滤器/预处理器"
app.use(cors()); // 允许跨域
app.use(express.json({ limit: '50mb' })); // 解析 JSON 请求体
app.use(express.raw({ type: 'application/octet-stream', limit: '50mb' })); // 解析二进制
app.use(express.static('public')); // 提供前端静态文件
中间件(Middleware)— 名词解释:
Express 的请求处理是"洋葱模型":请求进来后,一层一层穿过中间件,每层做一件事,最后到达路由处理函数。
请求 → [cors] → [json解析] → [静态文件] → [路由处理] → 响应
2.3.2 WebSocket 实时通信
// 在 HTTP 服务器上"挂载" WebSocket 服务器
const server = createServer(app);
const wss = new WebSocketServer({ server });
// 客户端集合 — 记录所有连上来的浏览器
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws); // 新连接加入集合
ws.on('close', () => clients.delete(ws)); // 断开时移除
});
// 广播函数 — 给所有浏览器发消息
function broadcast(data) {
const msg = JSON.stringify(data);
for (const ws of clients) {
if (ws.readyState === 1) { // 1 = OPEN 状态
ws.send(msg);
}
}
}
为什么用 WebSocket 而不用轮询(Polling):
| 方式 | 原理 | 缺点 |
|---|---|---|
| 轮询 | 前端每秒请求一次 /api/progress |
浪费带宽,有延迟 |
| WebSocket | 服务器主动推送 | 实时,低开销 |
爬虫一个任务可能跑 5 分钟,期间有大量日志和进度更新,WebSocket 是唯一合理选择。
2.3.3 核心 API 路由一览
// === 数据源管理 ===
app.get('/api/sources', (req, res) => { ... }); // 列出所有数据源
app.post('/api/sources', (req, res) => { ... }); // 添加自定义数据源
app.put('/api/sources/:id', (req, res) => { ... }); // 修改数据源
app.delete('/api/sources/:id', ...); // 删除(预设不可删)
// === 任务管理 ===
app.get('/api/tasks', ...); // 列出所有任务
app.post('/api/tasks', ...); // 创建新任务
app.post('/api/tasks/:id/start', ...); // 🔑 启动管线(最关键的接口)
app.delete('/api/tasks/:id', ...); // 删除任务 + 清理磁盘文件
// === 模型管理 ===
app.get('/api/models', ...); // 列出模型(支持筛选/搜索)
app.delete('/api/models/:id', ...);// 删除模型
// === 特殊接口 ===
app.post('/api/_upload-binary/:taskId/:filename', ...); // ⭐ 浏览器端解密后回传
app.get('/api/stats', ...); // 仪表盘统计数据
2.3.4 启动任务的完整流程(最重要的 API)
app.post('/api/tasks/:id/start', async (req, res) => {
const taskId = parseInt(req.params.id);
const task = db.getTask(taskId);
// 1. 重置任务状态
db.updateTask(taskId, {
status: 'pending',
progress: 0,
done_items: 0,
error: null
});
// 2. 清理旧数据
db.deleteLogsByTask(taskId); // 删除旧日志
db.deleteModelsByTask(taskId); // 删除旧模型记录
// 清理磁盘上的旧文件
const taskDir = `storage/models/task_${taskId}`;
if (fs.existsSync(taskDir)) {
fs.rmSync(taskDir, { recursive: true }); // 递归删除目录
}
// 3. 启动管线(异步执行,不阻塞响应)
runFullPipeline(taskId, broadcast).catch(err => {
db.updateTask(taskId, { status: 'failed', error: err.message });
});
// 4. 立即返回(不等管线跑完)
res.json({ success: true });
});
异步执行的关键: runFullPipeline 前面没有 await,所以它在后台跑,res.json 立即返回给前端"任务已启动"。管线通过 broadcast 实时推进度。
2.3.5 二进制上传接口 — 为 Meshy 解密设计
app.post('/api/_upload-binary/:taskId/:filename', (req, res) => {
// req.body 是 Buffer(二进制数据)
const dir = `storage/models/task_${req.params.taskId}`;
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, req.params.filename), req.body);
res.json({ success: true });
});
为什么需要这个接口:
Meshy 的 .meshy 加密文件只能在浏览器端解密(用 Meshy 自己的 Web Worker),解密后的 GLB 数据需要"回传"给 Node.js 服务器保存。浏览器通过 fetch('/api/_upload-binary/...') 把解密后的二进制数据发回来。
2.4 前端搭建(public/index.html)
2.4.1 技术栈选择
<!-- Vue 3 CDN 引入 — 无需 npm install,无需 webpack/vite -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<!-- Three.js 通过 Import Map 引入 -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.161.0/examples/jsm/"
}
}
</script>
Import Map — 名词解释: 浏览器原生支持的模块路径映射。当代码写 import * as THREE from 'three' 时,浏览器知道去 CDN 下载。不需要 webpack/vite 打包。
2.4.2 前端路由(SPA 实现)
const app = Vue.createApp({
data() {
return {
page: 'dashboard', // 当前页面
// ... 其他状态
}
}
});
// 通过 page 变量切换页面内容
// template 中: v-if="page === 'dashboard'" / v-if="page === 'library'" / ...
没有用 Vue Router 的原因: 项目简单,只有 6 个页面,用一个变量切换 v-if 就够了。
2.4.3 WebSocket 接收实时消息
// 连接到后端 WebSocket
const ws = new WebSocket(`ws://${location.host}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'task_progress':
// 更新进度条
this.updateTaskProgress(data.taskId, data.progress);
break;
case 'task_log':
// 追加日志
this.logs.push(data.log);
break;
case 'task_status':
// 更新任务状态
this.updateTaskStatus(data.taskId, data.status);
break;
}
};
2.4.4 Three.js 3D 模型预览
// 简化的 GLB 加载逻辑
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const loader = new GLTFLoader();
loader.load('/storage/models/task_1/model.glb', (gltf) => {
scene.add(gltf.scene); // 把模型加入 3D 场景
});
// OrbitControls 让用户可以旋转/缩放/平移查看模型
const controls = new OrbitControls(camera, renderer.domElement);
2.5 目录结构最终形态
vvai-platform/
├── server.js ← 服务器入口 (Express + WebSocket)
├── db.js ← 数据库层 (JSON 文件读写)
├── package.json ← 项目配置 + 依赖声明
├── services/
│ ├── crawler.js ← 🔥 爬虫引擎 (1800+ 行,最核心)
│ └── pipeline.js ← 管线调度 (crawl → clean → convert)
├── public/
│ ├── index.html ← 前端 SPA (Vue 3 + Three.js)
│ └── css/style.css ← 样式表
├── data/
│ └── vvai.json ← 数据库文件
└── storage/
└── models/
└── task_{id}/ ← 每个任务的模型文件目录
├── model1.glb
├── model2.glb
└── ...
2.6 搭建过程中的开发技巧
2.6.1 开发模式热重载
node --watch server.js
# --watch 标志:文件改动后自动重启服务器
# 比 nodemon 更轻量,Node.js 18+ 内置
2.6.2 静态文件服务的 Content-Type 处理
// 浏览器加载 .glb 文件时需要正确的 MIME Type
app.use('/storage', express.static('storage', {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.glb')) {
res.setHeader('Content-Type', 'model/gltf-binary');
} else if (filePath.endsWith('.gltf')) {
res.setHeader('Content-Type', 'model/gltf+json');
}
}
}));
MIME Type — 名词解释: 告诉浏览器"这个文件是什么类型"。如果 .glb 文件的 MIME Type 设错了(比如 application/octet-stream),Three.js 的 GLTFLoader 可能加载失败。
2.6.3 SPA Fallback
// 所有未匹配的路由都返回 index.html
app.get('*', (req, res) => {
res.sendFile(path.resolve('public/index.html'));
});
为什么需要: SPA 只有一个 HTML 文件,当用户直接访问 /library 时,服务器找不到 /library 路由,需要兜底返回 index.html,让前端 Vue 来处理路由。
第三章:MCP 协议与浏览器自动化技术
3.1 什么是 MCP?
3.1.1 官方定义
MCP(Model Context Protocol) 是 Anthropic(Claude 的开发公司)在 2024 年底提出的开放协议。它的目标是:让 AI 程序(或任何程序)通过一套统一的接口来控制外部工具。
3.1.2 通俗理解
想象一下万能遥控器的概念:
- 没有 MCP 之前: 你的电视用一个遥控器,空调用另一个,音响又是一个——每个设备的控制方式都不同
- 有了 MCP 之后: 所有设备都遵守同一套协议,一个遥控器(MCP Client)就能控制所有设备(MCP Server)
在我们的项目中:
Node.js 爬虫代码 (MCP Client) ──MCP协议──→ Chrome 浏览器 (MCP Server)
│
执行 JS / 截图 / 网络分析 / Hook
3.1.3 MCP 与传统方案对比
| 对比维度 | Puppeteer / Selenium | MCP |
|---|---|---|
| 控制粒度 | 函数级调用 | 工具级调用(更高层抽象) |
| 通信方式 | 直接 API 调用 | JSON-RPC over stdio |
| 扩展性 | 自己写代码封装 | 即插即用的 Tool 生态 |
| AI 集成 | 需要额外适配 | 原生支持 AI Agent 调用 |
| 适用场景 | 自动化测试为主 | AI Agent + 逆向工程 |
为什么这么做: "MCP 相比 Puppeteer 的优势在于更高的抽象层次和 AI 原生支持。Puppeteer 是'我告诉浏览器怎么做',MCP 是'我告诉浏览器做什么'。"
3.2 JSON-RPC 通信协议
3.2.1 什么是 JSON-RPC?
JSON-RPC = JSON + RPC(Remote Procedure Call,远程过程调用)
简单说:用 JSON 格式的消息来"远程调用函数"。
调用方发送:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {...} }
↑ ↑
要调用的方法 方法的参数
被调方回复:
{ "jsonrpc": "2.0", "id": 1, "result": {...} }
↑ ↑
对应哪个请求 执行结果
3.2.2 MCP 用的 stdio 传输方式
MCP Client 和 MCP Server 之间通过标准输入/输出(stdin/stdout)通信:
Node.js 进程 (Client) MCP Server 进程
│ │
│── stdin ──→ JSON-RPC 请求 ──→ │
│ │
│ ←── JSON-RPC 响应 ←── stdout ─│
│ │
NDJSON 格式: Newline Delimited JSON,每行一个完整的 JSON 对象,用换行符分隔。
3.2.3 完整的 MCP 握手流程
// 步骤 1: Client → Server: 初始化握手
→ {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05", // MCP 协议版本
"clientInfo": {
"name": "vvai-crawler",
"version": "1.0.0"
},
"capabilities": {} // Client 支持的能力
}
}
// 步骤 2: Server → Client: 返回 Server 信息
← {
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"serverInfo": { "name": "chrome-devtools-mcp" },
"capabilities": { "tools": {} }
}
}
// 步骤 3: Client → Server: 通知初始化完成(notification 无 id)
→ {
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
// 步骤 4: Client → Server: 列出所有可用工具
→ {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}
// 步骤 5: Server → Client: 返回工具列表
← {
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{ "name": "navigate_page", "description": "导航到 URL", "inputSchema": {...} },
{ "name": "evaluate_script", "description": "执行 JS", "inputSchema": {...} },
// ... 共 29 个工具 (DevTools) 或 78 个工具 (JSReverser)
]
}
}
3.3 双 MCP 架构
3.3.1 为什么需要两个 MCP Server?
| Chrome DevTools MCP | JSReverser MCP | |
|---|---|---|
| 来源 | Google 官方出品 | 专业逆向工程工具 |
| 工具数 | 29 个 | 78 个 |
| 擅长 | 标准浏览器操作 | 逆向分析 + 反检测 |
| 脚本执行 | evaluate_script |
evaluate_script |
| 特殊能力 | Lighthouse 性能审计、无障碍检查 | Hook 注入、反混淆、加密检测、Preload 脚本、断点调试 |
| 参数差异 | 脚本参数名:expression |
脚本参数名:function |
| 响应格式 | content[0] = 数据 |
content[0] = traceId, content[1] = 数据 |
3.3.2 McpClient 类实现详解
class McpClient {
constructor(name, command, args, options) {
this.name = name; // 'devtools' 或 'jsreverse'
this.command = command; // 启动命令
this.tools = []; // 可用工具列表
this.ready = false; // 是否就绪
}
async start() {
// 1. 启动子进程(spawn = 生成子进程)
this.process = spawn(this.command, this.args, {
stdio: ['pipe', 'pipe', 'pipe'], // 管道模式:可读写 stdin/stdout
...this.options
});
// 2. 监听 stdout,按 NDJSON 解析
this.process.stdout.on('data', (chunk) => {
// 按换行符分割,每行解析一个 JSON 消息
for (const line of chunk.toString().split('\n')) {
if (line.trim()) {
const msg = JSON.parse(line);
// 匹配 id 找到对应的 Promise 并 resolve
this.pendingRequests.get(msg.id)?.resolve(msg);
}
}
});
// 3. 执行握手
await this.request('initialize', { ... });
this.request('notifications/initialized'); // 通知不等回复
const toolsResult = await this.request('tools/list');
this.tools = toolsResult.result.tools;
this.ready = true;
}
async callTool(toolName, args) {
// 封装 tools/call 请求
return this.request('tools/call', {
name: toolName,
arguments: args
});
}
}
spawn — 名词解释: Node.js 的 child_process.spawn() 方法,用于创建子进程。与 exec() 不同的是 spawn 通过流(stream)传输数据,适合大量数据通信。
管道(pipe): 进程间通信的通道。stdio: ['pipe', 'pipe', 'pipe'] 表示 stdin、stdout、stderr 都通过管道连接,父进程可以读写。
3.3.3 McpManager 单例模式
class McpManager {
constructor() {
// DevTools MCP — 通过 npx 下载并运行
this.devtools = new McpClient('devtools',
'npx', ['-y', 'chrome-devtools-mcp@latest', '--isolated'],
{ shell: true }
);
// JSReverser MCP — 运行本地构建的版本
this.jsreverse = new McpClient('jsreverse',
'node', ['JSReverser-MCP/build/src/index.js', '--headless', '--isolated'],
{ env: { ...process.env, PUPPETEER_SKIP_DOWNLOAD: 'true' } }
);
}
async start() {
// 并行启动两个 Server(Promise.allSettled 不会因一个失败而全部失败)
const results = await Promise.allSettled([
this.devtools.start(),
this.jsreverse.start()
]);
// 即使一个失败,另一个照常用
}
}
// 全局单例 + 防重入锁
let _manager = null;
let _startPromise = null;
export async function ensureMcpManager() {
if (_manager) return _manager; // 已创建,直接返回
if (_startPromise) return _startPromise; // 正在创建中,等待
_startPromise = (async () => {
_manager = new McpManager();
await _manager.start();
return _manager;
})();
return _startPromise;
}
Promise.allSettled vs Promise.all — 面试知识点:
- Promise.all([A, B]): 一个失败,全部失败
- Promise.allSettled([A, B]): 都执行完,分别报告成功/失败
我们用 allSettled 是因为即使 JSReverser 启动失败,DevTools 仍然可以用。
防重入锁: 如果两个爬虫任务同时调用 ensureMcpManager(),不用 _startPromise 会创建两个 Manager。用了之后,第二个调用会等待第一个完成。
3.4 MCP 工具调用详解
3.4.1 工具调用的完整流程
// 爬虫代码中调用 MCP 工具的实际例子
// 1. 获取 MCP Manager
const mcpManager = await ensureMcpManager();
const devtools = mcpManager.devtools;
// 2. 导航到页面
await devtools.callTool('navigate_page', { url: 'https://meshy.ai' });
// 3. 在浏览器中执行 JavaScript
const result = await devtools.callTool('evaluate_script', {
expression: `document.title` // ← DevTools 用 'expression' 参数
});
// 4. 用 JSReverser 执行脚本(注意参数名不同!)
const result2 = await mcpManager.jsreverse.callTool('evaluate_script', {
function: `document.title` // ← JSReverser 用 'function' 参数
});
3.4.2 响应解析的差异处理
两个 MCP Server 返回的数据格式不同,需要不同的解析方式:
// DevTools MCP 返回格式:
// { content: [ { type: 'text', text: '实际数据' } ] }
// JSReverser MCP 返回格式:
// { content: [
// { type: 'text', text: '[traceId: xxx] ...' }, ← 元数据,要跳过
// { type: 'text', text: '实际数据' } ← 真正的数据
// ] }
function parseMcpResult(result) {
const content = result?.content;
if (!content?.length) return null;
// 从后往前找第一个非元数据的内容
for (let i = content.length - 1; i >= 0; i--) {
const text = content[i]?.text;
if (text && !text.startsWith('[traceId')) {
return text;
}
}
return content[content.length - 1]?.text;
}
function parseScriptResult(resultText) {
// evaluate_script 可能返回 markdown 代码块包裹的 JSON
// 需要提取: ```json\n{ ... }\n``` → { ... }
const match = resultText.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
if (match) return JSON.parse(match[1]);
return JSON.parse(resultText);
}
3.4.3 常用 MCP 工具速查
Chrome DevTools MCP (29 工具)
| 工具 | 功能 | 我们怎么用 |
|---|---|---|
navigate_page |
导航到 URL | 打开目标网站 |
evaluate_script |
执行 JS | 提取页面数据、触发操作 |
take_screenshot |
截图 | 调试时确认页面状态 |
list_network_requests |
列网络请求 | 分析 API 调用 |
get_network_request |
获取请求详情 | 读取 API 响应体 |
wait_for |
等待元素/文本 | 等页面加载完成 |
JSReverser MCP (78 工具,精选)
| 工具 | 功能 | 我们怎么用 |
|---|---|---|
inject_stealth |
注入反检测脚本 | 防止被网站识别为机器人 |
inject_preload_script |
页面 JS 之前注入代码 | WebGL Hook(Sketchfab) |
create_hook + inject_hook |
函数 Hook | 拦截运行时数据 |
get_hook_data |
获取 Hook 到的数据 | 读取拦截结果 |
deobfuscate_code |
AST 去混淆 | 还原压缩/混淆的 JS |
detect_crypto |
检测加密算法 | 分析加密方式 |
search_in_sources |
在所有脚本中搜索 | 定位关键代码 |
get_request_initiator |
追踪请求发起者 | 找到 API 调用的源代码 |
3.5 反检测技术
3.5.1 为什么需要反检测?
网站(特别是 Cloudflare 保护的)会检测访问者是否是"真人"。MCP 控制的 Chrome 会暴露一些特征:
| 被检测的特征 | 原因 | 我们的伪装方式 |
|---|---|---|
navigator.webdriver = true |
Chrome DevTools 协议会设为 true | 重新定义为 false |
window.cdc_adoQpo... 变量 |
Chrome 远程调试注入的标记 | 删除该变量 |
window.chrome 不完整 |
自动化浏览器缺少 chrome 对象 | 模拟完整的 chrome 对象 |
navigator.plugins 为空 |
自动化浏览器没有插件 | 模拟 5 个插件 |
navigator.languages 不自然 |
可能只有 en |
设为中英文混合 |
3.5.2 反检测代码详解
// 通过 JSReverser 的 inject_stealth 工具注入:
await mcpManager.jsreverse.callTool('inject_stealth', {
preset: 'windows-chrome' // 预设配置,模拟 Windows Chrome
});
// 还会额外注入自定义脚本:
const STEALTH_SCRIPT = `
// 1. 隐藏 webdriver 标志
Object.defineProperty(navigator, 'webdriver', {
get: () => false
});
// 2. 删除 CDP 运行时标记
// cdc_adoQpoasnfa76pfcZLmcfl 是 Chrome DevTools Protocol 的指纹
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;
// 3. 模拟完整的 chrome 对象
window.chrome = {
runtime: {},
loadTimes: function() { return {} },
csi: function() { return {} }
};
// 4. 模拟浏览器插件(正常浏览器至少有 PDF Viewer)
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5] // 假装有 5 个插件
});
// 5. 模拟多语言环境
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en-US', 'en']
});
// 6. 模拟硬件信息
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 8 // 8 核 CPU
});
Object.defineProperty(navigator, 'deviceMemory', {
get: () => 8 // 8GB 内存
});
`;
Object.defineProperty — 解释: JavaScript 中修改对象属性行为的方法。get: () => false 表示每次读取 navigator.webdriver 时都返回 false,而不是真实值 true。
3.5.3 JSReverser Preload 脚本
Preload 脚本: 在目标页面的任何 JS 之前执行的代码。这很关键——如果在页面 JS 之后注入,网站已经检测过了,为时已晚。
// inject_preload_script 的效果:
// 1. 页面开始加载 (HTML 解析)
// 2. ← 这里执行 Preload 脚本(修改原型链、注入 Hook)
// 3. 页面 JS 执行(此时看到的是修改后的环境)
这就是为什么 Sketchfab 的 WebGL Hook 必须用 Preload 而不是普通的 evaluate_script——我们需要在 Sketchfab 的渲染代码执行之前就 Hook 好 bufferData。
3.6 MCP 在项目中的实际应用场景
场景 1:Meshy 加密文件解密
Node.js → MCP → Chrome: "执行这段 JS:创建 Meshy 的 Web Worker 并解密文件"
Chrome → MCP → Node.js: "解密完成,这是 base64 编码的 GLB 数据"
场景 2:Sketchfab WebGL 数据捕获
Node.js → JSReverser MCP: "在页面加载前注入这段 Hook 代码"
Node.js → MCP → Chrome: "导航到 Sketchfab 模型页面"
Chrome 内部: WebGL 渲染 → Hook 自动捕获所有 bufferData 调用
Node.js → MCP → Chrome: "把捕获到的 buffer 数据发回来"
场景 3:通用逆向爬取
Node.js → JSReverser MCP: "注入反检测脚本"
Node.js → MCP → Chrome: "导航到目标页面"
Node.js → MCP → Chrome: "列出所有网络请求"
Node.js: 分析哪些请求包含 3D 模型文件 → 下载
第四章:爬虫实现深度剖析
4.1 爬虫调度总览
// startCrawl — 爬虫入口,根据任务模式和数据源分发到不同爬虫
export async function startCrawl(taskId, broadcast) {
const task = db.getTask(taskId);
const source = db.getSource(task.source_id);
if (task.mode === 'reverse') {
// 逆向模式 — 通过 MCP 控制浏览器
return crawlReverse(taskId, source, task.config, broadcast);
}
// API 模式 — 直接调用平台 API
switch (source.preset_key) {
case 'polyhaven': return crawlPolyHaven(taskId, task.config, broadcast);
case 'sketchfab': return crawlSketchfab(taskId, task.config, broadcast);
default: return crawlCustomAPI(taskId, source, task.config, broadcast);
}
}
设计思路: 用策略模式(Strategy Pattern)根据数据源和模式选择不同的爬虫实现。好处是新增爬虫只需要加一个 case 和对应函数。
4.2 API 模式爬虫
4.2.1 Poly Haven 爬虫
Poly Haven 是一个完全免费的 3D 资源库,API 公开无需注册。
async function crawlPolyHaven(taskId, config, broadcast) {
const { search = '', limit = 10 } = config;
// 第一步:获取所有模型的索引
// API 返回格式: { "model_name": { "name": "...", "tags": [...], "categories": {...} } }
const res = await fetch('https://api.polyhaven.com/assets?t=models');
const assets = await res.json();
// 第二步:按关键词过滤
let entries = Object.entries(assets); // [["name1", {...}], ["name2", {...}], ...]
if (search) {
entries = entries.filter(([id, info]) => {
const searchLower = search.toLowerCase();
return id.includes(searchLower)
|| info.name?.toLowerCase().includes(searchLower)
|| info.tags?.some(t => t.includes(searchLower))
|| Object.keys(info.categories || {}).some(c => c.includes(searchLower));
});
}
entries = entries.slice(0, limit); // 取前 N 个
// 第三步:逐个下载
for (const [id, info] of entries) {
// 获取下载链接
const filesRes = await fetch(`https://api.polyhaven.com/files/${id}`);
const files = await filesRes.json();
// 优先下载 GLB,其次 GLTF
const glbUrl = files.gltf?.['1k']?.gltf?.url // GLB 格式
|| files.gltf?.['1k']?.value?.url; // 备选路径
if (glbUrl) {
const destPath = `storage/models/task_${taskId}/${id}.glb`;
await downloadFile(glbUrl, destPath);
// 入库
db.addModel({
task_id: taskId,
name: info.name || id,
source_name: 'Poly Haven',
original_format: 'glb',
original_file: destPath,
status: 'raw'
});
}
}
}
Object.entries() 解释: 把对象 {a: 1, b: 2} 变成数组 [['a', 1], ['b', 2]],方便遍历。
4.2.2 通用 API 爬虫
为自定义数据源设计的万能爬虫,用户配置 JSON 路径来提取数据:
// 配置示例:
// extractPath: "data.results" → 从响应中取 data.results 数组
// modelUrlField: "download_url" → 每个 item 的下载链接字段
// modelNameField: "title" → 每个 item 的名称字段
function extractByPath(obj, path) {
// "data.results" → obj.data.results
return path.split('.').reduce((o, key) => o?.[key], obj);
}
4.3 Meshy AI 逆向爬虫(核心重点)
4.3.1 为什么需要逆向?
Meshy.ai 是一个 AI 3D 生成平台。它的模型文件使用自定义加密格式 .meshy,不能直接下载和使用。我们需要:
1. 调用 Meshy 内部 API 获取模型列表
2. 下载加密的 .meshy 文件
3. 用 Meshy 自己的 Web Worker 解密为标准 GLB 格式
4. 把解密后的 GLB 传回服务器
4.3.2 完整流程图
Node.js (爬虫) Chrome (MCP 控制)
│ │
│ ① evaluate_script: │
│ "调用 Meshy 内部 API" ──────→ │ fetch('/web/public/showcases')
│ ←────── │ 返回模型列表 JSON
│ │
│ ② evaluate_script: │
│ "初始化解密 Worker" ──────→ │ new Worker('loader-worker.min.js')
│ ←────── │ Worker ready
│ │
对每个模型: │
│ ③ evaluate_script: │
│ "下载并解密 .meshy" ──────→ │ fetch(modelUrl) → Worker 解密
│ ←────── │ 解密成功,GLB 存在 window.__lastGLB
│ │
│ ④ evaluate_script: │
│ "转 base64 + 分块读取" ──────→ │ window.__lastGLB_b64.substring(...)
│ ←────── │ 每块 400KB base64 数据
│ │
│ ⑤ Buffer.from(b64) │
│ 写入 .glb 文件 │
│ SHA-1 去重 │
│ 入库 │
4.3.3 签名生成算法详解
Meshy 的内部 API 需要签名验证。签名算法是 FNV-1a 64-bit 变体 + MurmurHash3 终结器。
function generateSignature(hostname, timestamp) {
const key = "Meshy_Crypto_Key"; // 固定密钥(从 Meshy 前端 JS 逆向获得)
const input = hostname + ":" + timestamp;
// === FNV-1a 哈希 ===
// FNV (Fowler–Noll–Vo) 是一种快速非加密哈希函数
let hash = BigInt("14695981039346656037"); // FNV offset basis (固定起始值)
const prime = BigInt("1099511628211"); // FNV prime (固定质数)
const mask = BigInt("0xFFFFFFFFFFFFFFFF"); // 64位掩码,防溢出
// 先把 key 的每个字符混入 hash
for (const char of key) {
hash ^= BigInt(char.charCodeAt(0)); // XOR 字符的 ASCII 码
hash = (hash * prime) & mask; // 乘以质数,截断到 64 位
}
// 再把 input (hostname:timestamp) 的每个字符混入
for (const char of input) {
hash ^= BigInt(char.charCodeAt(0));
hash = (hash * prime) & mask;
}
// === MurmurHash3 终结器 ===
// 增加雪崩效应(一个 bit 变化会影响所有 bit)
hash ^= hash >> 33n;
hash = (hash * BigInt("0xff51afd7ed558ccd")) & mask;
hash ^= hash >> 33n;
hash = (hash * BigInt("0xc4ceb9fe1a85ec53")) & mask;
hash ^= hash >> 33n;
return hash.toString(16).padStart(16, "0"); // 转 16 进制,补零到 16 位
}
BigInt 解释: JavaScript 普通数字最大精确到 2^53,但 64 位哈希需要 2^64 的精度。BigInt 是 JS 内置的任意精度整数类型,用 n 后缀表示(如 33n)。
XOR(异或)解释: ^ 运算,相同为 0,不同为 1。用于"混入"数据的每一个字节。
为什么用 FNV-1a: Meshy 选择它因为速度快(简单循环),且碰撞率低。不是加密哈希(不像 SHA-256),目的不是安全,而是轻量级签名验证。
4.3.4 Web Worker 解密流程
// 在浏览器中执行的脚本(通过 evaluate_script 注入)
const initScript = `
return new Promise((resolve, reject) => {
// 创建 Meshy 自带的解密 Worker
const worker = new Worker("/resource/decrypt/loader-worker.min.js");
worker.onmessage = function(e) {
if (e.data.type === 'loaded') {
// Worker 加载完毕 → 发送授权信息
const ts = Date.now();
const hostname = location.hostname;
const sig = generateSignature(hostname, ts);
worker.postMessage({
type: 'authorize',
hostname: hostname,
timestamp: ts,
signature: sig
});
}
if (e.data.type === 'ready') {
// 授权成功 → Worker 可用
window.__meshyWorker = worker;
resolve('Worker ready');
}
};
// 10 秒超时
setTimeout(() => reject('Worker init timeout'), 10000);
});
`;
Web Worker — 名词解释: 浏览器中的"后台线程"。主线程负责页面渲染,Worker 在独立线程中执行耗时任务(如解密),不会卡住页面。Worker 和主线程通过 postMessage / onmessage 通信。
4.3.5 解密 + 分块传输
// 对每个模型执行的解密脚本
const decryptScript = `
return new Promise((resolve, reject) => {
const worker = window.__meshyWorker;
// 1. 下载加密的 .meshy 文件
fetch('${modelUrl}')
.then(r => r.arrayBuffer())
.then(data => {
const header = new Uint8Array(data, 0, 8);
const headerStr = String.fromCharCode(...header);
// 2. 检查文件头是否是 'MESHY.AI'
if (headerStr === 'MESHY.AI') {
// 需要解密
const requestId = 'req_' + Date.now();
worker.onmessage = function(e) {
if (e.data.id === requestId) {
if (e.data.error) {
reject('Decrypt failed: ' + e.data.error);
} else {
// 3. 解密成功!存储结果
const glbData = new Uint8Array(e.data.data);
window.__lastGLB = glbData;
// 验证 GLB magic: 前 4 字节应该是 'glTF'
const magic = String.fromCharCode(...glbData.slice(0, 4));
resolve({ success: true, size: glbData.length, magic: magic });
}
}
};
// 4. 发给 Worker 解密(transferable 转移所有权,零拷贝)
worker.postMessage(
{ id: requestId, type: 'process', data: data, mode: 'default' },
[data] // Transferable — 不复制数据,直接转移内存所有权给 Worker
);
} else {
// 不需要解密(已经是 GLB)
window.__lastGLB = new Uint8Array(data);
resolve({ success: true, size: data.byteLength });
}
});
});
`;
Transferable Objects — 名词解释: 正常 postMessage 会复制数据(耗时耗内存)。Transferable 是零拷贝传输——把内存所有权从主线程移交给 Worker,传输后主线程不能再访问该数据。对大文件(几十 MB 的 3D 模型)性能提升巨大。
4.3.6 分块 Base64 回传
// 解密后的 GLB 存在浏览器内存 (window.__lastGLB)
// 但 MCP evaluate_script 一次返回的文本量有限
// 所以需要转 base64 后分块传输
// 第一步:转 base64
await evaluateScript(`
const binary = Array.from(window.__lastGLB)
.map(b => String.fromCharCode(b)).join('');
window.__lastGLB_b64 = btoa(binary); // btoa = Binary to ASCII (base64编码)
return window.__lastGLB_b64.length;
`);
// 第二步:分块读取(每块 400KB)
const chunkSize = 400000;
const totalChunks = Math.ceil(b64Length / chunkSize);
let fullBase64 = '';
for (let c = 0; c < totalChunks; c++) {
const chunk = await evaluateScript(`
return window.__lastGLB_b64.substring(${c * chunkSize}, ${(c + 1) * chunkSize});
`);
fullBase64 += chunk;
}
// 第三步:base64 → Buffer → 写文件
const glbBuffer = Buffer.from(fullBase64, 'base64');
fs.writeFileSync(destPath, glbBuffer);
为什么要分块: MCP 单次 evaluate_script 返回的文本有大小限制。一个 GLB 文件转 base64 后可能有几十 MB,必须分块传输。400KB 是实验后选择的安全阈值。
4.3.7 三层去重机制
// 第一层:批内 ID 去重
const seenBatchIds = new Set(); // Meshy 的 taskId
const seenBatchResultIds = new Set(); // Meshy 的 resultId
const seenBatchUrls = new Set(); // 模型下载 URL
for (const model of models) {
// 跳过已见过的
if (model.taskId && seenBatchIds.has(model.taskId)) continue;
if (model.resultId && seenBatchResultIds.has(model.resultId)) continue;
if (model.modelUrl && seenBatchUrls.has(model.modelUrl)) continue;
// 记录
if (model.taskId) seenBatchIds.add(model.taskId);
if (model.resultId) seenBatchResultIds.add(model.resultId);
if (model.modelUrl) seenBatchUrls.add(model.modelUrl);
// ... 继续处理
}
// 第二层:二进制内容 SHA-1 哈希去重
import { createHash } from 'crypto';
const taskBinaryHashes = new Set(); // 任务级别的已见哈希集合
const hash = createHash('sha1').update(glbBuffer).digest('hex');
// 例如: "a1b2c3d4e5f6..." (40 位十六进制)
if (taskBinaryHashes.has(hash)) {
// 文件内容完全相同,跳过
addLog('跳过重复文件 (SHA-1 匹配)');
continue;
}
taskBinaryHashes.add(hash);
// 第三层:唯一文件名
const uniqueSuffix = hash.substring(0, 8);
const filename = `${slug}_${uniqueSuffix}.glb`;
// 例如: "horror_girl_a1b2c3d4.glb"
// 即使名字相同,哈希不同 → 文件名不同
// 如果哈希相同 → 第二层就已经跳过了
SHA-1 — 名词解释: 安全散列算法,把任意大小的数据压缩成固定 40 个十六进制字符。两个不同文件产生相同 SHA-1 的概率极低(~2^-160)。这里用来判断两个 GLB 文件内容是否完全相同。
为什么需要三层去重: Meshy API 有时返回同一个模型的不同"变体"(不同 URL 但内容相同),单靠 ID 或 URL 无法排除。SHA-1 比较文件内容是最可靠的去重方式。
4.4 Sketchfab WebGL Hook 逆向爬虫
4.4.1 原理总述
Sketchfab 是一个在线 3D 模型预览平台。它的 3D 模型在浏览器里通过 WebGL(Web Graphics Library)渲染显示。我们的思路是:
Hook WebGL 的数据上传函数,拦截所有上传到 GPU 的几何数据,在 CPU 端重建 3D 模型。
正常流程:
JS 模型数据 → WebGL bufferData() → GPU 渲染 → 屏幕显示
我们的 Hook:
JS 模型数据 → [Hook 拦截并备份] → WebGL bufferData() → GPU 渲染
↓
我们获得原始顶点和索引数据
↓
重建为 GLB 文件
4.4.2 WebGL 基础知识
WebGL 是浏览器提供的 3D 渲染 API,底层基于 OpenGL ES。
3D 模型在 GPU 中的表示:
顶点数据 (Vertex Buffer):
[x1, y1, z1, x2, y2, z2, x3, y3, z3, ...]
每 3 个浮点数 = 一个 3D 点的坐标
索引数据 (Index Buffer):
[0, 1, 2, 2, 3, 0, ...]
每 3 个整数 = 一个三角形(引用顶点数组的下标)
一个正方体 = 8 个顶点 + 12 个三角面(每面 2 个三角形 × 6 面)
bufferData() 是 WebGL 中把数据从 CPU 内存传到 GPU 显存的关键函数:
gl.bufferData(target, data, usage);
// target:
// 0x8892 = ARRAY_BUFFER → 顶点数据
// 0x8893 = ELEMENT_ARRAY_BUFFER → 索引数据
4.4.3 Preload Hook 代码详解
const WEBGL_PRELOAD_HOOK = `
(function() {
// 全局存储捕获到的数据
window.__VVAI_CAPTURE = {
vertexBuffers: [], // 存放顶点数据
indexBuffers: [], // 存放索引数据
drawCalls: 0, // 绘制调用计数
ready: false // 捕获是否完成
};
// Hook 函数
function hookBufferData(proto) {
const original = proto.bufferData; // 保存原始函数
proto.bufferData = function(target, data, usage) {
// 调用原始函数(保持正常渲染不受影响)
original.call(this, target, data, usage);
// 拦截有效数据
if (data && data.byteLength > 100) { // 忽略太小的 buffer
if (target === 0x8892) {
// ARRAY_BUFFER → 顶点数据
window.__VVAI_CAPTURE.vertexBuffers.push({
data: Array.from(new Uint8Array(data.buffer || data)),
byteLength: data.byteLength,
type: 'vertex'
});
} else if (target === 0x8893) {
// ELEMENT_ARRAY_BUFFER → 索引数据
window.__VVAI_CAPTURE.indexBuffers.push({
data: Array.from(new Uint8Array(data.buffer || data)),
byteLength: data.byteLength,
type: 'index'
});
}
}
};
}
// Hook WebGL 1.0 和 2.0 的原型
if (window.WebGLRenderingContext) {
hookBufferData(WebGLRenderingContext.prototype);
}
if (window.WebGL2RenderingContext) {
hookBufferData(WebGL2RenderingContext.prototype);
}
// 同时 Hook drawElements/drawArrays 来统计绘制调用
// 当绘制调用稳定不再增长时,说明模型渲染完成
})();
`;
原型链 Hook(Prototype Hook)— 重点:
JavaScript 中所有对象都通过原型链共享方法。例如:
const gl = canvas.getContext('webgl');
gl.bufferData(...)
// 实际调用的是 WebGLRenderingContext.prototype.bufferData
当我们修改 prototype.bufferData 时,所有 gl 实例的 bufferData 调用都会经过我们的 Hook。这就是"一次 Hook,全局拦截"。
4.4.4 轮询等待渲染完成
// Sketchfab 的 3D 渲染是渐进式的(先加载低精度,再加载高精度)
// 需要等待 buffer 数量稳定
let stableCount = 0;
let lastBufferCount = 0;
for (let i = 0; i < 60; i++) { // 最多等 30 秒 (60 × 500ms)
await sleep(500);
const status = await evaluateScript(`
return {
vb: window.__VVAI_CAPTURE.vertexBuffers.length,
ib: window.__VVAI_CAPTURE.indexBuffers.length,
dc: window.__VVAI_CAPTURE.drawCalls
};
`);
const currentCount = status.vb + status.ib;
if (currentCount === lastBufferCount && currentCount > 0) {
stableCount++;
if (stableCount >= 4) {
// 连续 4 次检查数量没变 → 渲染完成
break;
}
} else {
stableCount = 0;
}
lastBufferCount = currentCount;
}
4.4.5 GLB 重建算法(buildGLBFromReadback)
这是整个项目技术含量最高的部分之一。
从 GPU 回读的数据是原始字节,需要推断其结构才能重建有效的 GLB 文件。
function buildGLBFromReadback(vertexBuffers, indexBuffers) {
// === 第一步:推断顶点步长 (stride) ===
// 一个顶点可能包含:位置(12B) + 法线(12B) + UV(8B) = 32B
// 但不同模型的步长不同,需要自动推断
const CANDIDATE_STRIDES = [32, 24, 48, 12, 36, 44, 52, 56, 60, 64, 16, 20, 28, 40];
function scoreStride(buffer, stride) {
const view = new DataView(buffer);
const vertexCount = Math.floor(buffer.byteLength / stride);
let nanCount = 0;
let rangeOk = 0;
// 采样 100 个顶点
const sampleSize = Math.min(100, vertexCount);
for (let i = 0; i < sampleSize; i++) {
const idx = Math.floor(i * vertexCount / sampleSize);
const offset = idx * stride;
// 读取前 3 个 float32 作为 x, y, z
const x = view.getFloat32(offset, true); // true = little-endian
const y = view.getFloat32(offset + 4, true);
const z = view.getFloat32(offset + 8, true);
// 检查是否是 NaN(无效数据)
if (isNaN(x) || isNaN(y) || isNaN(z)) {
nanCount++;
continue;
}
// 检查坐标范围是否合理(大部分 3D 模型在 ±10000 以内)
if (Math.abs(x) < 10000 && Math.abs(y) < 10000 && Math.abs(z) < 10000) {
rangeOk++;
}
}
// 评分:NaN 越少、合理范围内越多 → 分数越高
return (rangeOk / sampleSize) - (nanCount / sampleSize) * 2;
}
// 对最大的 vertexBuffer 尝试所有候选步长,选分数最高的
let bestStride = 32; // 默认
let bestScore = -Infinity;
for (const stride of CANDIDATE_STRIDES) {
const score = scoreStride(largestVertexBuffer, stride);
if (score > bestScore) {
bestScore = score;
bestStride = stride;
}
}
// === 第二步:提取顶点位置 ===
const vertexCount = Math.floor(largestVertexBuffer.byteLength / bestStride);
const positions = new Float32Array(vertexCount * 3);
for (let i = 0; i < vertexCount; i++) {
positions[i * 3] = view.getFloat32(i * bestStride, true); // x
positions[i * 3 + 1] = view.getFloat32(i * bestStride + 4, true); // y
positions[i * 3 + 2] = view.getFloat32(i * bestStride + 8, true); // z
}
// === 第三步:选择索引类型 ===
// uint16: 最大索引 65535 → 适合小模型 (vertexCount ≤ 65535)
// uint32: 最大索引 4294967295 → 适合大模型
const useUint32 = vertexCount > 65535;
// === 第四步:构建 glTF 2.0 JSON ===
const gltfJson = {
asset: { version: "2.0", generator: "VVAI-Capture" },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{
primitives: [{
attributes: { POSITION: 0 }, // accessor 0 = 顶点位置
indices: 1 // accessor 1 = 索引
}]
}],
accessors: [
{
bufferView: 0,
componentType: 5126, // FLOAT
count: vertexCount,
type: "VEC3", // 每个元素是 3 个 float
max: [maxX, maxY, maxZ], // 包围盒(GLTF 规范要求)
min: [minX, minY, minZ]
},
{
bufferView: 1,
componentType: useUint32 ? 5125 : 5123, // UNSIGNED_INT or UNSIGNED_SHORT
count: indexCount,
type: "SCALAR"
}
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: positions.byteLength },
{ buffer: 0, byteOffset: positions.byteLength, byteLength: indices.byteLength }
],
buffers: [{ byteLength: totalBinLength }]
};
// === 第五步:组装 GLB 二进制 ===
// GLB = 12字节文件头 + JSON Chunk + BIN Chunk
// 文件头: magic(4B) + version(4B) + totalLength(4B)
// 0x46546C67 = "glTF" 的 little-endian 表示
header.setUint32(0, 0x46546C67, true); // magic
header.setUint32(4, 2, true); // version 2
header.setUint32(8, totalLength, true); // total file length
// JSON Chunk: length(4B) + type(4B) + data
// 0x4E4F534A = "JSON"
// BIN Chunk: length(4B) + type(4B) + data
// 0x004E4942 = "BIN\0"
return Buffer.concat([header, jsonChunk, binChunk]);
}
glTF 2.0 文件格式 :
GLB 文件结构:
┌──────────────────────────────────────┐
│ 文件头 (12 bytes) │
│ magic: "glTF" (0x46546C67) │
│ version: 2 │
│ length: 文件总大小 │
├──────────────────────────────────────┤
│ JSON Chunk │
│ chunkLength + chunkType("JSON") │
│ → 场景/节点/网格/材质/纹理 元数据 │
├──────────────────────────────────────┤
│ BIN Chunk │
│ chunkLength + chunkType("BIN\0") │
│ → 顶点位置/法线/UV/索引 二进制数据 │
└──────────────────────────────────────┘
Accessor/BufferView/Buffer 关系(GLTF 核心概念):
Buffer: 原始二进制数据块(整个 BIN Chunk)
BufferView: Buffer 的一个切片(起始偏移 + 长度)
Accessor: BufferView 的解释方式(数据类型 + 元素个数)
类比:
Buffer = 一整本书的纸
BufferView = 第 50~100 页
Accessor = "这些页是地图,每张图 A4 大小"
4.5 通用逆向爬虫(crawlReverse)
4.5.1 MakerWorld URL 归一化
不同网站的 URL 结构不同,需要特殊处理:
// MakerWorld (拓竹旗下 3D 打印模型平台)
const isMakerWorld = url.includes('makerworld.com');
if (isMakerWorld) {
const u = new URL(url);
// 如果不是详情页 (/zh/models/xxxxx-slug)
if (!u.pathname.match(/\/zh\/models\/\d+/)) {
// 重写为搜索页
const keyword = config.search || u.searchParams.get('search') || '';
u.pathname = '/zh/search/models';
u.search = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
url = u.toString();
}
}
URL 对象 — 解释: new URL('https://makerworld.com/zh/models?search=cat') 会自动解析出 hostname、pathname、searchParams 等部分,比手动字符串拼接更安全。
4.5.2 SPA 页面滚动加载
现代网站很多用"无限滚动"加载内容,需要模拟滚动:
const scrollCount = Math.max(5, Math.ceil(limit / 3));
for (let i = 0; i < scrollCount; i++) {
await evaluateScript(`
window.scrollBy(0, window.innerHeight * 1.5);
`);
await sleep(1500); // 等待新内容加载
}
4.5.3 网络请求分析
// 列出所有网络请求
const requests = await mcpManager.callTool('list_network_requests', {});
// 过滤出 3D 模型文件
const modelExtensions = /\.(glb|gltf|obj|stl|fbx|usdz|ply|3ds|dae|blend)/i;
const modelRequests = requests.filter(r => modelExtensions.test(r.url));
4.5.4 DOM 数据提取
// 搜索页面中所有可能包含模型链接的元素
const domScript = `
const results = [];
// 方法1: 带 src/data-src/data-model/data-url 属性的元素
document.querySelectorAll('[src],[data-src],[data-model],[data-url]')
.forEach(el => {
const url = el.src || el.dataset.src || el.dataset.model || el.dataset.url;
if (url) results.push(url);
});
// 方法2: 链接中的 3D 文件引用
document.querySelectorAll('a[href]')
.forEach(a => results.push(a.href));
// 方法3: Next.js 服务端渲染数据
const nextData = document.getElementById('__NEXT_DATA__');
if (nextData) {
results.push({ type: 'nextdata', content: nextData.textContent });
}
// 方法4: JSON-LD 结构化数据
document.querySelectorAll('script[type="application/ld+json"]')
.forEach(s => results.push({ type: 'jsonld', content: s.textContent }));
return results;
`;
NEXT_DATA — 解释: Next.js(React 的服务端渲染框架)会把服务端获取的数据塞进 <script id="__NEXT_DATA__"> 标签中。很多 3D 模型平台用 Next.js,模型信息可以直接从这里提取,不用等 JS 渲染。
4.5.5 详情页多层爬取
// MakerWorld 详情页链接格式: /zh/models/123456-model-name
const makerWorldPattern = /\/zh\/models\/(\d+)-[^/]+$/;
// 从搜索结果页收集详情页链接
const detailLinks = allLinks.filter(link => makerWorldPattern.test(link));
// 逐个访问详情页
for (const link of detailLinks.slice(0, limit)) {
// CAPTCHA 检测
await detectAndHandleCaptcha(browser, jr, taskId, link, addLog, isJR);
// 导航到详情页
await navigateFn(link);
await sleep(2000);
// 分析页面中的资源请求
const resources = await evaluateScript(`
return performance.getEntriesByType('resource')
.map(r => r.name)
.filter(name => /\\.(glb|gltf|obj|stl)/i.test(name));
`);
// ...
}
performance.getEntriesByType('resource') — 解释: 浏览器原生 API,返回页面加载的所有资源(JS/CSS/图片/3D模型等)的 URL 和加载时间。比 MCP 的 list_network_requests 更快,但只能获取 URL,不能获取响应体。
第五章:数据清洗与格式转换管线
5.1 管线调度逻辑
export async function runFullPipeline(taskId, broadcast) {
try {
// === 阶段 1: 爬取 (0% → 40%) ===
db.updateTask(taskId, { status: 'crawling' });
await startCrawl(taskId, broadcast);
// === 阶段 2: 清洗 (40% → 70%) ===
db.updateTask(taskId, { status: 'cleaning', progress: 40 });
broadcast({ type: 'task_status', taskId, status: 'cleaning' });
await cleanModels(taskId, broadcast);
// === 阶段 3: 转换 (70% → 95%) ===
db.updateTask(taskId, { status: 'converting', progress: 70 });
broadcast({ type: 'task_status', taskId, status: 'converting' });
const task = db.getTask(taskId);
await convertModels(taskId, task.target_format || 'glb', broadcast);
// === 完成 ===
db.updateTask(taskId, { status: 'completed', progress: 100 });
broadcast({ type: 'task_status', taskId, status: 'completed' });
} catch (error) {
db.updateTask(taskId, { status: 'failed', error: error.message });
broadcast({ type: 'task_status', taskId, status: 'failed', error: error.message });
}
}
try...catch — 解释: 异常处理机制。try 块中的代码如果出错,不会导致程序崩溃,而是跳到 catch 块处理错误。这里确保管线任何阶段失败都能正确标记任务状态。
5.2 3D 格式解析器
5.2.1 GLB/GLTF 解析器
GLB(GL Binary) 是 glTF 的二进制打包格式,所有数据在一个文件里。
function parseGLTF(filePath) {
const buf = fs.readFileSync(filePath);
// === 检测是 GLB 还是 GLTF ===
// GLB 的前 4 字节 magic number 是 0x46546C67 = "glTF"
const magic = buf.readUInt32LE(0); // LE = Little Endian (小端序)
if (magic === 0x46546C67) {
// ======= GLB 格式 =======
const version = buf.readUInt32LE(4); // 版本号 (应该是 2)
const totalLen = buf.readUInt32LE(8); // 文件总长度
// JSON Chunk (第一个 chunk)
const jsonLen = buf.readUInt32LE(12); // JSON 数据长度
const jsonType = buf.readUInt32LE(16); // 类型标识 (0x4E4F534A = "JSON")
const jsonStr = buf.slice(20, 20 + jsonLen).toString('utf8');
const gltf = JSON.parse(jsonStr);
// 统计信息
return {
format: 'glb',
meshCount: gltf.meshes?.length || 0,
materialCount: gltf.materials?.length || 0,
textureCount: gltf.textures?.length || 0,
vertexCount: countVertices(gltf),
faceCount: countFaces(gltf)
};
} else {
// ======= GLTF 格式 (JSON 文本) =======
const gltf = JSON.parse(buf.toString('utf8'));
return { format: 'gltf', ... };
}
}
小端序(Little Endian)vs 大端序(Big Endian):
存储数字 0x12345678:
大端序 (人类阅读顺序): 12 34 56 78
小端序 (低位在前): 78 56 34 12
GLB 用小端序(和 x86 CPU 一致),所以读取时用 readUInt32LE。
如何统计顶点数和面数:
function countVertices(gltf) {
let total = 0;
for (const mesh of gltf.meshes || []) {
for (const prim of mesh.primitives || []) {
// POSITION accessor 的 count 就是顶点数
const posAccessor = gltf.accessors?.[prim.attributes?.POSITION];
if (posAccessor) total += posAccessor.count;
}
}
return total;
}
function countFaces(gltf) {
let total = 0;
for (const mesh of gltf.meshes || []) {
for (const prim of mesh.primitives || []) {
const idxAccessor = gltf.accessors?.[prim.indices];
if (idxAccessor) {
total += Math.floor(idxAccessor.count / 3); // 每 3 个索引 = 1 个三角面
}
}
}
return total;
}
5.2.2 OBJ 解析器
OBJ 是最简单的 3D 格式,纯文本:
# OBJ 文件示例
v 1.0 2.0 3.0 ← 顶点 (vertex)
v 4.0 5.0 6.0
v 7.0 8.0 9.0
vn 0.0 1.0 0.0 ← 法线 (vertex normal)
vt 0.5 0.5 ← 纹理坐标 (vertex texture)
f 1 2 3 ← 面 (face),引用顶点编号(1-based)
f 1/1/1 2/2/1 3/3/1 ← 面 (带法线和纹理坐标引用)
function parseOBJ(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
let vertexCount = 0;
let faceCount = 0;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('v ')) vertexCount++; // 以 "v " 开头 = 顶点
if (trimmed.startsWith('f ')) faceCount++; // 以 "f " 开头 = 面
}
return { format: 'obj', vertexCount, faceCount };
}
5.2.3 STL 解析器
STL 有两种编码:ASCII 和 Binary。
ASCII STL 示例:
solid MyModel
facet normal 0 0 1 ← 面的法线方向
outer loop
vertex 0 0 0 ← 三角形的 3 个顶点
vertex 1 0 0
vertex 0 1 0
endloop
endfacet
endsolid
Binary STL 结构:
┌──────────────────────────────────────┐
│ 头部 (80 bytes) — 通常是文件描述 │
├──────────────────────────────────────┤
│ 三角面数量 (4 bytes, uint32) │
├──────────────────────────────────────┤
│ 三角面 1: │
│ 法线 (12B) + 顶点1(12B) │
│ + 顶点2(12B) + 顶点3(12B) │
│ + 属性(2B) = 50B/面 │
├──────────────────────────────────────┤
│ 三角面 2: ... │
│ ... │
└──────────────────────────────────────┘
function parseSTL(filePath) {
const buf = fs.readFileSync(filePath);
const header = buf.slice(0, 80).toString('ascii');
if (header.trimStart().startsWith('solid') && buf.toString('ascii', 0, 200).includes('facet')) {
// ASCII STL
const content = buf.toString('utf8');
const faceCount = (content.match(/facet normal/g) || []).length;
return { format: 'stl', vertexCount: faceCount * 3, faceCount };
} else {
// Binary STL
const faceCount = buf.readUInt32LE(80); // 偏移 80 字节处读取面数
return { format: 'stl', vertexCount: faceCount * 3, faceCount };
}
}
5.3 清洗阶段(Clean)
async function cleanModels(taskId, broadcast) {
const models = db.getModelsByTask(taskId).filter(m => m.status === 'raw');
for (let i = 0; i < models.length; i++) {
const model = models[i];
const issues = []; // 收集问题
try {
// 1. 检查文件是否存在
if (!fs.existsSync(model.original_file)) {
issues.push('文件不存在');
db.updateModel(model.id, { status: 'rejected', metadata: { issues } });
continue;
}
// 2. 检查文件大小
const stats = fs.statSync(model.original_file);
if (stats.size < 100) {
issues.push('文件太小 (< 100 bytes)');
db.updateModel(model.id, { status: 'rejected', metadata: { issues } });
continue;
}
// 3. 解析文件格式,提取元数据
let parsed;
const ext = path.extname(model.original_file).toLowerCase();
switch (ext) {
case '.glb': case '.gltf':
parsed = parseGLTF(model.original_file);
break;
case '.obj':
parsed = parseOBJ(model.original_file);
break;
case '.stl':
parsed = parseSTL(model.original_file);
break;
default:
parsed = { vertexCount: 0, faceCount: 0 };
}
// 4. 质量校验
if (parsed.vertexCount === 0) issues.push('没有顶点数据');
if (parsed.faceCount === 0) issues.push('没有面数据');
// 5. 更新数据库
if (issues.length === 0) {
db.updateModel(model.id, {
status: 'cleaned', // ✅ 通过
file_size: stats.size,
vertex_count: parsed.vertexCount,
face_count: parsed.faceCount,
metadata: { ...parsed, issues: [] }
});
} else {
db.updateModel(model.id, {
status: 'rejected', // ❌ 不合格
metadata: { issues }
});
}
} catch (err) {
db.updateModel(model.id, {
status: 'rejected',
metadata: { issues: ['解析出错: ' + err.message] }
});
}
// 进度推送
const progress = 40 + Math.round((i + 1) / models.length * 30); // 40% → 70%
broadcast({ type: 'task_progress', taskId, progress });
}
}
5.4 格式转换阶段(Convert)
5.4.1 GLB → GLTF 转换
// GLB 是"打包"格式(一个文件),GLTF 是"拆分"格式(JSON + .bin + 纹理图片)
function convertGLBtoGLTF(glbPath, outputDir) {
const buf = fs.readFileSync(glbPath);
// 读取 JSON Chunk
const jsonLen = buf.readUInt32LE(12);
const gltfJson = JSON.parse(buf.slice(20, 20 + jsonLen).toString('utf8'));
// 读取 BIN Chunk
const binOffset = 20 + jsonLen;
const binLen = buf.readUInt32LE(binOffset);
const binData = buf.slice(binOffset + 8, binOffset + 8 + binLen);
// 保存 .bin 文件
const binFileName = 'data.bin';
fs.writeFileSync(path.join(outputDir, binFileName), binData);
// 修改 JSON 中的 buffer 引用:从内嵌变为外部文件
gltfJson.buffers[0].uri = binFileName;
// 保存 .gltf 文件(JSON 格式)
fs.writeFileSync(
path.join(outputDir, 'model.gltf'),
JSON.stringify(gltfJson, null, 2)
);
}
5.4.2 GLTF → GLB 转换(纹理嵌入)
function convertGLTFtoGLB(gltfPath) {
const gltf = JSON.parse(fs.readFileSync(gltfPath, 'utf8'));
const gltfDir = path.dirname(gltfPath);
// 加载外部 bin 文件
let binBuffer = Buffer.alloc(0);
if (gltf.buffers?.[0]?.uri) {
binBuffer = fs.readFileSync(path.join(gltfDir, gltf.buffers[0].uri));
}
// ⭐ 嵌入外部纹理图片
// GLTF 可能引用外部 PNG/JPG,GLB 需要全部内嵌
for (const image of gltf.images || []) {
if (image.uri && !image.uri.startsWith('data:')) {
const imgPath = path.join(gltfDir, image.uri);
if (fs.existsSync(imgPath)) {
const imgData = fs.readFileSync(imgPath);
// 添加到 bin 数据末尾
const offset = binBuffer.length;
binBuffer = Buffer.concat([binBuffer, imgData]);
// 创建新的 bufferView 指向图片数据
const bvIndex = gltf.bufferViews.length;
gltf.bufferViews.push({
buffer: 0,
byteOffset: offset,
byteLength: imgData.length
});
// 更新 image 引用
delete image.uri;
image.bufferView = bvIndex;
image.mimeType = imgPath.endsWith('.png') ? 'image/png' : 'image/jpeg';
}
}
}
// 更新 buffer 总长度
gltf.buffers = [{ byteLength: binBuffer.length }];
delete gltf.buffers[0].uri;
// 组装 GLB(12B header + JSON Chunk + BIN Chunk)
return assembleGLB(gltf, binBuffer);
}
5.4.3 GLB → STL 转换
function convertGLBtoSTL(glbPath, outputPath) {
// 1. 从 GLB 提取网格数据
const mesh = extractMeshFromGLB(glbPath);
// mesh = { positions: Float32Array, indices: Uint16Array/Uint32Array }
// 2. 写入 Binary STL
writeSTL(outputPath, mesh);
}
function writeSTL(outPath, { positions, indices }) {
const faceCount = Math.floor(indices.length / 3);
// Binary STL: 80B header + 4B faceCount + 50B × faceCount
const buf = Buffer.alloc(80 + 4 + 50 * faceCount);
// 写入文件描述头
buf.write('VVAI Platform Export', 0);
// 写入面数
buf.writeUInt32LE(faceCount, 80);
let offset = 84;
for (let i = 0; i < faceCount; i++) {
// 获取三角形的 3 个顶点
const i0 = indices[i * 3], i1 = indices[i * 3 + 1], i2 = indices[i * 3 + 2];
const v0 = [positions[i0*3], positions[i0*3+1], positions[i0*3+2]];
const v1 = [positions[i1*3], positions[i1*3+1], positions[i1*3+2]];
const v2 = [positions[i2*3], positions[i2*3+1], positions[i2*3+2]];
// 计算法线(两条边的叉积)
const edge1 = [v1[0]-v0[0], v1[1]-v0[1], v1[2]-v0[2]];
const edge2 = [v2[0]-v0[0], v2[1]-v0[1], v2[2]-v0[2]];
const normal = [
edge1[1]*edge2[2] - edge1[2]*edge2[1], // 叉积 x
edge1[2]*edge2[0] - edge1[0]*edge2[2], // 叉积 y
edge1[0]*edge2[1] - edge1[1]*edge2[0] // 叉积 z
];
// 写入法线 (3 × float32)
buf.writeFloatLE(normal[0], offset); offset += 4;
buf.writeFloatLE(normal[1], offset); offset += 4;
buf.writeFloatLE(normal[2], offset); offset += 4;
// 写入 3 个顶点 (3 × 3 × float32)
for (const v of [v0, v1, v2]) {
buf.writeFloatLE(v[0], offset); offset += 4;
buf.writeFloatLE(v[1], offset); offset += 4;
buf.writeFloatLE(v[2], offset); offset += 4;
}
// 属性字节(通常为 0)
buf.writeUInt16LE(0, offset); offset += 2;
}
fs.writeFileSync(outPath, buf);
}
叉积(Cross Product)— 数学解释:
两个向量的叉积 = 垂直于这两个向量的第三个向量(法线)。
edge2
╱
╱ normal ↑ (叉积方向 = 面的朝向)
╱
v0 ───→ edge1
STL 格式要求每个三角面都带法线方向,用于确定面的内外侧。
5.4.4 GLB → OBJ 转换
function writeOBJ(outPath, { positions, indices }) {
let content = '# Generated by VVAI Platform\n';
// 写入顶点(注意:OBJ 索引从 1 开始,不是 0)
const vertexCount = positions.length / 3;
for (let i = 0; i < vertexCount; i++) {
content += `v ${positions[i*3]} ${positions[i*3+1]} ${positions[i*3+2]}\n`;
}
// 写入面
const faceCount = indices.length / 3;
for (let i = 0; i < faceCount; i++) {
// +1 因为 OBJ 是 1-based 索引
content += `f ${indices[i*3]+1} ${indices[i*3+1]+1} ${indices[i*3+2]+1}\n`;
}
fs.writeFileSync(outPath, content);
}
0-based vs 1-based 索引 — 常见 bug 源:
- JavaScript/C/Python 数组从 0 开始
- OBJ 文件的顶点/面引用从 1 开始
- 忘记 +1 会导致面引用错误的顶点,模型变形
5.4.5 extractMeshFromGLB — 完整的 GLB 二进制解析
function extractMeshFromGLB(filePath) {
const buf = fs.readFileSync(filePath);
// 解析 GLB header
const jsonLen = buf.readUInt32LE(12);
const gltf = JSON.parse(buf.slice(20, 20 + jsonLen).toString('utf8'));
// BIN chunk 起始位置
const binStart = 20 + jsonLen + 8; // 跳过 JSON + BIN chunk header
// 获取第一个 primitive 的 accessor
const prim = gltf.meshes[0].primitives[0];
const posAccessor = gltf.accessors[prim.attributes.POSITION];
const idxAccessor = gltf.accessors[prim.indices];
// 读取顶点位置
const posBV = gltf.bufferViews[posAccessor.bufferView];
const posOffset = binStart + (posBV.byteOffset || 0) + (posAccessor.byteOffset || 0);
const positions = new Float32Array(
buf.buffer.slice(buf.byteOffset + posOffset, buf.byteOffset + posOffset + posAccessor.count * 12)
);
// 读取索引
const idxBV = gltf.bufferViews[idxAccessor.bufferView];
const idxOffset = binStart + (idxBV.byteOffset || 0) + (idxAccessor.byteOffset || 0);
let indices;
switch (idxAccessor.componentType) {
case 5121: // UNSIGNED_BYTE
indices = new Uint8Array(buf.buffer, buf.byteOffset + idxOffset, idxAccessor.count);
break;
case 5123: // UNSIGNED_SHORT (最常见)
indices = new Uint16Array(buf.buffer, buf.byteOffset + idxOffset, idxAccessor.count);
break;
case 5125: // UNSIGNED_INT (大模型)
indices = new Uint32Array(buf.buffer, buf.byteOffset + idxOffset, idxAccessor.count);
break;
}
return { positions, indices };
}
componentType 数值对照(glTF 规范):
| 值 | 类型 | 字节数 | 最大值 | 用途 |
|---|---|---|---|---|
| 5120 | BYTE | 1 | 127 | 很少用 |
| 5121 | UNSIGNED_BYTE | 1 | 255 | 极小模型索引 |
| 5122 | SHORT | 2 | 32767 | 很少用 |
| 5123 | UNSIGNED_SHORT | 2 | 65535 | 最常用索引类型 |
| 5125 | UNSIGNED_INT | 4 | ~42亿 | 高精度大模型 |
| 5126 | FLOAT | 4 | ±3.4×10³⁸ | 顶点坐标 |
5.5 转换路径总结
输入格式
┌──────┐
│ GLB │──→ GLTF (拆分 JSON + BIN + 纹理)
│ │──→ STL (提取网格 → 叉积法线 → Binary STL)
│ │──→ OBJ (提取网格 → 文本格式 + 1-based 索引)
└──────┘
┌──────┐
│ GLTF │──→ GLB (合并 + 嵌入纹理图片)
│ │──→ STL (同上)
│ │──→ OBJ (同上)
└──────┘
┌──────┐
│ OBJ │──→ 标记 ready (前端 Three.js 可直接加载)
│ STL │──→ 标记 ready
└──────┘
关键设计决策: 所有转换都在 Node.js 纯代码中实现,没有依赖外部工具(如 Blender CLI 或 assimp)。好处是零外部依赖、部署简单;坏处是不支持 FBX 等复杂私有格式。
为什么这么做: "我们选择纯 JavaScript 实现格式转换,避免了 Blender/assimp 等外部依赖,降低了部署复杂度。对于 GLB/GLTF/OBJ/STL 四种开放格式,自实现的解析器足够可靠;对于 FBX 等私有格式,可以在需要时接入 assimp 的 WASM 版本。"
第六章:问题与应对措施全记录
6.1 问题总览
| # | 问题 | 严重度 | 涉及模块 | 根因 |
|---|---|---|---|---|
| 1 | Cloudflare 验证码拦截 | 🔴 高 | 通用逆向爬虫 | 网站反爬机制 |
| 2 | Meshy 模型重复下载 | 🟡 中 | Meshy 爬虫 | API 返回重复数据 |
| 3 | MakerWorld 搜索 404 | 🟡 中 | 通用逆向爬虫 | URL 结构变更 |
| 4 | SPA 动态渲染数据获取 | 🟡 中 | 通用逆向爬虫 | 页面内容靠 JS 生成 |
| 5 | MCP 响应格式不统一 | 🟡 中 | MCP 通信层 | 两个 Server 设计不同 |
| 6 | 浏览器指纹检测 | 🟡 中 | 反检测 | CDP 暴露自动化特征 |
| 7 | 大文件传输限制 | 🟡 中 | Meshy 解密回传 | MCP evaluate_script 输出限制 |
| 8 | WebGL stride 推断不准 | 🟢 低 | Sketchfab 重建 | GPU 数据无元信息 |
| 9 | CTP 按钮检测误判 | 🟢 低 | Cloudflare 处理 | DOM 隐藏元素干扰 |
| 10 | MCP 属性名大小写错误 | 🟢 低 | MCP 连接 | API 命名不一致 |
6.2 问题 1:Cloudflare 验证码拦截(最高频面试题)
问题描述
Cloudflare 是全球最大的 CDN/安全服务商,很多 3D 平台(如 MakerWorld)使用 Cloudflare 的 Bot Protection。当检测到自动化访问时,会弹出验证码页面(Managed Challenge),阻断爬虫流程。
验证码类型
Cloudflare 验证码页面:
┌─────────────────────────────────────┐
│ │
│ Checking if the site connection │
│ is secure │
│ │
│ ┌─────────────────────────────┐ │
│ │ ☑ 请验证您是真人 │ │ ← CTP (Click-To-Proceed) 按钮
│ └─────────────────────────────┘ │
│ │
│ Powered by Cloudflare │
└─────────────────────────────────────┘
解决方案
层次 1:综合检测脚本
const CAPTCHA_DETECT_SCRIPT = `
const result = { isCaptcha: false, matchedKeywords: [], clickables: [] };
// 关键词检测(16 个,中英文覆盖)
const keywords = [
'captcha', 'recaptcha', 'hcaptcha', 'turnstile',
'challenge', '验证', '真人', '人机',
'安全检查', 'security check', 'verify you are human',
'just a moment', 'checking your browser',
'cf-challenge', 'ray id', 'ddos protection'
];
const bodyText = document.body?.innerText?.toLowerCase() || '';
for (const kw of keywords) {
if (bodyText.includes(kw)) result.matchedKeywords.push(kw);
}
// CSS 选择器检测(14 个)
const selectors = [
'iframe[src*="captcha"]', '.cf-turnstile', '.g-recaptcha',
'#challenge-running', '#challenge-form', '[data-sitekey]',
'.h-captcha', '#cf-challenge-running', '.challenge-form',
'input.ctp-button' // ⭐ Cloudflare CTP 按钮
];
for (const sel of selectors) {
if (document.querySelector(sel)) result.captchaElements.push(sel);
}
// 可点击元素收集
const clickables = document.querySelectorAll(
'input[type="checkbox"], input[type="button"], button, .ctp-button'
);
// ...
result.isCaptcha = result.matchedKeywords.length >= 2
|| result.captchaElements.length > 0;
return result;
`;
层次 2:自动处理(5 次重试)
async function detectAndHandleCaptcha(browser, jr, taskId, url, addLog, isJR) {
for (let attempt = 0; attempt < 5; attempt++) {
await sleep(2000);
const detection = await evaluateScript(CAPTCHA_DETECT_SCRIPT);
if (!detection.isCaptcha) return true; // ✅ 没有验证码
// === Cloudflare CTP 处理 ===
if (detection.captchaElements.includes('input.ctp-button')) {
// 方法 1: 直接 click()
await evaluateScript(`document.querySelector('input.ctp-button')?.click()`);
await sleep(8000);
// 检查是否通过(关键:检查元素可见性,不是存在性)
const passed = await evaluateScript(`
const el = document.querySelector('#challenge-success-text');
if (!el) return false;
// ⭐ 必须检查父元素是否可见!
// 因为 #challenge-success-text 始终存在于 DOM 中,只是隐藏的
const parent = el.closest('[style]');
return !parent || parent.style.display !== 'none';
`);
if (passed) return true; // ✅ 通过
}
// === Turnstile checkbox ===
// ...
// === 通用验证按钮 ===
for (const btn of detection.clickables) {
if (/验证|verify|check|confirm/i.test(btn.text)) {
await evaluateScript(`document.querySelector('${btn.selector}')?.click()`);
await sleep(5000);
}
}
// 重试 ≥ 3 次时刷新页面
if (attempt >= 2) {
await evaluateScript('location.reload()');
await sleep(3000);
}
}
// === 层次 3: 手动验证兜底 ===
addLog('⚠️ 自动处理失败,请在浏览器中手动完成验证');
addLog('⏳ 等待手动验证(最多 90 秒)...');
for (let i = 0; i < 18; i++) { // 18 × 5s = 90s
await sleep(5000);
const check = await evaluateScript(CAPTCHA_DETECT_SCRIPT);
if (!check.isCaptcha) {
addLog('✅ 手动验证成功!');
return true;
}
}
addLog('❌ 验证超时,跳过此页面');
return false;
}
关键经验教训
| 教训 | 说明 |
|---|---|
| DOM 存在 ≠ 可见 | #challenge-success-text 始终在 DOM 中,但被 display: none 隐藏。必须检查可见性而非存在性。这个 bug 导致了误判"验证已通过" |
| CDP 不能绕过 CF | Chrome DevTools Protocol 的 click() 和 dispatchEvent() 都不能可靠触发 Cloudflare 的人机验证通过。CF 的验证依赖鼠标移动轨迹等行为特征 |
| 手动兜底必不可少 | 自动化方案永远有失败概率,必须预留手动干预窗口 |
| CTP 按钮是 input | Cloudflare 的"请验证您是真人"按钮不是 <button> 而是 <input type="button" class="ctp-button">,用常规 button 选择器找不到 |
怎么做的
"Cloudflare 的 Managed Challenge 是目前反爬最难绕过的机制之一。我的方案分三层:
1. 综合检测脚本(关键词 + CSS 选择器 + 可点击元素收集)
2. 自动处理(CTP 按钮点击 + 状态检测 + 5 次重试)
3. 手动兜底(90 秒超时轮询,给用户时间手动操作)核心经验是:DOM 元素的存在性和可见性是两回事——Cloudflare 的成功标识
#challenge-success-text始终存在于 DOM 中,只是通过 CSSdisplay:none隐藏。我最初检测'元素是否存在'导致误判,改为检测'父元素是否可见'才解决。"
6.3 问题 2:Meshy 模型重复下载
问题描述
Meshy 的 Showcase API 返回的数据中,同一个模型可能以不同的 ID 或不同的 URL 出现多次,导致下载重复文件浪费存储和带宽。
根因分析
Meshy 一个 AI 生成任务(task)可能有多个结果(result),但每个 result 的模型内容可能完全相同。API 返回时会包含同一 task 的多个 result。
解决方案:三层去重
第一层:元数据去重 第二层:内容去重 第三层:文件名唯一
┌─────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ seenBatchIds │ │ SHA-1 哈希比对 │ │ hash前缀作文件名 │
│ seenBatchResultIds│─→ │ 文件内容完全一致 │─→ │ model_a1b2c3d4.glb │
│ seenBatchUrls │ │ → 跳过 │ │ 保证磁盘不冲突 │
└─────────────────┘ └──────────────────┘ └────────────────────┘
过滤 ~60% 过滤 ~30% 保证 100% 唯一
验证结果
测试任务 #57:Meshy Showcase 返回了 4 条记录,经三层去重后保留 2 个唯一模型(Praying Girl + Photorealistic Face),与预期完全一致。
6.4 问题 3:MakerWorld 搜索页 404
问题描述
用户搜索 "cat" 时,爬虫拼接 URL 为 makerworld.com/zh/models?search=cat,但 MakerWorld 已更改搜索 URL 结构,正确地址是 makerworld.com/zh/search/models?keyword=cat。
解决方案
// URL 归一化
if (isMakerWorld) {
const u = new URL(url);
if (!u.pathname.match(/\/zh\/models\/\d+/)) {
// 非详情页 → 重写为正确的搜索路径
const keyword = config.search || u.searchParams.get('search') || '';
u.pathname = '/zh/search/models'; // 正确路径
u.search = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
url = u.toString();
}
}
经验教训
网站 URL 结构经常变化,爬虫必须有 URL 归一化层。最好通过实际访问确认 URL 是否正确,而不是只靠文档。
6.5 问题 4:SPA 动态渲染数据获取
问题描述
现代网站(如 MakerWorld、Sketchfab)使用 React/Vue/Next.js 等框架,页面内容由 JavaScript 动态生成。传统 HTML 爬取(fetch + 正则匹配)拿到的是空壳 HTML。
解决方案组合
策略 1: MCP 控制真实浏览器
→ 等 JS 渲染完成 → evaluate_script 提取数据
策略 2: 模拟滚动加载
→ scrollBy(0, innerHeight * 1.5) × N 次
→ 触发懒加载和无限滚动
策略 3: 网络请求分析
→ list_network_requests → 直接找到数据 API
→ 跳过 DOM 渲染,直接请求 API
策略 4: Next.js __NEXT_DATA__ 提取
→ 直接从 <script id="__NEXT_DATA__"> 读取 SSR 数据
→ 最快最可靠(如果网站用 Next.js)
策略 5: HTML 回退
→ 当 MCP 不可用时,用 fetch + 正则提取
→ 能力有限,但作为兜底方案
6.6 问题 5:MCP 响应格式不统一
问题描述
DevTools MCP 和 JSReverser MCP 的 evaluate_script 工具参数名不同(expression vs function),返回格式也不同(直接数据 vs traceId+数据)。
解决方案
// 1. 参数名适配
const scriptParam = isJR ? 'function' : 'expression';
await browser.callTool('evaluate_script', { [scriptParam]: script });
// 2. 响应解析适配
function parseMcpResult(result) {
const content = result?.content;
if (!content?.length) return null;
// 从后往前找非 traceId 的内容
for (let i = content.length - 1; i >= 0; i--) {
if (!content[i]?.text?.startsWith('[traceId')) {
return content[i].text;
}
}
return content[content.length - 1]?.text;
}
面试话术
"两个 MCP Server 来自不同项目,API 设计不完全一致。我通过抽象层(parseMcpResult / parseScriptResult)屏蔽了差异,上层代码无需关心用的是哪个 Server。这是适配器模式(Adapter Pattern)的应用。"
6.7 问题 6:浏览器指纹检测
详见第三章 3.5 节。核心应对:
1. JSReverser 的 inject_stealth 预设
2. 自定义 Stealth 脚本(webdriver/chrome/plugins/languages/硬件信息)
3. Preload 脚本确保在网站 JS 之前注入
6.8 问题 7:大文件传输限制
问题描述
Meshy 解密后的 GLB 文件可能有几十 MB,MCP evaluate_script 单次返回文本量有限。
解决方案
方案 A(采用): Base64 分块传输
浏览器内 GLB → btoa() 转 base64 → 分 400KB 块 → 逐块读回 Node.js
方案 B(备选): 浏览器端直接上传
浏览器内 GLB → fetch POST 到 /api/_upload-binary → 直接写磁盘
实际采用方案 A,因为更通用、不依赖服务器网络可达性。方案 B 作为备选,在特殊场景(如浏览器和服务器在同一网络)下可能更高效。
6.9 问题 8:WebGL stride 推断不准
问题描述
从 WebGL bufferData Hook 到的原始字节数据没有元信息(不知道一个顶点占多少字节)。不同模型的顶点步长(stride)不同:
- 只有位置:12 bytes (3 × float32)
- 位置 + 法线:24 bytes
- 位置 + 法线 + UV:32 bytes
- 位置 + 法线 + UV + 切线:48 bytes
解决方案
自动评分机制:对每种候选步长采样 100 个顶点,检查坐标是否合理(非 NaN、在 ±10000 范围内),选得分最高的步长。
详见第四章 4.4.5 节。
6.10 问题 9:Cloudflare 成功标识误判
问题描述
最初用 document.querySelector('#challenge-success-text') 检测验证是否通过。但该元素始终存在于 Cloudflare 的 HTML 中(只是被 display: none 隐藏),导致每次都误判为"已通过"。
解决方案
// 错误 ❌
const passed = !!document.querySelector('#challenge-success-text');
// 正确 ✅
const el = document.querySelector('#challenge-success-text');
const parent = el?.closest('[style]');
const passed = el && (!parent || parent.style.display !== 'none');
核心教训
DOM 存在性 ≠ 可见性。现代网页大量使用 CSS 控制元素显隐,爬虫检测时必须验证元素的实际可见状态。
6.11 问题 10:MCP 属性名大小写错误
问题描述
代码中写 mcpManager.jsReverse(驼峰命名),但实际属性名是 mcpManager.jsreverse(全小写)。JavaScript 属性名区分大小写,导致 undefined。
解决方案
统一为 mcpManager.jsreverse(全小写),并在所有引用处修正。
经验教训
JavaScript 是大小写敏感的语言。跨模块的属性名要确保一致性,最好在一处定义常量或使用 TypeScript 的类型检查。
6.12 问题应对总结矩阵
| 问题类型 | 应对策略 | 设计模式 |
|---|---|---|
| 外部系统变化 (URL 改变) | URL 归一化层 | 适配器模式 |
| 数据重复 | 多层去重 (ID + 哈希) | 过滤器模式 |
| 反爬检测 | 反检测注入 + 手动兜底 | 策略模式 |
| API 不一致 | 抽象解析层 | 适配器模式 |
| 大数据传输 | 分块传输 | 分治策略 |
| 动态渲染 | 多策略组合 | 责任链模式 |
| 数据格式未知 | 自动推断 + 评分 | 启发式算法 |
| # 第七章:您可能关心什么 |
7.1 自我介绍
面试官您好,我叫周誉,中国石油大学(北京)统计学专业。
我最近独立开发了一个3D 模型数据采集与清洗平台,直接服务于 AI 3D 生成模型的训练数据需求。
技术栈方面:后端用 Node.js + Express 搭建了 REST API 和 WebSocket 实时通信;前端用 Vue 3 + Three.js 实现了 3D 模型预览和任务管理仪表盘。
核心亮点是爬虫层:我通过 MCP 协议(Anthropic 的 AI Agent 标准)控制 Chrome 浏览器,实现了对 Meshy、Sketchfab 等 3D 平台的逆向爬取。包括:
- 逆向 Meshy 的自定义加密格式(.meshy → GLB 解密)
- 通过 WebGL 原型链 Hook 从 Sketchfab 捕获 GPU 几何数据并重建 GLB
- 处理 Cloudflare 验证码的三层防御策略
- 基于 SHA-1 哈希的三层去重机制清洗层实现了 GLB/GLTF/OBJ/STL 四种格式的解析、质量校验和互转,全部纯 JavaScript 实现,零外部依赖。
这个项目让我深入理解了 3D 数据管线、Web 逆向工程和浏览器自动化技术,我认为与 Meshy 数据团队的工作方向高度吻合。
7.2 岗位要求逐条对标
我对大规模互联网 3D 数据收集的经验
我的平台覆盖了 8 个主流 3D 平台(Poly Haven、Sketchfab、MakerWorld、Meshy 等),支持两种采集模式:
- API 模式:直接调用平台公开 API(如 Poly Haven 的 RESTful API),适合有开放接口的平台
- 逆向模式:通过 MCP 协议控制浏览器,模拟真人操作,适合没有公开 API 或有反爬保护的平台
整个管线是自动化的三阶段流水线(爬取→清洗→转换),有 WebSocket 实时进度推送。
在规模化方面,我实现了:
- 多页自动翻页(无限滚动 + 分页请求)
- 批内三层去重(元数据 + SHA-1 + 唯一文件名)
- 失败重试和错误隔离(单个模型失败不影响整批任务)
我了解的glTF/OBJ/FBX 这些 3D 格式
我在项目中从零实现了 GLB/GLTF/OBJ/STL 四种格式的解析器和转换器,对它们的二进制结构非常了解:
GLB/GLTF (GL Transmission Format):3D 行业的"JPEG"。GLB 是二进制打包格式,文件头 12 字节(magic + version + length),后跟 JSON Chunk(场景/材质/纹理元数据)和 BIN Chunk(顶点/索引二进制数据)。核心概念是 Buffer → BufferView → Accessor 三层抽象。
OBJ (Wavefront):最古老的纯文本格式,
v行是顶点、f行是面。索引是 1-based(不是 0-based),这是常见 bug 源。只支持几何,不支持动画和 PBR 材质。STL (Stereolithography):3D 打印常用格式。有 ASCII 和 Binary 两种。Binary STL 每个三角面 50 字节(法线 12B + 3 顶点 36B + 属性 2B)。
FBX (Filmbox):Autodesk 的私有格式,支持动画骨骼等复杂数据。我的项目暂未实现 FBX 解析,如果需要可以通过 assimp 的 WASM 版本支持。
我用过的 MCP 或 AI Agent 相关工具
MCP (Model Context Protocol) 是我项目的核心技术。我使用了两个 MCP Server:
- Chrome DevTools MCP (Google 出品,29 个工具) — 负责标准浏览器操作
- JSReverser MCP (78 个工具) — 负责逆向分析、Hook 注入、反检测
MCP 的底层是 JSON-RPC 2.0 over stdio。我实现了完整的 MCP Client:
-spawn启动 MCP Server 子进程
- NDJSON 解析 stdin/stdout 流
- 握手(initialize → notifications/initialized → tools/list)
- 工具调用(tools/call)+ 超时控制
- 单例模式 + 防重入锁保证全局唯一我还处理了两个 Server 的 API 差异(参数名
expressionvsfunction、响应格式 traceId 前缀),通过抽象解析层(适配器模式)统一了调用接口。
我的 JavaScript/WebGL 开发经验
我的项目大量使用了 JavaScript 的高级特性:
- 原型链 Hook: 修改
WebGLRenderingContext.prototype.bufferData拦截所有 WebGL 数据上传,这是 Sketchfab 爬虫的核心- Web Worker: 利用 Meshy 自带的 Worker 解密加密文件,涉及
postMessage+ Transferable Objects 零拷贝传输- BigInt: 实现 FNV-1a 64 位哈希签名算法(Meshy API 验证)
- TypedArray:
Float32Array,Uint16Array,DataView等操作二进制 3D 数据- Prototype Hook + Preload 注入: 在页面 JS 执行前修改全局原型,实现透明拦截
WebGL 方面,我理解
ARRAY_BUFFER(0x8892) 和ELEMENT_ARRAY_BUFFER(0x8893) 的区别,以及从 GPU 回读数据重建 GLB 的完整流程(stride 推断 + 评分机制 + glTF 2.0 JSON 组装 + BIN Chunk 打包)。
我的 Cloudflare 相关经验吗
我实现了完整的 Cloudflare 验证码检测与处理系统,分三层:
- 检测层: 16 个关键词 + 14 个 CSS 选择器 + 可点击元素收集
- 自动处理层: CTP 按钮点击(
input.ctp-button)+ 5 次重试 + 页面刷新- 手动兜底层: 90 秒轮询等待用户手动完成验证
关键经验:CDP (Chrome DevTools Protocol) 无法完全绕过 Cloudflare 的 Managed Challenge,因为 CF 依赖鼠标移动轨迹等行为特征来判断真人。自动化方案的成功率约 60%,手动兜底是必须的。
还有一个重要 bug 教训:
#challenge-success-text元素始终存在于 DOM 中,只是被 CSS 隐藏。必须检查可见性而非存在性。
Producer-Consumer 模式实现
我的三阶段管线就是 Producer-Consumer 的体现:
爬虫 (Producer) → DB 队列 (Buffer) → 清洗 (Consumer/Producer) → DB → 转换 (Consumer)
- 爬虫 produce 原始模型 (
status: 'raw')- 清洗器 consume
raw模型,producecleaned或rejected模型- 转换器 consume
cleaned模型,produceready模型中间用 JSON 数据库作为 buffer,解耦了各阶段。WebSocket 广播是 Observer 模式 的实现——任何阶段的状态变化都通过
broadcast()通知所有前端客户端。
7.3 技术深层次问题
为什么 Meshy 爬虫要用浏览器解密,不直接在 Node.js 里逆向解密算法
Meshy 的解密逻辑在混淆的 Web Worker (
loader-worker.min.js) 中,逆向完整的解密算法成本极高。而复用 Meshy 自己的 Worker 是最高效的方式——我们只需要:
1. 生成正确的授权签名(FNV-1a 哈希,这部分我已逆向)
2. 把.meshy文件发给 Worker
3. 等 Worker 解密完成后取回 GLB这就是"站在巨人肩膀上"——不重新发明轮子,而是巧妙复用目标网站的现有能力。
WebGL Hook 为什么要用 Preload 而不是普通的 evaluate_script
时序问题。
evaluate_script是在页面加载后执行,此时 Sketchfab 的 WebGL 渲染代码已经调用过bufferData了——我们会错过所有数据。Preload 脚本在页面任何 JS 之前执行,所以我们能在 Sketchfab 代码执行前就 Hook 好原型链。这样 Sketchfab 每次调用
bufferData时,实际执行的是我们的包装函数。
如何保证 SHA-1 去重的可靠性?
SHA-1 产生 160 位(20 字节)摘要。两个不同文件产生相同 SHA-1 的概率约为 2^-160 ≈ 10^-48——对于我们的数据规模(数百个模型),碰撞概率可以忽略不计。
虽然 SHA-1 在密码学场景已被攻破(Google 在 2017 年找到了第一个碰撞),但在内容寻址/去重场景(如 Git 也用 SHA-1)仍然安全可靠。
如你优化平台的性能
- 并行爬取: 当前是串行处理模型,可以用
Promise.allSettled+ 并发控制(如 p-limit 库)实现 N 路并行下载- 增量爬取: 记录上次爬取时间戳,只获取新增模型,避免全量重爬
- 流式传输: 用
Stream替代Buffer.from(arrayBuffer)处理大文件,降低内存峰值- 数据库升级: JSON 文件在写入时需要全量序列化,可迁移到 SQLite(嵌入式,无需服务器)或 MongoDB
- 分布式: 爬虫和清洗分离为独立服务,通过消息队列(如 Redis)通信,支持水平扩展
如何推断 WebGL buffer 的 vertex stride
这是一个启发式推断问题。GPU 回读的原始字节没有任何元信息,所以我设计了一个评分机制:
- 候选步长列表: 根据常见顶点格式枚举(12/24/32/48 字节等 14 种)
- 对每种步长采样 100 个顶点: 按该步长切分数据,读取前 3 个 float 作为 x/y/z
- 评分标准: NaN 比例(float 数据如果按错误步长切分会产生 NaN)+ 坐标范围合理性(大部分 3D 模型坐标在 ±10000 内)
- 选最高分: 得分最高的步长最可能是正确的
这个方法在测试的多种 Sketchfab 模型上都正确推断了步长。
7.4 困境与突破
遇到过最困难的技术问题
Cloudflare 验证码的误判 bug。
现象:验证码处理函数始终报告"验证已通过",但实际上页面仍然停留在验证页。
排查过程:通过 MCP 截图功能保存每次尝试的页面截图,发现页面确实还在验证状态。然后检查检测逻辑,发现
#challenge-success-text在验证前就存在于 DOM 中——Cloudflare 预渲染了成功和失败两套 UI,通过 CSSdisplay切换。解决:改为检查元素的父容器 style 属性,确认
display !== 'none'才算通过。教训:调试爬虫时,截图是最有价值的调试工具——它让你"看到"浏览器看到的东西。
如何学习一项新技术?
以 MCP 协议为例:
- 读规范: 先通读 MCP 的协议文档,理解 JSON-RPC、握手流程、工具调用
- 看实现: 阅读 chrome-devtools-mcp 和 JSReverser-MCP 的源码,理解 Server 端实现
- 写 Client: 自己实现 McpClient 类,通过 stdio 与 Server 通信
- 用起来: 在爬虫场景中实际使用,遇到问题再回头看文档
- 记录: 把踩过的坑(如响应格式差异、参数名不一致)记录下来
制作者本人最大的优势
端到端的全栈实现能力。这个项目从前端 Three.js 3D 预览、后端 Express 服务器、爬虫 MCP 浏览器控制、到 3D 格式二进制解析,全部是我独立完成的。我不局限于某一层,而是能够从需求出发,贯穿整个技术栈找到解决方案。
7.5 关心的问题
- "Meshy 的训练数据管线目前主要用什么技术栈?和我的 Node.js + MCP 方案有多大差异?"
- "数据团队目前面临的最大技术挑战是什么?是数据获取难度、质量校验准确率、还是管线吞吐量?"
- "实习生主要负责哪个环节?是爬虫开发、数据清洗脚本,还是管线工具开发?"
- "团队对 3D 数据的质量标准是什么?除了几何完整性(顶点/面数),还关注材质、动画、拓扑结构吗?"
7.6 关键数字解释
| 数字 | 含义 |
|---|---|
| 107 | 两个 MCP Server 的总工具数 (29 + 78) |
| 1800+ | crawler.js 的代码行数 |
| 2200+ | crawler.js 迭代后的代码行数(新增 ~400 行) |
| 8 | 预设数据源平台数量 |
| 4 | 支持的 3D 格式 (GLB/GLTF/OBJ/STL) |
| 3 | 管线阶段数 (crawl → clean → convert) |
| 3 | Meshy 去重层数 (元数据 + SHA-1 + 文件名) |
| 3 | Sketchfab 逆向捕获层数 (网络URL + 浏览器fetch + WebGL Hook) |
| 3 | Cloudflare 处理层数 (检测 + 自动 + 手动) |
| 5 | 验证码自动处理最大重试次数 |
| 5 | Layer 3 CSP bypass 快速注入尝试次数 |
| 8s | Layer 3 Retrigger 超时阈值 |
| 90s | 手动验证超时时间 |
| 400KB | Base64 分块传输的块大小 |
| 3200 | 服务器端口号 |
| 9222 | Chrome 调试端口号 |
| 0x46546C67 | GLB 文件的 magic number ("glTF") |
| 0x8892/0x8893 | WebGL ARRAY_BUFFER / ELEMENT_ARRAY_BUFFER |
| FNV-1a | Meshy 签名的哈希算法名称 |
7.7 术语表
| 术语 | 全称 | 一句话解释 |
|---|---|---|
| Accessor | glTF Accessor | 描述如何从原始字节中读取有意义的数据(类型、数量、偏移) |
| ArrayBuffer | — | JavaScript 中的固定长度二进制数据容器 |
| Base64 | — | 把二进制数据编码成纯文本的方式,体积增大约 33% |
| BigInt | — | JavaScript 的任意精度整数类型,用于 64 位运算 |
| bufferData | WebGL API | 把 CPU 内存中的数据上传到 GPU 显存 |
| BufferView | glTF BufferView | Buffer 的一个切片(偏移 + 长度) |
| bundleGltfToGlb | — | 将分离式 glTF JSON + 多个 .bin 合并打包为单个 GLB 文件 |
| CDP | Chrome DevTools Protocol | Chrome 的远程调试协议 |
| CORS | Cross-Origin Resource Sharing | 跨域资源共享,浏览器安全策略 |
| CSP | Content Security Policy | 浏览器安全策略,限制页面可执行的脚本来源 |
| CTP | Click-To-Proceed | Cloudflare 的点击验证按钮 |
| DataView | — | JavaScript 中读写 ArrayBuffer 的接口,支持指定字节序 |
| ESM | ECMAScript Modules | 现代 JS 模块系统 (import/export) |
| FNV-1a | Fowler-Noll-Vo hash | 快速非加密哈希函数 |
| GLB | GL Binary | glTF 的二进制打包格式,单文件 |
| glTF | GL Transmission Format | 3D 行业标准传输格式 |
| Hook | — | 拦截并修改函数调用的技术 |
| isValidGLB | — | GLB 格式校验函数,检查 magic bytes 0x46546C67 |
| JSON-RPC | JSON Remote Procedure Call | 用 JSON 格式的远程函数调用协议 |
| Little Endian | — | 低位字节在前的存储方式 |
| Magic Number | — | 文件头部的固定字节,用于识别格式 |
| MCP | Model Context Protocol | Anthropic 的 AI Agent 工具调用标准 |
| MIME Type | Media Type | 标识文件/内容类型的字符串 |
| NDJSON | Newline Delimited JSON | 每行一个 JSON 对象,用换行分隔 |
| OBJ | Wavefront OBJ | 纯文本 3D 格式 |
| Preload Script | — | 在页面 JS 之前注入执行的脚本 |
| Prototype Chain | — | JavaScript 对象通过原型链共享方法的机制 |
| Retrigger | — | Hook 注入后无数据时的重触发机制(viewport/resize/reload) |
| Runtime.evaluate | CDP API | V8 层面直接执行 JS,绕过 CSP 限制 |
| SHA-1 | Secure Hash Algorithm 1 | 160 位密码学哈希函数 |
| SPA | Single Page Application | 单页应用 |
| spawn | — | Node.js 创建子进程的方法 |
| Stealth | — | 反检测技术,伪装自动化浏览器为正常浏览器 |
| stdio | Standard Input/Output | 标准输入输出流 |
| STL | Stereolithography | 3D 打印常用格式 |
| stride | — | 顶点数据中一个顶点占用的字节数 |
| Transferable | — | Web Worker 零拷贝传输机制 |
| TypedArray | — | JavaScript 中的类型化数组 (Float32Array 等) |
| WebGL | Web Graphics Library | 浏览器 3D 渲染 API |
| WebSocket | — | 全双工通信协议 |
| Worker | Web Worker | 浏览器后台线程 |
第八章:迭代更新 — 三层捕获策略与工程化改进
本章覆盖: 平台初版完成后的所有技术迭代,包括 Sketchfab 逆向捕获架构重写、GLB 格式校验、去重增强、通用逆向 WebGL 截取、以及开发环境自动化。
8.1 初版方案的致命缺陷
初版 crawlSketchfabReverse 只有一条路径:inject_preload_script + 轮询 __VVAI_CAPTURE。在实际运行中暴露了三个问题:
| 问题 | 根因 | 现象 |
|---|---|---|
| Hook 注入静默失败 | inject_preload_script 内部用 new Function(script)() 执行,被 Sketchfab 的 CSP (unsafe-eval) 拦截 |
WEBGL_CAPTURE_STATUS 始终返回 { error: 'not_injected' } |
| 回退导致死循环 | 检测到 not_injected 后执行 evaluate_script 注入 + location.reload() — 刷新会销毁刚注入的 Hook,新页面又回到 not_injected |
任务永远卡在轮询循环中(25 次 × 1 秒 = 最低 25 秒无效等待) |
| 下载到非法文件 | Sketchfab 的 .bin/.binz/.osgjs 是私有加密格式,代码把它们盲目重命名为 .glb |
清洗阶段 magic bytes 校验失败:Invalid GLB magic,0/1 通过 |
8.1.1 关键技术发现:CSP 与 CDP 注入的差异
通过阅读 JSReverser-MCP 源码,确认了两个关键事实:
evaluate_script走 CDPRuntime.evaluate(Puppeteer 的frame.evaluate),完全绕过 CSP 限制inject_preload_script走page.evaluateOnNewDocument+new Function(script)(),受 CSP 约束
inject_preload_script → page.evaluateOnNewDocument((script) => {
new Function(script)(); // ← 被 CSP unsafe-eval 拦截!
}, scriptContent)
evaluate_script → frame.evaluate(async fn => {
return JSON.stringify(await fn()); // ← CDP 直接执行, 不受 CSP 限制
}, fnHandle)
面试话术: "我们通过阅读 MCP Server 的源码发现 evaluateOnNewDocument 内部用了 new Function() 动态求值,而 Sketchfab 设置了严格的 CSP 策略禁止 unsafe-eval。解决方案是改用 CDP Runtime.evaluate 直接在 V8 层面注入 prototype hook,彻底绕过 CSP。"
8.2 三层递进捕获架构
8.2.1 架构总览
导航到 Sketchfab embed 页面
│
▼
┌─── Layer 1: 网络请求 URL 捕获 ───┐
│ list_network_requests → 扫描 │
│ .glb/.gltf/.bin URL → 直接下载 │
│ + GLB magic 校验 │
└─────────┬──────────┬─────────────┘
成功 │ │ 失败
▼ │ ▼
保存 │ ┌── Layer 2: 浏览器内 fetch ──┐
│ │ evaluate_script 在页面内 │
│ │ fetch URL → base64 回传 │
│ │ 绕过 CORS/Auth/Cookie │
│ │ + GLB/glTF 格式验证 │
│ └─────────┬──────────┬────────┘
│ 成功 │ │ 失败
│ ▼ │ ▼
│ 保存 │ ┌── Layer 3: WebGL Hook ──┐
│ │ │ evaluate_script 注入 │
│ │ │ bufferData prototype │
│ │ │ hook → 等待渲染稳定 │
│ │ │ → 提取缓冲区 → GLB 重建 │
│ │ └─────────────────────────┘
▼ ▼
数据库写入 + 缩略图下载
8.2.2 Layer 1 — 网络请求 URL 捕获
原理: Sketchfab embed 页面加载模型时会发出 HTTP 请求,MCP 的 NetworkCollector 自动记录所有请求。
// 1. 导航到 embed 页 (autostart=1 强制自动播放)
const embedUrl = `https://sketchfab.com/models/${uid}/embed?autostart=1`;
await jr.callTool('navigate_page', { url: embedUrl, timeout: 30000 });
// 2. 等待 10 秒让模型数据完全加载
await new Promise(r => setTimeout(r, 10000));
// 3. 获取所有网络请求
const netResult = await jr.callTool('list_network_requests', {});
const netText = netResult?.content?.map(c => c?.text || '').join('\n') || '';
// 4. extractModelUrlsFromNetLog: 从日志文本中匹配模型文件 URL
// 正则: /(?:GET|POST)\s+(https?:\/\/\S+?)(?:\s+\[|\s*$)/i
// 过滤: /\.(glb|gltf|bin|binz|osgjs)(\?|$)/i
const modelFileUrls = extractModelUrlsFromNetLog(netText);
成功条件: URL 直接下载后 isValidGLB(buf) 通过 — magic bytes 0x46546C67 ('glTF')。
8.2.3 Layer 2 — 浏览器内 fetch
原理: 有些 URL 需要特定的 Cookie/Auth 或被 CORS 保护,服务端直接下载会 403。通过浏览器上下文 fetch 可以继承当前会话。
async function fetchFileViaBrowser(jr, url, parseMcpResult) {
const fetchScript = `async () => {
const resp = await fetch('${safeUrl}');
if (!resp.ok) return { error: resp.status };
const ab = await resp.arrayBuffer();
const bytes = new Uint8Array(ab);
const CHUNK = 8192;
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, end));
}
return { data64: btoa(binary), size: ab.byteLength };
}`;
const result = parseMcpResult(
await jr.callTool('evaluate_script', { function: fetchScript })
);
return Buffer.from(result.data64, 'base64');
}
关键: 不再盲目保存——下载后必须通过 isValidGLB() 或 isGltfJson() 验证。如果是 glTF JSON,还会自动下载关联的 .bin 文件并用 bundleGltfToGlb() 打包为标准 GLB。
8.2.4 Layer 3 — WebGL Prototype Hook (CSP bypass 版)
这是最复杂的一层,解决了初版的所有问题:
阶段 A: 快速注入
// 导航后立即注入 (不等 WebGL 初始化!)
await jr.callTool('navigate_page', { url: embedUrl, timeout: 30000 });
// 5 次尝试, 每次间隔 500ms — 抢在 WebGL context 创建之前
for (let attempt = 0; attempt < 5; attempt++) {
await jr.callTool('evaluate_script', {
function: `() => { ${WEBGL_PRELOAD_HOOK} }` // CDP 直接执行, 绕过 CSP
});
const check = parseMcpResult(await jr.callTool('evaluate_script', {
function: WEBGL_CAPTURE_STATUS
}));
if (check.ready) break;
await new Promise(r => setTimeout(r, 500));
}
阶段 B: 重触发机制
如果 Hook 注入成功但第 8 秒仍然 0 个顶点缓冲区,说明 WebGL 在 Hook 之前已经完成初始化:
第 8 秒检测: ready=true, vertexBuffers=0
↓
Step 1: 触发 canvas viewport 变化 + resize + contextlost/restored
↓ (等 3 秒)
Step 2: 仍无数据 → 清空 __VVAI_CAPTURE → location.href 重新导航
↓ (等 5 秒)
Step 3: 重新注入 Hook (document 已重建)
↓
继续稳定性监控循环
阶段 C: 稳定性检测 + 提取
每秒轮询 WEBGL_CAPTURE_STATUS
→ vertexBuffers 连续 4 次不变 → 认为渲染稳定
→ 分批提取 (BATCH=3): makeExtractScript(arrName, start, 3)
→ buildGLBFromReadback(vertexBuffers, indexBuffers)
面试话术: "三层策略的核心思想是优先用最稳定、成本最低的方式获取数据。网络请求直接拿 URL 是最快的(10 秒);浏览器 fetch 解决了 CORS 问题;WebGL Hook 是最后手段,只在前两层都失败时才启动,避免不必要的 prototype 污染。"
8.3 GLB 格式校验与 glTF 打包
8.3.1 为什么需要格式校验
Sketchfab 内部使用私有压缩格式传输模型数据(文件头是 0x3548FC78 等非标准 magic bytes)。如果不做校验,这些无效文件会进入清洗管线,导致 "Invalid GLB magic" 错误。
8.3.2 校验函数
function isValidGLB(buf) {
return buf && buf.length > 12 && buf.readUInt32LE(0) === 0x46546C67;
// 0x46546C67 = 'glTF' little-endian
}
function isGltfJson(buf) {
if (!buf || buf.length < 2) return false;
const first = buf[0];
return first === 0x7B || first === 0xEF; // '{' 或 UTF-8 BOM
}
8.3.3 bundleGltfToGlb — glTF + bin → GLB 打包
当网络请求中发现分离式 glTF(.gltf JSON + 多个 .bin)时,需要合并为单个 GLB:
GLB 文件结构 (GLB Container Format):
┌──────────────────────┐
│ Header (12 bytes) │ magic='glTF', version=2, length=total
├──────────────────────┤
│ JSON Chunk │ type=0x4E4F534A ('JSON')
│ 4 字节对齐 (空格) │ 内容: gltf JSON (移除 buffer.uri)
├──────────────────────┤
│ BIN Chunk │ type=0x004E4942 ('BIN\0')
│ 4 字节对齐 (0x00) │ 内容: 所有 bin 拼接
└──────────────────────┘
多 buffer 场景的处理逻辑:
1. 所有 bin 用 Buffer.concat 拼接
2. gltf.buffers 合并为单个 { byteLength: combinedBin.length }
3. 遍历 bufferViews,将引用第 N (N>0) 个 buffer 的 view 的 byteOffset 累加前序 buffer 长度
4. JSON 部分 4 字节对齐用 0x20(空格),BIN 部分用 0x00
8.4 去重增强
8.4.1 Sketchfab API 模式 — UID 级去重
初版只按模型名称去重,但 Sketchfab 上不同作者可能上传同名模型。增加了 UID 级去重:
// 收集已有的 Sketchfab UID
const existingUids = new Set();
allModels.forEach(m => {
if (m.metadata?.sketchfabUid) existingUids.add(m.metadata.sketchfabUid);
// 从文件名 pattern 提取: {32位hex}.glb
const uidMatch = m.original_file?.match(/([a-f0-9]{32})\.glb$/);
if (uidMatch) existingUids.add(uidMatch[1]);
});
// 双重过滤
const freshModels = allResults.filter(m =>
!existingNames.has(m.name) && !existingUids.has(m.uid)
);
为什么这么做: "我们实现了两级去重——名称级和 UID 级。名称去重处理大多数情况;UID 去重防止 API 翻页时返回同一模型的不同名称变体。UID 从数据库 metadata 和文件名两个来源收集,确保跨任务一致性。"
8.4.2 请求量优化
API 模式请求 limit × 3 + 已有数量 个结果,去重后截取前 limit 个。这样即使库中已有大量相同搜索结果,也能保证拿到足够的新模型。
8.5 通用逆向 WebGL 截取
8.5.1 设计目标
需求: "不论输入什么 3D 模型网页,逆向模式都要能调用 MCP 工具进行数据截取"。
8.5.2 架构
通用 crawlReverse 函数对非 Sketchfab/Meshy 的网站执行以下流程:
MCP 逆向 (通用网站)
├── 反检测注入 (inject_stealth: full preset)
│ ├── navigator.webdriver = false
│ ├── 删除 cdc_* 变量
│ ├── 伪造 chrome.runtime, Permissions.query
│ └── 伪造 plugins, languages, platform, hardwareConcurrency
│
├── WebGL Preload Hook 预注册
│ └── inject_preload_script(WEBGL_PRELOAD_HOOK)
│
├── 多层模型发现
│ ├── 网络请求 URL 扫描 (支持翻页, ≤20 页)
│ ├── DOM 属性提取 ([src], [data-src], <model-viewer>)
│ ├── JSON <script> 数据提取 (__NEXT_DATA__ 等)
│ └── 详情页深入爬取
│
├── 详情页 WebGL 截取
│ ├── CAPTCHA 检测 → 暂停/跳过
│ ├── performance.getEntriesByType 检查
│ ├── 网络请求检查
│ └── WebGL Hook 捕获 (同 Sketchfab Layer 3 逻辑)
│ ├── preload 未生效 → 直接注入 + reload
│ ├── 25 秒稳定监控
│ ├── 分批提取 + GLB 重建
│ └── 清空 __VVAI_CAPTURE (下一页复用)
│
├── 单页兜底
│ └── 无详情页链接时, 当前页直接 WebGL 截取
│
└── URL 下载 → HTML 回退
8.5.3 关键技术点
跨页复用 Hook: 每个详情页访问后都 window.__VVAI_CAPTURE = { vertexBuffers: [], ... } 清空,保证下一页的数据干净。
CAPTCHA 联动: 进入详情页后先运行 CAPTCHA_DETECT_SCRIPT,如果检测到验证码/安全检查,记录日志并跳过当前页。
model-viewer 组件: 对使用 <model-viewer> Web Component 的网站,直接从 src 属性读取模型 URL。
8.6 开发环境自动化
8.6.1 VS Code 自动启动
解决的问题: 重启电脑后需要手动打开终端启动 Chrome 和 VVAI 服务。
方案: .vscode/tasks.json + runOn: folderOpen
{
"tasks": [
{
"label": "Start Chrome Debug :9222",
"type": "process",
"command": "chrome.exe",
"args": ["--remote-debugging-port=9222", "--user-data-dir=..."],
"isBackground": true,
"runOptions": { "runOn": "folderOpen" }
},
{
"label": "Start VVAI Server :3200",
"type": "shell",
"command": "node server.js",
"isBackground": true,
"runOptions": { "runOn": "folderOpen" },
"dependsOn": "Start Chrome Debug :9222"
}
]
}
配合 .vscode/settings.json:
{ "task.allowAutomaticTasks": "on" }
效果: 打开 VS Code 工作区即自动启动 Chrome Debug + VVAI 服务,无需手动操作。
8.6.2 启停脚本
| 脚本 | 功能 |
|---|---|
start-vvai-3200.bat |
5 步启动: 端口检测 → Chrome 启动 → 等待验证 → 服务启动 → 健康检查 |
stop-vvai.bat |
一键停止: 杀死 :3200 和 :9222 上的进程 |
start-chrome-debug.bat |
端口冲突检测 + Chrome debug 模式启动 |
8.7 问题排查与调试经验
8.7.1 "所有模型清洗未通过"
场景: Sketchfab 逆向下载了 97KB 文件,清洗阶段报 Invalid GLB magic。
排查路径:
1. 检查文件头: 35 48 FC 78 B8 76 52 DE — 不是 glTF magic
2. 确认来源: 浏览器 fetch 下载的 .bin 文件,是 Sketchfab 私有加密格式
3. 代码问题: fetchFileViaBrowser 拿到数据后直接重命名为 .glb,没有校验
修复: 在所有下载后添加 isValidGLB() 检查,不合格的不保存,自动 fallthrough 到下一层。
8.7.2 "爬取卡住不动"
场景: Sketchfab 逆向进入 Layer 3 后长时间无输出。
排查路径:
1. Layer 3 重新 navigate_page 后轮询 WEBGL_CAPTURE_STATUS
2. 第 3 次轮询时检测到 not_injected → 注入 Hook + location.reload()
3. 刷新销毁了 Hook → 下次检测又是 not_injected → 但 w === 3 条件只触发一次
4. 剩余 22 次轮询: status.error 且 w < 5 前几次 continue,之后 break
5. 提取到 0 个 buffer → 模型失败
修复: 不用 reload,导航后立即注入 Hook;增加 retrigger 机制处理"Hook 在但无数据"的情况。
8.7.3 Sketchfab Embed 页面的特殊性
Sketchfab embed 页面 (/models/{uid}/embed) 的技术特征:
- 使用 WebGL2 渲染
- 有严格 CSP (script-src 限制, 禁止 unsafe-eval)
- 模型数据通过私有格式 .binz/.bin 传输,不是标准 glTF
- autostart=1 参数可以跳过"点击播放"按钮
- WebGL context 创建通常在页面加载后 2-5 秒
8.8 补充说明
怎么发现 CSP 导致 Hook 注入失败的
日志显示
not_injected状态持续存在。我去看了 JSReverser-MCP 的源码,发现inject_preload_script在evaluateOnNewDocument内部用了new Function(script)(),这是动态代码求值,会被 CSP 的unsafe-eval规则拦截。解决方案是改用evaluate_script,它底层走 CDP 的Runtime.evaluate,在 V8 引擎层面直接执行,不受页面 CSP 限制。
为什么不直接用 Layer 3 WebGL Hook,而是要做三层
稳定性递减、成本递增。Layer 1 从网络请求拿 URL 直接下载,10 秒完成,100% 可靠;Layer 2 通过浏览器 fetch 绕过 CORS,多花几秒;Layer 3 需要 prototype hook + 渲染稳定监控 + buffer 提取 + GLB 重建,最慢可达 40 秒,且 Hook 可能被各种原因干扰。所以我们优先用稳定方案,只在必要时才降级。
bundleGltfToGlb 多 buffer 场景怎么处理
glTF 规范允许多个 buffer,每个 bufferView 引用不同 buffer。打包为 GLB 时只能有一个 BIN chunk,所以需要: (1) 按顺序拼接所有 bin; (2) 遍历 bufferViews,把引用第 N 个 buffer 的 view 的 byteOffset 加上前 N-1 个 buffer 的总长度; (3) 所有 bufferView 的 buffer 索引统一改为 0。
怎么验证下载的文件是有效模型
GLB 文件前 4 字节必须是
0x46546C67(ASCII 'glTF'),这是 glTF Binary 容器的 magic number。我在每次下载后立即检查这个 magic,不匹配就标记为无效,不写入磁盘,流程自动降级到下一层捕获策略。这避免了"下载了 Sketchfab 私有加密 .bin 格式却当作 GLB 保存"的问题。
通用逆向 WebGL 截取和 Sketchfab 专用逻辑的区别
核心 Hook 逻辑完全相同——都是 prototype hook
bufferData。区别在于: (1) 通用逆向多了反检测注入(full preset,覆盖 webdriver/cdc/plugins 等);(2) 通用逆向需要自己发现详情页链接,Sketchfab 有公开搜索接口直接给 UID;(3) 通用逆向每访问一个详情页后需要清空__VVAI_CAPTURE防止数据混入;(4) 通用逆向有 CAPTCHA 检测联动。