VVAI 3D 数据采集与清洗平台 — 搭建全流程展示。

目标岗位: Meshy — AI 数据工程实习生(3D 训练数据采集/清洗/分析)
独立开发者: 周誉
本手册结构: 由宏观到微观、由浅入深,覆盖平台架构、技术实现、问题应对。


📖 目录

💡核心搭建流程

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 中,只是通过 CSS display: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 等),支持两种采集模式:

  1. API 模式:直接调用平台公开 API(如 Poly Haven 的 RESTful API),适合有开放接口的平台
  2. 逆向模式:通过 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:

  1. Chrome DevTools MCP (Google 出品,29 个工具) — 负责标准浏览器操作
  2. 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 差异(参数名 expression vs function、响应格式 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 验证码检测与处理系统,分三层:

  1. 检测层: 16 个关键词 + 14 个 CSS 选择器 + 可点击元素收集
  2. 自动处理层: CTP 按钮点击(input.ctp-button)+ 5 次重试 + 页面刷新
  3. 手动兜底层: 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 模型,produce cleanedrejected 模型
  • 转换器 consume cleaned 模型,produce ready 模型

中间用 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)仍然安全可靠。

如你优化平台的性能

  1. 并行爬取: 当前是串行处理模型,可以用 Promise.allSettled + 并发控制(如 p-limit 库)实现 N 路并行下载
  2. 增量爬取: 记录上次爬取时间戳,只获取新增模型,避免全量重爬
  3. 流式传输: 用 Stream 替代 Buffer.from(arrayBuffer) 处理大文件,降低内存峰值
  4. 数据库升级: JSON 文件在写入时需要全量序列化,可迁移到 SQLite(嵌入式,无需服务器)或 MongoDB
  5. 分布式: 爬虫和清洗分离为独立服务,通过消息队列(如 Redis)通信,支持水平扩展

如何推断 WebGL buffer 的 vertex stride

这是一个启发式推断问题。GPU 回读的原始字节没有任何元信息,所以我设计了一个评分机制:

  1. 候选步长列表: 根据常见顶点格式枚举(12/24/32/48 字节等 14 种)
  2. 对每种步长采样 100 个顶点: 按该步长切分数据,读取前 3 个 float 作为 x/y/z
  3. 评分标准: NaN 比例(float 数据如果按错误步长切分会产生 NaN)+ 坐标范围合理性(大部分 3D 模型坐标在 ±10000 内)
  4. 选最高分: 得分最高的步长最可能是正确的

这个方法在测试的多种 Sketchfab 模型上都正确推断了步长。


7.4 困境与突破

遇到过最困难的技术问题

Cloudflare 验证码的误判 bug

现象:验证码处理函数始终报告"验证已通过",但实际上页面仍然停留在验证页。

排查过程:通过 MCP 截图功能保存每次尝试的页面截图,发现页面确实还在验证状态。然后检查检测逻辑,发现 #challenge-success-text 在验证前就存在于 DOM 中——Cloudflare 预渲染了成功和失败两套 UI,通过 CSS display 切换。

解决:改为检查元素的父容器 style 属性,确认 display !== 'none' 才算通过。

教训:调试爬虫时,截图是最有价值的调试工具——它让你"看到"浏览器看到的东西。

如何学习一项新技术?

以 MCP 协议为例:

  1. 读规范: 先通读 MCP 的协议文档,理解 JSON-RPC、握手流程、工具调用
  2. 看实现: 阅读 chrome-devtools-mcp 和 JSReverser-MCP 的源码,理解 Server 端实现
  3. 写 Client: 自己实现 McpClient 类,通过 stdio 与 Server 通信
  4. 用起来: 在爬虫场景中实际使用,遇到问题再回头看文档
  5. 记录: 把踩过的坑(如响应格式差异、参数名不一致)记录下来

制作者本人最大的优势

端到端的全栈实现能力。这个项目从前端 Three.js 3D 预览、后端 Express 服务器、爬虫 MCP 浏览器控制、到 3D 格式二进制解析,全部是我独立完成的。我不局限于某一层,而是能够从需求出发,贯穿整个技术栈找到解决方案。


7.5 关心的问题

  1. "Meshy 的训练数据管线目前主要用什么技术栈?和我的 Node.js + MCP 方案有多大差异?"
  2. "数据团队目前面临的最大技术挑战是什么?是数据获取难度、质量校验准确率、还是管线吞吐量?"
  3. "实习生主要负责哪个环节?是爬虫开发、数据清洗脚本,还是管线工具开发?"
  4. "团队对 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 magic0/1 通过

8.1.1 关键技术发现:CSP 与 CDP 注入的差异

通过阅读 JSReverser-MCP 源码,确认了两个关键事实:

  1. evaluate_script 走 CDP Runtime.evaluate(Puppeteer 的 frame.evaluate),完全绕过 CSP 限制
  2. inject_preload_scriptpage.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.errorw < 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_scriptevaluateOnNewDocument 内部用了 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 检测联动。