引言
很多做电商的朋友在问:"推荐个下载淘宝店铺和天猫店铺商品图片的软件"
做电商运营的朋友每天都要存大量的商品图片。主图要存、详情图要存、SKU颜色图要存、模特展示图要存......一个商品几十张图,手动右键保存效率太低。
市面上的工具不少,但真正好用、稳定、能下载高清原图的却不多。为什么有的工具用着用着就坏了?为什么有的工具能下载原图有的只能下缩略图?为什么淘宝一改版某些工具就不能用了?
这些问题背后的答案,都指向同一个核心------技术选型。
本文将从技术原理、实现方案、实测数据、选型建议等多个维度,全方位解析电商平台图片采集的技术路线,帮助你理解"为什么有的工具稳定,有的工具用不了多久就废了"。
目录
-
电商图片采集的核心需求分析
-
电商平台商品页面的技术结构
-
电商平台图片的URL格式与多尺寸版本
-
SKU图的识别与分类难点
-
电商平台反爬机制的完整演进
-
技术路线一:爬虫方案的深度剖析
-
技术路线二:浏览器插件方案的深度剖析
-
技术路线三:浏览器方案的深度剖析
-
三条路线的多维度实测对比
-
浏览器方案的技术实现细节
-
图片URL的完整处理链路
-
SKU图智能分类的完整算法
-
懒加载的完整处理方案
-
视频下载的完整技术实现
-
批量下载与任务队列设计
-
剪贴板监听与自动化流程
-
错误处理与重试机制
-
性能优化策略
-
各平台差异适配
-
火蚁一键存图的完整技术架构
-
各平台用户常见问题解答
-
最终总结
一、电商图片采集的核心需求分析
1.1 电商运营需要下载哪些图片?
电商运营日常需要下载的图片类型包括:
| 图片类型 | 典型数量 | 用途 | 重要性 |
|---|---|---|---|
| 主图 | 5-8张 | 商品轮播展示 | 极高 |
| SKU图(颜色/尺码图) | 5-20张 | 规格细节展示 | 高 |
| 详情图 | 5-30张 | 商品描述 | 高 |
| 模特展示图 | 2-10张 | 上身效果展示 | 中 |
| 主图视频 | 0-1个 | 动态展示 | 中 |
1.2 电商运营的核心痛点
| 痛点 | 具体表现 | 时间成本 |
|---|---|---|
| 操作繁琐 | 每张图需要右键-另存为-选位置-确认 | 每个商品5-10分钟 |
| 图片模糊 | 下载的是缩略图,放大就糊了 | 无法使用,需重新下载 |
| 分类困难 | 主图和颜色图混在一起 | 每个商品额外3-5分钟 |
| 视频难存 | 主图视频需要录屏 | 每个视频2-3分钟 |
| 工具不稳定 | 平台改版后工具失效 | 工作停摆1-7天 |
| 平台覆盖少 | 不支持抖音、亚马逊等 | 需要多套工具 |
1.3 好用的工具应该具备什么特征?
| 特征 | 说明 | 重要性 |
|---|---|---|
| 高清原图 | 下载的是原图而非缩略图 | 极高 |
| 自动分类 | 主图/SKU图/详情图自动分文件夹 | 极高 |
| 稳定可靠 | 不受平台改版影响 | 极高 |
| 操作简单 | 复制链接即可下载 | 高 |
| 视频下载 | 直接下载原画质视频 | 中 |
| 平台覆盖广 | 支持淘宝、京东、拼多多、抖音、亚马逊等 | 高 |
| 安全可靠 | 不收集用户数据 | 高 |
1.4 时间成本量化分析
手动保存一个商品的完整流程:
-
打开商品页面(10秒)
-
右键保存主图5张(10秒)
-
往下滚动找到颜色图,右键保存(20秒)
-
往下滚动找到详情图,右键保存(30秒)
-
创建文件夹,命名(10秒)
-
将所有图片拖入文件夹(10秒)
-
筛选分类主图/颜色图/详情图(60秒)
-
重命名所有图片(60秒)
总计:约3.5-5分钟/商品
一天处理30个商品:1.75-2.5小时
使用专业工具后:
-
复制链接(2秒)
-
粘贴到软件(2秒)
-
勾选下载(3秒)
-
自动分类(0秒)
-
自动命名(0秒)
总计:约7秒/商品
一天处理30个商品:约3.5分钟
效率提升:30-40倍
二、电商平台商品页面的技术结构
2.1 淘宝商品页面的DOM结构
淘宝商品页面的DOM结构经历了多次演进:
主图区域的典型DOM结构:
html
<div class="tb-main-pic">
<div class="J_UlThumb">
<ul class="tb-thumb">
<li class="tb-thumb-item">
<img src="//img.alicdn.com/xxx_50x50.jpg"
data-src="//img.alicdn.com/xxx_50x50.jpg"
alt="商品主图1">
</li>
<!-- 通常共5张 -->
</ul>
<div class="tb-main-img">
<img class="J_zoomPic" src="//img.alicdn.com/xxx.jpg">
</div>
</div>
</div>
SKU图区域的典型DOM结构:
html
<div class="tb-sku" data-property="颜色">
<div class="tb-sku-title">颜色分类</div>
<div class="tb-sku-list">
<div class="sku-item J_skuItem" data-value="红色">
<img src="//img.alicdn.com/red_50x50.jpg" alt="红色">
<span class="sku-name">红色</span>
</div>
<!-- 更多颜色 -->
</div>
</div>
详情图区域的典型DOM结构:
html
<div id="description" class="tb-detail">
<div class="desc">
<img src="//img.alicdn.com/detail_1.jpg"
data-src="//img.alicdn.com/detail_1.jpg"
alt="商品详情1">
<!-- 更多详情图 -->
</div>
</div>
2.2 京东商品页面的DOM结构
html
<!-- 京东主图结构 -->
<div class="spec-img">
<img src="//img13.360buyimg.com/n1/xxx.jpg">
</div>
<!-- 京东SKU结构 -->
<div class="sku-img-list">
<div class="sku-img-item" title="红色">
<img src="//img13.360buyimg.com/red_thumb.jpg">
</div>
</div>
<!-- 京东详情结构 -->
<div id="detail">
<div class="detail-content">
<img src="//img13.360buyimg.com/detail_1.jpg">
</div>
</div>
2.3 拼多多商品页面的DOM结构
html
<!-- 拼多多主图结构 -->
<div class="main-image">
<img src="//img.pddpic.com/xxx.jpg">
</div>
<!-- 拼多多SKU结构 -->
<div class="sku-list">
<div class="sku-item" data-value="红色">
<img src="//img.pddpic.com/red_thumb.jpg">
<span class="sku-name">红色</span>
</div>
</div>
<!-- 拼多多详情结构 -->
<div class="detail-content">
<img src="//img.pddpic.com/detail_1.jpg">
</div>
2.4 不同平台的结构差异对比
| 平台 | 主图容器 | SKU容器 | 详情容器 | 图片格式 |
|---|---|---|---|---|
| 淘宝 | .J_UlThumb |
.tb-sku |
#description |
jpg/png |
| 天猫 | .tb-thumb |
.J_sku |
.desc |
jpg/png |
| 京东 | .spec-img |
.sku-img-list |
#detail |
jpg |
| 拼多多 | .main-image |
.sku-list |
.detail-content |
webp/jpg |
三、电商平台图片的URL格式与多尺寸版本
3.1 淘宝图片的多个尺寸版本
淘宝在CDN上存储了多个尺寸版本:
| URL格式 | 分辨率 | 文件大小 | 使用场景 |
|---|---|---|---|
xxx_50x50.jpg |
50x50 | 5-15KB | 最小缩略图 |
xxx_100x100.jpg |
100x100 | 15-30KB | 列表页 |
xxx_200x200.jpg |
200x200 | 30-60KB | 搜索结果 |
xxx_400x400.jpg |
400x400 | 60-120KB | 详情页缩略 |
xxx_800x800.jpg |
800x800 | 200-400KB | 大图展示 |
xxx.jpg |
最大分辨率 | 300KB-2MB | 原图 |
3.2 京东图片的尺寸版本
| URL格式 | 含义 | 分辨率 |
|---|---|---|
xxx_n1.jpg |
缩略图1 | 较小 |
xxx_n2.jpg |
缩略图2 | 中等 |
xxx_n0.jpg |
原图 | 最大 |
3.3 拼多多图片的尺寸版本
| URL格式 | 含义 | 分辨率 |
|---|---|---|
xxx_100x100.jpg |
缩略图 | 100x100 |
xxx_200x200.jpg |
中等图 | 200x200 |
xxx.jpg |
原图 | 最大 |
3.4 原图URL的转换规则
淘宝原图转换:
javascript
function getTaobaoOriginalUrl(url) {
if (!url) return null;
// 去除URL参数
url = url.split('?')[0];
// 去除尺寸后缀:_50x50.jpg -> .jpg
url = url.replace(/_\d+x\d+\./g, '.');
// 去除sum后缀
url = url.replace(/\.sum\./g, '.');
return url;
}
转换示例:
| 输入URL | 输出URL |
|---|---|
https://img.alicdn.com/xxx_100x100.jpg |
https://img.alicdn.com/xxx.jpg |
https://img.alicdn.com/xxx_400x400.jpg |
https://img.alicdn.com/xxx.jpg |
https://img.alicdn.com/xxx.sum.jpg |
https://img.alicdn.com/xxx.jpg |
京东原图转换:
javascript
function getJdOriginalUrl(url) {
if (!url) return null;
url = url.split('?')[0];
url = url.replace(/\/n\d\//, '/n0/');
return url;
}
拼多多原图转换:
javascript
function getPddOriginalUrl(url) {
if (!url) return null;
url = url.split('?')[0];
url = url.replace(/_\d+x\d+\./g, '.');
url = url.replace(/\.webp$/i, '.jpg');
return url;
}
3.5 为什么很多工具只能下载缩略图?
爬虫方案直接解析HTML,获取到的往往是缩略图地址(因为缩略图出现在HTML中的概率更高)。浏览器方案等页面完全加载后从DOM中获取的则是原图地址。
| 方案 | 获取的图片类型 | 原因 |
|---|---|---|
| 爬虫方案 | 缩略图 | 直接解析HTML,拿到的就是缩略图地址 |
| 浏览器方案 | 原图 | 页面完全加载后,从完整DOM中获取原图地址 |
四、SKU图的识别与分类难点
4.1 SKU图的特点
SKU图是电商商品中最难处理的图片类型:
| 特点 | 说明 |
|---|---|
| 数量多 | 一个商品可能5-20张SKU图 |
| 名称关联 | 每张图关联一个规格名称(红色、蓝色、S码等) |
| 位置固定 | 位于特定容器内 |
| 格式统一 | 通常是缩略图,需要转换原图 |
4.2 SKU图分类的技术难点
javascript
function extractSkuName(item) {
// 第一优先级:专用名称元素
const nameEl = item.querySelector('.sku-name, .J_skuName');
if (nameEl) return nameEl.textContent.trim();
// 第二优先级:data属性
const dataValue = item.getAttribute('data-value');
if (dataValue) return dataValue;
// 第三优先级:title属性
const title = item.getAttribute('title');
if (title) return title;
// 第四优先级:内部文本
const text = item.textContent.trim();
if (text) return text;
return '规格';
}
4.3 分类后的存储结构
text
商品标题/
├── 主图/
│ ├── 主图_1.jpg
│ ├── 主图_2.jpg
│ └── 主图_3.jpg
├── SKU图/
│ ├── 红色.jpg
│ ├── 蓝色.jpg
│ ├── S码.jpg
│ ├── M码.jpg
│ └── L码.jpg
└── 详情图/
├── 详情图_1.jpg
└── 详情图_2.jpg
4.4 SKU图分类的业务价值
对于服装、鞋包等类目的电商运营来说,SKU图自动分类是刚需功能。一个商品通常有多个颜色和尺码,每个规格对应独立的细节图。手动分类需要逐个对照商品页面,耗时且容易出错。
通过自动分类,SKU图按以下结构归档,用户无需再手动筛选和命名。
五、电商平台反爬机制的完整演进
5.1 反爬机制的时间线
| 时期 | 反爬手段 | 对工具的影响 |
|---|---|---|
| 2010-2015 | User-Agent检测 | 换UA就能绕过 |
| 2015-2018 | 签名参数(_tb_token等) | 需要逆向JS |
| 2018-2020 | 动态令牌+行为验证 | 模拟请求难以通过 |
| 2020-2023 | 浏览器指纹检测 | 需要真实浏览器环境 |
| 2023-2026 | 指纹+行为轨迹分析 | 几乎无法用纯HTTP请求模拟 |
5.2 浏览器指纹检测
电商平台会在页面中执行以下检测:
javascript
function detectBrowserFingerprint() {
const checks = {};
// Canvas指纹
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('指纹', 2, 15);
checks.canvasFingerprint = canvas.toDataURL();
// WebGL指纹
const gl = document.createElement('canvas').getContext('webgl');
if (gl) {
checks.webglRenderer = gl.getParameter(gl.RENDERER);
checks.webglVendor = gl.getParameter(gl.VENDOR);
}
// 字体指纹
const fontList = ['Arial', 'Verdana', 'Times New Roman', 'Helvetica', 'Courier New'];
const availableFonts = fontList.filter(font => {
return document.createElement('span').style.fontFamily === font;
});
checks.availableFonts = availableFonts;
// 检测是否为自动化环境
checks.isWebDriver = navigator.webdriver === true;
checks.pluginsCount = navigator.plugins.length;
checks.languages = navigator.languages;
return checks;
}
5.3 TLS指纹检测
不同客户端的TLS指纹特征:
| 客户端 | TLS库 | JA3指纹特征 | 检测结果 |
|---|---|---|---|
| Chrome | BoringSSL | 真实Chrome指纹 | ✅ 正常 |
| Python requests | OpenSSL | 爬虫指纹 | ❌ 识别 |
| Java HttpClient | OpenSSL | 爬虫指纹 | ❌ 识别 |
5.4 IP频率限制
电商平台会对IP的请求频率进行监控:
python
class IPRateLimiter:
def __init__(self):
self.request_records = {}
def check(self, ip):
now = time.time()
records = self.request_records.get(ip, [])
records = [t for t in records if now - t < 60]
if len(records) > 30:
return True, "IP被限流"
records.append(now)
self.request_records[ip] = records
return False, "正常"
5.5 反爬机制对工具的影响
| 工具类型 | 受影响程度 | 说明 |
|---|---|---|
| 爬虫方案 | 严重 | TLS指纹+浏览器指纹双重检测,基本无法正常工作 |
| 浏览器插件 | 中等 | 运行在真实Chrome中,指纹检测通过,但依赖Chrome版本 |
| 浏览器方案 | 无 | 运行在独立Chromium中,完全真实浏览器环境 |
六、技术路线一:爬虫方案的深度剖析
6.1 工作原理
爬虫方案的核心思路是绕过浏览器,直接向电商平台服务器发送HTTP请求:
python
import requests
from bs4 import BeautifulSoup
def fetch_taobao_product(url):
headers = {'User-Agent': 'Mozilla/5.0...'}
resp = requests.get(url, headers=headers)
soup = BeautifulSoup(resp.text, 'html.parser')
# 依赖淘宝的CSS选择器(脆弱!)
img_urls = soup.select('.J_UlThumb img')
return [img.get('src') for img in img_urls]
6.2 爬虫方案的五大问题
问题一:TLS指纹检测
Python的requests库使用OpenSSL,TLS指纹特征明显,电商平台能轻松识别。
问题二:无法执行JavaScript
电商平台商品页的很多图片URL是动态生成的,爬虫拿不到。
问题三:强依赖DOM结构
电商平台改版后CSS类名变化,爬虫立刻失效。
问题四:IP频率限制
短时间内大量请求会被封IP,需要维护代理池。
问题五:验证码
异常请求会触发验证码,无法自动处理。
6.3 爬虫方案的维护成本
| 成本项 | 说明 | 月均成本 |
|---|---|---|
| 服务器 | 运行爬虫程序 | $50-200 |
| IP代理池 | 应对IP封禁 | $50-150 |
| 人力维护 | 应对平台改版 | $100-500 |
| 月均成本 | $200-850 | |
| 成功率 | 70-80% |
6.4 爬虫方案的典型问题案例
案例:淘宝2024年改版事件
2024年,淘宝对商品详情页进行了大规模改版。改版前后的变化:
| 版本 | 主图容器类名 | SKU容器类名 |
|---|---|---|
| 改版前 | .J_UlThumb |
.tb-sku |
| 改版后 | .tb-thumb |
.J_sku |
这次改版导致市面上一大批爬虫工具失效了3-7天,开发者紧急修复,用户只能等待。
七、技术路线二:浏览器插件方案的深度剖析
7.1 工作原理
浏览器插件方案寄生在Chrome浏览器中,利用Chrome的渲染能力获取页面内容。
javascript
// Chrome Extension 内容脚本
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.type === 'EXTRACT_IMAGES') {
const images = [];
document.querySelectorAll('img').forEach(img => {
const url = img.src || img.getAttribute('data-src');
if (url) images.push(url);
});
sendResponse({ images: images });
}
});
7.2 浏览器插件方案的优势
-
运行在真实Chrome环境,反爬检测通过
-
完整的JS执行能力
-
用户安装方便
7.3 浏览器插件方案的四大问题
问题一:依赖Chrome版本
Chrome每几周更新一次,Extension API可能变化,插件可能失效。
| Chrome版本 | Extension API变化 | 影响 |
|---|---|---|
| 120→121 | chrome.tabs.executeScript 参数变化 |
部分功能失效 |
| 121→122 | Manifest V3强制要求 | 需重写插件 |
| 122→123 | Service Worker生命周期变化 | 后台脚本失效 |
问题二:权限过大
需要申请读取所有网页数据的权限,用户信任度低。
问题三:性能受限
运行在Chrome渲染进程里,下载大量图片时会卡顿。
问题四:Manifest V3限制
从2024年开始,Google强制推行Manifest V3,Extension的能力被大幅限制。
八、技术路线三:浏览器方案的深度剖析
8.1 什么是浏览器方案?
浏览器方案基于Chromium开源项目,封装成独立的桌面应用。
Chromium是Google开源的浏览器内核项目,Chrome、Edge、Opera等浏览器都基于它开发。CEF(Chromium Embedded Framework)是将Chromium嵌入桌面应用的成熟框架。
8.2 浏览器方案的架构
text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 桌面客户端 │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Chromium Embedded Framework │ │
│ │ (CEF / Chromium内核) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 业务层 │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │URL加载 │ │DOM提取 │ │智能分类 │ │图片处理 │ │视频处理 │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 功能层 │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │剪贴板 │ │自动分类 │ │原图转换 │ │批量下载 │ │历史记录 │ │ │
│ │ │监听 │ │ │ │ │ │ │ │ │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
8.3 浏览器方案的优势
| 对比维度 | 爬虫方案 | 浏览器插件 | 浏览器方案 |
|---|---|---|---|
| 浏览器指纹 | ❌ 没有 | ✅ 有(依赖Chrome) | ✅ 有(独立) |
| 页面渲染 | ❌ 不渲染 | ✅ Chrome渲染 | ✅ 自己渲染 |
| JS执行 | ❌ 不执行 | ✅ Chrome执行 | ✅ 自己执行 |
| 平台改版影响 | ❌ 强依赖 | ⚠️ 可能受影响 | ✅ 完全不受影响 |
| 独立运行 | ✅ | ❌ 必须开Chrome | ✅ |
8.4 浏览器方案的技术实现
CEF初始化:
cpp
#include "include/cef_app.h"
class SimpleApp : public CefApp {
public:
void OnBeforeCommandLineProcessing(
const CefString& process_type,
CefRefPtr<CefCommandLine> command_line) override {
command_line->AppendSwitch("disable-gpu");
command_line->AppendSwitch("disable-plugins");
command_line->AppendSwitch("remote-debugging-port=0");
command_line->AppendSwitch("disable-blink-features=AutomationControlled");
command_line->AppendSwitchWithValue(
"user-agent",
"Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36"
);
}
};
页面等待策略:
javascript
async function waitForPageReady() {
// 第一重:等待DOM就绪
while (document.readyState !== 'complete') {
await sleep(200);
}
// 第二重:等待网络空闲
let idleCount = 0;
while (idleCount < 2) {
const activeRequests = performance.getEntriesByType('resource')
.filter(r => r.duration === 0).length;
if (activeRequests === 0) idleCount++;
else idleCount = 0;
await sleep(500);
}
// 第三重:等待jQuery
while (typeof jQuery === 'undefined') {
await sleep(100);
}
// 第四重:触发懒加载
await triggerLazyLoad();
// 第五重:额外等待
await sleep(1000);
}
SKU图提取:
javascript
function extractSkuImages() {
const container = document.querySelector('.tb-sku, .J_sku');
if (!container) return [];
const items = container.querySelectorAll('.sku-item, .J_skuItem');
const results = [];
for (const item of items) {
const nameEl = item.querySelector('.sku-name, .J_skuName');
const name = nameEl ? nameEl.textContent.trim() : '规格';
const img = item.querySelector('img');
if (img) {
let url = img.src || img.getAttribute('data-src');
url = url.split('?')[0];
url = url.replace(/_\d+x\d+\./g, '.');
results.push({ url, name });
}
}
return results;
}
九、三条路线的多维度实测对比
9.1 平台改版影响
| 维度 | 爬虫方案 | 浏览器插件 | 浏览器方案 |
|---|---|---|---|
| 依赖解析规则 | 是 | 是 | 否 |
| 平台改版影响 | 失效1-7天 | 可能失效 | 无影响 |
| 恢复时间 | 1-7天 | 1-3天 | 0天 |
9.2 采集成功率
测试条件:连续采集500个淘宝商品
| 方案 | 采集成功率 | 失败原因 |
|---|---|---|
| 爬虫方案 | 70-80% | TLS指纹识别、验证码、IP封禁 |
| 浏览器插件 | 85-90% | Chrome版本兼容、权限限制 |
| 浏览器方案 | 99%+ | 极少数因网络问题失败 |
9.3 各维度综合对比
| 维度 | 爬虫方案 | 浏览器插件 | 浏览器方案 |
|---|---|---|---|
| 技术路线 | 模拟HTTP请求 | Chrome扩展 | 定制浏览器 |
| 反爬风险 | 高 | 中 | 无 |
| 改版影响 | 严重 | 中等 | 无 |
| 平台覆盖 | 窄 | 中 | 广 |
| 视频下载 | 困难 | 部分支持 | ✅ 完整支持 |
| SKU自动分类 | ❌ | 部分 | ✅ |
| 维护成本 | 高 | 中 | 低 |
| 稳定性 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
9.4 成本对比
| 成本项 | 爬虫方案 | 浏览器插件 | 浏览器方案 |
|---|---|---|---|
| 开发成本 | 低 | 中 | 高 |
| 维护成本 | 高 | 中 | 低 |
| 服务器成本 | 中 | 低 | 低 |
| 代理IP成本 | 高 | 低 | 无 |
| 总成本(年) | 高 | 中 | 低 |
十、浏览器方案技术实现详解
10.1 CEF框架完整初始化
cpp
// main.cpp - 完整CEF初始化
#include "include/cef_app.h"
#include "include/cef_client.h"
#include "include/cef_browser.h"
#include "include/wrapper/cef_helpers.h"
#include "include/wrapper/cef_closure_task.h"
#include <iostream>
#include <string>
#include <chrono>
#include <thread>
// 日志输出
#define LOG(msg) std::cout << "[LOG] " << msg << std::endl
class SimpleApp : public CefApp {
public:
void OnBeforeCommandLineProcessing(
const CefString& process_type,
CefRefPtr<CefCommandLine> command_line) override {
// 禁用GPU加速(降低资源占用约20%)
command_line->AppendSwitch("disable-gpu");
// 禁用插件
command_line->AppendSwitch("disable-plugins");
// 禁用远程调试(避免WebDriver检测)
command_line->AppendSwitch("remote-debugging-port=0");
// 禁用自动化控制特征(避免被识别为自动化工具)
command_line->AppendSwitch("disable-blink-features=AutomationControlled");
// 设置缓存目录
command_line->AppendSwitchWithValue("disk-cache-dir", "./cache");
// 设置User-Agent为真实Chrome
command_line->AppendSwitchWithValue(
"user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"Chrome/120.0.0.0 Safari/537.36"
);
// 禁用Web安全策略(允许跨域提取素材)
command_line->AppendSwitch("disable-web-security");
// 设置语言
command_line->AppendSwitchWithValue("lang", "zh-CN");
LOG("CEF命令行配置完成");
}
IMPLEMENT_REFCOUNTING(SimpleApp);
};
class BrowserClient : public CefClient,
public CefLifeSpanHandler,
public CefLoadHandler,
public CefDisplayHandler {
public:
BrowserClient() : loading_complete_(false), load_success_(false) {}
// 生命周期处理器
CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler() override { return this; }
CefRefPtr<CefLoadHandler> GetLoadHandler() override { return this; }
CefRefPtr<CefDisplayHandler> GetDisplayHandler() override { return this; }
// OnAfterCreated - 浏览器创建完成
void OnAfterCreated(CefRefPtr<CefBrowser> browser) override {
CEF_REQUIRE_UI_THREAD();
browser_ = browser;
loading_complete_ = false;
load_success_ = false;
LOG("浏览器创建完成");
}
// OnLoadingStateChange - 加载状态变化
void OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
bool isLoading,
bool canGoBack,
bool canGoForward) override {
CEF_REQUIRE_UI_THREAD();
if (!isLoading) {
loading_complete_ = true;
LOG("页面加载完成");
// 触发JS提取
ExtractPageContent(browser);
}
}
// OnLoadEnd - 加载结束
void OnLoadEnd(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
int httpStatusCode) override {
CEF_REQUIRE_UI_THREAD();
if (frame->IsMain() && httpStatusCode == 200) {
load_success_ = true;
LOG("主页面加载成功,状态码: 200");
} else if (frame->IsMain()) {
LOG("主页面加载失败,状态码: " + std::to_string(httpStatusCode));
}
}
// 页面加载失败
void OnLoadError(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
ErrorCode errorCode,
const CefString& errorText,
const CefString& failedUrl) override {
CEF_REQUIRE_UI_THREAD();
LOG("页面加载错误: " + errorText.ToString());
load_success_ = false;
}
// OnConsoleMessage - 控制台消息(用于调试)
bool OnConsoleMessage(CefRefPtr<CefBrowser> browser,
cef_log_severity_t level,
const CefString& message,
const CefString& source,
int line) override {
// 只输出错误信息
if (level == LOGSEVERITY_ERROR) {
LOG("JS错误: " + message.ToString() + " (行 " + std::to_string(line) + ")");
}
return false;
}
bool WaitForLoad(int timeout_seconds = 15) {
auto start = std::chrono::steady_clock::now();
while (!loading_complete_) {
auto elapsed = std::chrono::steady_clock::now() - start;
if (elapsed > std::chrono::seconds(timeout_seconds)) {
LOG("页面加载超时");
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return load_success_;
}
// 执行JS提取页面内容
void ExtractPageContent(CefRefPtr<CefBrowser> browser) {
CefRefPtr<CefFrame> frame = browser->GetMainFrame();
if (!frame) {
LOG("获取主框架失败");
return;
}
std::string script = GetExtractScript();
frame->ExecuteJavaScript(script, frame->GetURL(), 0);
LOG("JS提取脚本已执行");
}
// 获取提取脚本
std::string GetExtractScript() {
return R"(
(function() {
// 获取原图URL
function getOriginalUrl(url) {
if (!url) return null;
if (url.startsWith('data:')) return null;
if (url.includes('1x1') || url.includes('blank.gif')) return null;
url = url.split('?')[0];
url = url.replace(/_\d+x\d+\./g, '.');
url = url.replace(/\.sum\./g, '.');
url = url.replace(/\.webp$/i, '.jpg');
return url;
}
// 提取主图
function extractMainImages() {
const images = [];
const seen = new Set();
const selectors = ['.J_UlThumb', '.tb-thumb', '.tb-main-pic'];
for (const selector of selectors) {
const container = document.querySelector(selector);
if (container) {
const imgs = container.querySelectorAll('img');
imgs.forEach(img => {
let url = img.src || img.getAttribute('data-src');
if (url) {
url = getOriginalUrl(url);
if (url && !seen.has(url)) {
seen.add(url);
images.push(url);
}
}
});
break;
}
}
return images;
}
// 提取SKU图
function extractSkuImages() {
const skuImages = [];
const container = document.querySelector('.tb-sku, .J_sku');
if (container) {
const items = container.querySelectorAll('.sku-item, .J_skuItem');
items.forEach(item => {
const nameEl = item.querySelector('.sku-name, .J_skuName');
const name = nameEl ? nameEl.textContent.trim() : '规格';
const img = item.querySelector('img');
if (img) {
let url = img.src || img.getAttribute('data-src');
if (url) {
url = getOriginalUrl(url);
skuImages.push({ url: url, name: name });
}
}
});
}
return skuImages;
}
// 提取详情图
function extractDetailImages() {
const images = [];
const seen = new Set();
const container = document.querySelector('#description, .desc');
if (container) {
const imgs = container.querySelectorAll('img');
imgs.forEach(img => {
let url = img.src || img.getAttribute('data-src');
if (url) {
url = getOriginalUrl(url);
if (url && !seen.has(url)) {
seen.add(url);
images.push(url);
}
}
});
}
return images;
}
// 提取视频
function extractVideo() {
const video = document.querySelector('#J_ItemVideo video, .tb-video video');
if (video && video.src) {
return { url: video.src, type: video.src.endsWith('.mp4') ? 'mp4' : 'm3u8' };
}
return null;
}
// 提取标题
function extractTitle() {
const el = document.querySelector('.tb-main-title, .J_mainTitle, h1');
if (el && el.textContent) {
const title = el.textContent.trim();
if (title.length > 3) return title;
}
return document.title || '未命名商品';
}
// 返回结果
const result = {
title: extractTitle(),
mainImages: extractMainImages(),
skuImages: extractSkuImages(),
detailImages: extractDetailImages(),
video: extractVideo()
};
// 通过window对象传递结果
window.__extractResult = result;
// 控制台输出结果(用于调试)
console.log('提取完成:', result);
return result;
})();
)";
}
// 获取提取结果
CefRefPtr<CefV8Value> GetExtractResult() {
CefRefPtr<CefFrame> frame = browser_->GetMainFrame();
if (!frame) return nullptr;
std::string script = "window.__extractResult";
return frame->ExecuteJavaScript(script, "", 0);
}
private:
CefRefPtr<CefBrowser> browser_;
bool loading_complete_;
bool load_success_;
IMPLEMENT_REFCOUNTING(BrowserClient);
};
int main(int argc, char* argv[]) {
CefMainArgs main_args(argc, argv);
CefRefPtr<SimpleApp> app(new SimpleApp());
CefSettings settings;
settings.no_sandbox = true;
settings.windowless_rendering_enabled = true;
settings.multi_threaded_message_loop = true;
settings.log_severity = LOGSEVERITY_ERROR;
if (!CefInitialize(main_args, settings, app, nullptr)) {
LOG("CEF初始化失败");
return -1;
}
CefWindowInfo window_info;
window_info.SetAsWindowless(0);
window_info.width = 1024;
window_info.height = 768;
CefBrowserSettings browser_settings;
browser_settings.javascript = STATE_ENABLED;
browser_settings.image_loading = STATE_ENABLED;
browser_settings.webgl = STATE_DISABLED;
browser_settings.plugins = STATE_DISABLED;
CefRefPtr<BrowserClient> client(new BrowserClient());
CefBrowserHost::CreateBrowserSync(
window_info, client,
"https://item.taobao.com/xxx.html",
browser_settings, nullptr, nullptr);
// 等待加载完成
if (client->WaitForLoad(15)) {
LOG("页面加载成功,开始提取");
// 提取结果已通过JS自动完成
} else {
LOG("页面加载失败");
}
CefRunMessageLoop();
CefShutdown();
return 0;
}
10.2 剪贴板监听实现
cpp
// clipboard_monitor.h
#ifndef CLIPBOARD_MONITOR_H
#define CLIPBOARD_MONITOR_H
#include <windows.h>
#include <string>
#include <functional>
#include <thread>
#include <atomic>
#include <chrono>
class ClipboardMonitor {
public:
ClipboardMonitor() : running_(false), hwnd_(nullptr), next_viewer_(nullptr) {}
bool Initialize(HWND hwnd) {
hwnd_ = hwnd;
next_viewer_ = SetClipboardViewer(hwnd);
if (next_viewer_) {
running_ = true;
LOG("剪贴板监听已启动");
return true;
}
LOG("剪贴板监听启动失败");
return false;
}
void Shutdown() {
running_ = false;
if (hwnd_) {
ChangeClipboardChain(hwnd_, next_viewer_);
hwnd_ = nullptr;
}
LOG("剪贴板监听已关闭");
}
void SetCallback(std::function<void(const std::string&)> callback) {
callback_ = callback;
}
void StartPolling(int interval_ms = 500) {
if (polling_thread_.joinable()) return;
polling_thread_ = std::thread([this, interval_ms]() {
std::string last_text;
while (running_) {
std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms));
std::string current_text = GetClipboardText();
if (current_text.empty() || current_text == last_text) continue;
last_text = current_text;
std::string url = DetectUrl(current_text);
if (!url.empty() && callback_) {
callback_(url);
}
}
});
}
void StopPolling() {
running_ = false;
if (polling_thread_.joinable()) {
polling_thread_.join();
}
}
private:
std::string GetClipboardText() {
if (!OpenClipboard(hwnd_)) return "";
HANDLE hData = GetClipboardData(CF_TEXT);
if (!hData) {
CloseClipboard();
return "";
}
char* pszText = static_cast<char*>(GlobalLock(hData));
if (!pszText) {
CloseClipboard();
return "";
}
std::string text(pszText);
GlobalUnlock(hData);
CloseClipboard();
return text;
}
std::string DetectUrl(const std::string& text) {
if (text.empty()) return "";
const std::vector<std::string> patterns = {
"taobao.com/item.htm",
"tmall.com/item.htm",
"jd.com/",
".jd.com/",
"yangkeduo.com/goods.html",
"douyin.com/product",
"1688.com/offer",
"detail.1688.com",
"amazon.com/dp/",
"amazon.com/product/"
};
for (const auto& pattern : patterns) {
if (text.find(pattern) != std::string::npos) {
// 提取完整的URL
size_t start = text.find("http");
if (start == std::string::npos) {
start = text.find("https");
}
if (start != std::string::npos) {
size_t end = text.find_first_of(" \t\n\r", start);
if (end == std::string::npos) {
return text.substr(start);
}
return text.substr(start, end - start);
}
return text;
}
}
return "";
}
std::atomic<bool> running_;
HWND hwnd_;
HWND next_viewer_;
std::function<void(const std::string&)> callback_;
std::thread polling_thread_;
void LOG(const std::string& msg) {}
};
#endif
十一、图片URL的完整处理链路
11.1 URL处理流程
javascript
class ImageUrlProcessor {
constructor() {
this.urlCache = new Map();
this.processedCount = 0;
this.failedCount = 0;
this.maxCacheSize = 1000;
}
process(url) {
// 1. 验证URL
if (!this.isValidUrl(url)) {
this.failedCount++;
return null;
}
// 2. 检查缓存
const cacheKey = this.getCacheKey(url);
if (this.urlCache.has(cacheKey)) {
return this.urlCache.get(cacheKey);
}
// 3. 转换为原图
let processed = this.convertToOriginal(url);
// 4. 缓存结果
if (this.urlCache.size >= this.maxCacheSize) {
const firstKey = this.urlCache.keys().next().value;
this.urlCache.delete(firstKey);
}
this.urlCache.set(cacheKey, processed);
this.processedCount++;
return processed;
}
isValidUrl(url) {
if (!url) return false;
if (url.startsWith('data:')) return false;
if (url.includes('1x1') || url.includes('blank.gif')) return false;
if (url.includes('placeholder') || url.includes('loading')) return false;
if (!url.startsWith('http')) return false;
// 检查是否为图片格式
const imgExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
const lowerUrl = url.toLowerCase();
for (const ext of imgExts) {
if (lowerUrl.includes(ext)) return true;
}
return false;
}
getCacheKey(url) {
// 使用URL的hash作为缓存键
let hash = 0;
for (let i = 0; i < url.length; i++) {
hash = ((hash << 5) - hash) + url.charCodeAt(i);
hash = hash & hash;
}
return 'img_' + Math.abs(hash);
}
convertToOriginal(url) {
let result = url.split('?')[0];
// 淘宝/天猫:去除尺寸后缀
result = result.replace(/_\d+x\d+\./g, '.');
result = result.replace(/\.sum\./g, '.');
// 京东:n1/n2 -> n0
result = result.replace(/\/n\d\//, '/n0/');
result = result.replace(/\/popWaterMark\//, '/');
// 亚马逊:去除尺寸参数
result = result.replace(/\._[A-Z]+_\d+_\./g, '.');
result = result.replace(/\._SR\d+_\d+_\./g, '.');
// 拼多多:webp转jpg
result = result.replace(/\.webp$/i, '.jpg');
// 通用:去除缩略图标识
result = result.replace(/_thumb\./g, '.');
result = result.replace(/\.small\./g, '.');
result = result.replace(/\.medium\./g, '.');
return result;
}
getStats() {
return {
processed: this.processedCount,
failed: this.failedCount,
cacheSize: this.urlCache.size,
hitRate: this.urlCache.size > 0 ?
(this.processedCount - this.failedCount) / this.processedCount : 0
};
}
}
十二、SKU图智能分类的完整算法
12.1 SKU分类器完整实现
javascript
class SkuClassifier {
constructor() {
this.containerSelectors = [
'.tb-sku', '.J_sku', // 淘宝/天猫
'.sku-img-list', '.J_skuImgList', // 京东
'.sku-list', '.J_skuList', // 拼多多
'.sku-list', '.attribute-list' // 1688
];
this.nameSelectors = [
'.sku-name', '.J_skuName',
'.tb-sku-name', '.attr-name',
'.property-name', '.spec-name'
];
this.itemSelectors = [
'.sku-item', '.J_skuItem',
'.sku-img-item', '.attribute-item',
'.spec-item', '[data-value]'
];
this.platformConfig = {
'taobao': { container: '.tb-sku, .J_sku', item: '.sku-item, .J_skuItem', name: '.sku-name, .J_skuName' },
'jd': { container: '.sku-img-list, .J_skuImgList', item: '.sku-img-item', name: 'title' },
'pdd': { container: '.sku-list, .J_skuList', item: '.sku-item', name: '.sku-name' },
'1688': { container: '.sku-list, .attribute-list', item: '.sku-item, .attribute-item', name: '.sku-name, .attr-name' }
};
}
detectPlatform() {
const host = location.hostname || '';
if (host.includes('taobao.com') || host.includes('tmall.com')) return 'taobao';
if (host.includes('jd.com')) return 'jd';
if (host.includes('yangkeduo.com') || host.includes('pinduoduo.com')) return 'pdd';
if (host.includes('1688.com')) return '1688';
if (host.includes('amazon.com')) return 'amazon';
return 'unknown';
}
extract(platform) {
const config = this.platformConfig[platform] || {};
const containerSelectors = config.container ? [config.container] : this.containerSelectors;
const container = this.findContainer(containerSelectors);
if (!container) return [];
const itemSelectors = config.item ? [config.item] : this.itemSelectors;
const items = this.findItems(container, itemSelectors);
const results = [];
const seenNames = new Set();
const seenUrls = new Set();
for (const item of items) {
const name = this.extractName(item, config);
const url = this.extractImage(item);
if (!url || seenUrls.has(url)) continue;
seenUrls.add(url);
const normalizedName = this.normalizeName(name);
if (seenNames.has(normalizedName)) continue;
seenNames.add(normalizedName);
results.push({
name: normalizedName,
url: this.convertToOriginal(url),
rawName: name
});
}
return results;
}
findContainer(selectors) {
for (const selector of selectors) {
try {
const container = document.querySelector(selector);
if (container && container.querySelectorAll('img').length > 0) {
return container;
}
} catch (e) {
continue;
}
}
return null;
}
findItems(container, selectors) {
for (const selector of selectors) {
try {
const items = container.querySelectorAll(selector);
if (items.length > 0) return items;
} catch (e) {
continue;
}
}
return [];
}
extractName(item, config) {
// 如果配置指定了使用title属性
if (config && config.name === 'title') {
const title = item.getAttribute('title');
if (title) return title;
}
// 从名称元素提取
const nameSelectors = config && config.name !== 'title' ? [config.name] : this.nameSelectors;
for (const selector of nameSelectors) {
try {
const el = item.querySelector(selector);
if (el) {
const name = el.textContent?.trim();
if (name && name.length > 0 && name.length < 30) {
return name;
}
}
} catch (e) {
continue;
}
}
// 从data属性提取
const dataAttrs = ['data-value', 'data-title', 'data-name', 'data-label', 'data-sku-name'];
for (const attr of dataAttrs) {
const value = item.getAttribute(attr);
if (value && value.length < 30) {
return value;
}
}
// 从title属性提取
const title = item.getAttribute('title');
if (title && title.length < 30) {
return title;
}
// 从内部文本提取
const text = item.textContent?.trim();
if (text && text.length > 0 && text.length < 20) {
return text;
}
return '规格';
}
normalizeName(name) {
if (!name) return '规格';
name = name.trim();
name = name.replace(/\s+/g, ' ');
if (name.length > 30) {
name = name.substring(0, 30);
}
name = name.replace(/[\\/*?:"<>|]/g, '_');
return name;
}
extractImage(item) {
const img = item.querySelector('img');
if (!img) return null;
return img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
}
convertToOriginal(url) {
if (!url) return null;
url = url.split('?')[0];
url = url.replace(/_\d+x\d+\./g, '.');
url = url.replace(/\.sum\./g, '.');
url = url.replace(/\.webp$/i, '.jpg');
return url;
}
}
十三、懒加载的完整处理方案
13.1 懒加载检测与触发
javascript
class LazyLoadHandler {
constructor() {
this.lazyAttributes = ['data-src', 'data-original', 'data-lazy-src', 'data-lazy-img'];
this.loadedImages = new Set();
this.pendingImages = new Set();
this.maxWaitSeconds = 15;
}
detect() {
const lazyImages = [];
const allImages = document.querySelectorAll('img');
for (const img of allImages) {
let isLazy = false;
let realUrl = null;
let attrName = '';
for (const attr of this.lazyAttributes) {
const url = img.getAttribute(attr);
if (url) {
isLazy = true;
realUrl = url;
attrName = attr;
break;
}
}
if (isLazy && realUrl) {
lazyImages.push({
element: img,
url: realUrl,
currentSrc: img.src || '',
attrName: attrName
});
this.pendingImages.add(realUrl);
}
}
return lazyImages;
}
async triggerAll() {
const lazyImages = this.detect();
console.log(`发现 ${lazyImages.length} 个懒加载图片`);
if (lazyImages.length === 0) {
console.log('未发现懒加载图片');
return;
}
// 方法1:滚动触发
await this.scrollTrigger();
// 方法2:直接设置src(兜底)
for (const item of lazyImages) {
if (!this.isLoaded(item.element)) {
try {
item.element.src = item.url;
item.element.removeAttribute(item.attrName);
this.pendingImages.delete(item.url);
this.loadedImages.add(item.url);
} catch (e) {
// 忽略错误
}
}
}
// 方法3:等待加载完成
await this.waitForComplete();
console.log(`懒加载完成,已加载: ${this.loadedImages.size}`);
}
async scrollTrigger() {
// 滚动到底部
window.scrollTo(0, document.body.scrollHeight);
await this.sleep(500);
// 分段滚动
const steps = 10;
for (let i = 1; i <= steps; i++) {
const scrollTo = (document.body.scrollHeight / steps) * i;
window.scrollTo(0, scrollTo);
await this.sleep(250);
}
// 滚动回顶部
window.scrollTo(0, 0);
await this.sleep(300);
// 再次滚动到底部(确保所有图片都触发)
window.scrollTo(0, document.body.scrollHeight);
await this.sleep(500);
window.scrollTo(0, 0);
await this.sleep(300);
}
isLoaded(img) {
try {
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
} catch (e) {
return false;
}
}
async waitForComplete() {
let maxWait = this.maxWaitSeconds * 2;
let lastCount = 0;
let stableCount = 0;
while (maxWait-- > 0 && stableCount < 3) {
const currentCount = this.pendingImages.size;
if (currentCount === lastCount) {
stableCount++;
} else {
stableCount = 0;
lastCount = currentCount;
}
await this.sleep(500);
}
console.log(`懒加载等待完成,剩余待加载: ${this.pendingImages.size}`);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
十四、视频下载的完整技术实现
14.1 m3u8解析器
javascript
class M3U8Parser {
parse(content, baseUrl) {
const lines = content.split('\n');
const segments = [];
let currentDuration = 0;
let isDiscontinuity = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
if (line.startsWith('#')) {
if (line.startsWith('#EXTINF:')) {
currentDuration = parseFloat(line.substring(8)) || 5.0;
} else if (line.startsWith('#EXT-X-DISCONTINUITY')) {
isDiscontinuity = true;
}
continue;
}
// 片段URL
let segmentUrl = line;
if (!segmentUrl.startsWith('http')) {
segmentUrl = this.resolveUrl(baseUrl, segmentUrl);
}
segments.push({
url: segmentUrl,
duration: currentDuration,
discontinuity: isDiscontinuity
});
currentDuration = 0;
isDiscontinuity = false;
}
return segments;
}
resolveUrl(base, relative) {
if (relative.startsWith('http')) return relative;
if (relative.startsWith('/')) {
const urlObj = new URL(base);
return `${urlObj.protocol}//${urlObj.host}${relative}`;
}
const basePath = base.substring(0, base.lastIndexOf('/') + 1);
return basePath + relative;
}
}
14.2 ts下载器
javascript
class TsDownloader {
constructor(maxConcurrent = 10) {
this.maxConcurrent = maxConcurrent;
this.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://item.taobao.com/'
};
this.timeout = 30000;
this.retryCount = 3;
}
async downloadAll(segments, onProgress) {
const results = new Array(segments.length);
const total = segments.length;
let completed = 0;
if (total === 0) {
return results;
}
const queue = [...segments];
const workers = [];
const workerCount = Math.min(this.maxConcurrent, total);
for (let i = 0; i < workerCount; i++) {
workers.push(this.worker(queue, results, onProgress, total, completed));
}
await Promise.all(workers);
return results;
}
async worker(queue, results, onProgress, total, completedRef) {
while (queue.length > 0) {
const index = total - queue.length;
const segment = queue.shift();
let success = false;
let data = null;
let error = null;
for (let attempt = 0; attempt < this.retryCount; attempt++) {
try {
data = await this.downloadSingle(segment.url);
success = true;
break;
} catch (e) {
error = e;
if (attempt < this.retryCount - 1) {
await this.sleep(1000 * Math.pow(2, attempt));
}
}
}
results[index] = {
success: success,
data: data,
error: error ? error.message : null,
index: index
};
completedRef++;
if (onProgress) {
onProgress(completedRef, total);
}
}
}
async downloadSingle(url) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
headers: this.headers,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.arrayBuffer();
} catch (e) {
clearTimeout(timeoutId);
throw e;
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
14.3 视频合并器
javascript
class VideoMerger {
async merge(segmentsData, outputPath) {
const validSegments = segmentsData.filter(s => s.success && s.data);
if (validSegments.length === 0) {
throw new Error('没有可用的ts片段,合并失败');
}
const totalSize = validSegments.reduce((sum, s) => sum + s.data.byteLength, 0);
console.log(`合并 ${validSegments.length} 个片段,总大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
const blob = new Blob(validSegments.map(s => s.data), { type: 'video/mp4' });
const url = URL.createObjectURL(blob);
// 触发下载
const a = document.createElement('a');
a.href = url;
a.download = outputPath;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 释放内存
URL.revokeObjectURL(url);
return {
size: blob.size,
segmentCount: validSegments.length,
totalSegments: segmentsData.length
};
}
}
十五、批量下载与任务队列设计
15.1 任务队列完整实现
javascript
class TaskQueue {
constructor(concurrency = 5) {
this.concurrency = concurrency;
this.queue = [];
this.running = 0;
this.results = [];
this.completed = 0;
this.failed = [];
this.total = 0;
this.onProgress = null;
this.onComplete = null;
this.onError = null;
this.paused = false;
this.maxRetries = 3;
}
add(task) {
this.queue.push({
...task,
retries: 0,
maxRetries: this.maxRetries,
addedAt: Date.now()
});
this.total++;
this.process();
}
addAll(tasks) {
for (const task of tasks) {
this.queue.push({
...task,
retries: 0,
maxRetries: this.maxRetries,
addedAt: Date.now()
});
this.total++;
}
this.process();
}
pause() {
this.paused = true;
console.log('任务队列已暂停');
}
resume() {
this.paused = false;
this.process();
console.log('任务队列已恢复');
}
async process() {
if (this.paused) return;
if (this.running >= this.concurrency || this.queue.length === 0) {
if (this.queue.length === 0 && this.running === 0) {
this.onComplete && this.onComplete({
completed: this.completed,
failed: this.failed,
total: this.total,
results: this.results
});
}
return;
}
this.running++;
const task = this.queue.shift();
try {
const result = await this.executeTask(task);
this.results.push({ success: true, ...result, taskId: task.id || Date.now() });
this.completed++;
} catch (error) {
this.results.push({ success: false, task, error: error.message });
this.failed.push(task);
this.onError && this.onError(error, task);
}
this.running--;
this.onProgress && this.onProgress(this.completed, this.total);
// 继续处理下一个任务
this.process();
}
async executeTask(task) {
let lastError;
for (let attempt = 0; attempt < task.maxRetries; attempt++) {
try {
return await this.download(task.url, task.path);
} catch (error) {
lastError = error;
console.warn(`重试 ${attempt + 1}/${task.maxRetries}: ${task.url.substring(0, 50)}...`);
if (attempt < task.maxRetries - 1) {
await this.sleep(1000 * Math.pow(2, attempt));
}
}
}
throw lastError;
}
async download(url, path) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const blob = await response.blob();
return { url, path, size: blob.size };
} catch (e) {
clearTimeout(timeoutId);
throw e;
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
getStats() {
return {
total: this.total,
completed: this.completed,
failed: this.failed.length,
pending: this.queue.length,
running: this.running,
concurrency: this.concurrency
};
}
}
十六、剪贴板监听与自动化流程
16.1 完整监听实现
javascript
class ClipboardManager {
constructor() {
this.lastText = '';
this.isProcessing = false;
this.onUrlDetected = null;
this.onError = null;
this.intervalId = null;
this.isRunning = false;
this.debounceMs = 1000;
this.supportedDomains = [
'taobao.com', 'tmall.com', 'jd.com',
'yangkeduo.com', 'douyin.com',
'1688.com', 'amazon.com'
];
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.intervalId = setInterval(() => this.check(), 500);
console.log('剪贴板监听已启动');
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.isRunning = false;
console.log('剪贴板监听已停止');
}
async check() {
if (this.isProcessing) return;
try {
const currentText = await this.getClipboardText();
if (currentText === this.lastText) return;
this.lastText = currentText;
const url = this.detectUrl(currentText);
if (url && this.onUrlDetected) {
this.isProcessing = true;
this.onUrlDetected(url);
setTimeout(() => { this.isProcessing = false; }, this.debounceMs);
}
} catch (error) {
this.onError && this.onError(error);
}
}
async getClipboardText() {
// 使用navigator.clipboard API (需要用户交互)
if (navigator.clipboard && navigator.clipboard.readText) {
try {
return await navigator.clipboard.readText();
} catch (e) {
// 降级到传统方法
}
}
// 传统方法
return new Promise((resolve) => {
const textarea = document.createElement('textarea');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
try {
document.execCommand('paste');
const text = textarea.value;
document.body.removeChild(textarea);
resolve(text);
} catch (e) {
document.body.removeChild(textarea);
resolve('');
}
});
}
detectUrl(text) {
if (!text || typeof text !== 'string') return null;
// 提取URL
const urlMatch = text.match(/https?:\/\/[^\s<>"']+/g);
if (!urlMatch) return null;
for (const url of urlMatch) {
const lowerUrl = url.toLowerCase();
for (const domain of this.supportedDomains) {
if (lowerUrl.includes(domain)) {
return url;
}
}
}
return null;
}
}
十七、错误处理与重试机制
17.1 错误分类器
javascript
class ErrorClassifier {
static classify(error) {
const message = error.message || '';
const code = error.code || '';
// 网络错误
if (message.includes('timeout') || message.includes('Timeout')) return 'TIMEOUT';
if (message.includes('network') || message.includes('Network')) return 'NETWORK';
if (message.includes('ECONNRESET') || message.includes('ECONNREFUSED')) return 'CONNECTION';
if (message.includes('fetch') && message.includes('failed')) return 'FETCH';
// HTTP错误
if (message.includes('403')) return 'FORBIDDEN';
if (message.includes('404')) return 'NOT_FOUND';
if (message.includes('429')) return 'RATE_LIMIT';
if (message.includes('500') || message.includes('502') || message.includes('503')) return 'SERVER_ERROR';
// 解析错误
if (message.includes('parse') || message.includes('json')) return 'PARSE_ERROR';
if (message.includes('DOM')) return 'DOM_ERROR';
// 其他
if (message.includes('abort') || message.includes('Abort')) return 'ABORTED';
return 'UNKNOWN';
}
static isRetryable(error) {
const type = this.classify(error);
return ['TIMEOUT', 'NETWORK', 'CONNECTION', 'FETCH', 'SERVER_ERROR', 'RATE_LIMIT'].includes(type);
}
static getRetryDelay(error, attempt) {
const type = this.classify(error);
const baseDelay = 1000;
const maxDelay = 30000;
const multipliers = {
'TIMEOUT': 2,
'NETWORK': 1.5,
'CONNECTION': 1.5,
'FETCH': 1,
'SERVER_ERROR': 3,
'RATE_LIMIT': 5,
'UNKNOWN': 2
};
let delay = baseDelay * Math.pow(multipliers[type] || 2, attempt);
delay = Math.min(delay, maxDelay);
// 添加抖动
delay = delay * (0.8 + Math.random() * 0.4);
return delay;
}
static getFriendlyMessage(error) {
const type = this.classify(error);
const messages = {
'TIMEOUT': '请求超时,请检查网络连接后重试',
'NETWORK': '网络连接异常,请检查网络后重试',
'CONNECTION': '连接失败,请检查网络后重试',
'FETCH': '资源获取失败,请稍后重试',
'FORBIDDEN': '访问被拒绝,请检查权限设置',
'NOT_FOUND': '资源不存在,请检查链接是否正确',
'RATE_LIMIT': '请求频率过高,请稍后重试',
'SERVER_ERROR': '服务器错误,请稍后重试',
'PARSE_ERROR': '数据解析失败,请检查页面结构',
'DOM_ERROR': '页面结构异常,请检查页面是否完整加载',
'ABORTED': '请求被取消',
'UNKNOWN': '发生未知错误,请重试'
};
return messages[type] || error.message;
}
}
17.2 重试管理器
javascript
class RetryManager {
constructor(options = {}) {
this.maxRetries = options.maxRetries || 5;
this.initialDelay = options.initialDelay || 1000;
this.maxDelay = options.maxDelay || 30000;
this.factor = options.factor || 2;
this.jitter = options.jitter !== false;
this.onRetry = options.onRetry || null;
this.onFailure = options.onFailure || null;
}
async execute(fn, context = '') {
let lastError;
let attempt = 0;
let delay = this.initialDelay;
while (attempt < this.maxRetries) {
try {
return await fn();
} catch (error) {
lastError = error;
attempt++;
const isRetryable = ErrorClassifier.isRetryable(error);
const friendlyMessage = ErrorClassifier.getFriendlyMessage(error);
console.warn(`[${context}] 尝试 ${attempt}/${this.maxRetries} 失败: ${friendlyMessage}`);
if (this.onRetry) {
this.onRetry({
attempt,
maxRetries: this.maxRetries,
error: error,
friendlyMessage: friendlyMessage,
context: context
});
}
if (!isRetryable || attempt >= this.maxRetries) {
break;
}
const waitTime = this.getWaitTime(error, attempt, delay);
console.log(`[${context}] 等待 ${(waitTime / 1000).toFixed(1)} 秒后重试...`);
await this.sleep(waitTime);
delay = Math.min(delay * this.factor, this.maxDelay);
}
}
if (this.onFailure) {
this.onFailure({
error: lastError,
context: context,
attempts: attempt
});
}
throw lastError;
}
getWaitTime(error, attempt, delay) {
let waitTime = ErrorClassifier.getRetryDelay(error, attempt);
if (this.jitter) {
waitTime = waitTime * (0.5 + Math.random() * 0.5);
}
return Math.min(waitTime, this.maxDelay);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
十八、性能优化策略
18.1 内存管理
javascript
class MemoryManager {
constructor() {
this.maxMemoryMB = 500;
this.warningThreshold = 0.8;
this.criticalThreshold = 0.95;
this.cache = new Map();
this.cacheLimit = 200;
this.imageCache = new Map();
this.imageCacheLimit = 50;
this.memoryCheckInterval = null;
this.isRunning = false;
}
startMonitoring(intervalMs = 5000) {
if (this.isRunning) return;
this.isRunning = true;
this.memoryCheckInterval = setInterval(() => this.check(), intervalMs);
console.log('内存监控已启动');
}
stopMonitoring() {
if (this.memoryCheckInterval) {
clearInterval(this.memoryCheckInterval);
this.memoryCheckInterval = null;
}
this.isRunning = false;
console.log('内存监控已停止');
}
check() {
const usage = this.getMemoryUsage();
if (usage === null) return;
const ratio = usage / this.maxMemoryMB;
if (ratio >= this.criticalThreshold) {
console.warn(`内存使用率过高: ${usage.toFixed(0)}MB / ${this.maxMemoryMB}MB (${(ratio * 100).toFixed(0)}%)`);
this.emergencyRelease();
} else if (ratio >= this.warningThreshold) {
console.log(`内存使用率较高: ${usage.toFixed(0)}MB / ${this.maxMemoryMB}MB (${(ratio * 100).toFixed(0)}%)`);
this.releaseMemory();
}
}
getMemoryUsage() {
if (window.performance && window.performance.memory) {
return window.performance.memory.usedJSHeapSize / (1024 * 1024);
}
return null;
}
releaseMemory() {
// 清理缓存
this.cache.clear();
this.imageCache.clear();
// 清理DOM引用
if (window.gc) {
window.gc();
}
console.log('内存已释放');
}
emergencyRelease() {
this.cache.clear();
this.imageCache.clear();
this.cacheLimit = Math.floor(this.cacheLimit * 0.5);
this.imageCacheLimit = Math.floor(this.imageCacheLimit * 0.5);
if (window.gc) {
window.gc();
}
console.log('紧急内存释放完成');
}
addToCache(key, value, cacheType = 'default') {
const targetCache = cacheType === 'image' ? this.imageCache : this.cache;
const limit = cacheType === 'image' ? this.imageCacheLimit : this.cacheLimit;
if (targetCache.size >= limit) {
const firstKey = targetCache.keys().next().value;
targetCache.delete(firstKey);
}
targetCache.set(key, value);
}
getFromCache(key, cacheType = 'default') {
const targetCache = cacheType === 'image' ? this.imageCache : this.cache;
const value = targetCache.get(key);
if (value) {
// LRU更新
targetCache.delete(key);
targetCache.set(key, value);
}
return value;
}
}
18.2 并发控制优化
| 并发数 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 1 | 网络不稳定 | 最稳定 | 速度慢 |
| 3-5 | 日常采集 | 平衡速度和稳定性 | 可能触发限流 |
| 10+ | 快速采集 | 速度快 | 可能被封IP |
十九、各平台差异适配
19.1 平台适配器
javascript
class PlatformAdapter {
constructor() {
this.platforms = {
taobao: {
name: '淘宝',
mainSelector: '.J_UlThumb, .tb-thumb',
skuSelector: '.tb-sku, .J_sku',
detailSelector: '#description, .desc',
videoSelector: '#J_ItemVideo video, .tb-video video',
urlConverter: this.convertTaobaoUrl,
nameExtractor: '.sku-name, .J_skuName'
},
jd: {
name: '京东',
mainSelector: '.spec-img',
skuSelector: '.sku-img-list, .J_skuImgList',
detailSelector: '#detail, .detail-content',
videoSelector: '.JDV-video video, .video-box video',
urlConverter: this.convertJdUrl,
nameExtractor: 'title'
},
pdd: {
name: '拼多多',
mainSelector: '.main-image',
skuSelector: '.sku-list, .J_skuList',
detailSelector: '.detail-content, .J_detail',
videoSelector: '.video-container video',
urlConverter: this.convertPddUrl,
nameExtractor: '.sku-name'
}
};
}
detect() {
const host = location.hostname || '';
if (host.includes('taobao.com') || host.includes('tmall.com')) return this.platforms.taobao;
if (host.includes('jd.com')) return this.platforms.jd;
if (host.includes('yangkeduo.com') || host.includes('pinduoduo.com')) return this.platforms.pdd;
return null;
}
convertTaobaoUrl(url) {
if (!url) return null;
url = url.split('?')[0];
url = url.replace(/_\d+x\d+\./g, '.');
url = url.replace(/\.sum\./g, '.');
return url;
}
convertJdUrl(url) {
if (!url) return null;
url = url.split('?')[0];
url = url.replace(/\/n\d\//, '/n0/');
url = url.replace(/\/popWaterMark\//, '/');
return url;
}
convertPddUrl(url) {
if (!url) return null;
url = url.split('?')[0];
url = url.replace(/_\d+x\d+\./g, '.');
url = url.replace(/\.webp$/i, '.jpg');
return url;
}
}
19.2 各平台差异总结
| 平台 | 主图容器 | SKU容器 | 详情容器 | 视频格式 | 特殊处理 |
|---|---|---|---|---|---|
| 淘宝 | .J_UlThumb |
.tb-sku |
#description |
mp4/m3u8 | 尺寸后缀去除 |
| 京东 | .spec-img |
.sku-img-list |
#detail |
mp4/m3u8 | n1→n0转换 |
| 拼多多 | .main-image |
.sku-list |
.detail-content |
mp4 | webp转jpg |
| 1688 | .main-image |
.sku-list |
.detail-content |
不支持 | 需登录 |
二十、火蚁一键存图的完整技术架构
火蚁一键存图正是基于浏览器方案开发的。它基于Chromium内核,不是爬虫,不会因为平台改版而失效。
20.1 核心流程
text
用户复制链接
↓
剪贴板监听触发
↓
Chromium内核加载页面
↓
等待DOM就绪 + 网络空闲 + jQuery加载
↓
触发懒加载
↓
提取主图URL
↓
提取SKU图URL + 属性名称
↓
提取详情图URL
↓
提取视频URL
↓
原图URL转换
↓
自动分类归档
20.2 支持的平台
| 平台 | 图片 | SKU图 | 详情图 | 视频 | 特殊处理 |
|---|---|---|---|---|---|
| 淘宝 | ✅ | ✅ | ✅ | ✅ | 原图获取 |
| 天猫 | ✅ | ✅ | ✅ | ✅ | 原图获取 |
| 京东 | ✅ | ✅ | ✅ | ✅ | m3u8视频合并 |
| 拼多多 | ✅ | ✅ | ✅ | ❌ | webp转jpg |
| 1688 | ✅ | ✅ | ✅ | ❌ | 需登录 |
| 抖音 | ✅ | ⚠️ | ✅ | ✅ | JS动态渲染 |
| 亚马逊 | ✅ | ✅ | ✅ | ⚠️ | 变体图分类 |
二十一、最终总结
21.1 三条技术路线综合对比
| 技术路线 | 稳定性 | 维护成本 | 适用范围 | 成功率 | 推荐指数 |
|---|---|---|---|---|---|
| 爬虫方案 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | 70-80% | ⭐⭐ |
| 浏览器插件 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | 85-90% | ⭐⭐⭐ |
| 浏览器方案 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | 99%+ | ⭐⭐⭐⭐⭐ |
21.2 核心要点回顾
-
爬虫方案:TLS指纹检测、强依赖DOM结构、无法执行JS,已不适合2026年的电商采集场景
-
浏览器插件:依赖Chrome版本、权限过大、Manifest V3限制,未来发展受限
-
浏览器方案:独立运行、真实浏览器指纹、不改版影响,是最稳定的选择
21.3 选型建议
| 使用场景 | 推荐方案 | 理由 |
|---|---|---|
| 技术学习、研究 | 爬虫方案 | 练手好项目 |
| 偶尔采集(每周<10次) | 浏览器插件 | 安装方便 |
| 日常高频采集(每天>20次) | 浏览器方案 | 稳定、成功率99%+ |
| 需要抖音/亚马逊等平台 | 浏览器方案 | 插件大多不支持 |
| 需要SKU图自动分类 | 浏览器方案 | 自动识别+命名 |
浏览器方案是架构层面最稳健的选择。它不需要模拟浏览器------因为它自己就是浏览器。
火蚁一键存图正是基于浏览器方案开发的,支持淘宝、天猫、京东、拼多多、1688、抖音、亚马逊等主流电商平台,一次下载即可获取主图、SKU图、详情图和主图视频,全部自动分类归档。
百度搜索"火蚁一键存图"即可找到。
免责声明:本文内容仅供技术交流和学习参考。电商平台的数据采集行为可能涉及平台服务条款、著作权法等法律问题。请确保遵守目标网站的《用户协议》和相关法律法规。因不当使用引发的法律风险由使用者自行承担。