尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Scrapy+Selenium实战:药品数据采集与MySQL存储方案

Scrapy+Selenium实战:药品数据采集与MySQL存储方案 简介本资源是一套面向计算机、电子信息及生物信息相关专业学生的药品数据采集实践项目聚焦于构建覆盖10万条中成药与化学药品的结构化数据库解决真实场景下多源异构药品信息获取与清洗难题。压缩包共61个文件含23个核心Python脚本涵盖Scrapy爬虫主逻辑、Selenium反爬绕过、MySQL入库、ICD-9/ICD-10编码映射等模块、9个JSON配置与编码字典、4个CSV原始数据样例及README说明文档整体14.59MB结构清晰、模块解耦便于课程设计或毕设二次开发。已有44人学习下载资源完整提供从网页解析XPath正则、动态渲染处理Selenium、数据校验CFDA官方数据比对到知识图谱关联drug/disease/symptom三元组生成的全链路实现附带ATC分类、药源网字段映射表及中文医学知识图谱基础数据具备强工程落地参考价值。 前阵子接了一个药品信息采集的项目目标是搭一个覆盖药源网的药品数据库数据量定在10万条级别。当时的第一反应就是Scrapy配合Selenium这套组合——Scrapy负责批量调度和并发抓取Selenium解决那些依赖JavaScript渲染的动态页面最后落到MySQL里做统一存储。这个方案跑下来数据是抓到了但中间踩了不少坑尤其是在动态页面等待、元素定位、断点续爬这几个环节折腾了好几天才理顺。这篇就把整个方案的完整思路和实现过程拆开讲清楚。包括为什么选ScrapySelenium而不是纯requests数据库字段怎么设计才能支撑10万条数据的查询和去重Scrapy和Selenium到底怎么融合才不卡效率以及运行过程中一定会遇到的iframe、超时、浏览器资源泄漏等问题的排查方法。不管你是刚接触爬虫的新手还是已经在用Scrapy做数据采集的开发者这套方案里的细节和坑都能给你省下不少时间。1. 项目整体设计与技术选型思路1.1 先看清楚需求这张药源网页面到底长什么样药源网是一个面向药品流通领域的B2B信息平台上面的药品信息覆盖了国产药、进口药、保健品、医疗器械等多个分类。我们要采集的核心是药品基础数据每一款药品页面里通常包含批准文号、药品名称、商品名、生产厂家、剂型、规格、成分、适应症、用法用量、批准日期等字段。动手之前我先把页面结构摸了一遍。药源网的列表页是标准的动态加载模式——用浏览器打开开发工具能直接看到数据接口但如果直接用requests去请求列表地址返回的HTML里没有完整的药品列表数据是通过页面里的异步脚本渲染出来的。这种情况下纯requests方案基本走不通要么去逆向分析接口签名要么用浏览器自动化工具直接渲染页面。逆向接口费时费力而且接口结构说变就变Selenium这种黑盒方案反而是性价比更高的选择。详情页的情况比列表页好一些大部分详情内容在服务端渲染时就写进了HTML可以直接用请求返回的HTML解析。这意味着不需要所有请求都走Selenium只有列表页和少数动态区块用浏览器渲染详情页可以用Scrapy原生下载器批量抓取。这一层判断直接决定了整个架构怎么设计10万条数据如果每条都开浏览器去渲染时间成本会翻几十倍。1.2 技术方案选型为什么是ScrapySelenium这个组合如果项目只是抓几十条数据requestsBeautifulSoup就够了完全没必要上框架。但10万条的体量就不一样了需要应对的问题包括并发度控制、请求去重、失败重试、断点续爬、数据管道清洗入库、运行日志监控。这些功能如果全部自己手写工程量不小而且写出来的鲁棒性大概率比不上成熟框架。Scrapy自带了一套完整的抓取流程调度器负责请求队列管理下载器负责并发请求爬虫解析response并产出itempipeline接力做清洗和入库中间件可以在请求前和响应后做各种处理。这套机制配合内存磁盘双层队列处理10万条级别的数据没有问题。Scrapy还有一个很实用的特性settings.py里可以直接控制并发数、下载延迟、自动限速这些东西在后期调优时非常关键。Selenium的角色不是替代Scrapy而是补上Scrapy缺失的“浏览器渲染”能力。我用下载中间件的方式把Selenium封装进去Spider发起请求时给request打上标记中间件判断如果这个URL需要JS渲染就交给Selenium处理返回一个HtmlResponse对象后续解析逻辑照常走。这样从代码结构上看Spider里只关心解析规则不需要关心请求是浏览器渲染的还是原生下载的整个链路是统一的。这个组合的另一个好处是——Selenium只处理必须处理的请求。实际运行中我用Selenium负责列表页每一页的翻页获取该页所有药品详情页的URL然后把这些URL提交给Scrapy原生下载器。这个模式跑下来效率非常可观一个浏览器实例坐着翻页同时几十个Scrapy并发请求在抓详情页数据两者互不阻塞。1.3 数据流路径规划从请求到入库的完整链路整个系统跑通后的数据流向是这样的入口请求从分类页开始Selenium下载中间件接管渲染出第一页药品列表Spider解析列表页提取当前页所有药品详情页的URL交给Scrapy调度器调度器对这些详情页URL去重后由Scrapy下载器并发发起请求下载器返回HTML后Spider解析每个药品页面抽取字段组装成ItemItem进入Pipeline依次做字段清洗、去重判断、格式转换最终写入MySQL数据库同时记录一条日志到运行日志文件列表页翻到最后一页后Spider触发下一页请求循环上述流程直到所有分类全部完成这套链路设计好之后代码实现上最重要的就是两个耦合点一个是Selenium中间件和Spider之间怎么传递数据另一个是Pipeline里怎么处理脏数据。这两个点在后面章节详细展开。2. 数据库设计与数据建模2.1 字段设计10万条药品数据该存哪些字段数据库设计是爬虫项目里最容易被忽略的部分。很多人的做法是上来就建一张表抓一个字段加一个字段最后表结构乱成一团查询效率也低。我这次在设计表结构之前先把药源网药品详情页的所有字段梳理了一遍并考虑后续实际业务查询的需求最终设计了下面这些核心字段字段名类型说明索引策略idINT UNSIGNED AUTO_INCREMENT自增主键主键drug_nameVARCHAR(128)药品通用名普通索引commodity_nameVARCHAR(128)商品名/品牌名无approval_numberVARCHAR(64)批准文号唯一索引manufacturerVARCHAR(255)生产厂家索引dosage_formVARCHAR(50)剂型片剂/胶囊/注射液等索引specificationVARCHAR(128)规格无ingredientsTEXT成分无indicationsTEXT适应症无usage_dosageTEXT用法用量无approval_dateDATE批准日期无categoryVARCHAR(50)药品分类索引source_urlVARCHAR(255)原始页面URL无created_atDATETIME创建时间无updated_atDATETIME更新时间无批准文号这个字段需要特别说明。药品批准文号的格式是“国药准字1位字母8位数字”比如“国药准字H20090032”这个编号对每一款药品来说具有唯一性就像药品的身份证号。把它设置成唯一索引之后天然就能用来做数据去重。同一个药品如果有多个厂家生产批准文号是各不相同的不同厂家的同类药品会被识别为不同记录这个符合实际情况。剂型和分类字段值得建索引。后续如果要做按科室、按剂型、按厂家的统计报表这两个字段能帮上大忙。至于成分、适应症、用法用量这些长文本字段直接用TEXT类型存储即可不需要拆分。10万条数据的量级单表完全扛得住不需要分表分区。2.2 MySQL还是MongoDB我是怎么选的项目最开始有人建议用MongoDB理由是药品字段结构不统一文档型数据库更灵活。这个说法有道理但实际跑下来之后我仍然选了MySQL。药品信息虽然字段多但这些字段在大多数药品页面上都是存在的结构差异并不大。既然结构相对固定就没有必要引入文档型数据库MySQL的表结构约束反而能帮我们提前发现数据问题。比如如果某条记录缺少approval_number说明这次抓取很可能漏了字段可以在Pipeline里直接打日志排查。不要小看这个“约束”的作用——爬虫抓回来的数据天然是不干净的一个严格的表结构能挡住至少一半的错误写入。另一个考量的点是查询性能。10万条数据做条件筛选、分页、排序、统计MySQL配上合适的索引可以毫秒级返回结果。MongoDB也能做类似的事但如果团队不熟悉它维护成本会高不少。再加上MySQL生态里可视化工具Navicat、DataGrip等和人力的熟悉度都是现成的选MySQL是个稳妥的决定。如果在你的业务场景里药品数据结构差异非常大比如不同分类的字段完全不同那可以退回到MongoDB这种schema-free的方案。不过在数据量不大、字段差异可控的前提下我的建议是优先上MySQL从简单可靠的方案开始不要为了“未来的扩展性”过度设计。2.3 去重与增量更新设计10万条数据不是一次性抓完就结束了药源网的药品信息会更新新药会上架老药信息会修改。数据库设计时就要考虑增量更新怎么做。最基础的去重逻辑是利用批准文号唯一索引。写入数据时使用INSERT ... ON DUPLICATE KEY UPDATE语句如果批准文号不存在就插入新数据如果已经存在就更新变化了的字段。这样同一个药品第二次被抓取时不会产生重复记录还能顺便刷新updated_at时间戳一举两得。增量更新还有一个前置步骤——在Spider端做URL级去重。Scrapy默认的去重器是基于request URL的指纹去重同一个URL不会重复抓取。这个机制在爬虫任务重启时尤为重要如果跑了一半程序崩了重新运行时会跳过已经抓取过的详情页URL只处理剩余部分。这一点在项目里就是断点续爬的基础。有一类情况需要额外处理如果详情页URL中携带了某种随机参数每次访问都会变化Scrapy默认去重就会失效。遇到这种情况需要自定义去重类用URL去掉参数后的路径部分计算指纹。药源网的URL结构还算规范详情页URL是固定的数字ID形式用默认去重就够了。3. 核心实现Scrapy配合Selenium的爬虫实现3.1 环境准备与项目初始化开发环境建议直接用Python 3.9以上版本创建虚拟环境后安装依赖pip install scrapy selenium pymysqlSelenium还需要下载对应的浏览器驱动。我用的是Chrome所以下载ChromeDriver注意版本一定要和本机Chrome浏览器版本一致否则启动时会直接报SessionNotCreatedException。另外在Linux服务器上跑无头模式的话还需要安装一些基础运行库否则Chrome启动时会缺so文件。# Ubuntu/Debian 系统可能需要安装的依赖 apt-get install -y libx11-xcb1 libxcomposite1 libxdamage1 libgbm1 libasound2这些依赖看起来跟爬虫没什么关系但缺了它们Chrome无头模式就是起不来属于最容易在环境部署环节坑到人的问题。建议提前装好省得后面排查半天。初始化Scrapy项目执行下面的命令创建基础骨架scrapy startproject drug_spider cd drug_spider scrapy genspider drug_spider yaoyuan.com生成后的项目目录结构主要关注这几个文件settings.py负责所有配置middlewares.py写自定义中间件pipelines.py写数据入库逻辑spiders/drug_spider.py是核心爬虫代码。我把数据库连接信息放在settings.py里用环境变量注入避免把明文密码提交到代码仓库。3.2 编写Selenium下载中间件这是整个方案里最关键的一个组件。中间件的作用是拦截发往指定URL的请求用Selenium替代Scrapy原生下载器来获取网页。代码整体如下# middlewares.py from scrapy.http import HtmlResponse from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait class SeleniumMiddleware: 处理需要JS渲染的请求 def __init__(self): chrome_options Options() chrome_options.add_argument(--headless) chrome_options.add_argument(--no-sandbox) chrome_options.add_argument(--disable-dev-shm-usage) chrome_options.add_argument(--disable-gpu) chrome_options.add_argument(--window-size1920,1080) self.driver webdriver.Chrome(optionschrome_options) self.wait WebDriverWait(self.driver, 10) def process_request(self, request, spider): if not request.meta.get(render_js): return None self.driver.get(request.url) # 等待页面中关键元素出现 self.wait.until( lambda d: d.find_elements( xpath, //div[contains(class, drug-list-item)] ) ) return HtmlResponse( urlrequest.url, bodyself.driver.page_source, encodingutf-8, requestrequest ) def close_spider(self, spider): self.driver.quit()注意中间件里用了request.meta.get(render_js)来判断一个请求是否需要浏览器渲染。这个标记是在Spider构造请求时设置的没有这个标记的请求直接返回None走Scrapy原生下载流程。这样就把两条链路彻底分开了互不干扰。关于等待条件需要多说两句。不要用time.sleep(2)这种固定等待页面加载速度会因为网络状况波动很大固定等待要么等久了浪费时间要么等短了页面没渲染完。正确的做法是用WebDriverWait配合expected condition等到你真正需要的那个元素出现才继续。等哪一类元素取决于页面结构这个需要根据实际情况微调。另外一个细节close_spider方法是Scrapy中间件的钩子所有爬虫任务结束后会被自动调用在这里关闭浏览器释放资源防止僵尸Chrome进程堆积。在settings.py中启用中间件DOWNLOADER_MIDDLEWARES { drug_spider.middlewares.SeleniumMiddleware: 200, }中间件的数字200是优先级数字越小的中间件越靠近引擎越大的越靠近下载器。这里设置200意味着在请求进入真正下载前会经过它顺序上比较合理。3.3 核心Spider代码实现列表页翻页与详情页抓取Spider代码是整个功能实现的核心。布局思路是从药品分类入口页开始用Selenium渲染列表页解析列表页中所有详情页URL对每个详情页URL发起正常请求详情页解析完成后处理翻页继续下一轮代码如下# spiders/drug_spider.py import scrapy from drug_spider.items import DrugItem class DrugSpiderSpider(scrapy.Spider): name drug_spider start_urls [https://www.yaoyuan.com/sell/medicine/] def parse(self, response): 列表页解析提取详情页URL触发翻页 # 提取当前页所有详情页链接 detail_links response.xpath( //div[contains(class, drug-item-info)]/a/href ).extract() for link in detail_links: yield scrapy.Request( urlresponse.urljoin(link), callbackself.parse_detail, ) # 翻页找下一页按钮的URL next_page response.xpath(//a[contains(text(), 下一页)]/href).get() if next_page: yield scrapy.Request( urlresponse.urljoin(next_page), callbackself.parse, meta{render_js: True}, # 列表页走selenium渲染 ) def parse_detail(self, response): 详情页解析提取药品全部字段 item DrugItem() item[drug_name] self.extract_text(response, 药品通用名) item[commodity_name] self.extract_text(response, 商品名) item[approval_number] self.extract_text(response, 批准文号) item[manufacturer] self.extract_text(response, 生产厂家) item[dosage_form] self.extract_text(response, 剂型) item[specification] self.extract_text(response, 规格) item[ingredients] self.extract_text(response, 成分) item[indications] self.extract_text(response, 适应症) item[usage_dosage] self.extract_text(response, 用法用量) item[approval_date] self.extract_text(response, 批准日期) item[category] response.xpath( //div[contains(class, breadcrumb)]/a[2]/text() ).get() item[source_url] response.url # 简单校验必填字段 if not item[approval_number]: self.logger.warning(f缺失批准文号: {response.url}) return yield item staticmethod def extract_text(response, label_name): 根据字段标签名提取内容 xpath_expr ( f//div[contains(class, detail-row)] f[contains(.//span/text(), {label_name})]//div[contains(class, detail-value)]/text() ) value response.xpath(xpath_expr).get() return value.strip() if value else None这里有两个细节值得说明。第一个是在翻页请求上加了meta{render_js: True}这样下一页的列表页面会走Selenium渲染。详情页请求不设置这个标记走Scrapy原生下载两者各司其职。第二个是详情页解析函数中的字段校验。因为详情页偶尔会出现缺失字段的情况比如某个字段页面里恰好没有展示如果不做校验脏数据就会进入数据库。我在批准文号这个核心字段上做了防空判断缺失就直接丢弃并打warning日志后面排查时直接看日志就能发现问题。extract_text这个方法也是在实际调试中逐渐完善的。最初我按标签名称精确找值后来发现页面上有些标签后面跟的是空格或者换行符所以加了.strip()处理。还有些字段是放在链接里的用text()拿不到完整内容这类特殊字段我会单独写xpath逻辑这里不再展开。3.4 Item Pipeline数据清洗与MySQL入库数据从Spider产出来之后Pipeline负责把它们洗得干干净常再落库。我在pipelines.py里实现了两个Pipeline一个负责数据清洗和标准化一个负责MySQL写入。# pipelines.py import datetime import pymysql from scrapy.exceptions import DropItem class CleanPipeline: 数据清洗处理空值、格式统一、去除重复 def process_item(self, item, spider): # 批准文号统一转为大写 if item.get(approval_number): item[approval_number] item[approval_number].upper() # 日期格式化 if item.get(approval_date): try: item[approval_date] datetime.datetime.strptime( item[approval_date], %Y-%m-%d ).date() except ValueError: item[approval_date] None # 将空字符串统一为 None for key in item.fields: if item.get(key) : item[key] None return item class MySQLPipeline: 写入MySQL按批准文号去重更新 def __init__(self, db_config): self.connection pymysql.connect( hostdb_config[host], portdb_config[port], userdb_config[user], passworddb_config[password], databasedb_config[database], charsetutf8mb4, cursorclasspymysql.cursors.DictCursor, ) classmethod def from_crawler(cls, crawler): return cls(db_configcrawler.settings.get(MYSQL_CONFIG)) def process_item(self, item, spider): keys list(item.keys()) values [item.get(key) for key in keys] update_columns [f{k}VALUES({k}) for k in keys] sql f INSERT INTO drug_info ({, .join(keys)}) VALUES ({, .join([%s] * len(keys))}) ON DUPLICATE KEY UPDATE {, .join(update_columns)} try: with self.connection.cursor() as cursor: cursor.execute(sql, values) self.connection.commit() except Exception as e: self.connection.rollback() spider.logger.error(f数据库写入失败: {e}, item{item}) return item def close_spider(self, spider): self.connection.close()在settings.py中激活Pipeline并设置优先级清洗先于入库ITEM_PIPELINES { drug_spider.pipelines.CleanPipeline: 300, drug_spider.pipelines.MySQLPipeline: 400, }这里需要重点解释一下MySQL写入的ON DUPLICATE KEY UPDATE逻辑。它利用了批准文号的唯一索引——如果一条记录的approval_number已经存在于数据库中插入操作就会触发冲突然后自动执行更新语句把该条记录的各个字段刷新为最新值。这个机制完美实现了“存在的更新、不存在的插入”配合每天定时任务跑一遍增量采集脚本整个数据库就能保持最新状态完全不需要人工干预。在实际运行时我还给MySQL连接加了心跳检测因为如果爬虫跑几个小时MySQL默认的wait_timeout可能会断开空闲连接导致后续写入报错。PyMySQL每次写入前会自动重连但如果碰到大批量写入时断连就得主动捕获pymysql.err.OperationalError并重建连接。这个坑在长时间任务里迟早会遇到建议提前做防呆处理。4. 反爬避坑与稳定性优化4.1 别当愣头青限速、UA池、cookies管理任何公开站点都不会喜欢一个以毫秒级速度疯狂请求的客户端。采集这种数据量级的项目第一原则就是不要给对方服务器造成压力否则封IP都是轻的严重了可能波及同机房的其它服务。控制请求频率最直接的手段是DOWNLOAD_DELAY。我在settings.py里设置的是1.5秒同时开启了Scrapy的自动限速模块AUTOTHROTTLE_ENABLED。自动限速会动态调整延迟——如果服务器响应变慢自动限速会让请求间隔变大服务器响应快时适当缩短间隔。这个机制比固定延迟聪明得多既不会因为固定间隔太长导致采集效率过低也不会在对方服务器不堪重负时还傻乎乎地继续猛冲。UA池的应用对于采集也是必需的。同一个UA从头用到尾很容易被对方服务器的风控规则识别出来。我在项目里准备了一个包含20多个常见浏览器UA的列表每次请求随机取一个伪装成真实用户的浏览器环境。实现方式可以写在中间件里也可以直接写一个在下发请求时动态设置header的下载中间件我为了代码简洁直接在Spider里写了个random_ua()函数在Request构造时设置header。cookies管理方面Selenium处理的列表页是自带完整浏览器环境的cookie、session都不会有问题。详情页走原生下载器默认情况下没有cookies但详情页的数据接口一般不需要登录态实际运行验证过没问题。如果你的目标站点的详情页需要登录cookie才能访问可以在settings里配置对应的cookie字符串Scrapy会自动带上。4.2 处理iframe与动态元素药源网的页面里面有一定数量的iframe嵌套层尤其是在药品详情弹窗、筛选器这些区域。Selenium默认只能访问当前DOM树里的元素如果目标元素在iframe里直接定位会报NoSuchElementException。排查方法是这样的在浏览器开发工具的Elements面板里搜索元素如果发现元素是被包裹在iframe标签内的就需要先切换到iframe再定位。Selenium代码如下# 如果有iframe先切换进去 iframe driver.find_element(xpath, //iframe[contains(id, iframe-content)]) driver.switch_to.frame(iframe) # 切换完成后才能定位iframe内的元素 elements driver.find_elements(xpath, //div[classdrug-item]) # 如果还要操作外层页面记得切回默认文档 driver.switch_to.default_content()switch_to.default_content()这一步很容易漏掉。如果在一个页面里连续切换多个iframe不切回默认文档的话下一个iframe的切换动作就会报找不到元素。我在调试时踩了两次坑才把逻辑理顺——每次等元素出现、提取完数据后立刻切回默认文档再继续后续步骤。另外Selenium定位元素时如果页面里同时存在多个相同class的元素find_element默认只返回第一个。如果页面结构里有隐藏的列表项或者多个模块复用了同一个class名定位到的可能根本不是目标元素。稳妥的做法是先find_elements把候选元素一次性捞出来然后根据元素文本内容做二次过滤。4.3 断点续爬与异常恢复10万条数据不是一次就能抓完的中间会有网络波动、进程被杀、服务器重启等各种意外。如果没有断点续爬机制每次挂了都从头抓一遍时间和流量都浪费不起。Scrapy天然支持断点续爬启用方式是# settings.py JOBDIR crawls/drug_spider启用JOBDIR之后Scrapy会在指定的目录里保存调度队列、去重集合、Spider状态等数据。任务被中断后再次启动它会自动恢复上次的进度继续处理未完成的请求。我在实际项目中设置好JOBDIR中途两次因为数据源服务器维护导致页面无法访问爬虫跑了大半的时候断掉重启后直接从断点继续没有重复抓取任何详情页。不过有一点需要提醒JOBDIR目录会随着爬取任务的推进越来越大尤其是去重集合和请求队列的持久化文件跑完整个项目后别忘了清理不然会占着几十GB磁盘空间。4.4 数据校验与日志监控数据入库之后不等于万事大吉必须在流程中加入校验环节。我在Pipeline里对每一批写入的数据做了计数统计日志里会输出当前成功入库的累计条数、失败条数、重复更新条数。跑完一轮后再用SQL对比一下源站分类页的总数和库里实际数据量的差异可以快速判断哪些分类页没有被爬到。-- 检查各分类的数据量分布 SELECT category, COUNT(*) AS cnt FROM drug_info GROUP BY category;这个SQL极其好用。如果某个分类下一条数据都没有说明这个分类的爬取逻辑有问题需要单独检查这个分类的页面结构是否有特殊处理。日志配置同样重要。Scrapy默认会把日志打到控制台但长时间后台运行的任务最好把日志输出到文件方便事后排查。在settings.py里直接配置LOG_ENABLED True LOG_FILE drug_spider.log LOG_LEVEL INFO注意日志级别不要调成DEBUG那会记录每个请求的详细信息文件增长速度惊人普通排查用INFO级别就够了。如果某一天数据异常再临时改成DEBUG深挖具体请求细节跑完记得改回来。5. 常见问题与排查技巧实录整个项目跑完我在真实环境里遇到过一批挺有代表性的问题挑几个典型问题整理成表格再详细解释排查过程这些都是常规文档里写不到的内容。问题现象核心原因解决方案Selenium报SessionNotCreatedExceptionChromeDriver版本与浏览器版本不匹配下载与浏览器版本完全一致的ChromeDriver翻页后拿到的是同样的数据点击下一页时页面异步刷新没有等待新内容渲染用WebDriverWait等待下一页列表元素出现或URL变化详情页出现TimeoutError目标服务器响应缓慢默认超时时间过短调大DOWNLOAD_TIMEOUT到30-60秒配合重试机制抓取中途数据量不再增长详情页URL被去重器误判为已抓取检查URL是否带随机参数自定义去重逻辑Chrome进程占用大量内存无头浏览器长时间运行产生内存泄漏定期重启浏览器实例或限制列表页连续处理数量5.1 Selenium等待元素超时这个问题概率最高。页面在浏览器里渲染并不是所有元素同一时间出现而是由多种异步请求逐步填充。如果WebDriverWait设置的时间太短或者等待条件选错了元素很容易在中间某一步直接抛TimeoutException。我实际调试的一个例子列表页第一版等待的是//div[classpagination]分页区域结果这个元素在页面刚加载时就出现了里面的药品列表却还没加载出来。等分页条件满足后我立刻去提取列表项得到的列表是空的然后Spider就报错。后来把等待条件改成等待“药品列表容器里出现至少一个列表项”通过d.find_elements(...)判断长度大于0才返回问题就解决了。等待条件的核心思路是“等真正要处理的那个数据出现”而不是等一个标志性的外围元素。5.2 Redis分布式扩展问题单机跑10万条数据按照1.5秒延迟加批量并发大概需要几小时到十几个小时。如果数据量再翻一倍或者要求更短的时间窗口就需要考虑横向扩容了。把Scrapy的调度队列换成Redis版本可以实现多台机器协同抓取共享请求队列和去重集合这就是常说的scrapy-redis方案。我在项目里没有上Redis因为单机已经足够覆盖需求。但如果未来要做更大规模的数据采集方向可以先搭一个Redis启用scrapy_redis.scheduler.Scheduler和scrapy_redis.dupefilter.RFPDupeFilter然后把所有机器的SCHEDULER_PERSIST设为True。这样一个请求只会被一台机器处理不会重复抓取。需要注意的细节是Redis队列里的请求对象需要能被序列化自定义的Request参数不要太复杂否则反序列化时报错很隐蔽。5.3 数据库连接断开的处理这个问题在长时间运行任务里几乎必然遇到。爬虫抓了半小时后第一批数据入库正常然后突然所有pipeline报错日志里全是pymysql.err.OperationalError: (2006, MySQL server has gone away)。根因是MySQL服务器空闲连接超时默认情况下超过8小时没活动的连接会被服务端断开客户端不知道下一笔写入时就报错了。我这个项目实际跑的时间没有那么长但是有几次程序在调试中断后再次运行数据库连接可能是旧的就碰到了这个错误。最终处理方式是每次写入前先执行一次cursor.execute(SELECT 1)做ping检测如果执行失败就重建连接。稳妥又简单def _ensure_connection(self): try: self.connection.ping(reconnectTrue) except Exception: self.connection.pymysql.connect(...)5.4 药品页面里特殊的“成分”字段大部分药品详情页的字段都是简单的文本但成分字段有时候会嵌套多个元素比如“本品主要成分为阿司匹林、对乙酰氨基酚、咖啡因”。我第一版xpath只取了第一个text()导致很多药品的成分字段只有第一个成分后面的都丢了。后来改用string(.)函数获取元素下的所有文本内容问题解决//div[contains(class, detail-row)][contains(.//span/text(), 成分)]//div[contains(class, detail-value)]这里用string(.)会拼接出该节点下所有子孙文本节点拿到的就是完整内容。凡是遇到这种“一个字段对应多个文本节点”的情况都需要检查是不是只取了第一个文本。这是爬虫字段提取中非常容易踩的坑强烈建议每种字段类型都用多个样本页面验证后再批量跑。5.5 断点恢复后重复写入问题启用JOBDIR后理论上断点恢复不会重复抓取但也有少数情况因为请求在下载器里完成但response还没被处理重启时这部分URL不在去重集合里会再次发起请求。这时候数据库的批准文号唯一索引就是最后一道防线——允许重复抓取但不会允许重复写入更新掉旧数据就算正常处理。所以我一直强调数据库唯一索引的重要性。它是整个爬虫系统的兜底机制即使上游逻辑有各种遗漏数据库层面依然能保证数据一致性。最后再分享一点项目落地的小技巧这个项目做到后面我越来越觉得整个方案里最关键的并不是代码本身而是怎么让爬虫任务稳定可控。我最终把采集任务封装成了一个带壳脚本配合Linux crontab定时执行每天凌晨跑一次增量更新日志自动按日期归档。数据入库后用之前设计的SQL定期检查数据质量哪一类缺失一眼就能看出来。还有一个小细节整个项目跑完后我顺手把详情页HTML源码做了快照保存按药品ID分目录存好。一方面方便后续重新解析提取新字段另一方面也算对数据来源的留痕真要追溯某条数据的来源时方便回去查。采集项目做到这个份上就不光是“抓数据”了而是形成了一套完整的“数据生产管线”。下次项目换目标站只需要换Spider解析规则和数据表设计整体架构完全可以复用这套框架本身的价值反而比这次抓回来的10万条数据更大。本文还有配套的精品资源点击获取
返回列表