ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Scrapy爬虫参数化实战:动态start_urls与自定义设置完全指南

Scrapy爬虫参数化实战:动态start_urls与自定义设置完全指南 接手爬虫需求时最常见的改动不是解析逻辑而是入口URL。今天要抓分类A明天要抓分类B后天还得换一个站点、换一套请求头。如果每个需求都去改spider里的start_urls和settings再重新部署一整个下午就耗在重复劳动上了。所以我干脆把Scrapy Spider整体参数化——启动时动态传入start_urls和自定义设置同一个spider几乎不再改动。这个方案我用了很久从十几个分类扩展到几百个分类核心代码基本没怎么动改的只是启动命令里的参数。这篇文章就把参数化的设计思路、可落地的几种姿势、以及我踩过的坑完整写出来适合维护多个网站、多个分类、多种抓取策略的Scrapy使用者也适合准备把爬虫接到调度平台或API体系的工程师。1. 参数化的本质为什么需要动态start_urls和自定义设置1.1 从硬编码到参数化你的第一个痛点很多Scrapy新手写spider是这样的class CategorySpider(scrapy.Spider): name category start_urls [https://example.com/list/1] def parse(self, response): pass这个写法在“只抓一个固定页面”时没有任何问题。但现实需求往往是同一个栏目要抓第1页到第50页同一套页面结构要抓多个分类同一套网站模板要切不同的域名。这时候如果继续硬编码你就得不停改代码、不停重启爬虫、不停维护一堆长得几乎一样的spider文件。参数化要解决的核心问题就是把“变化点”从代码里剥离出来交给启动命令、调度接口、配置文件来驱动。代码只负责“怎么抓”调用方决定“抓什么”。这也是Scrapy官方文档里反复强调的spider应该尽量把参数通过构造器传入而不是写死在类属性里。1.2 Scrapy的参数体系-a参数、自定义设置、custom_settings的关系Scrapy的参数体系其实有三层很多新人容易混第一层是spider参数通过命令行-a传入或者通过CrawlerProcess.crawl()的kwargs传入。这些参数最终会作为关键字参数传给spider的__init__方法是动态start_urls最直接的来源。第二层是Scrapy设置settings控制下载延迟、并发数、Cookie策略、中间件启停等。设置可以通过命令行-s指定也可以在spider里用custom_settings类属性覆盖项目级settings还可以在启动API里直接传入一个settings dict。第三层是运行时通过self.crawler.settings.set()修改。这一层最灵活但也有很多陷阱比如某些引擎级参数在下载器初始化之后修改不生效我会在后面的章节详细说。理解这三层的关系参数化才能做得干净。我的建议是业务变化用spider参数环境配置用settings参数二者不要搅在一起。2. 动态传入start_urls的几种可靠姿势2.1 从命令行 -a 参数传入URL最简单的一招Scrapy的-a参数会把值作为字符串传递给spider的__init__方法。比如scrapy crawl my_spider -a urlshttps://a.com,https://b.com,https://c.com在spider里接收import scrapy import json class MySpider(scrapy.Spider): name my_spider def __init__(self, urlsNone, *args, **kwargs): super().__init__(*args, **kwargs) self.start_urls [] if urls: # 尝试按JSON解析支持数组格式 try: data json.loads(urls) if isinstance(data, list): self.start_urls data except json.JSONDecodeError: self.start_urls [u.strip() for u in urls.split(,) if u.strip()]这里有几个细节值得注意。第一-a参数进来的永远是字符串所以如果传入的是[https://a.com, https://b.com]需要json.loads转成列表。第二如果URL本身包含逗号用逗号分隔就会拆错所以我更推荐用JSON格式传递或者用竖线|做分隔符。第三空字符串、空格、换行符要过滤掉否则start_urls会出现脏数据。我还习惯在__init__里打印一行日志确认实际接收到的URL数量和样例方便调度方排查问题self.logger.info(start_urls count: %s, len(self.start_urls)) self.logger.info(first url: %s, self.start_urls[:1])别看这行日志简单在参数化落地之后它是我排查“为什么爬虫空跑”的第一线索。2.2 通过CrawlerProcess和API方式传入URL列表如果你不走命令行而是想从自己的Python脚本、调度平台、Flask/FastAPI接口里启动爬虫可以用CrawlerProcessfrom scrapy.crawler import CrawlerProcess from myproject.spiders.my_spider import MySpider process CrawlerProcess(settings{ DOWNLOAD_DELAY: 1.5, CONCURRENT_REQUESTS: 4, }) process.crawl(MySpider, urls[https://a.com, https://b.com], categoryphone) process.start()关键在process.crawl()这一句第二个位置参数之后的urls、category都会传给spider的__init__。这比命令行清爽很多因为你可以用程序动态构建参数而不是拼字符串命令。如果你接的是Scrapyd调度请求也是同样的思路例如curl http://localhost:6800/schedule.json \ -d projectmyproject \ -d spidermy_spider \ -d urls[https://a.com, https://b.com]Scrapyd会把urls参数作为spider参数传入。只要spider的__init__能解析JSON这种调度方式就完全不需要改代码。2.3 在spider内部动态构造start_urls的常见模式除了外部传入完整URL列表更常见的需求是“我只传分类和页码URL由spider自己拼”。这一招在电商分类页、新闻列表页、分页抓取场景特别实用。class CategorySpider(scrapy.Spider): name category def __init__(self, categoryNone, page_count1, base_urlNone, *args, **kwargs): super().__init__(*args, **kwargs) self.start_urls [] if not category: return page_count int(page_count) for c in category.split(,): for page in range(1, page_count 1): # base_url可以通过参数传入也可以从项目配置里读取 url f{base_url or https://example.com}/list/{c}?page{page} self.start_urls.append(url)启动命令变成scrapy crawl category -a categoryphone,computer -a page_count3 -a base_urlhttps://example.com这样一次启动就能生成6个URL后续加分类完全不用动代码。要注意的是page_count从命令行进来是字符串必须int()转换否则构建URL时会直接拼成“1”这种字符串没问题但如果你后续要做数值运算就会翻车。然后start_urls的请求入口我建议统一写成start_requests方法而不是直接依赖类属性。这样可以在每个请求上额外挂载自定义信息def start_requests(self): for url in self.start_urls: yield scrapy.Request( url, callbackself.parse, cb_kwargs{source_url: url}, dont_filterFalse, )3. 自定义设置让同一个Spider适配不同场景3.1 用custom_settings覆盖全局配置Scrapy里每个spider都可以声明自己的custom_settings类属性这个优先级高于项目settings。比如同一个spider抓站A时要遵守robots协议抓站B时不需要可以这样设计class MySpider(scrapy.Spider): name my_spider custom_settings { ROBOTSTXT_OBEY: False, DOWNLOAD_DELAY: 1.0, FEED_EXPORT_ENCODING: utf-8, }但如果每次启动都要改延迟、改并发那custom_settings这种写死的类属性就不够灵活了。这时候需要把settings也参数化。3.2 动态设置DOWNLOAD_DELAY、CONCURRENT_REQUESTS的正确打开方式先说结论像DOWNLOAD_DELAY、COOKIES_ENABLED这类请求级设置可以在spider实例化之后、发起请求之前动态修改但CONCURRENT_REQUESTS这类引擎级设置最好在爬虫启动前通过命令行-s或CrawlerProcess(settings...)传入运行时改往往不生效。动态延迟的例子class MySpider(scrapy.Spider): name my_spider def __init__(self, delay1, *args, **kwargs): super().__init__(*args, **kwargs) self.delay float(delay) def start_requests(self): # 此时self.crawler已经绑定settings可以修改 self.crawler.settings.set(DOWNLOAD_DELAY, self.delay) for url in self.start_urls: yield scrapy.Request(url, callbackself.parse)这样在启动命令里指定不同的延迟scrapy crawl my_spider -a urlshttps://a.com,https://b.com -a delay2.5但CONCURRENT_REQUESTS我实测过在start_requests里settings.set()之后下载器并发数并不会改变因为下载器在引擎初始化时就已经读走了这个值。正确做法是用-s CONCURRENT_REQUESTS8scrapy crawl my_spider -s CONCURRENT_REQUESTS8 -a urlshttps://a.com,https://b.com所以自定义设置参数化不要全部依赖运行时settings.set()要根据参数类型选对时机。3.3 从from_crawler拿到settings以及和-a参数的分工如果你的spider需要更复杂的初始化比如读取API密钥、配置中间件参数可以重写from_crawler类方法class MySpider(scrapy.Spider): name my_spider def __init__(self, api_keyNone, *args, **kwargs): super().__init__(*args, **kwargs) self.api_key api_key classmethod def from_crawler(cls, crawler, *args, **kwargs): spider super().from_crawler(crawler, *args, **kwargs) # 从settings里读取项目级配置作为默认值 spider.default_timeout crawler.settings.getint(DEFAULT_TIMEOUT, 10) return spider这里我踩过一个坑from_crawler里的kwargs和__init__的kwargs是同一份也就是说-a api_keyxxx传入的值在from_crawler里也能拿到。但要注意执行顺序super().from_crawler()内部会调用cls(*args, **kwargs)也就是先执行__init__再返回spider。所以如果要在from_crawler里覆盖__init__中设置的属性直接赋值即可。参数化和settings的分工我建议这样-a参数负责业务数据比如URL、分类、页码、API Key-s参数和CrawlerProcess(settings)负责运行配置比如延迟、超时、代理。两者分开后续排查问题时会清晰很多。4. 实战一个可复用的参数化Spider完整案例4.1 设计需求与目录结构假设我要做一个“通用列表页爬虫”同一个模板的网站只需要通过参数告诉它抓哪些分类、翻多少页、请求延迟多少、输出到哪个文件。要求是不改核心代码就能接入新的站点。项目结构保持Scrapy默认myproject/ ├── scrapy.cfg └── myproject/ ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py └── spiders/ └── generic_list.py这个spider的核心逻辑只有三块接收参数、生成URL、解析列表页里的详情链接。4.2 核心代码实现import json import scrapy from urllib.parse import urljoin class GenericListSpider(scrapy.Spider): name generic_list allowed_domains [] # 会在构造时自动填充 custom_settings { FEED_EXPORT_ENCODING: utf-8, } def __init__(self, urlsNone, categoryNone, page_count1, base_urlNone, delay1.0, allow_domain, *args, **kwargs): super().__init__(*args, **kwargs) self.delay float(delay) self.start_urls [] if urls: self.start_urls self._parse_urls(urls) elif category: for c in category.split(,): for page in range(1, int(page_count) 1): url f{base_url or https://example.com}/list/{c}?page{page} self.start_urls.append(url) if allow_domain: self.allowed_domains [d.strip() for d in allow_domain.split(,)] else: # 从start_urls中自动提取域名 for u in self.start_urls[:5]: from urllib.parse import urlparse domain urlparse(u).netloc if domain and domain not in self.allowed_domains: self.allowed_domains.append(domain) self.logger.info(启动参数: urls数量%s, delay%s, len(self.start_urls), self.delay) def _parse_urls(self, urls): 支持 JSON 数组和逗号分隔两种格式。 try: data json.loads(urls) if isinstance(data, list): return [u.strip() for u in data if u.strip()] except json.JSONDecodeError: pass return [u.strip() for u in urls.split(,) if u.strip()] def start_requests(self): self.crawler.settings.set(DOWNLOAD_DELAY, self.delay) for url in self.start_urls: yield scrapy.Request( url, callbackself.parse_list, cb_kwargs{list_url: url}, errbackself.handle_error, ) def parse_list(self, response, list_url): # 建议把列表页里的详情链接提取规则做成参数这里只演示思路 detail_links response.css(a.detail::attr(href)).getall() for link in detail_links: yield scrapy.Request( urljoin(response.url, link), callbackself.parse_detail, ) def parse_detail(self, response): # 这里按你的页面结构写解析逻辑 yield { url: response.url, title: response.css(h1::text).get(), } def handle_error(self, failure): self.logger.error(请求失败: %s, failure.request.url)这段代码有几个细节可以参考。第一allowed_domains如果留空Scrapy不会主动限制域名但如果从-a allow_domain传入了域名就按域名白名单走避免被恶意URL带偏。第二_parse_urls先尝试JSON解析失败再按逗号拆兼容两种调用方式。第三DOWNLOAD_DELAY放在start_requests里设置对后续请求有效并发数仍建议用-s指定。4.3 运行与验证启动了跑一遍scrapy crawl generic_list \ -a urls[https://a.com/news, https://b.com/news] \ -a delay0.5 \ -s CONCURRENT_REQUESTS8 \ -o output.json日志里能看到start_urls数量和延迟值随后正常下载。如果你要接入一个新的类目页比如在另一个站点https://c.com但页面结构和现有站点完全一致只需要scrapy crawl generic_list \ -a categoryphone,laptop \ -a page_count5 \ -a base_urlhttps://c.com \ -a allow_domainc.com \ -s CONCURRENT_REQUESTS4不需要改任何文件。这也是参数化最大的收益新站点接入成本降到“写一条启动命令”。5. 进阶参数化遇上动态页面和iframe5.1 为什么动态页面会让参数化变得更复杂前面讲的参数化假设的是“只要把start_urls和settings传进去就能直接抓到数据”。但现实中很多页面的数据是JS动态渲染的甚至藏在iframe里。这时候参数URL只是第一步你还要告诉spider“这个页面需要先等JS、再切iframe、再拿数据”。不过参数化思路依然有效只是需要把“渲染模式”也变成参数。比如render0不渲染直接抓静态HTML。render1用Playwright渲染后抓取。iframe_selectoriframe#content指定要切入的iframe。5.2 用Scrapy Playwright处理动态iframe中的start_urls先安装依赖pip install scrapy-playwright playwright playwright install chromium在settings.py里启用DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } TWISTED_REACTOR twisted.internet.asyncioreactor.AsyncioSelectorReactor然后在spider中根据参数决定是否启用渲染class DynamicIframeSpider(scrapy.Spider): name dynamic_iframe def __init__(self, urlsNone, render0, iframe_selector, *args, **kwargs): super().__init__(*args, **kwargs) self.start_urls self._parse_urls(urls) self.render render self.iframe_selector iframe_selector def start_requests(self): for url in self.start_urls: meta {} if self.render 1: meta[playwright] True meta[playwright_include_page] True yield scrapy.Request(url, callbackself.parse, metameta) async def parse(self, response): if not self.render: # 静态页面直接解析 yield {url: response.url, title: response.css(title::text).get()} return page response.meta[page] try: # 等待iframe出现再获取iframe内的内容 await page.wait_for_selector(self.iframe_selector or iframe) iframe_src await page.eval_on_selector( self.iframe_selector or iframe, el el.src ) iframe_content await page.eval_on_selector( self.iframe_selector or iframe, el el.contentDocument.body.innerText ) yield { url: response.url, iframe_src: iframe_src, content: iframe_content, } finally: await page.close()这里的render参数就是参数化的延伸同一个spider既能在静态页面模式下快速跑又能在动态页面模式下渲染跑。没必要为一个动态站点单独复制一份spider。5.3 参数化建模思路把请求参数与数据解析分离做久了你会发现真正的参数化不是“多传几个URL”而是把整个抓取流程抽象成几个可替换的模块请求构造、渲染策略、数据提取、输出格式。URL只是请求构造的输入之一。我习惯把每个目标站点抽象成一个“配置字典”SITE_CONFIG { site_a: { list_url_pattern: https://a.com/{category}?page{page}, detail_link_css: a.detail::attr(href), detail_title_css: h1::text, render: 0, delay: 0.5, }, site_b: { list_url_pattern: https://b.com/{category}/{page}/, detail_link_css: a.link__item::attr(href), detail_title_css: .article-title::text, render: 1, iframe_selector: iframe#main, delay: 1.0, }, }然后spider通过sitesite_a参数读取配置class MultiSiteSpider(scrapy.Spider): name multi_site def __init__(self, siteNone, category, *args, **kwargs): super().__init__(*args, **kwargs) conf SITE_CONFIG.get(site, {}) # 用配置项去构造start_urls和settings这就叫参数化建模把“变化”收敛到配置层代码层保持稳定。这个方法特别适合同时维护十几个同构站点的情况。6. 常见问题与排查技巧实录6.1 start_urls是空列表时发生了什么如果不小心传了空值spider会启动但不会产生任何请求日志里也不会报错看起来像正常运行但啥都没抓。我第一次遇到的时候排查了很久。我的解决方案是在start_requests开头强制检查def start_requests(self): if not self.start_urls: raise ValueError(start_urls is empty, please check input params) self.crawler.settings.set(DOWNLOAD_DELAY, self.delay) ...这样启动时就能立刻暴露问题。另外日志里打印start_urls数量也是一个好习惯。6.2 -a参数里的JSON字符串总是解析失败命令行传JSON要特别注意引号。在Linux shell里外面用单引号里面用双引号scrapy crawl my_spider -a urls[https://a.com, https://b.com]在Windows cmd里转义规则又不一样。为了避免这种跨平台问题我更推荐在spider里做兼容先用json.loads解析失败再按逗号分隔。如果JSON包含太复杂的嵌套建议不要用-a改成用CrawlerProcess或直接读文件。6.3 自定义settings没有生效的排查路径这个问题我遇到过很多次几个常见原因settings.set()调用时机太晚。比如在parse里改CONCURRENT_REQUESTS肯定不生效。custom_settings类属性写错层级结果被项目settings覆盖。命令行-s参数写成了-a比如-a DOWNLOAD_DELAY2设置值跑到spider参数里去了settings根本没变。调试了很久之后发现是下一个spider实例覆盖了前一个spider的settings因为用了全局状态。排查路径我一般是这样先在spider的__init__里打印self.settings.getint(DOWNLOAD_DELAY)确认当前值是什么再确认命令行是否真的传进去了最后再看custom_settings是否需要改成实例级动态设置。6.4 常见错误速查表现象可能原因解决建议爬虫启动后无任何请求start_urls为空或解析失败在start_requests里判断空列表并抛错-a传入的JSON解析失败shell引号转义问题改用单引号包裹JSON或走CrawlerProcessDOWNLOAD_DELAY不生效修改时机太晚在start_requests之前设置CONCURRENT_REQUESTS不生效引擎初始化后无法修改用-s参数传入抓到的URL跳到了站外allowed_domains未正确配置用-a allow_domain显式传入域名参数都是字符串类型Scrapy设计如此在__init__显式转换类型7. 我踩过坑之后的一点心得这个参数化spider我在实际项目里坚持用了很长一段时间从几十个分类扩展到几百个分类核心代码几乎没有动过。最大的体会是参数化不是炫技而是把变化点收敛到启动命令和配置里让“加一个站点”变成“加一条配置”。几点个人建议所有传入spider的参数最好在启动日志里完整打印一次包括URL数量、关键settings、渲染模式。出问题的时候这一行日志能帮你节省大量排查时间。能用文件传URL列表就不要在命令行硬拼几百个URL。urls_file参数比超长命令行友好得多。动态修改settings要谨慎只改你有把握的参数。引擎级参数老老实实用-s传。参数化做到最后代码会越来越稳定真正每天都在变的只是调用方的参数。这个方向值得花时间打磨。
返回列表