1. 为什么你的爬虫会漏链:搞清楚LinkExtractor在Scrapy里的角色做了几年爬虫,见得最多的一个场景就是:明明目标网站里有大量链接,规则也写了,可跑完之后发现只抓到一小部分页面,很多深层链接根本进不去。问题往往不是出在下载器、也不是出在反爬,而是出在最容易被忽视的环节——链接提取。Scrapy里的LinkExtractor,就是专门负责从响应内容中"扫描并筛选"链接的组件。你可以把它理解成一个很挑剔的接待员:它只放行符合你设定条件的链接,其它一律拒之门外。这篇文章围绕Scrapy LinkExtractor参数详解与复杂链接提取展开,想把每个参数背后的匹配逻辑、使用场景和容易踩的坑都掰开讲一遍。适合刚上手Scrapy没多久、对Rule和LinkExtractor的配合似懂非懂的人,也适合已经写过一段时间爬虫、但经常被"链接提取不准"折磨的开发者。读完这篇文章,你至少能清楚知道:一个链接从HTML里被挑出来之后,还要经过多少道“审查”才会真正进入后续爬取流程。LinkExtractor在Scrapy里的定位,其实是一个“链接采集策略器”。它做的事情可以拆成四步:在给定的HTML或XML片段里,找出所有满足标签和属性要求的链接元素;把href或src等属性值提取出来,并解析成绝对URL;用allow、deny、allow_domains等参数对URL做一轮或多轮过滤;最终输出一份“合规链接”列表。如果你使用的是CrawlSpider,那LinkExtractor通常和Rule绑定在一起。每个Rule都包含一个LinkExtractor实例,CrawlSpider会在每个已爬取页面的Response上运行这些提取器,然后把得到的链接清单交给回调函数或后续的爬取队列。简单来说,LinkExtractor决定了“接下来爬哪里”,而callback决定了“爬下来的页面怎么处理”。有意思的是,Scrapy本身自带的LinkExtractor是从SgmlLinkExtractor和HtmlLinkExtractor演变过来的,目前在通用情况下直接用scrapy.linkextractors.LinkExtractor就行。这个类内部封装了正则过滤、XPath/CSS约束、域名限制、URL规范化、去重等多个功能,说它是个“小型的链接决策引擎”也不算夸张。接下来我们进入正题,逐个啃参数。我先从大家最常用的allow和deny讲起,因为这两个参数直接决定了你能“放行”哪些链接,也是复杂链接提取的第一道大门。2. allow与deny的正则博弈:最常用参数背后的匹配逻辑2.1 allow是怎么工作的allow参数接收一个正则表达式或正则表达式列表。说得直白点,它就是一个“白名单”:URL只有匹配了这里的正则,才会被保留。所有不匹配的链接,不管出现在页面上哪个位置,都会被直接丢弃。代码写起来非常简洁:from scrapy.linkextractors import LinkExtractor # 只提取URL中包含 /product/ 的链接 le = LinkExtractor(allow=r'/product/')这里有个很容易被忽略的细节:allow里的正则不是全字符串匹配,而是使用了re.search的语义。也就是说,正则只需要在URL的某个位置命中即可,不需要^和$首尾锚定。比如上面这个例子,/product/这个模式能匹配到https://example.com/product/123?id=1,也能匹配到https://example.com/category/product/list,因为搜索会扫描整个字符串。不过在实际项目中,这种“松散匹配”经常带来意外。我见过有人写allow=r'product',结果把/products/、/production/、/copy_product/全放出来了,后面不得不用deny再补一刀。所以我的建议是:能写精确就写精确,最好把路径结构也考虑进去。比如:le = LinkExtractor(allow=r'/product/\d+$')这样只匹配以/product/加数字结尾的URL,把列表页、详情页之外的杂链接挡在门外。这种写法在复杂链接提取场景里非常实用,因为你往往只需要某一类URL。2.2 deny是“黑名单”不是替补deny参数同样接收正则或正则列表,作用正好相反:只要URL命中它,就会被排除。它的优先级高于allow,也就是说,哪怕某个链接同时匹配了allow和deny,最终结果也是被拒绝。看起来很简单,但实际使用中有一个经典问题:很多人把deny写成了allow的“反义正则”,结果发现链接没少多少。原因很简单——deny是在allow通过之后才发挥作用的,如果allow已经把链接全放行了,deny就得“一刀一个”精准干掉你不想要的URL。举个例子,想提取所有/news/下面的文章链接,但不要“置顶”和“专题”两类页面:le = LinkExtractor( allow=r'/news/\d+\.html$', deny=[r'/news/top\.html$', r'/news/special/'], )再用re.search逐条匹配,如果命中任意一条deny正则,这个链接就会被丢掉。这种“白名单+黑名单”的配合方式,算得上是复杂链接提取里最基础、也最核心的打法之一。2.3 正则里的转义与陷阱写allow和deny时,最大的坑其实不在逻辑,而在转义。URL里常见的字符如?、.、+、[]等,在正则里都有特殊含义。如果直接拿URL片段当正则用,往往结果莫名其妙。我见过有人这么写:le = LinkExtractor(allow=r'product.php?id=123')这里的.可以匹配任意字符,所以它不止能匹配product.php,还能匹配productXphp这种离谱的URL。更麻烦的是?,如果出现在正则里,它表示“前面的字符出现0次或1次”。也就是说,product.php?id=123这段表达式里的?,并不是匹配URL里的问号,而是把p变为可选。最终能匹配的URL五花八门,完全不是作者本意。正确的做法是把URL当成普通字符串来转义,或者用re.escape把它变成“字面量”:import re # 用 re.escape 把URL文本转成正则 le = LinkExtractor(allow=re.escape('product.php?id=123'))不过这种写法只适合精确匹配。更常用的方式是只针对路径部分做正则,查询参数用额外的过滤逻辑去处理,这样反而更清晰。我在后面讲复杂链接的时候还会提到这一点。2.4 多个allow表达式怎么配合allow允许传列表,例如:le = LinkExtractor(allow=[r'/product/\d+$', r'/item/\d+$'])多个正则之间是“或”的关系,只要命中任意一个就被保留。这在聚合多个内容类目时非常好用。比如一个网站既有商品详情页,又有评测文章页,你想同时抓这两类页面,用一个列表就能搞定。不过要注意,如果两个正则之间本身就有重叠,你可能需要先想清楚“到底想要什么类型”,否则后面处理数据时会很混乱。另一个常见场景是允许列表页和详情页同时进队列,但要求它们走不同的回调逻辑。这就要通过Rule里的callback和follow来实现了,我留在第6章讲。3. restrict_xpaths与restrict_css:把链接提取范围锁进“局部区域”3.1 为什么需要限制提取范围一个页面上可能有几十甚至上百个链接,但真正值得爬的往往只在几个区块里:侧边栏的“你可能喜欢”、页脚的推荐位、导航菜单,这些通常不是你的目标。如果不加限制,LinkExtractor会把它们全部提取出来,既浪费请求,又增加被反爬策略盯上的风险。restrict_xpaths和restrict_css就是用来“画地盘”的。它们让链接提取器只关注网页的特定区域,区域之外的链接一概不碰。3.2 restrict_xpaths使用实践restrict_xpaths接收字符串或列表,里面的表达式是XPath。作用相当于先用XPath选出某些节点,然后在节点内部的HTML片段上做链接提取。看一个典型的例子:le = LinkExtractor( allow=r'/article/\d+\.html$', restrict_xpaths='//div[@class="article-list"]//a', )这条规则的意思是:先定位到所有class为article-list的div元素,然后只从这些div内部的a标签里提取链接。注意,restrict_xpaths的语义比你想的还要严格一些——它会把匹配到的节点内容作为“候选元素”,LinkExtractor再从这些候选元素里寻找链接。如果你把//a写在后面,表示“这些a标签本身就是链接来源”,效果和最常用的定位方式一致。实际项目里,我还经常配合contains来过滤侧边栏的干扰:le = LinkExtractor( allow=r'/news/\d+$', restrict_xpaths='//div[contains(@class, "news-list") and not(contains(@class, "sidebar"))]//a', )不过要提醒一点:restrict_xpaths匹配到的节点,如果内部还有嵌套链接,也会被提取。也就是说,你选了一个区块,那么区块内所有符合tags和attrs要求的链接都会被搜出来。3.3 restrict_css怎么用restrict_css的写法和XPath类似,但更简洁,适合熟悉CSS选择器的朋友:le = LinkExtractor( allow=r'/product/\d+', restrict_css='div.product-list a.item-link', )这会先找到所有div.product-list里的a.item-link,然后从这些链接的href属性里提取URL。相比XPath,CSS选择器在语义上更直白,处理不了太复杂的结构时,选CSS会更省心。注意,restrict_css内部会被转换成XPath再执行,性能上差别不大。但如果你需要写“包含文本内容”的复杂条件,还是直接用restrict_xpaths更顺手,例如:restrict_xpaths='//a[contains(text(), "下一页")]'这种“按链接文字定位”的需求,用CSS选择器实现要绕一大圈,XPath拍上去就完了。3.4 多个区域怎么同时限制这两个参数都支持传列表:le = LinkExtractor( allow=r'/story/\d+', restrict_xpaths=['//div[@id="main-content"]//a', '//ul[@class="pagination"]//a'], )只要链接出现在任意一个指定区域内,并且满足allow条件,就会被提取。这种写法在内容站里很常见:主列表区和分页区都要抓,侧边栏和页脚则不碰。有一点要记住:restrict_xpaths与restrict_css不能混用。你只能选一种方式。如果你确实需要同时用XPath和CSS,可以把其中一个结果先手动处理,或者拆分两个Rule,分别配置。我在项目里经常拆分Rule来解决这类问题,虽然多写几行,但逻辑会很清爽。4. 复杂场景下的链接提取实战:分页、相对路径与动态URL4.1 相对链接和绝对链接:LinkExtractor已经处理了80%写爬虫最怕的一件事,就是页面里的链接全是相对路径,比如/product/123、../list这种,没有域名前缀。如果你手动去拼接,容易出各种边界错误。而LinkExtractor在处理链接时,会自动把它转成绝对URL。具体做法是:用Response的url作为基准,调用urljoin把相对路径拼全。所以就算页面里的href是/product/123,提取出来后你拿到的也是https://example.com/product/123,省去了自己写拼接逻辑的麻烦。但这里有一个容易忽略的问题:如果页面上有<base>标签,会改变相对路径的基准URL。Scrapy的LinkExtractor在处理时,会优先使用<base>标签指定的href作为基准。你要是发现某些链接被拼接成了预期之外的域名,可以先检查页面的<base>标签。4.2 分页链接:不要只盯着“下一页”很多人在做分页提取时,只想着抓“下一页”按钮。这本身没错,但真正的复杂场景往往藏在几个维度:总页数、跳页链接、异步加载的分页数据、以及“下一页”按钮的多种写法。拿一个典型的分页结构来说:<ul class="pagination"> <li><a href="/news/1">1</a></li> <li><a href="/news/2">2</a></li> <li><a href="/news/3">3</a></li> <li><a href="/news/4">4</a></li> <li><a href="/news/5">5</a></li> <li><a href="/news/2">下一页</a></li> </ul>你想提取/news/2到/news/5这四页,而不要“下一页”这种起导航作用的重复链接。用之前讲的方案可以这样配置:Rule( LinkExtractor(allow=r'/news/[2-9]$', restrict_css='ul.pagination'), callback='parse_list', follow=True, )为什么用[2-9]而不是\d+?因为我不想把第1页也放进后续队列。第1页通常是入口URL,已经在start_urls里爬过了。如果让\d+把第1页也匹配上,爬虫就可能重复请求首页。这种细节在复杂链接提取里非常关键,因为你设计规则时,除了考虑“能不能匹配”,还要考虑“会不会造成重复请求”。4.3 列表页与详情页的链接分开处理内容站通常有两种链接:列表页和详情页。比如/list/1.html是列表页,/detail/100.html是详情页。我们希望爬虫先在列表页上提取详情页链接,把详情页交给解析函数,同时继续跟进下一页列表页。考虑一个Rule配置:Rule( LinkExtractor(allow=r'/list/\d+\.html$'), follow=True, ), Rule( LinkExtractor(allow=r'/detail/\d+\.html$'), callback='parse_detail', ),这里第一个Rule只负责列表页链接,follow=True表示“把提取到的列表页链接继续加入爬取队列”。第二个Rule匹配详情页,callback指定详情页的解析函数,follow不写则默认为False,也就是不继续从详情页里提取链接,避免爬进无关页面。这种“一个页面里同时存在两类链接”的场景,恰恰是allow加restrict最擅长解决的。如果页面上还有推广链接、外链导航,只要它们不符合allow,就会被自动过滤掉。4.4 动态加载的链接怎么破很多网站的链接是JavaScript动态插入的,初始HTML的<a>标签里什么都没有。此时LinkExtractor直接提取会得到空列表。面对这种情况,一般有两条路:第一条是用Selenium或Playwright渲染页面后,再把它交给Scrapy的Response。这种方案最直接,但性能损耗明显,适合规模不大的站点。第二条是通过分析网络请求,找到数据接口,直接请求接口拿链接。从我的经验看,第二种方案才是复杂链接提取的正解,因为数据接口通常返回结构化JSON,提取链接反而比解析HTML更精准。举个例子,某网站上动态加载了一组商品列表,接口返回:{ "items": [ {"id": 1, "url": "/product/1"}, {"id": 2, "url": "/product/2"} ] }这种情况完全不需要LinkExtractor,直接在回调里解析JSON就行:def parse(self, response): data = response.json() for item in data['items']: yield response.follow(item['url'], callback=self.parse_product)但如果你还是想用LinkExtractor处理渲染后的HTML,可以在渲染中间件里把<a>标签补回响应。总之,动态链接的核心在于“拿到真实URL的来源”,LinkExtractor不是万能的,它只处理现成的HTML。5. 容易被忽略的参数与性能调优:canonicalize、unique与去重策略5.1 process_value:链接清洗的最后一道闸process_value是一个回调函数,接收提取到的链接字符串,返回处理后的字符串或None。返回None表示丢弃该链接。这个参数在处理特殊格式的href时特别有用。比如某些模板会把空链接写成#,或者用javacript:void(0)代替无操作。你可以在process_value里把这些脏数据全部清洗掉:def clean_url(value): if value.startswith(('javascript:', 'mailto:')): return None if value == '#': return None # 去掉空格和换行 return value.strip() le = LinkExtractor(allow=r'/product/\d+', process_value=clean_url)注意,process_value是在allow/deny过滤之前还是之后执行的?答案是在正则过滤之前。Scrapy的流程是:先提取原始链接,调用process_value获得处理后URL,再进行allow/deny判断。所以你可以把一些“重写URL”的逻辑放在这里,比如把带中文编码的URL转成ASCII,或者给相对路径补齐域名。这个参数还有一个高级用法:从非标准标签里提取链接。比如页面里用>le = LinkExtractor(tags=('img',), attrs=('src',))注意,tags和attrs这里的标签名称都需要小写,attrs的取值一般是('href', 'src')。如果你同时想提取多个标签的多个属性,可以一起传:le = LinkExtractor(tags=('a', 'area', 'iframe'), attrs=('href', 'src'))这个参数用得不多,但在某些老站点、图片站、文档站里非常管用。我有一次接手一个历史数据迁移项目,页面里的下载链接全部放在<a rel="download">里面,href是一样的,但下载地址是>Rule(LinkExtractor(allow=r'/list/'), follow=True), Rule(LinkExtractor(allow=r'/detail/\d+$'), callback='parse_detail'),如果某个链接既符合/list/又符合/detail/(虽然这不多见),它会被第一个Rule接管,进入列表页的流程而不是详情页流程。所以Rule顺序是有讲究的,一般把范围更宽的规则放前面,把专门匹配详情页的规则放后面,能减少意外。6.2 follow的真意:是否继续“顺着爬”follow控制的是:从当前页面提取到的、被该Rule接受的链接,要不要继续作为后续爬取的种子页面。如果为False,则该页面即使用匹配到链接,也只会把这些链接作为结果输出,而不去继续跟进它们。最常见的使用组合是:Rule(LinkExtractor(allow=r'/article/\d+$'), callback='parse_article', follow=False)这个规则意味着:一旦爬到文章详情页,就调用parse_article解析内容,然后不再从文章页里提取链接。很多文章详情页里会有“相关推荐”,如果follow设成True,爬虫就会顺着推荐链接到处跑,很容易跑出控制范围。反过来,列表页的规则通常要follow=True,因为你还想继续翻下一列表页。二者差别很大,写之前多问自己一句:“这个页面还需要继续往外爬吗?”6.3 用多个Rule处理不同站点结构当你面对一个结构混杂的站点时,不要试图在一个Rule里把链接全包住。可以按页面功能拆成几个Rule,例如:Rule( LinkExtractor( allow=r'/category/\d+$', restrict_xpaths='//div[@class="category"]//a' ), follow=True, ), Rule( LinkExtractor( allow=r'/product/\d{4,}$', restrict_css='div.product-list a' ), callback='parse_item', ), Rule( LinkExtractor( allow=r'/brand/\w+$', restrict_css='div.brand-list a' ), callback='parse_brand', ),这里的三个Rule各有各的职责:翻分类页、拿商品页、拿品牌页。这样写的好处是:代码结构清晰、调试方便,后面想限流或者加延迟,只需要针对某一个Rule调整即可。不过要提醒一下,Rule的数量不是越多越好。每个Rule执行都会遍历页面上的链接,Rule太多会拖慢提取速度。如果一个站点能把链接统一成一种风格,尽量用一个宽Rule加多个callback判断来搞定。6.4 动态链接在Rule里的变通动态链接出现时,Rule也不是完全没用。如果你能把JS渲染后的HTML写回Response,LinkExtractor照常工作。我实际中更喜欢在下载中间件里用Selenium渲染,然后把渲染后的response.text替换成新HTML。这样Scrapy的LinkExtractor完全不需要改,Rule配置能继续复用。这里给出一个中间件片段思路(不是完整代码,只是演示思路):class RenderMiddleware: def process_response(self, request, response, spider): # 只有满足某些条件时才渲染 if request.meta.get('render'): driver = get_driver() driver.get(response.url) html = driver.page_source response = response.replace(text=html) return response只要在Request里带上meta={'render': True},这个页面就会以渲染后的HTML进入后续LinkExtractor流程。注意,这种方式会降低抓取速度,尽量只给列表页用,不要给所有详情页都开渲染。6.5 调试LinkExtractor拿到正确表达式写规则最怕的是“感觉对,实际不对”。你可以利用Scrapy的shell快速验证LinkExtractor到底提取了哪些链接:scrapy shell https://example.com进入shell后执行:from scrapy.linkextractors import LinkExtractor le = LinkExtractor(allow=r'/product/\d+$') links = le.extract_links(response) for l in links: print(l.url)这样就能直接看到当前规则下,页面上到底哪些链接被提取出来了。比运行一整个爬虫再翻日志快多了。你也可以同时试几种规则,看哪种覆盖面最准确。这是我在做复杂链接提取时最常用的调试手段。另外提醒一句:extract_links是同步方法,返回的是Link对象列表,每个Link有url、text、fragment等属性。当你需要记录“链接锚文本”时,可以读link.text。7. 从实际项目里总结的若干经验与教训7.1 别过度依赖LinkExtractor做业务过滤LinkExtractor擅长做“URL形态”的过滤,但不擅长做“业务语义”的过滤。比如你只想爬下价格低于100元的商品,这种条件就别指望在LinkExtractor里做。你可以先放宽allow规则,然后在详情页回调里再用价格字段筛选。否则,规则越复杂,正则越难维护,之后改需求时牵一发动全身。我见过一个项目,为了不爬不满足条件的商品,把十几个正则串成一条长长的allow,最后谁也看不懂。后来换了方案:链接提取只负责“是不是商品详情页”,商品是否满足条件交给管道处理。代码瞬间清爽很多。7.2 注意Base URL和域名差异爬虫里经常遇到一个网站挂了好几个域名:比如www.example.com和m.example.com是同一个站点的PC版和移动版。如果不加限制,链接可能在两个域名之间反复横跳,直到把请求数耗尽。这种场景用allow_domains来限定:le = LinkExtractor(allow_domains=['www.example.com', 'm.example.com'])注意,allow_domains匹配的是域名后缀,不是完整地址。allow_domains=['example.com']也能匹配www.example.com和m.example.com,因为域名部分以example.com结尾。但它不会匹配notexample.com,因为这里是域名边界比较,不是子串查找。7.3 分页链接与详情链接的模式冲突有时候,分页URL长得和详情URL几乎一样,只是多了一个参数。例如/article/1.html和/article/1.html?page=2。如果你的allow写得太宽,就会把整个分页树都当成文章链接。这时候可以把查询参数纳入考虑:# 只允许没有page参数的文章链接 le = LinkExtractor(allow=r'/article/\d+\.html$', deny=r'\?page=')这样既能拿文章详情页,又不会把分页链接放进来。这个方法在复杂链接提取里很实用,因为它不仅靠路径判断,还能对查询参数做筛选。7.4 合理控制最大提取数量一个页面上的链接数量可能非常大,LinkExtractor提供了一个由restrict_xpaths和allow配合的天然限流方式,但如果你想硬性限制提取个数,可以在外部处理:links = le.extract_links(response)[:20]这种截断方法简单有效,适合只需要前几条热门链接的场景。但要注意,截断发生在提取之后,意味着该提取的还是要提取,性能上没有优化。真正的性能优化还是靠精确的restrict_xpaths缩小搜索范围。7.5 日志里看到“Ignoring link”并不总是坏事在调试爬虫时,经常会看到类似Ignoring link <...> (no match)的日志。很多人一看到就慌,以为是规则写错了。其实这只是LinkExtractor在执行过滤,把不符合allow的链接给丢了。如果你确认某个链接应该被提取,但它被ignore,再回头排查正则和restrict_xpaths。如果ignore的本就是你不需要的链接,完全可以忽略这条日志,或者调整日志级别过滤噪音。7.6 复杂链接提取的最终答案永远是“分层”回到开头的场景:漏链问题为什么频繁出现?本质上是很多人试图用一个规则搞定所有链接类型。实际情况是,链接形态越复杂,越应该分层处理。第一层用Restrict划定区域,第二层用allow/deny锁定URL形态,第三层用process_value清洗异常数据,第四层用Rule的callback与follow决定后续走向。每一层各司其职,出了问题也能快速定位。从我个人的实战体会来说,Scrapy LinkExtractor参数确实多,但真正用得顺手的就那么几个:allow、deny、restrict_xpaths、process_value、follow。把这些吃透,复杂链接提取的大多数问题都能解决。剩下那些Special Case,就靠scrapy shell里反复调试验证,别凭感觉写正则。这也是我在一次次踩坑后,形成的最可靠的工作方式。
1. 为什么你的爬虫会漏链:搞清楚LinkExtractor在Scrapy里的角色做了几年爬虫,见得最多的一个场景就是:明明目标网站里有大量链接,规则也写了,可跑完之后发现只抓到一小部分页面,很多深层链接根本进不去。问题往往不是出在下载器、也不是出在反爬,而是出在最容易被忽视的环节——链接提取。Scrapy里的LinkExtractor,就是专门负责从响应内容中"扫描并筛选"链接的组件。你可以把它理解成一个很挑剔的接待员:它只放行符合你设定条件的链接,其它一律拒之门外。这篇文章围绕Scrapy LinkExtractor参数详解与复杂链接提取展开,想把每个参数背后的匹配逻辑、使用场景和容易踩的坑都掰开讲一遍。适合刚上手Scrapy没多久、对Rule和LinkExtractor的配合似懂非懂的人,也适合已经写过一段时间爬虫、但经常被"链接提取不准"折磨的开发者。读完这篇文章,你至少能清楚知道:一个链接从HTML里被挑出来之后,还要经过多少道“审查”才会真正进入后续爬取流程。LinkExtractor在Scrapy里的定位,其实是一个“链接采集策略器”。它做的事情可以拆成四步:在给定的HTML或XML片段里,找出所有满足标签和属性要求的链接元素;把href或src等属性值提取出来,并解析成绝对URL;用allow、deny、allow_domains等参数对URL做一轮或多轮过滤;最终输出一份“合规链接”列表。如果你使用的是CrawlSpider,那LinkExtractor通常和Rule绑定在一起。每个Rule都包含一个LinkExtractor实例,CrawlSpider会在每个已爬取页面的Response上运行这些提取器,然后把得到的链接清单交给回调函数或后续的爬取队列。简单来说,LinkExtractor决定了“接下来爬哪里”,而callback决定了“爬下来的页面怎么处理”。有意思的是,Scrapy本身自带的LinkExtractor是从SgmlLinkExtractor和HtmlLinkExtractor演变过来的,目前在通用情况下直接用scrapy.linkextractors.LinkExtractor就行。这个类内部封装了正则过滤、XPath/CSS约束、域名限制、URL规范化、去重等多个功能,说它是个“小型的链接决策引擎”也不算夸张。接下来我们进入正题,逐个啃参数。我先从大家最常用的allow和deny讲起,因为这两个参数直接决定了你能“放行”哪些链接,也是复杂链接提取的第一道大门。2. allow与deny的正则博弈:最常用参数背后的匹配逻辑2.1 allow是怎么工作的allow参数接收一个正则表达式或正则表达式列表。说得直白点,它就是一个“白名单”:URL只有匹配了这里的正则,才会被保留。所有不匹配的链接,不管出现在页面上哪个位置,都会被直接丢弃。代码写起来非常简洁:from scrapy.linkextractors import LinkExtractor # 只提取URL中包含 /product/ 的链接 le = LinkExtractor(allow=r'/product/')这里有个很容易被忽略的细节:allow里的正则不是全字符串匹配,而是使用了re.search的语义。也就是说,正则只需要在URL的某个位置命中即可,不需要^和$首尾锚定。比如上面这个例子,/product/这个模式能匹配到https://example.com/product/123?id=1,也能匹配到https://example.com/category/product/list,因为搜索会扫描整个字符串。不过在实际项目中,这种“松散匹配”经常带来意外。我见过有人写allow=r'product',结果把/products/、/production/、/copy_product/全放出来了,后面不得不用deny再补一刀。所以我的建议是:能写精确就写精确,最好把路径结构也考虑进去。比如:le = LinkExtractor(allow=r'/product/\d+$')这样只匹配以/product/加数字结尾的URL,把列表页、详情页之外的杂链接挡在门外。这种写法在复杂链接提取场景里非常实用,因为你往往只需要某一类URL。2.2 deny是“黑名单”不是替补deny参数同样接收正则或正则列表,作用正好相反:只要URL命中它,就会被排除。它的优先级高于allow,也就是说,哪怕某个链接同时匹配了allow和deny,最终结果也是被拒绝。看起来很简单,但实际使用中有一个经典问题:很多人把deny写成了allow的“反义正则”,结果发现链接没少多少。原因很简单——deny是在allow通过之后才发挥作用的,如果allow已经把链接全放行了,deny就得“一刀一个”精准干掉你不想要的URL。举个例子,想提取所有/news/下面的文章链接,但不要“置顶”和“专题”两类页面:le = LinkExtractor( allow=r'/news/\d+\.html$', deny=[r'/news/top\.html$', r'/news/special/'], )再用re.search逐条匹配,如果命中任意一条deny正则,这个链接就会被丢掉。这种“白名单+黑名单”的配合方式,算得上是复杂链接提取里最基础、也最核心的打法之一。2.3 正则里的转义与陷阱写allow和deny时,最大的坑其实不在逻辑,而在转义。URL里常见的字符如?、.、+、[]等,在正则里都有特殊含义。如果直接拿URL片段当正则用,往往结果莫名其妙。我见过有人这么写:le = LinkExtractor(allow=r'product.php?id=123')这里的.可以匹配任意字符,所以它不止能匹配product.php,还能匹配productXphp这种离谱的URL。更麻烦的是?,如果出现在正则里,它表示“前面的字符出现0次或1次”。也就是说,product.php?id=123这段表达式里的?,并不是匹配URL里的问号,而是把p变为可选。最终能匹配的URL五花八门,完全不是作者本意。正确的做法是把URL当成普通字符串来转义,或者用re.escape把它变成“字面量”:import re # 用 re.escape 把URL文本转成正则 le = LinkExtractor(allow=re.escape('product.php?id=123'))不过这种写法只适合精确匹配。更常用的方式是只针对路径部分做正则,查询参数用额外的过滤逻辑去处理,这样反而更清晰。我在后面讲复杂链接的时候还会提到这一点。2.4 多个allow表达式怎么配合allow允许传列表,例如:le = LinkExtractor(allow=[r'/product/\d+$', r'/item/\d+$'])多个正则之间是“或”的关系,只要命中任意一个就被保留。这在聚合多个内容类目时非常好用。比如一个网站既有商品详情页,又有评测文章页,你想同时抓这两类页面,用一个列表就能搞定。不过要注意,如果两个正则之间本身就有重叠,你可能需要先想清楚“到底想要什么类型”,否则后面处理数据时会很混乱。另一个常见场景是允许列表页和详情页同时进队列,但要求它们走不同的回调逻辑。这就要通过Rule里的callback和follow来实现了,我留在第6章讲。3. restrict_xpaths与restrict_css:把链接提取范围锁进“局部区域”3.1 为什么需要限制提取范围一个页面上可能有几十甚至上百个链接,但真正值得爬的往往只在几个区块里:侧边栏的“你可能喜欢”、页脚的推荐位、导航菜单,这些通常不是你的目标。如果不加限制,LinkExtractor会把它们全部提取出来,既浪费请求,又增加被反爬策略盯上的风险。restrict_xpaths和restrict_css就是用来“画地盘”的。它们让链接提取器只关注网页的特定区域,区域之外的链接一概不碰。3.2 restrict_xpaths使用实践restrict_xpaths接收字符串或列表,里面的表达式是XPath。作用相当于先用XPath选出某些节点,然后在节点内部的HTML片段上做链接提取。看一个典型的例子:le = LinkExtractor( allow=r'/article/\d+\.html$', restrict_xpaths='//div[@class="article-list"]//a', )这条规则的意思是:先定位到所有class为article-list的div元素,然后只从这些div内部的a标签里提取链接。注意,restrict_xpaths的语义比你想的还要严格一些——它会把匹配到的节点内容作为“候选元素”,LinkExtractor再从这些候选元素里寻找链接。如果你把//a写在后面,表示“这些a标签本身就是链接来源”,效果和最常用的定位方式一致。实际项目里,我还经常配合contains来过滤侧边栏的干扰:le = LinkExtractor( allow=r'/news/\d+$', restrict_xpaths='//div[contains(@class, "news-list") and not(contains(@class, "sidebar"))]//a', )不过要提醒一点:restrict_xpaths匹配到的节点,如果内部还有嵌套链接,也会被提取。也就是说,你选了一个区块,那么区块内所有符合tags和attrs要求的链接都会被搜出来。3.3 restrict_css怎么用restrict_css的写法和XPath类似,但更简洁,适合熟悉CSS选择器的朋友:le = LinkExtractor( allow=r'/product/\d+', restrict_css='div.product-list a.item-link', )这会先找到所有div.product-list里的a.item-link,然后从这些链接的href属性里提取URL。相比XPath,CSS选择器在语义上更直白,处理不了太复杂的结构时,选CSS会更省心。注意,restrict_css内部会被转换成XPath再执行,性能上差别不大。但如果你需要写“包含文本内容”的复杂条件,还是直接用restrict_xpaths更顺手,例如:restrict_xpaths='//a[contains(text(), "下一页")]'这种“按链接文字定位”的需求,用CSS选择器实现要绕一大圈,XPath拍上去就完了。3.4 多个区域怎么同时限制这两个参数都支持传列表:le = LinkExtractor( allow=r'/story/\d+', restrict_xpaths=['//div[@id="main-content"]//a', '//ul[@class="pagination"]//a'], )只要链接出现在任意一个指定区域内,并且满足allow条件,就会被提取。这种写法在内容站里很常见:主列表区和分页区都要抓,侧边栏和页脚则不碰。有一点要记住:restrict_xpaths与restrict_css不能混用。你只能选一种方式。如果你确实需要同时用XPath和CSS,可以把其中一个结果先手动处理,或者拆分两个Rule,分别配置。我在项目里经常拆分Rule来解决这类问题,虽然多写几行,但逻辑会很清爽。4. 复杂场景下的链接提取实战:分页、相对路径与动态URL4.1 相对链接和绝对链接:LinkExtractor已经处理了80%写爬虫最怕的一件事,就是页面里的链接全是相对路径,比如/product/123、../list这种,没有域名前缀。如果你手动去拼接,容易出各种边界错误。而LinkExtractor在处理链接时,会自动把它转成绝对URL。具体做法是:用Response的url作为基准,调用urljoin把相对路径拼全。所以就算页面里的href是/product/123,提取出来后你拿到的也是https://example.com/product/123,省去了自己写拼接逻辑的麻烦。但这里有一个容易忽略的问题:如果页面上有<base>标签,会改变相对路径的基准URL。Scrapy的LinkExtractor在处理时,会优先使用<base>标签指定的href作为基准。你要是发现某些链接被拼接成了预期之外的域名,可以先检查页面的<base>标签。4.2 分页链接:不要只盯着“下一页”很多人在做分页提取时,只想着抓“下一页”按钮。这本身没错,但真正的复杂场景往往藏在几个维度:总页数、跳页链接、异步加载的分页数据、以及“下一页”按钮的多种写法。拿一个典型的分页结构来说:<ul class="pagination"> <li><a href="/news/1">1</a></li> <li><a href="/news/2">2</a></li> <li><a href="/news/3">3</a></li> <li><a href="/news/4">4</a></li> <li><a href="/news/5">5</a></li> <li><a href="/news/2">下一页</a></li> </ul>你想提取/news/2到/news/5这四页,而不要“下一页”这种起导航作用的重复链接。用之前讲的方案可以这样配置:Rule( LinkExtractor(allow=r'/news/[2-9]$', restrict_css='ul.pagination'), callback='parse_list', follow=True, )为什么用[2-9]而不是\d+?因为我不想把第1页也放进后续队列。第1页通常是入口URL,已经在start_urls里爬过了。如果让\d+把第1页也匹配上,爬虫就可能重复请求首页。这种细节在复杂链接提取里非常关键,因为你设计规则时,除了考虑“能不能匹配”,还要考虑“会不会造成重复请求”。4.3 列表页与详情页的链接分开处理内容站通常有两种链接:列表页和详情页。比如/list/1.html是列表页,/detail/100.html是详情页。我们希望爬虫先在列表页上提取详情页链接,把详情页交给解析函数,同时继续跟进下一页列表页。考虑一个Rule配置:Rule( LinkExtractor(allow=r'/list/\d+\.html$'), follow=True, ), Rule( LinkExtractor(allow=r'/detail/\d+\.html$'), callback='parse_detail', ),这里第一个Rule只负责列表页链接,follow=True表示“把提取到的列表页链接继续加入爬取队列”。第二个Rule匹配详情页,callback指定详情页的解析函数,follow不写则默认为False,也就是不继续从详情页里提取链接,避免爬进无关页面。这种“一个页面里同时存在两类链接”的场景,恰恰是allow加restrict最擅长解决的。如果页面上还有推广链接、外链导航,只要它们不符合allow,就会被自动过滤掉。4.4 动态加载的链接怎么破很多网站的链接是JavaScript动态插入的,初始HTML的<a>标签里什么都没有。此时LinkExtractor直接提取会得到空列表。面对这种情况,一般有两条路:第一条是用Selenium或Playwright渲染页面后,再把它交给Scrapy的Response。这种方案最直接,但性能损耗明显,适合规模不大的站点。第二条是通过分析网络请求,找到数据接口,直接请求接口拿链接。从我的经验看,第二种方案才是复杂链接提取的正解,因为数据接口通常返回结构化JSON,提取链接反而比解析HTML更精准。举个例子,某网站上动态加载了一组商品列表,接口返回:{ "items": [ {"id": 1, "url": "/product/1"}, {"id": 2, "url": "/product/2"} ] }这种情况完全不需要LinkExtractor,直接在回调里解析JSON就行:def parse(self, response): data = response.json() for item in data['items']: yield response.follow(item['url'], callback=self.parse_product)但如果你还是想用LinkExtractor处理渲染后的HTML,可以在渲染中间件里把<a>标签补回响应。总之,动态链接的核心在于“拿到真实URL的来源”,LinkExtractor不是万能的,它只处理现成的HTML。5. 容易被忽略的参数与性能调优:canonicalize、unique与去重策略5.1 process_value:链接清洗的最后一道闸process_value是一个回调函数,接收提取到的链接字符串,返回处理后的字符串或None。返回None表示丢弃该链接。这个参数在处理特殊格式的href时特别有用。比如某些模板会把空链接写成#,或者用javacript:void(0)代替无操作。你可以在process_value里把这些脏数据全部清洗掉:def clean_url(value): if value.startswith(('javascript:', 'mailto:')): return None if value == '#': return None # 去掉空格和换行 return value.strip() le = LinkExtractor(allow=r'/product/\d+', process_value=clean_url)注意,process_value是在allow/deny过滤之前还是之后执行的?答案是在正则过滤之前。Scrapy的流程是:先提取原始链接,调用process_value获得处理后URL,再进行allow/deny判断。所以你可以把一些“重写URL”的逻辑放在这里,比如把带中文编码的URL转成ASCII,或者给相对路径补齐域名。这个参数还有一个高级用法:从非标准标签里提取链接。比如页面里用>le = LinkExtractor(tags=('img',), attrs=('src',))注意,tags和attrs这里的标签名称都需要小写,attrs的取值一般是('href', 'src')。如果你同时想提取多个标签的多个属性,可以一起传:le = LinkExtractor(tags=('a', 'area', 'iframe'), attrs=('href', 'src'))这个参数用得不多,但在某些老站点、图片站、文档站里非常管用。我有一次接手一个历史数据迁移项目,页面里的下载链接全部放在<a rel="download">里面,href是一样的,但下载地址是>Rule(LinkExtractor(allow=r'/list/'), follow=True), Rule(LinkExtractor(allow=r'/detail/\d+$'), callback='parse_detail'),如果某个链接既符合/list/又符合/detail/(虽然这不多见),它会被第一个Rule接管,进入列表页的流程而不是详情页流程。所以Rule顺序是有讲究的,一般把范围更宽的规则放前面,把专门匹配详情页的规则放后面,能减少意外。6.2 follow的真意:是否继续“顺着爬”follow控制的是:从当前页面提取到的、被该Rule接受的链接,要不要继续作为后续爬取的种子页面。如果为False,则该页面即使用匹配到链接,也只会把这些链接作为结果输出,而不去继续跟进它们。最常见的使用组合是:Rule(LinkExtractor(allow=r'/article/\d+$'), callback='parse_article', follow=False)这个规则意味着:一旦爬到文章详情页,就调用parse_article解析内容,然后不再从文章页里提取链接。很多文章详情页里会有“相关推荐”,如果follow设成True,爬虫就会顺着推荐链接到处跑,很容易跑出控制范围。反过来,列表页的规则通常要follow=True,因为你还想继续翻下一列表页。二者差别很大,写之前多问自己一句:“这个页面还需要继续往外爬吗?”6.3 用多个Rule处理不同站点结构当你面对一个结构混杂的站点时,不要试图在一个Rule里把链接全包住。可以按页面功能拆成几个Rule,例如:Rule( LinkExtractor( allow=r'/category/\d+$', restrict_xpaths='//div[@class="category"]//a' ), follow=True, ), Rule( LinkExtractor( allow=r'/product/\d{4,}$', restrict_css='div.product-list a' ), callback='parse_item', ), Rule( LinkExtractor( allow=r'/brand/\w+$', restrict_css='div.brand-list a' ), callback='parse_brand', ),这里的三个Rule各有各的职责:翻分类页、拿商品页、拿品牌页。这样写的好处是:代码结构清晰、调试方便,后面想限流或者加延迟,只需要针对某一个Rule调整即可。不过要提醒一下,Rule的数量不是越多越好。每个Rule执行都会遍历页面上的链接,Rule太多会拖慢提取速度。如果一个站点能把链接统一成一种风格,尽量用一个宽Rule加多个callback判断来搞定。6.4 动态链接在Rule里的变通动态链接出现时,Rule也不是完全没用。如果你能把JS渲染后的HTML写回Response,LinkExtractor照常工作。我实际中更喜欢在下载中间件里用Selenium渲染,然后把渲染后的response.text替换成新HTML。这样Scrapy的LinkExtractor完全不需要改,Rule配置能继续复用。这里给出一个中间件片段思路(不是完整代码,只是演示思路):class RenderMiddleware: def process_response(self, request, response, spider): # 只有满足某些条件时才渲染 if request.meta.get('render'): driver = get_driver() driver.get(response.url) html = driver.page_source response = response.replace(text=html) return response只要在Request里带上meta={'render': True},这个页面就会以渲染后的HTML进入后续LinkExtractor流程。注意,这种方式会降低抓取速度,尽量只给列表页用,不要给所有详情页都开渲染。6.5 调试LinkExtractor拿到正确表达式写规则最怕的是“感觉对,实际不对”。你可以利用Scrapy的shell快速验证LinkExtractor到底提取了哪些链接:scrapy shell https://example.com进入shell后执行:from scrapy.linkextractors import LinkExtractor le = LinkExtractor(allow=r'/product/\d+$') links = le.extract_links(response) for l in links: print(l.url)这样就能直接看到当前规则下,页面上到底哪些链接被提取出来了。比运行一整个爬虫再翻日志快多了。你也可以同时试几种规则,看哪种覆盖面最准确。这是我在做复杂链接提取时最常用的调试手段。另外提醒一句:extract_links是同步方法,返回的是Link对象列表,每个Link有url、text、fragment等属性。当你需要记录“链接锚文本”时,可以读link.text。7. 从实际项目里总结的若干经验与教训7.1 别过度依赖LinkExtractor做业务过滤LinkExtractor擅长做“URL形态”的过滤,但不擅长做“业务语义”的过滤。比如你只想爬下价格低于100元的商品,这种条件就别指望在LinkExtractor里做。你可以先放宽allow规则,然后在详情页回调里再用价格字段筛选。否则,规则越复杂,正则越难维护,之后改需求时牵一发动全身。我见过一个项目,为了不爬不满足条件的商品,把十几个正则串成一条长长的allow,最后谁也看不懂。后来换了方案:链接提取只负责“是不是商品详情页”,商品是否满足条件交给管道处理。代码瞬间清爽很多。7.2 注意Base URL和域名差异爬虫里经常遇到一个网站挂了好几个域名:比如www.example.com和m.example.com是同一个站点的PC版和移动版。如果不加限制,链接可能在两个域名之间反复横跳,直到把请求数耗尽。这种场景用allow_domains来限定:le = LinkExtractor(allow_domains=['www.example.com', 'm.example.com'])注意,allow_domains匹配的是域名后缀,不是完整地址。allow_domains=['example.com']也能匹配www.example.com和m.example.com,因为域名部分以example.com结尾。但它不会匹配notexample.com,因为这里是域名边界比较,不是子串查找。7.3 分页链接与详情链接的模式冲突有时候,分页URL长得和详情URL几乎一样,只是多了一个参数。例如/article/1.html和/article/1.html?page=2。如果你的allow写得太宽,就会把整个分页树都当成文章链接。这时候可以把查询参数纳入考虑:# 只允许没有page参数的文章链接 le = LinkExtractor(allow=r'/article/\d+\.html$', deny=r'\?page=')这样既能拿文章详情页,又不会把分页链接放进来。这个方法在复杂链接提取里很实用,因为它不仅靠路径判断,还能对查询参数做筛选。7.4 合理控制最大提取数量一个页面上的链接数量可能非常大,LinkExtractor提供了一个由restrict_xpaths和allow配合的天然限流方式,但如果你想硬性限制提取个数,可以在外部处理:links = le.extract_links(response)[:20]这种截断方法简单有效,适合只需要前几条热门链接的场景。但要注意,截断发生在提取之后,意味着该提取的还是要提取,性能上没有优化。真正的性能优化还是靠精确的restrict_xpaths缩小搜索范围。7.5 日志里看到“Ignoring link”并不总是坏事在调试爬虫时,经常会看到类似Ignoring link <...> (no match)的日志。很多人一看到就慌,以为是规则写错了。其实这只是LinkExtractor在执行过滤,把不符合allow的链接给丢了。如果你确认某个链接应该被提取,但它被ignore,再回头排查正则和restrict_xpaths。如果ignore的本就是你不需要的链接,完全可以忽略这条日志,或者调整日志级别过滤噪音。7.6 复杂链接提取的最终答案永远是“分层”回到开头的场景:漏链问题为什么频繁出现?本质上是很多人试图用一个规则搞定所有链接类型。实际情况是,链接形态越复杂,越应该分层处理。第一层用Restrict划定区域,第二层用allow/deny锁定URL形态,第三层用process_value清洗异常数据,第四层用Rule的callback与follow决定后续走向。每一层各司其职,出了问题也能快速定位。从我个人的实战体会来说,Scrapy LinkExtractor参数确实多,但真正用得顺手的就那么几个:allow、deny、restrict_xpaths、process_value、follow。把这些吃透,复杂链接提取的大多数问题都能解决。剩下那些Special Case,就靠scrapy shell里反复调试验证,别凭感觉写正则。这也是我在一次次踩坑后,形成的最可靠的工作方式。