写爬虫最烦的事情之一,就是页面里全是无关链接。你只想抓正文区的文章,结果导航栏、页脚、分享按钮、广告位全给抓进来了,爬虫跟个无头苍蝇一样满站乱窜。Scrapy里真正负责干这事的组件叫LinkExtractor,它决定哪些链接会被提取、进入后续的抓取队列。很多初学者把它当成一个只会按正则过滤的东西,其实它的参数组合起来能做相当精细的链接筛选,尤其面对复杂页面时,用好了能省一大半写解析代码的功夫。
这篇文章我会把LinkExtractor的每个参数掰开讲清楚,配合复杂页面的提取场景说一说怎么组合使用,最后分享一些我实际踩过的坑。不管你是刚接触Scrapy,还是已经在写CrawlSpider、Rule,这些细节应该都用得上。
1. LinkExtractor的核心定位:它是爬虫的“路口交警”
1.1 为什么不能自己在parse方法里写正则?
很多人在刚开始写爬虫的时候,喜欢直接在parse方法里搞“一把梭”:
def parse(self, response): for href in response.xpath('//a/@href').getall(): url = response.urljoin(href) if '/article/' in url: yield scrapy.Request(url, callback=self.parse_detail)这段代码能跑,但问题很多。第一,提取href之后要做urljoin拼接绝对路径,如果忘了这步,相对链接直接抓崩;第二,正则过滤逻辑写在解析函数里,页面一多,几十个URL模式混在一起,代码迅速变成一坨自己都不想看的“意大利面”;第三,去重、域名限制这些通用能力全得自己造轮子,明明Scrapy都封装好了却不用,等于每次都在重复踩同样的坑。
LinkExtractor的定位,就是把“从页面HTML里找出符合规则的链接”这件事完整抽象出来。它不关心你后续怎么处理链接,只负责把候选链接洗干净、筛一遍、按规则交出来。CrawlSpider里的Rule第一个参数就是LinkExtractor,它决定了爬虫的“活动边界”:哪些链接继续跟进,哪些链接交给回调函数解析,哪些链接根本不配被爬虫看见。
这个抽象最大的价值,不只是省几行代码,而是把“抓取哪些URL”和“怎么解析页面内容”这两件事解耦了。你可以先花时间把链接规则打磨好,再专心写页面解析逻辑,后面维护起来清晰得多。
1.2 LinkExtractor的工作流程和返回结果
LinkExtractor收到一个Response对象之后,内部到底干了哪些事?我拆成几步说明:
- 用lxml把response.body解析成HTML文档树。
- 根据tags参数确定要扫描哪些标签,默认是a标签和area标签。
- 在这些标签里,根据attrs参数读取对应的属性值,默认是href。
- 对每个属性值用urljoin拼成绝对URL,处理掉相对路径。
- 应用deny_extensions参数,把明显是静态文件(比如.zip、.pdf)的链接先排除掉。
- 依次应用allow正则、deny正则、allow_domains、deny_domains这些过滤规则。
- 对通过过滤的URL做规范化处理(canonicalize)、去空格(strip)、去重(unique)。
- 最终返回一个Link对象的列表。
Link对象本身也很有用,它包含url、text、fragment、nofollow这几个属性。text是链接的锚文本,在判断“这个链接是不是我要的”时经常能派上用场;fragment是URL里的锚点部分;nofollow表示链接是否带rel="nofollow"属性。
这个流程决定了LinkExtractor的一个底层层面的限制:它只看HTML源码里的静态链接。如果某个链接是JavaScript动态生成、或者藏在iframe里、或者通过AJAX接口返回的,直接用它提取是提取不到的,后面第四部分我会专门讲这类情况的处理思路。
2. 参数详解:每个参数背后的设计逻辑
2.1 四大过滤参数:allow、deny、allow_domains、deny_domains
先说最常用的四个过滤参数。它们负责回答一个问题:“哪些链接配进候选池?”
allow:接收单个正则字符串或字符串列表。只有URL能匹配这些正则,链接才会被保留。注意这里匹配的是完整的URL字符串,包括协议、域名、路径、参数,所以写正则的时候要有全局意识。如果不写开头和结尾锚点,它是模糊匹配,也就是URL任意位置包含这个模式就算匹配成功。
举个例子,一个新闻站的文章URL长这样:
https://example.com/news/2024/05/12/economy-growth.html我只需要文章链接,可以这样写:
from scrapy.linkextractors import LinkExtractor le = LinkExtractor(allow=r'/news/\d{4}/\d{2}/\d{2}/[a-z\-]+\.html$')这里面的坑有两个。第一,正则里的.如果不转义,会匹配任意字符,写成\.html才不会把aXhtml这种URL也放进来。第二,如果URL带查询参数,比如/news/.../economy-growth.html?from=homepage,上面的正则因为带了$锚点就匹配不上了。这时候要么去掉锚点,要么在正则里对参数部分做处理。
deny:接收单个正则或字符串列表,和allow相反,匹配上的链接会被直接丢弃。它的优先级高于allow,也就是说不管allow有没有匹配到,只要deny匹配了,这个链接就进不了候选池。
典型的用法是排除登录页、退出登录、购物车、后台管理这类不想碰的链接:
le = LinkExtractor( allow=r'/product/\d+\.html$', deny=[r'login', r'logout', r'/cart/', r'/admin/'] )allow_domains:接收域名列表,只要链接的域名不在这张白名单里,就排除。一个容易忽略的细节是,它做的是后缀匹配,不是精确匹配。你写['example.com'],那么www.example.com、news.example.com、m.example.com都会匹配通过,因为它们的后缀都是example.com。很多爬虫跑到后面突然开始抓子域名下的页面,多半就是这个原因。
deny_domains:接收域名列表,命中就排除。通常配合allow_domains一起用,用来封掉某些讨厌的子域名或第三方统计域名。
这里有个经验之谈:allow_domains不是万能的,它只能拦截域名层面的匹配,不能做路径级过滤。如果同一个域名下,有的路径要抓、有的路径不能抓,还是得靠allow和deny来解决。
我展示一个完整的组合:
le = LinkExtractor( allow=[r'/product/\d+\.html$', r'/category/[a-z]+\.html$'], deny=[r'product/\d+\.html\?', r'/login'], allow_domains=['example.com', 'img.example.com'], deny_domains=['m.example.com'] )这个配置允许抓商品页和分类页,但拒绝带查询参数的商品页(防止掉进动态参数陷阱),只允许主站和图片子域名,禁止移动端子域名。
2.2 区域限定:restrict_xpaths与restrict_css
页面上不是所有链接都有价值,最典型的场景是:正文区的文章链接要抓,但导航栏、页脚、友链区、广告位里的链接纯粹是噪音。这时候用allow去过滤是能过滤,但正则得写得非常复杂,因为你是在用正则描述一堆不想要的URL模式,而不是描述“从哪里找链接”。
更优雅的方案是restrict_xpaths:先划定几个HTML区域,然后只在区域内提取链接。
le = LinkExtractor( allow=r'/article/\d+\.html$', restrict_xpaths=['//div[@class="article-list"]', '//div[@class="pagination"]'] )这个参数接收一个XPath字符串或XPath字符串列表,代表多个限定区域。LinkExtractor会先找到这些区域对应的DOM节点,然后在这些节点的上下文范围内查找a标签。区域之间的提取结果是并集关系,不是交集——只要你写的多个XPath里任意一个区域命中了链接,链接就会被提取。
restrict_css的作用完全一样,只是改用CSS选择器。很多用Scrapy的人写XPath特别痛苦,这时候用CSS就舒服多了:
le = LinkExtractor( allow=r'/article/\d+\.html$', restrict_css='div.article-list a.item-link' )restrict_xpaths和restrict_css可以同时写,也是取并集。我实际项目中大概率只用其中一个,除非页面结构特别复杂。
关于XPath的选择,我强烈建议在写进LinkExtractor之前,先用scrapy shell跑一遍验证。比如:
scrapy shell https://example.com/news进shell里手动执行:
response.xpath('//div[@class="article-list"]//a/@href').getall()如果这一步连数据都拿不到,那restrict_xpaths写成什么样都不可能生效。这个验证习惯能帮你过滤掉至少一半的“为什么提不到链接”问题。
2.3 标签与属性挖掘:tags、attrs、process_value
默认情况下,LinkExtractor只会从a标签的href属性里找链接。但对很多项目来说这远远不够。比如你想抓图片地址,或者想从<area>标签里提取地图链接,甚至想提取>le = LinkExtractor(tags=('img',), attrs=('src',))
如果要同时抓a标签的href和img标签的src:
le = LinkExtractor(tags=('a', 'img'), attrs=('href', 'src'))这里会同时提取a[href]和img[src]两类链接。
还有个常见需求:很多图片站或懒加载页面,真实图片地址不在src里,而在data-original或者data-src属性里。这时候把attrs换成对应的属性名就行:
le = LinkExtractor(tags=('img',), attrs=('data-original',))process_value是个容易被忽略但极其实用的参数。它接收一个函数,这个函数会在提取到链接原始属性值之后、apply规则之前被调用。函数接收属性值字符串,返回处理后的字符串;如果返回None,这个链接就被丢弃。
典型使用场景是处理那些不规范的链接。比如页面里混着一堆javascript:void(0)或者mailto:这种伪链接,直接清洗掉:
def clean_link(value): if value.startswith(('javascript:', 'mailto:', 'tel:')): return None return value再比如有些URL带了明显的追踪参数,想提取时顺手清理:
from urllib.parse import urlsplit, parse_qs, urlencode, urlunsplit def strip_tracking(value): parts = urlsplit(value) query = parse_qs(parts.query) query.pop('utm_source', None) query.pop('utm_medium', None) return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query, doseq=True), parts.fragment))这里的处理逻辑很直观:提取到的值先进process_value清洗一轮,后面的allow、deny这些规则再继续过滤。所以process_value很适合做“链接的预清洗”。
2.4 链接去噪与规范化:canonicalize、unique、strip、deny_extensions
这四个参数负责“链接收尾”,让最终进入队列的URL干净、可复用。
canonicalize参数默认True,会按照URL规范化规则对链接做标准化处理,包括把相对路径装成绝对路径、去掉默认端口、把多余的斜杠合并、把字符做合规转义等。听起来很美,但有个实际的坑:某些网站的URL是带状态的,规范化可能会改变URL语义。比如https://example.com/api/item?id=123&这种URL,规范化后末尾的空参数可能被清理掉,如果服务端很傻,请求就会变样。我遇到过一次,最后就是把canonicalize设成False才解决。
unique参数默认True,做去重。这个去重是基于URL字符串的精确去重,也就是说,https://example.com/item/1和https://example.com/item/1?from=news会被当成两个不同链接。想跨参数去重,还是得靠process_value提前把URL洗干净。
strip参数默认True,去掉URL首尾的空格和不可见字符。看起来小事,但有的时候HTML源码里写链接时戳了个空格,不清理的话URL就带着%20,请求直接404。
deny_extensions参数,排除指定文件扩展名的链接。Scrapy的默认值排除了一大堆常见静态资源后缀,包括.7z、.jpg、.pdf、.css、.js、.zip等几十种。但这里有个反直觉的点:如果一个URL是https://example.com/file.pdf但实际返回的是HTML页面,默认的deny_extensions会把这条路堵死。真要抓这类链接,得手动把deny_extensions清掉:
le = LinkExtractor( allow=r'/file/[a-z0-9]+\.pdf', deny_extensions=set() )默认值用的是scrapy.linkextractors包里的一个集合,你直接传一个空set就能绕过。
3. 复杂链接提取的三个实战组合案例
3.1 案例一:正文区+分页导航的精准链接收编
假设要爬一个新闻站,它的页面结构比较典型:
- 文章列表页里,正文区在
div.article-list,里面是带/article/数字.html形式的链接。 - 列表页底部有一个分页导航,在
div.pagination里,链接形如/list/2.html、/list/3.html。 - 页面顶部导航和页脚有大量栏目链接、推荐位、友情链接,全不想要。
如果只用allow正则不用restrict_xpaths,写起来也还行,但一旦页面加了个“热门新闻”版块,里面也有文章链接,你又必须去改正则,麻烦。直接用区域限定:
from scrapy.spiders import CrawlSpider, Rule from scrapy.linkextractors import LinkExtractor class NewsSpider(CrawlSpider): name = 'news_spider' start_urls = ['https://example.com/news/'] rules = ( Rule( LinkExtractor( allow=r'/article/\d+\.html$', deny=r'login|register', restrict_xpaths=[ '//div[@class="article-list"]', '//div[@class="pagination"]' ] ), callback='parse_article', follow=False ), Rule( LinkExtractor( allow=r'/list/\d+\.html$', restrict_xpaths='//div[@class="pagination"]' ), follow=True ), )这里有三个细节值得说。
第一,第一条Rule的callback只处理文章页,follow设为False,因为文章页里可能不存在值得继续跟进的分页链接。第二条Rule专门处理列表分页,命中后不设callback、继续follow,这样爬虫才会沿着分页一路爬下去,同时每个列表页里的文章链接又被第一条Rule接收。
第二,restrict_xpaths可以同时限定文章列表区和分页区,这是常见的双区域组合。但要注意,restrict_xpaths所有区域提取到的链接,必须还要再过一次allow正则,区域只是先把候选范围收窄,不是最终放行。
第三,分页链接如果用相对路径list/2.html,LinkExtractor内部会基于当前页URL做urljoin,所以不用担心相对路径问题。这也是为什么我建议直接用shell验证页面结构——你看到的href是啥,LinkExtractor处理完是啥,得心里有数。
3.2 案例二:多域名与子域名共存时的链接筛选
很多中大型网站的体系是分散的:主站www.example.com,图片服务器static.example.com,移动站m.example.com,海外站en.example.com,另外一个独立的cdn域名cdn.othersite.net。直接用一条爬虫想把这些全部理清楚,filter就得好好设计。
如果只想抓主站和移动站,不碰图片站,最简单的写法:
le = LinkExtractor( allow=r'/product/[a-z0-9\-]+\.html$', allow_domains=['example.com', 'm.example.com'], deny_domains=['static.example.com', 'cdn.othersite.net'] )但前面说过,allow_domains是后缀匹配,['example.com']会连同static.example.com也放进来。所以如果只想要www和m,光靠allow_domains是不够的,得加上正则限制。
更严谨一点的写法是用allow来限制URL的结构:
le = LinkExtractor( allow=[ r'^https?://(www\.)?example\.com/product/', r'^https?://m\.example\.com/product/' ], deny_domains=['static.example.com', 'cdn.othersite.net'] )这样就精确多了。allow里锚定了协议的https?、域名带www则可选、路径限制在product下,把子域名混进来的可能性降到了最低。
这里我多说一句关于性能的考虑:allow_domains和deny_domains的匹配是字符串后缀匹配,比正则快很多。如果只是单纯想排除某些域名,优先用deny_domains而不是写在deny正则里,毕竟正则匹配要跑一遍完整的正则引擎,域名后缀匹配一次equals就能结束。爬虫跑个几百万个链接,这个性能差距是实打实的。
3.3 案例三:带跳转、动态与相对路径的链接处理
有一种非常折磨人的链接形态:跳转链接。页面上的URL不是直接指向目标页面,而是先指向一个中转地址,比如/goto?target=https%3A%2F%2Fexample.com%2Fitem%2F123,服务端再通过302重定向带你飞。
这时候不能只提取跳转链接就完事,因为一个中转地址可能对应几万个不同目标,全让爬虫去跑重定向,效率很低。更好的做法是在process_value阶段就把真实target参数拿出来:
from urllib.parse import urlsplit, parse_qs def extract_real_url(value): # 只处理跳转链接,其他链接原样返回 if '/goto?' in value: qs = parse_qs(urlsplit(value).query) real = qs.get('target', [None])[0] if real: return real return None return value le = LinkExtractor( allow=r'example\.com/item/\d+', process_value=extract_real_url )parse_qs会自动做URL解码,https%3A%2F%2Fexample.com%2Fitem%2F123会解成完整的URL。拿到真实链接后,再交给allow正则做二次过滤,整个链路就通了。
还有一种情况是链接带动态参数,比如带sessionid、order这类随访问变化的参数。如果不去掉,同一个页面可能被抓几十遍,因为有几十个不同的参数组合。处理方式还是在process_value里裁掉:
def clean_url(value): parts = urlsplit(value) return urlunsplit((parts.scheme, parts.netloc, parts.path, '', parts.fragment))把query整体丢掉,URL就稳定了。
最后说相对路径。LinkExtractor里的urljoin处理相对路径是默认行为,但有一个容易踩的坑:process_value返回的URL会再走一次urljoin,如果你在process_value里拼接时用错了base_url,整个链接就废了。所以我建议process_value里尽量只做字符串变换和参数解析,不要自己拼路径——拼路径这种脏活交给LinkExtractor的urljoin去做。
4. 常见问题与排查技巧实录
4.1 restrict_xpaths选了区域却什么都提不到
这个问题的出现频率高到我几乎每个项目都会遇到。最常见的四种原因:
第一种,XPath本身写错了。查错的最好办法不是瞪着屏幕看,是进scrapy shell直接验证:
scrapy shell https://example.com/target然后:
response.xpath('//div[@class="article-list"]//a').getall()如果返回空列表,说明页面里压根没有这个class的div。这时候用浏览器开发者工具再看一眼真实的class名,很多前端框架会生成类似article-list__item--active这种组合class,精确匹配就挂了。
第二种,页面里的目标链接不是static HTML,而是通过JavaScript动态渲染出来的。你在浏览器里能看到链接,但curl拿回来的源码里没有。这种情况restrict_xpaths怎么写都没用,得用动态页面方案(下面单独说)。
第三种是页面是iframe结构,内容在子iframe里。lxml解析的是当前页面的HTML源码,根本不会进入iframe,自然提取不到。除非你能构造出iframe的src地址,把iframe内容也当一个独立页面请求一次。
第四种是XPath区域里有链接,但那些链接的href属性是空字符串或者#,LinkExtractor提取时把这些当成了无效链接直接弃了。这种情况要在页面验证里额外看看href的实际值。
4.2 链接重复抓取与参数噪声
很多时候爬虫抓着抓着就开始疯狂重复抓同一个页面,日志里刷屏,但数据库里数据没新增几条。去重这个环节,你以为unique=True就万事大吉,但实际的问题是URL字符串不一致,而不是有没有去重。
举几个真实案例:
- 同一个商品,URL有时带
?from=channel,有时不带,字符串不同。 - 同一个页面,有时出现在
https://,有时出现在https://www.,也算两个链接。 - URL末尾有时带着
#comments这种锚点,字符串不同。 - 有的URL末尾多一个斜杠,有的没有,比如
/product/123和/product/123/。
处理方案很简单,还是回到process_value里做统一格式化。一个我自己常用的清洗函数长这样:
def normalize_url(value): # 去掉锚点 value = value.split('#', 1)[0] # 统一去掉末尾斜杠(根路径除外) if value.endswith('/') and value != '/': value = value[:-1] return value再配合strip_tracking这种去参数逻辑,URL就稳定多了。如果想要更稳,还可以把URL再做一次md5摘要作为去重键。
4.3 动态渲染页面中的链接提取
这是LinkExtractor最大的“盲区”,因为它只基于HTML源码工作。如果页面里的链接是<a href="#" @click="goToDetail(123)">这种形式,href只是个摆设,真实链接藏在Javascript函数里,LinkExtractor就完全失效了。
应对策略大致有三条路。
第一条路是走渲染方案,用无头浏览器或渲染服务拿到JavaScript执行完之后的完整DOM,再把渲染后的HTML喂给LinkExtractor。比如配合Playwright或Splash,在下载中间件里做页面渲染。这个方案最通用,但代价是性能直线下降,一个页面可能要等好几秒,服务器资源也很吃紧。
第二条路是从页面内嵌数据里直接挖链接。很多前端框架会把初始数据以JSON形式塞进script标签,比如:
<script id="__NEXT_DATA__" type="application/json"> {"props":{"pageProps":{"items":[{"id":123,"url":"/item/123"}]}}} </script>这种数据完全可以自己解析,不一定非要走LinkExtractor的路子。一旦页面结构是这个形态,我一般直接在parse方法里用正则或者json解析把真实URL拉出来,比渲染快一个数量级。
第三条路是用XHR接口逆向。找到前端请求的AJAX接口,直接请求接口拿JSON数据,从JSON里提取URL。这种方式速度最快、稳定性最高,前提是接口没有复杂的加密签名策略。
这条选择路径我的倾向是:能用JSON提取就用JSON提取,实在不行才上渲染。毕竟爬虫是个工程问题,不是炫技问题,稳定和速度永远优先。
5. 一些我养成的使用习惯
文章快写完了,我分享几个自己用LinkExtractor时积累的习惯,希望对你有参考价值。
第一个习惯,写Rule之前先验证提取效果。新建项目后,先别急着写一整页的Rule,打开scrapy shell,手动创建LinkExtractor实例,对目标页面跑一次extract_links,看看返回的链接列表是不是心里想要的那批。这一步能提前暴露绝大部分配置错误,省得跑起爬虫来才发现规则不对。
from scrapy.linkextractors import LinkExtractor le = LinkExtractor( allow=r'/product/\d+\.html$', restrict_xpaths='//div[@class="product-list"]' ) links = le.extract_links(response) for link in links[:20]: print(link.url)这个方法是我在项目开发阶段用频率最高的一段代码,没有之一。
第二个习惯,能用区域限定就用区域限定,别超大正则。每次看到一个几百字符、到处是管道符和分组的大正则,我都头皮发麻。restrict_xpaths、restrict_css、deny_domains这些参数,本来就是用来减轻正则负担的。组合使用比堆一个大正则清晰得多,后续维护成本也低。
第三个习惯,注意Rule的书写顺序。CrawlSpider的规则是按顺序匹配的,先匹配到的规则会优先处理链接。所以具体规则放前面、兜底规则放后面。比如文章页规则放第一条,分页规则放第二条,因为文章链接比分页链接更具体,先匹配的就是它。
最后一个习惯,开发阶段把日志级别调成DEBUG,专门观察LinkExtractor行为。Scrapy的debug日志会打印提取到的链接和过滤掉的原因,虽然信息量大,但排查“为什么我没提到某个链接”这类问题时,日志能给很多线索。定位到问题再把日志调回INFO,避免刷屏。
我在实际使用中感受最深的一点是,LinkExtractor这东西,参数看着不多,真正组合起来的花样远超预期。每加一个参数,都要回到真实页面上去确认一遍,别凭感觉写配置。爬虫出问题大多不是引擎不行,而是入口规则没定好。规则稳了,整个抓取链路就稳了一大半。希望这篇分享能让你少走点我走过的弯路。