ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

WebMagic垂直爬虫封装:从组件边界到Pipeline工程化实践

WebMagic垂直爬虫封装:从组件边界到Pipeline工程化实践 简介基于WebMagic封装的垂直爬虫高分项目面向计算机相关专业在校学生、教师及企业开发者也适合作为毕业设计、课程设计或项目初期立项演示的参考。资源包共98个文件以Java源码、class字节码、XML配置和依赖jar包为主体同时附有爬虫教程docx文档、README说明以及Maven工程配置文件整体约53.88MB目录结构清晰按Maven规范组织便于快速定位采集入口、解析器和管道存储等核心模块。目前已有61人学习下载。项目源码已通过导师指导认可和答辩评审代码经测试运行成功附带的详细文档与全部资料能帮助读者理解WebMagic框架下垂直爬虫的实现思路覆盖页面抓取、数据解析、结果存储等关键环节可直接用于项目演示也可在此基础上修改扩展适配不同数据源与业务场景适合作为课程作业或企业预研的起点。1. 垂直爬虫需要 WebMagic 封装层的真实原因解析规则不该长在流程代码里直接拿 WebMagic 写垂直爬虫第一版都很快new 一个 SpiderPageProcessor 里写几行 SelectorPipeline 里连上数据库数据就开始落了。问题是第二个需求进来就变味。同一个站点要加字段产品改了个翻页参数或者你要把同一个解析逻辑复用到另一个同类型站点你会发现 PageProcessor 里的代码开始长出大量 if 分支Site 对象的构建散落在各个启动类里去重、重试、落库这些横切逻辑每条爬虫各写一套。垂直爬虫和通用爬虫最大的区别是「垂直」这两个字。站点范围固定业务领域固定变的是字段映射、分页规则、增量周期。把这些经常变的东西和基本不变的生命周期剥离开就是封装的本质。本文按一条完整路径来讲先划清 WebMagic 各组件的封装边界再把采集任务、去重、稳定性和数据补偿逐一装进封装层最后给出一套「换站不换代码」的验证方法。适合正在维护两条以上爬虫、或者准备把爬虫模块工程化的读者。2. 把 WebMagic 的四个生命周期改造成可插拔接口封装层才算立住2.1 原生 WebMagic 的组件边界以及封装时为什么要重新切一刀WebMagic 本身有四个核心组件Spider 负责调度Downloader 负责抓取PageProcessor 负责解析Pipeline 负责持久化。Scheduler 在中间管待抓取队列和去重。原生用法里开发者的业务代码几乎全堆在 PageProcessor 和 Pipeline 的实现类中Site 配置散落在 main 方法里。这不算设计问题它只是个框架没义务替你规划业务边界。封装要做的不是重新发明一套爬虫引擎而是重新切一刀——把「变化的」和「稳定的」分开。稳定的是Spider 的启动流程、下载重试机制、去重调度链、管道写入顺序。变化的是站点域名、列表页规则、详情页字段配置、入库表结构。前端把 axios 请求封装成拦截器和实例后端把 WebMagic 封装成一套管线思路同源。我一般会按下面这张表来定边界防止封着封着又把业务代码塞回框架里关注点原生 WebMagic 的位置封装后的位置请求头、Cookie、UA启动时手写 SiteSiteBuilder 配置对象翻页和列表提取PageProcessor 硬编码解析策略接口字段格式化和校验散落在 Pipeline统一字段管道去重策略默认 URL 去重可替换的 Scheduler 组件库表写入逻辑每处各写各的通用 Pipeline 映射配置2.2 SiteBuilder 与 SpiderFactory把请求参数变成独立配置封装的第一步永远是先把 Site 的构建收敛到一个工厂里。SiteBuilder 的作用不是帮你少写几行site.addHeader而是把「每个站点的请求特征」固化成一个可序列化的配置对象后面接新站时不需要翻代码。public class SiteBuilder { private MapString, String headers new HashMap(); private MapString, String cookies new HashMap(); private String userAgent; private int retryTimes 3; private long retrySleepMillis 3000; private int sleepTime 500; private int cycleRetryTimes 2; private String charset UTF-8; private int timeOut 10000; public SiteBuilder addHeader(String key, String value) { this.headers.put(key, value); return this; } public SiteBuilder addCookie(String key, String value) { this.cookies.put(key, value); return this; } public SiteBuilder withUserAgent(String userAgent) { this.userAgent userAgent; return this; } public SiteBuilder withRetry(int times, long sleepMillis) { this.retryTimes times; this.retrySleepMillis sleepMillis; return this; } public SiteBuilder withThreadSleep(int sleepTime) { this.sleepTime sleepTime; return this; } public Site build() { Site site Site.me(); site.setHeaders(headers); site.setCookies(cookies); site.setUserAgent(userAgent ! null ? userAgent : DEFAULT_UA()); site.setRetryTimes(retryTimes); site.setRetrySleepMillis(retrySleepMillis); site.setSleepTime(sleepTime); site.setCycleRetryTimes(cycleRetryTimes); site.setCharset(charset); site.setTimeOut(timeOut); return site; } private String DEFAULT_UA() { return Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36; } }这段代码没有玄机但有一个容易被忽略的设计点retrySleepMillis和sleepTime必须分开配置。WebMagic 的retrySleepMillis是重试前的等待sleepTime是每两个请求之间的间隔两者语义完全不同。很多封装把这两个参数混成一个结果一遇到限流重试请求反而以更快的频率打出去。SiteBuilder 的下一步是配合一个配置加载器把 JSON 文件映射成 SiteBuilder 对象。这样就可以做到一个站点一个配置文件改请求头不需要重新编译。配置加载器可以用 Jackson 直接绑定但要注意 Cookie 这类带点号的 key建议在 JSON 里用嵌套结构表达避免字段名的解析问题。2.3 Pipeline 的抽象数据库写入动作从 PageProcessor 里拆出来PageProcessor 的职责应该是「从页面里提取出结构化的 Map」仅此而已。写入行为全部丢给 Pipeline。垂直爬虫常见的问题是解析完直接jdbcTemplate.update(...)一旦要同时支持 MySQL、Elasticsearch、文件导出三套落库目标Processor 就变成了一个不可维护的类。封装层的做法是定义一个统一的写入模型public interface VerticalPipeline { void process(ResultItems resultItems, Task task); }基于 WebMagic 自带的Pipeline接口做一层抽象AbstractVerticalPipeline里面处理两类通用逻辑字段空值校验、重复主键的幂等策略。具体的库表写入由子类实现public abstract class AbstractVerticalPipeline implements Pipeline { public void process(ResultItems resultItems, Task task) { MapString, Object data resultItems.getAll(); if (data null || data.isEmpty()) { return; } String tableName fetchTableName(resultItems); preHandle(data); write(tableName, data); } protected abstract void write(String tableName, MapString, Object data); private void preHandle(MapString, Object data) { data.values().removeIf(v - v null || StringUtils.isEmpty(v.toString())); } }与原生 Pipeline 相比封装后多了一个tableName的解析动作。这个值不需要在代码里写死而是由 PageProcessor 写入resultItems.put(__table__, job_detail)这样的约定 key。每个业务站点只需要关心自己往哪个表写通用 Pipeline 不知道业务字段也能完成写入。注意Pipeline 里不建议做数据清洗。清洗规则属于页面解析的一部分应该留在 Processor 层。Pipeline 只做写入和幂等控制职责不对改起来会两头打架。3. 垂直爬虫的采集任务编排从 seed 配置到增量数据3.1 垂直站点和通用爬虫的分叉点任务粒度不等于 URL 粒度WebMagic 的默认模型里一个Spider对应一组 URL。垂直爬虫通常没有这么简单一个站点会拆成列表页任务和详情页任务列表页负责产出 URL详情页负责产出数据两者的频率和解析策略都不一样。我的做法是引入一个轻量的任务描述对象它不替代 WebMagic 的 Spider而是描述一个站点的采集拓扑配置项含义示例site站点标识用于区分配置和数据表前缀lagou-jobseeds种子 URL 列表城市列表页入口listRule列表页链接提取规则div.job-list a[href]detailRule详情页字段选择器字段 XPath 映射cycle采集周期每天 2 次incremental是否增量true这个配置可以直接用 JSON 表达比写在 Java 类里更直观也让「运营人员调配置、开发人员写代码」的分工成立。3.2 用 RedisScheduler 做分布式去重再用布隆过滤器补详情页去重WebMagic 默认的QueueScheduler是 JVM 内存级的单机跑没问题但垂直爬虫一旦要做定时增量或者将来要横向扩容就必须换 Scheduler。官方提供的RedisScheduler是首选它把待抓取队列和已消费的 URL 集合都放到 Redis天然支持多实例还附带一个去重集合RedisScheduler redisScheduler new RedisScheduler(redis://127.0.0.1:6379); Spider spider Spider.create(processor) .setScheduler(redisScheduler) .addUrl(seeds);换 Scheduler 只是第一步。RedisScheduler 的去重基于 URL 字符串URL 带排序参数、带时间戳、带无意义的追踪参数时去重会失效同一篇详情页被反复抓。更麻烦的是列表页的翻页 URL 有规律详情页 URL 被嵌入了多个参数不同参数组合指向同一内容。针对详情页级别的去重我一般叠加一层基于内容特征的布隆过滤器。等详情页抓下来后取标题字段的规范化值去掉空格、统一大小写计算哈希拼接上站点前缀后写入 BloomFilterBloomFilterString bloomFilter BloomFilter.create( Funnels.stringFunnel(Charsets.UTF_8), 1_000_000, // 预计存量数据量 0.001 // 期望误判率 ); boolean isDuplicate bloomFilter.mightContain(detailKey); if (!isDuplicate) { bloomFilter.put(detailKey); }两个参数要根据数据的量级来定。expectedInsertions设得比实际数据小误判率会急剧上升详情页被吞掉设得太大bit 数组占用内存成倍放大。0.001的误判率意味着 1000 条里有 1 条可能被误杀这个比例对大多数业务可接受。注意BloomFilter 的去重结果只能作为参考不能当作最终数据是否入库的依据。库里已经有主键唯一索引的数据库会替你兜底BloomFilter 承担的是「减少无效请求」的任务而不是「保证数据唯一」的任务。3.3 增量抓取的水位线方案垂直爬虫的增量抓取有个常见误区以为抓到新 URL 就是增量。实际上很多站点的列表页只保留最近几页老数据在列表里消失了直接用列表页驱动增量会丢数据。正确做法是按详情页的更新时间字段进行判断。在详情页解析时把publishTime或modifyTime抽出来和库里该条记录的时间做对比。如果列表页里解析出来的最老一条已经比水位线上次抓取时间旧就没必要再翻后续页。这是列表页层面的提前终止条件。详情页层面则是在数据入库前用时间字段做一次 UPDATE 或 INSERT 的判断。具体的 SQL 策略也不复杂主键冲突时用时间字段决定是否覆盖INSERT INTO job_detail(job_id, title, salary, update_time) VALUES(#{jobId}, #{title}, #{salary}, #{updateTime}) ON DUPLICATE KEY UPDATE salary CASE WHEN update_time VALUES(update_time) THEN VALUES(salary) ELSE salary END;这个写法把增量控制的逻辑压进了数据库爬虫侧只需要保证 update_time 解析准确。要注意的是时间格式必须统一成yyyy-MM-dd HH:mm:ss否则字符串比较会出现「2024-01-02」大于「2024-01-10」的问题。4. 高频采集下的稳定性配置限速、重试与请求特征构造4.1 别等被拒之后才加限速先给每类任务设定速率垂直爬虫面对的站点特征差异很大有的站点几乎不做限制有的站点对高频请求非常敏感。WebMagic 的Site.setSleepTime()是全局的一个任务一个睡眠值做不到按页面类型差异化限制。封装层应该把速率控制放到更细的粒度。我一般会在任务配置里增加三个独立参数参数作用推荐初始值listSleepTime列表页请求间隔毫秒800~1200detailSleepTime详情页请求间隔毫秒500~1000concurrentRequests同时处理的 request 数1~3列表页的请求密度比详情页低但单个列表页能带出十几条详情 URL所以列表页的间隔应该更长。这样做还有一个附带好处即使详情页被临时调快整体对外呈现的请求节奏仍然是列表页主导不会出现一个尖峰。WebMagic 自带的 sleepTime 是全局值要实现按页面类型差异化需要自定义一个 Downloader。重写download方法在调用真正下载逻辑之前根据 URL 特征判断是列表页还是详情页分别Thread.sleep():public class RateLimitDownloader extends HttpUrlConnectionDownloader { private final long listSleepTime; private final long detailSleepTime; Override public Page download(Request request, Task task) { if (isListPage(request.getUrl())) { sleep(listSleepTime); } else { sleep(detailSleepTime); } return super.download(request, task); } private boolean isListPage(String url) { return url.contains(/list/) || url.contains(?page); } }这个 Downloader 要注意两个细节。第一sleep 放在下载之前才能真正起到间隔作用放在 super 调用之后等于白等。第二判断逻辑不要写死在 Downloader 实现类里用 URL 特征或 Request 的 meta 属性来区分。WebMagic 允许对 Request 设置putMetaData(type, list)这是比解析 URL 字符串更干净的做法。4.2 重试策略的指数退避与随机抖动WebMagic 默认的重试机制很简单setRetryTimes(3)每次重试前固定等待setRetrySleepMillis(3000)。但固定等待在遇到持续限流时效果不好。站点端大概率是滑动窗口统计频率你每隔固定时间打一次仍然会被识别出规律性的请求节奏。更可取的策略是指数退避加随机抖动。第一次重试等 2 秒第二次等 4 秒第三次等 8 秒再加上一个随机偏移量让等待时间不再呈现固定规律public class ExponentialBackoffDownloader extends RateLimitDownloader { private final int maxRetry 3; private final long baseSleepMillis 2000; Override protected Page download(Request request, Task task) { Page page null; int retryCount 0; while (retryCount maxRetry) { try { page super.download(request, task); if (page.isDownloadSuccess()) { return page; } } catch (Exception e) { // do nothing, fall through to retry logic } retryCount; long delay baseSleepMillis * (1L Math.min(retryCount, 6)) randomJitter(); sleep(delay); } return page; } private long randomJitter() { return ThreadLocalRandom.current().nextLong(0, 1000); } }指数退避的价格是重试总耗时变长所以maxRetry一般不要超过 4 次一次请求的最坏等待时间在 30 秒左右。重试次数设置过高时爬虫整体的吞吐会被拖垮原本 1000 条数据的任务可能要多花几倍的时间。还有一点重试日志必须打出来否则站点端封了你的请求特征你还在原地傻等。4.3 Headers 特征与 Cookie 失效的应对站点端判断爬虫的手段多半是请求 Header 特征不一致比如返回的页面要求带 referer你的请求没有。常见的做法是对所有请求统一补上浏览器标准头。但要注意请求头不是越长越像浏览器而是「顺序」和「值」都要匹配目标站点从浏览器拿到的实际请求。稳定起见UA 不要把几十条硬编码在代码里而是构建一个 UA 池按轮询或随机策略分配public class UserAgentPool { private static final ListString UA_POOL Arrays.asList( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0 Firefox/120.0 ); public static String randomUA() { return UA_POOL.get(ThreadLocalRandom.current().nextInt(UA_POOL.size())); } }UA 池的实际作用是降低长时间运行时请求特征的单一性。如果任务并发开得不高其实固定一个常规 UA 问题也不大风险反而在于把 Chrome 和 Firefox 的 UA 特征混在一起出现「Chrome 的 User-Agent Firefox 的 Header 顺序」这种组合。Cookie 失效的问题是另一个高频故障点。登录态的 Cookie 过期后页面返回的内容会变成一个登录跳转页。此时解析逻辑不会报错但抽取出来的字段全是空值数据质量问题比请求失败更隐蔽。处理办法是在PageProcessor里对页面内容做一次特征判断if (page.getHtml().getDocument().selectFirst(form.login-form) ! null) { // 说明登录态已失效把这个请求放回待抓取队列 request.putMetaData(retry_for_cookie, true); page.setSkip(true); // 触发告警 alertService.notify(cookie expired: request.getUrl()); }消息队列的投放我习惯用 WebMagic 的Spider.addRequest()在 Pipeline 里重新放回但要加一个重放次数的校验避免 Cookie 一直失效时形成死循环。4.4 落库校验和失败补偿队列数据抓下来不校验就入库最容易出现的是字段缺失和类型异常。页面上某个节点临时改版选择器抽不到内容select方法返回 null写入数据库时如果是 NOT NULL 字段就会抛 SQLException整个管道因此中断。封装层里要做的校验集中在三层空值校验、长度校验、枚举校验。长度校验经常被忽略数据库表字段设定 varchar(255)页面里的公司全称可能有 300 个字符不校验就会导致入库失败。这部分直接在 AbstractVerticalPipeline 里做成通用逻辑if (data.get(company_name) ! null) { String value data.get(company_name).toString(); if (value.length() 255) { data.put(company_name, value.substring(0, 252)); } }失败补偿队列是通用的可靠性兜底。WebMagic 的 Pipeline 抛异常时该条数据进入补偿队列而不是直接丢弃。补偿队列用 Redis List 实现入队时保存完整的数据 JSON启动时由一个独立的补偿消费者读取队列并重新写入。这个消费者和 Spider 的生命周期解耦避免 Spider 崩溃时补偿逻辑一并失效。注意不要用 Spider 的 onError 回调做数据补偿。onError 负责的是页面抓取异常的兜底不是数据入库失败的兜底。两者混在一起抓取层和数据层的故障定位会变得混乱。5. 验证封装层达标的最小动作换一个站点不碰业务代码封装层的成果最终要经受一次实测接一个同类型新站点全程只改配置文件和映射规则不新增 Java 业务代码。这个验证动作可以拆成五个检查项验证点操作通过标准配置生效新增 JSON 配置指定新站点的种子 URL 和选择器启动即开始抓取无需改代码下载器生效观察请求日志中的间隔列表页间隔和详情页间隔与配置一致去重生效连续跑两轮采集任务第二轮详情页请求量趋近于 0增量生效修改数据库中的某条 update_time 为一天前再次采集后该条记录被更新失败补偿生效手动停止数据库服务后触发 Pipeline 写入恢复后补偿消费者自动补写最后一件事是验证数据质量。每次采集结束用一条简单的聚合 SQL 检查本次任务的有效数据率SELECT COUNT(*) AS total_rows, SUM(CASE WHEN title IS NULL OR title THEN 1 ELSE 0 END) AS bad_rows FROM job_detail WHERE create_time NOW() - INTERVAL 1 HOUR;bad_rows / total_rows超过 5% 时建议把告警接进钉钉或企业微信。垂直爬虫的数据量不大但质量波动往往比吞吐量更影响下游信任。封装层做出来后不要指望它能覆盖一切站点形态。JS 动态渲染的页面需要换用基于浏览器内核的 Downloader登录流程复杂的站点需要单独定制预登录模块。把这些特殊形态留在封装层之外通过替换组件的方式接入比在通用代码里堆开关更利于长期维护。上线一个新站点后观察完一个采集周期至少覆盖两次增量任务再并入正式调度这个习惯能挡掉大部分「配置看着对但实际解析失败」的问题。本文还有配套的精品资源点击获取
返回列表