一、我遇到的问题 & 根因分析
问题1:Printables 网站架构迁移(根本原因)
Printables(Prusa 旗下)从传统的服务端渲染架构完整迁移到了 SvelteKit + GraphQL:
- 旧架构:REST API
api.printables.com/v1/prints/...返回 JSON,文件直接挂在media.printables.com下的固定路径 - 新架构:前端是 SvelteKit SSR,数据全部来自 GraphQL,文件存储路径从数字ID改为 UUID(如
16d5e365-e284-4440-9ae1-055cf6797802),下载链接变为签名临时 URL(TTL=24h)
直接后果:所有旧的 REST API 端点返回 404,旧的文件直链全部失效。
问题2:Cloudflare WAF 精准拦截 GraphQL
Printables 在 www.printables.com 前面部署了 Cloudflare Bot Management:
www.printables.com/graphql/ → 403 Forbidden(Cloudflare challenge)
这不是简单的 User-Agent 检测,而是 TLS 指纹 (JA3/JA4) + HTTP/2 指纹 + JavaScript Challenge 的多层防护。即使用 curl_cffi 模拟 Chrome TLS 指纹,POST 到 www.printables.com/graphql/ 仍然被拦截。
问题3:关键发现 —— API 子域未设防
通过分析 SvelteKit 打包的 JS 模块中的 fetch() 调用,发现前端实际请求的是:
https://api.printables.com/graphql/ ← 这个端点没有 Cloudflare 保护!
api.printables.com 是一个独立的 API 服务(可能是 Django/DRF + Graphene),它返回正常的 GraphQL 响应而不是 403。这是整个修复的突破口。
问题4:GraphQL Schema 变更
通过 introspection 发现 STLType 不再有 filePath 字段(旧代码依赖这个字段),下载需要通过专门的 getDownloadLink mutation:
mutation GetDownloadLink($printId: ID!, $source: DownloadSourceEnum!,
$files: [DownloadFileInput!]) {
getDownloadLink(printId: $printId, source: $source, files: $files) {
ok
output { link ttl files { id link ttl fileType } }
}
}
关键限制:必须逐个文件请求,一次传多个 ID → ok: false。
问题5:下载超时挂起
engine.py 中 download_file() 的 session.get() 没有设置 timeout,当 CDN 响应慢(如 130MB 文件通过低带宽连接)时,整个进程无限阻塞,crawl job 永远停在 "running"。
问题6:超大文件阻塞
Printables 的 STL 文件可达 130MB+(高精度花瓶模型),服务器带宽约 4MB/s,单文件下载需要 30 秒以上。旧代码未按大小排序,会先尝试下载最大的文件。
二、解决方案
反爬虫绕过层(engine.py)
# 1. TLS 指纹伪装 —— curl_cffi 模拟真实浏览器的 JA3 指纹
self._session = cffi_requests.Session(impersonate="chrome124")
# 2. 遇到 403 时自动轮换浏览器指纹
browsers = ["chrome124", "chrome120", "chrome119", "safari17_0", "edge101"]
self._session = cffi_requests.Session(impersonate=random.choice(browsers))
# 3. 随机延迟模拟人类行为(8-20秒/请求)
delay = random.uniform(8, 20)
# 4. User-Agent 随机轮换(4个真实浏览器 UA)
"User-Agent": random.choice(USER_AGENTS)
# 5. 完整的 Sec-Fetch-* 请求头模拟
"Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate" ...
原理:Cloudflare 除了检查 HTTP 头之外,会分析 TLS ClientHello 中的密码套件列表、扩展顺序等生成 JA3 指纹。curl_cffi 基于 curl-impersonate 项目,在 libcurl 层面重写了 TLS 握手过程,使其与真实 Chrome 完全一致。
GraphQL 突破层(printables.py)
三步流程:
步骤1: prints(query, limit) → [{id, name, stls: [{id, name, fileSize}]}]
步骤2: getDownloadLink(printId, source, files) → {ok, output: {files: [{link}]}}
步骤3: GET link → 二进制 STL 文件
关键设计:
- 请求发到 api.printables.com/graphql/(非 www.printables.com)
- Headers 中 Origin: https://www.printables.com 通过 CORS 检查
- 每次 mutation 只传一个文件 ID
- mutation 之间加 0.5-1.5s 随机延迟防限流
- 文件按 fileSize 升序排序,优先下载小文件
- 超过 100MB 的文件直接跳过
- 全部失败时尝试 pack (ZIP) 下载作为兜底
下载稳定性(engine.py)
resp = session.get(url, headers=dl_headers, allow_redirects=True, timeout=120)
显式设置 120 秒超时,防止无限挂起。3 次重试,每次失败后随机等待 5-15 秒。
三、数据清洗算法详解(pipeline.py)
8 级流水线,任一阶段不通过即拒绝并归入 rejected/ 目录:
Stage 1: 文件大小检查
MIN_FILE_SIZE = 100 # < 100 字节 → 可能是空文件/错误页面
MAX_FILE_SIZE = 100 MB # > 100 MB → 超出处理能力
Stage 2: 文件完整性(Magic Bytes 校验)
针对每种 3D 格式检查文件头:
| 格式 | Magic Bytes | 额外校验 |
|---|---|---|
.stl binary |
80字节头 + uint32 面数 |
84 + face_count * 50 ≈ file_size(允许 10% 误差) |
.stl ASCII |
solid\n 开头 |
检查后续是否有 facet/vertex 关键字 |
.glb |
glTF (4 bytes) |
— |
.3mf |
PK (ZIP 格式) |
— |
.ply |
ply |
— |
.obj |
v/#/mtllib |
— |
原理:二进制 STL 结构为 [80字节头][4字节面数N][N × 50字节面片数据]。如果 file_size < 84 + N×50 × 0.9,说明文件被截断(下载不完整)。
Stage 3: Trimesh 网格加载
mesh = trimesh.load(str(path), force='mesh')
force='mesh' 确保即使是 Scene(含多个几何体)也强制合并为单一 Mesh。加载失败 → CORRUPT_FILE。
Stage 4: 网格结构验证
len(mesh.vertices) >= 4 # 最少 4 个顶点(一个四面体)
len(mesh.faces) > 0 # 必须有面片
Stage 5: 几何质量检查
退化面检测:
areas = mesh.area_faces # 每个三角面的面积
degenerate_count = np.sum(areas < 1e-10) # 面积 < 10^-10 视为退化
ratio = degenerate_count / total_faces
if ratio > 0.3: → REJECT # 超过 30% 退化 → 拒绝
原理:退化面(面积≈0)通常由以下原因产生:
- 三个顶点共线或重合
- 建模软件的数值精度问题
- 文件导出时的坐标截断
退化面不影响视觉效果但会导致布尔运算、切片打印等下游操作失败。
法线一致性检查:
nan_normals = np.sum(np.isnan(mesh.face_normals).any(axis=1))
if nan_normals > len(mesh.face_normals) * 0.1: → REJECT
NaN 法线说明面片的三个顶点完全共线(叉积为零向量),是数据损坏的强信号。
Stage 6: 复杂度边界检查
MIN_FACE_COUNT = 10 # < 10 面 → 噪声数据(不是有效模型)
MAX_FACE_COUNT = 2,000,000 # > 200 万面 → 超出 Web 端渲染能力
Stage 7: 内容哈希去重
h = hashlib.sha256()
h.update(mesh.vertices.tobytes()) # 顶点坐标的原始字节
h.update(mesh.faces.tobytes()) # 面片索引的原始字节
content_hash = h.hexdigest()
原理:不使用文件哈希,而是对几何内容做哈希。同一个模型即使存为不同格式(STL/OBJ/3MF)、不同文件头、不同注释,只要顶点和面片相同就会被检测为重复。比单纯的文件 MD5 更精准。
Stage 8: 格式转换 → GLB
glb_data = mesh.export(file_type='glb')
所有通过清洗的模型统一转为 GLB(Binary glTF) 格式,原因:
- Web 端 Three.js 原生支持
- 单文件包含几何 + 材质 + 纹理
- 二进制格式,体积比 ASCII STL 小 60-80%
- 支持 Draco 压缩扩展
流形检测(辅助指标)
edges = mesh.edges_sorted
unique, counts = np.unique(edges, axis=0, return_counts=True)
is_manifold = np.all(counts <= 2) # 每条边最多被 2 个面共享
原理:流形(manifold)是 3D 打印的基本要求。非流形边(被 3+ 个面共享)意味着网格在该位置有自相交或非物理结构。当前仅作为元数据记录,未用于拒绝。
四、我认为后续可优化的点
爬虫层
| 优化点 | 原理 | 难度 |
|---|---|---|
| 代理池轮换 | 单 IP 频繁请求容易触发速率限制,使用代理池分散请求源 | 中 |
| 并发下载 | 当前是串行下载每个模型,可用 asyncio.Semaphore 控制并发数(当前配置 MAX_CONCURRENT_DOWNLOADS=2,但未实际使用) |
低 |
| 增量爬取 | 记录已爬取的 model ID,下次只爬新增模型,避免重复工作 | 低 |
| GraphQL 批量查询 | 将多个 print(id:) 查询合并为一次请求(GraphQL alias),减少往返延迟 |
低 |
| Cookie 持久化 | 保存 session cookie 到磁盘,避免每次重启都需要新建会话 | 低 |
| 动态限流自适应 | 根据 429/403 响应频率自动调整 CRAWL_DELAY_RANGE |
中 |
清洗层
| 优化点 | 原理 | 难度 |
|---|---|---|
| 网格修复 | 使用 trimesh.repair.fill_holes() / fix_normals() 尝试修复非流形/法线翻转,而非直接拒绝 |
中 |
| Draco 压缩 | GLB 支持 Draco 扩展,可将几何数据压缩至原来的 10-20%(trimesh 支持 draco_compression) |
低 |
| LOD 生成 | 使用 pyfqmr 或 trimesh.simplify_quadric_decimation() 生成多级细节模型(100%/50%/10% 面数),提升 Web 端加载性能 |
中 |
| 感知哈希去重 | 当前的几何哈希对坐标变换敏感(旋转/平移/缩放后视为不同模型),可改用基于 形状描述子(如体积/表面积比、惯性矩特征值)的感知哈希 | 高 |
| 法线修复 + winding order 统一 | trimesh.repair.fix_winding() + fix_normals() 可自动统一法线朝向 |
低 |
| 水密性修复 | 对非水密模型尝试 fill_holes() → 重新检测,可提升 3D 打印可用性 |
中 |
| 材质/纹理保留 | 当前转 GLB 时丢弃材质信息,可保留 OBJ 的 MTL 和 3MF 的纹理贴图 | 高 |
| 异步清洗 | 当前清洗是同步阻塞的,大文件加载 trimesh 耗时较长,可用进程池 (ProcessPoolExecutor) 并行清洗 |
中 |
架构层
| 优化点 | 原理 | 难度 |
|---|---|---|
| 任务队列 | 用 Celery/RQ 替代当前的 asyncio.create_task(),支持任务持久化、失败重试、优先级调度 |
中 |
| 对象存储 | 当前文件存本地磁盘,可迁移到 MinIO/S3 + CDN,支持水平扩展 | 中 |
| Webhook 通知 | 任务完成后回调通知,而非前端轮询 | 低 |
| 模型预览图生成 | 用 pyrender 或 headless Three.js 自动生成缩略图,提升前端展示 |
中 |