一、我遇到的问题 & 根因分析

问题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 生成 使用 pyfqmrtrimesh.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 自动生成缩略图,提升前端展示