Scrapy遍历URL列表保存response.body到XML/TXT问题排查
问题根因
- 启动即抛
DropItem异常:你把raise DropItem写在了TicketsPipeline类的方法外层,属于类定义阶段就会执行的代码,Scrapy加载pipeline组件时会直接触发这个异常,根本不会进入实际爬取流程。另外DropItem的作用是标记单条无效item、跳过后续处理,只能在process_item方法内部、满足丢弃条件时抛出,正常处理完的item必须返回,不能处理完就直接抛异常。 - 早期版本批量爬取文件被覆盖:你把生成的文件名变量定义在了
start_requests循环的局部作用域,没有和单个请求绑定,parse_api回调执行时引用的是循环结束后最后一次赋值的文件名,所有请求的响应都会写入同一个文件,导致内容被覆盖。 - 你最早的代码里
parse_api方法缩进错误,被定义在了start_requests方法内部,Spider实际找不到对应的回调方法,运行时也会报错。 - 你写的新版pipeline逻辑还有两个隐藏bug:一是
filename_xml是单个字符串,不是可迭代对象,写for循环遍历会逐字符拆分文件名,生成一堆错误的单字符命名文件;二是你存入item的data是XPath返回的Selector对象或者BeautifulSoup对象,直接写入文件会触发类型错误,必须转成字节或者字符串格式才能写入。
修正方案
如果不需要做额外的字段提取,完全可以不用写Item和Pipeline,直接把文件名存在请求meta里和单条请求绑定,回调里直接写文件即可,逻辑最简单也不容易出错:
import os import random from datetime import datetime import pandas as pd import scrapy from scrapy.crawler import CrawlerProcess # 配置项 自行修改 SAVE_DIR = "./ticket_output" os.makedirs(SAVE_DIR, exist_ok=True) linkarr = df['URLOUT'].tolist() # 提前加载你的df数据源 today = datetime.today().strftime('%Y%m%d') class MpvticketSpider(scrapy.Spider): name = 'mpvticket' handle_httpstatus_list = [403,502,503,404] def start_requests(self): username = 'XXXX' password = 'XXXX' port = 22225 headers = { 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 'accept-language': 'en-US,en;q=0.9', 'cache-control': 'max-age=0', 'referer': 'https://www.mlb.com/', 'sec-ch-ua': '".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"', 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin', 'sec-fetch-user': '?1', 'upgrade-insecure-requests': '1', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36', } for url in linkarr: eventid = str(url).strip().split("pid=")[1].split("&")[0] # 文件名提前计算,存在meta中和当前请求绑定,不会出现串数据问题 filename = os.path.join(SAVE_DIR, f"{eventid}_{today}.xml") session_id = random.random() super_proxy_url = f'http://{username}-country-us-session-{session_id}:{password}@zproxy.lum-superproxy.io:{port}' yield scrapy.Request( url, callback=self.parse_api, meta={'proxy': super_proxy_url, 'save_filename': filename}, headers=headers ) def parse_api(self,response): filename = response.meta['save_filename'] # 用wb模式直接写原始响应字节,不会破坏XML原有编码,避免乱码 with open(filename, "wb") as f: f.write(response.body) if __name__ == '__main__': process = CrawlerProcess(settings={ "LOG_LEVEL": "INFO", "ROBOTSTXT_OBEY": False }) process.crawl(MpvticketSpider) process.start()
如果一定要用Item+Pipeline的架构,修正对应代码即可:
- 修正
pipelines.py
import os from itemadapter import ItemAdapter class TicketsPipeline: def process_item(self, item, spider): adapter = ItemAdapter(item) filename = adapter['filename_xml'] data = adapter['data'] os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, "wb") as fd: fd.write(data) # 处理完成必须返回item,不要随意抛DropItem return item
- 修正spider中的
parse_api方法
def parse_api(self,response): url = response.url eventid = str(url).strip().split("pid=")[1].split("&")[0] filename_xml = os.path.join(SAVE_DIR, f"{eventid}_{today}.xml") item = TicketsItem() # 直接存原始响应字节,不要存Selector/BeautifulSoup对象 item['data'] = response.body item['filename_xml'] = filename_xml yield item
- 在
settings.py中开启pipeline
ITEM_PIPELINES = { "tickets.pipelines.TicketsPipeline": 300, }
注意事项
- 所有和单个请求绑定的自定义参数(文件名、代理标记等)都存在
request.meta字典中跟着请求走,不要用全局变量或者循环外的公共变量,Scrapy是异步执行框架,用公共变量一定会出现数据串扰、覆盖问题。 - 存储XML/HTML类原始响应时,优先用
"wb"模式直接写入response.body字节内容,不要先转解析对象再转字符串,会破坏原有格式和编码,大概率出现乱码。
内容的提问来源于stack exchange,提问作者Suren Gunaseelan
相关产品推荐
相关产品推荐

