2.5 LinkExtractor 与 CrawlSpider:自动跟页 本节摘要:当"发现链接就跟进"成为规律而非例外,手写 yield 就该升级成规则声明。LinkExtractor 负责从页面筛链接,CrawlSpider 按 Rule 自动续票,回调只需要处理详情页。本节讲规则引擎的写法、过滤参数的调法,以及它与普通 Spider 的选型边界。 上一节的翻页还是手写的:找到下一页的地址,yield,等响应,再找。当站点有列表翻页加分类页加详情页三层结构时,手写续票代码会迅速膨胀成一堆互相调用的回调。CrawlSpider 把这件事翻转过来:你声明"什么样的链接跟进、跟到了交给谁",续票本身由框架代劳。这个思路的来源是 2.
本节摘要:当"发现链接就跟进"成为规律而非例外,手写 yield 就该升级成规则声明。LinkExtractor 负责从页面筛链接,CrawlSpider 按 Rule 自动续票,回调只需要处理详情页。本节讲规则引擎的写法、过滤参数的调法,以及它与普通 Spider 的选型边界。
上一节的翻页还是手写的:找到下一页的地址,yield,等响应,再找。当站点有列表翻页加分类页加详情页三层结构时,手写续票代码会迅速膨胀成一堆互相调用的回调。CrawlSpider 把这件事翻转过来:你声明"什么样的链接跟进、跟到了交给谁",续票本身由框架代劳。这个思路的来源是 2.2 节那张 Request 车票——续票动作太规律了,规律的事就该交给机器。
LinkExtractor 是筛选器:从响应里抽链接、按参数过滤。Rule 是调度声明:匹配到哪类链接、交给哪个回调、是否递归跟进。
from scrapy.linkextractors import LinkExtractor from scrapy.spiders import CrawlSpider, Rule class BookSiteSpider(CrawlSpider): name = "booksite" allowed_domains = ["books.example.com"] start_urls = ["https://books.example.com/index"] rules = ( # 规则一:分类页。匹配 category 路径,跟进后继续套用全部规则 Rule(LinkExtractor(allow=r"/category/\d+"), follow=True), # 规则二:翻页。匹配 page 参数,只负责跟进,不产出数据 Rule(LinkExtractor(allow=r"/list.*page=\d+"), follow=True), # 规则三:详情页。匹配 book 路径,交回调拆包,不再递归 Rule(LinkExtractor(allow=r"/book/\d+"), callback="parse_book"), ) def parse_book(self, response): yield { "title": response.css("h1::text").get(default="").strip(), "price": response.css("p.price::text").get(default=""), "url": response.url, }
注意 CrawlSpider 不再使用默认的 parse 作为入口——parse 被规则引擎占用了。所有数据提取写在规则指定的回调里。运行日志能看到规则引擎的工作量:
DEBUG: Crawled (200) books.example.com/index (referer: None) DEBUG: Crawled (200) books.example.com/category/3 (referer: index) DEBUG: Crawled (200) books.example.com/list?cat=3&page=2 (referer: category/3) DEBUG: Crawled (200) books.example.com/book/4521 (referer: list?cat=3&page=2) ... 'item_scraped_count': 340, 'offsite/filtered': 12, 'linkextractor/no_link': 0
offsite/filtered 是域名白名单拦下的越界链接数——allowed_domains 在这里继续生效。
筛选器的力量全在参数上,常用五个:
LinkExtractor( allow=(r"/book/\d+",), # 白名单正则:地址必须匹配 deny=(r"/tag/",), # 黑名单正则:匹配即剔除 allow_domains=("books.example.com",), # 域名白名单 restrict_css=("div.pagination",), # 只在这个区块里找链接,范围收窄 unique=True, # 同页去重 )
restrict_css 是性价比最高的参数:翻页链接只在分页区块里找,模板里其他长得像的地址全部免疫。参数从宽到窄依次收拢,比写一个巨大的正则好维护得多。
多条规则按声明顺序匹配,先中先用。所以"越具体的规则越靠前":详情页规则必须排在翻页规则之前,否则一个同时匹配两类正则的地址会被错误分流。规则声明的顺序就是分流优先级,这一点没有隐式补救。
⚠️ 常见坑:在 CrawlSpider 里定义了 parse 方法却没被调用,是规则没匹配到链接,不是回调写错。用 scrapy shell 先确认链接的完整形态,再回来核对该写 allow 还是 restrict_css——相对地址会被框架自动补全域名后才参与匹配。
| 场景 | 建议 | 理由 |
|---|---|---|
| 列表页加详情页两层、结构规整 | CrawlSpider | 规则一写,翻页免费 |
| 需要精细控制每类请求的参数 | 普通 Spider | 手写 yield 才能逐条定制 meta 与 priority |
| 链接结构混乱、需要边拆边判断 | 普通 Spider | 声明式规则表达不了复杂判断 |
| 全站爬取、只求覆盖 | CrawlSpider | 规则加白名单即收敛 |
我的习惯是先用 CrawlSpider 铺骨架,遇到需要逐请求定制的地方(如某些详情页要 POST、要带特殊 meta),再把那一类链接改回普通 Spider 手写。两者在同一个工程里可以共存,按站点复杂度分配。
纯声明式规则偶尔力不从心:某些链接要不要跟,得看页面上别的信息。Rule 留了 process_links 钩子,每批候选链接先经你过目再出票:
def filter_links(self, links): # links 是本轮筛出的全部候选,逐个检查或丢弃 kept = [] for link in links: if "flag=ad" in link.url: # 广告位链接丢弃 continue kept.append(link) return kept rules = ( Rule(LinkExtractor(allow=r"/book/\d+"), callback="parse_book", process_links="filter_links"), )
它还适合做一件实用事:把 Spider 级的判断结果(比如当前会话有权限访问哪些分类)注入候选链接的 meta 里,跟下去的回调就能直接取用——规则引擎与业务判断的握手点,就在这里。
到此,Spider 车间的工序齐了:印票、拆包、登记、续票。下一章车票进站排队——去看看候车大厅里的调度器、去重指纹与全局配置。