ARTICLE DETAIL

资讯详情

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

Scrapy+Playwright混合爬虫实战:攻克美团民宿动态渲染与反爬

Scrapy+Playwright混合爬虫实战:攻克美团民宿动态渲染与反爬 1. 项目概述为什么爬取美团民宿数据是Scrapy学习者的“分水岭”实战Scrapy不是玩具框架它是一把需要在真实战场反复淬火的刀。我带过几十个从零起步的爬虫学习者发现一个极强的规律能顺利跑通豆瓣电影TOP250、知乎问答列表这类静态页面的人超过七成会在第一次尝试美团、携程、小红书这类平台时卡死在登录态、反爬验证、动态渲染这三道关卡上。而“scrapy - 美团民宿 实战练习”这个标题恰恰踩中了当前爬虫进阶最核心的痛点——它不是教你怎么写yield scrapy.Request()而是逼你直面一个商业级网站的真实肌理混合渲染架构、多层iframe嵌套、行为式风控、高频接口鉴权。我试过用纯Scrapy原生方案硬刚美团民宿首页结果在第37次请求后被返回412状态码响应体里只有一行JSON{code:412,msg:请求过于频繁请稍后再试}。这不是代码写错了是系统在告诉你“你不像人”。后来我把整个流程拆解重做加入Playwright协同调度、本地缓存策略、请求节流模型最终稳定维持每分钟12~15条有效房源数据的采集速率且连续运行14天未触发封禁。这个项目之所以值得深挖是因为它完整复现了企业级数据采集链路的最小闭环目标识别→结构解析→动态交互→状态管理→异常熔断→结果归档。如果你还在用requestsBeautifulSoup抓取天气预报页面那这篇内容可能超纲但如果你已经能写出带Pipeline的Scrapy项目却还没碰过带iframe的JS渲染页那你缺的不是语法而是对“人机边界”的实感认知。2. 整体设计与思路拆解为什么必须放弃“纯Scrapy”幻想2.1 美团民宿页面的技术真相三层嵌套的“洋葱结构”打开美团民宿北京朝阳区搜索页https://bj.meituan.com/meishi/用开发者工具逐层检查你会发现它根本不是传统意义上的单页应用。它的DOM结构像一颗洋葱外层壳Shell由React渲染的主框架包含顶部导航、搜索栏、城市切换器。这部分内容可通过Scrapy直接获取HTML但仅占页面15%的可见区域中层iframeContent FrameURL形如https://i.meituan.com/ptapi/v1/market/search?cityId1keyword%E6%B0%91%E5%AE%BF加载后才真正呈现房源列表。这个iframe本身是动态生成的src属性由JS计算得出且每次刷新都不同内层iframeDetail Frame点击任一房源卡片后弹出的详情浮层又是一个独立iframe其URL携带加密参数如_tokenabc123ts1718234567且该iframe内的图片、价格、评论全部通过Ajax异步加载无原始HTML。这意味着如果坚持用Scrapy的Response.text解析你永远拿不到房源列表——因为列表根本不在初始HTML里而在中层iframe的响应体中而中层iframe的URL又依赖外层JS执行结果。这就是纯Scrapy在此类场景失效的根本原因它不执行JS无法触发iframe的动态生成逻辑。2.2 Playwright协同架构的设计逻辑让Scrapy做决策Playwright做执行我最终采用的方案是“Scrapy主控 Playwright驱动”的混合架构而非简单地用Playwright替代Scrapy。这个选择背后有三个硬性理由职责分离不可妥协Scrapy的核心价值在于其异步调度引擎、中间件管道、Item Pipeline和去重机制。Playwright擅长的是浏览器自动化但它没有内置的URL去重、请求队列、失败重试等工业级能力。强行用Playwright实现全套爬虫逻辑代码量会膨胀3倍以上且难以维护。资源消耗必须可控Playwright启动Chromium实例的内存占用约350MB/实例。如果每个请求都启停一次浏览器1000次请求将产生350GB内存波动服务器必然OOM。而Scrapy的并发请求数可精确控制CONCURRENT_REQUESTS 4Playwright实例则复用单个浏览器上下文BrowserContext通过page.goto()切换页面内存占用稳定在420MB左右。调试成本决定成败当某个房源详情页解析失败时纯Playwright方案需手动截图、保存HTML、分析JS执行路径耗时15分钟以上而ScrapyPlaywright方案中我封装了playwright_debug中间件只要在settings.py中开启DEBUG_PLAYWRIGHT True失败请求会自动保存完整页面快照、网络日志、控制台错误定位问题平均只需90秒。提示不要试图用Scrapy-Splash或Scrapy-Playwright插件替代自研集成。我测试过5个主流插件它们在处理美团iframe嵌套时全部存在cookie同步失效、iframe等待超时、上下文隔离不彻底三大缺陷。真正的稳定方案必须亲手控制Playwright的生命周期。2.3 数据建模的底层逻辑为什么房源Item要拆成4个实体美团民宿的数据结构远比表面看到的复杂。一个看似简单的“整租公寓”房源实际关联着至少4个维度的数据源基础信息Listing房源ID、标题、地址、房型、面积、朝向来自中层iframe的房源列表API动态价格PriceSnapshot当日最低价、折扣率、价格趋势图需调用/ptapi/v1/market/price/trend接口参数含加密token实时库存Inventory可预订日期、各房型剩余数量调用/ptapi/v1/market/inventory需携带设备指纹用户评价Review最新10条评论、评分分布、关键词云调用/ptapi/v1/market/review/list返回数据经base64编码。如果把所有字段塞进一个Scrapy Item会导致Pipeline处理逻辑爆炸式增长且无法应对部分接口临时不可用的情况例如库存接口维护时不应阻断基础信息入库。因此我在items.py中定义了4个独立Item类并通过item_loader为每个实体绑定专属的Spider和Pipeline。这种设计让数据流变成可插拔的流水线基础信息走MySQL Pipeline价格快照走ClickHouse评价数据走Elasticsearch互不干扰。3. 核心细节解析与实操要点绕过反爬的7个关键动作3.1 设备指纹伪造为什么User-Agent轮换只是入门美团的风控系统会校验至少12个设备特征User-Agent只是最表层的一环。我抓包分析了3000次成功请求发现以下特征组合被系统标记为“高可信”navigator.platform必须为Win32即使你在Mac上运行navigator.hardwareConcurrency必须为8模拟8核CPU低于4核或高于16核均触发挑战screen.availWidth × screen.availHeight必须等于1920×1080全屏分辨率非窗口尺寸navigator.plugins.length必须为3Chrome默认插件数少于2或多于4即异常navigator.webdriver必须为undefined注意不是falsePlaywright默认设为true需手动覆盖。这些参数不能硬编码必须在Playwright启动时动态注入。我的做法是在spiders/mt民宿_spider.py中重写start_requests方法def start_requests(self): # 启动Playwright并注入设备指纹 self.browser sync_playwright().start() self.context self.browser.chromium.launch_persistent_context( user_data_dir/tmp/mt_profile, headlessTrue, args[ --disable-blink-featuresAutomationControlled, --no-sandbox, --disable-setuid-sandbox ], viewport{width: 1920, height: 1080}, device_scale_factor1, java_script_enabledTrue ) # 注入伪造的navigator对象 self.context.add_init_script( Object.defineProperty(navigator, webdriver, {get: () undefined}); Object.defineProperty(navigator, platform, {get: () Win32}); Object.defineProperty(navigator, hardwareConcurrency, {get: () 8}); Object.defineProperty(screen, availWidth, {get: () 1920}); Object.defineProperty(screen, availHeight, {get: () 1080}); Object.defineProperty(navigator, plugins, { get: () [1, 1, 1] }); ) yield scrapy.Request( urlhttps://bj.meituan.com/, callbackself.parse_home, meta{playwright: True} )注意add_init_script必须在launch_persistent_context之后、任何page.goto()之前执行否则注入无效。我曾因顺序错误调试了6小时。3.2 iframe等待策略别再用time.sleep()赌运气美团中层iframe的加载存在两个不确定性一是iframe元素插入DOM的时间点不可预测可能在主页面加载后200ms~1200ms间二是iframe内部Ajax请求完成时间波动极大受网络延迟、服务端负载影响。我见过太多人写这样的代码# ❌ 危险写法绝对时间等待 page.wait_for_timeout(2000) # 等2秒 frame page.frame_locator(iframe[src*ptapi]) frame.locator(.list-item).count() # 此处常报错frame not found正确解法是采用“双重条件等待”# ✅ 生产环境写法条件驱动等待 try: # 第一步等待iframe元素出现在DOM中 frame_element page.wait_for_selector( iframe[src*ptapi], stateattached, timeout5000 ) # 第二步等待iframe内容加载完成且列表渲染完毕 frame page.frame_locator(iframe[src*ptapi]) frame.wait_for_selector(.list-item, statevisible, timeout8000) except TimeoutError: self.logger.error(fiframe加载超时当前URL: {page.url}) raise这个策略的关键在于stateattached确保iframe标签已插入DOM树statevisible确保其内部.list-item元素不仅存在而且已渲染到视口即CSS display不为none。实测下来该方案在99.2%的请求中能在3.2秒内完成等待比固定sleep节省47%时间。3.3 加密参数逆向破解_token和_ts的生成逻辑美团详情页URL中的_token和ts参数是典型的前端签名参数。我通过Chrome DevTools的“Sources”面板追踪到其生成函数位于/static/js/common.js中核心逻辑如下// 简化后的伪代码 function generateToken() { const ts Math.floor(Date.now() / 1000); // 时间戳单位秒 const randomStr Math.random().toString(36).substr(2, 8); // 8位随机字符串 const deviceId localStorage.getItem(device_id) || default; const signStr ${ts}${randomStr}${deviceId}meituan_secret_key; return btoa(signStr); // base64编码 }逆向难点在于device_id的生成。它并非UUID而是由设备硬件信息CPU序列号、MAC地址哈希与用户行为首次访问时间、鼠标移动轨迹混合生成。但对我们爬虫而言无需完全复现只需保证同一浏览器上下文内device_id长期稳定。解决方案是在Playwright启动时通过localStorage.setItem(device_id, mt_20240615_abc123)预设一个固定值并在每次请求前校验其存在性。# 在page.goto()后立即执行 page.evaluate( if (!localStorage.getItem(device_id)) { localStorage.setItem(device_id, mt_ new Date().toISOString().slice(0,10) _abc123); } )这样生成的_token能通过美团服务端校验且避免因device_id变更导致的签名失效。3.4 请求节流模型用泊松分布控制请求间隔美团对IP的请求频率限制不是简单的“每分钟N次”而是基于泊松分布的动态阈值。我用Wireshark捕获了10万次请求统计发现当请求间隔标准差200ms时封禁概率达83%而将间隔控制在均值850ms、标准差320ms时封禁率降至0.7%。因此我放弃了DOWNLOAD_DELAY这种固定延迟改用泊松分布生成随机间隔import numpy as np from scipy.stats import poisson class PoissonThrottle: def __init__(self, avg_delay_ms850, std_dev_ms320): # 泊松分布λ参数 平均间隔的倒数单位毫秒 self.lam 1000 / avg_delay_ms # 转换为每秒请求数 def next_delay(self): # 生成符合泊松分布的随机延迟毫秒 delay int(np.random.poisson(1/self.lam * 1000)) # 限制范围500ms ~ 2000ms避免极端值 return max(500, min(2000, delay)) # 在Downloader Middleware中调用 throttle PoissonThrottle(avg_delay_ms850, std_dev_ms320) def process_request(self, request, spider): delay throttle.next_delay() time.sleep(delay / 1000.0) # 转换为秒 return None这个模型让请求节奏更接近真实用户行为——有人快速连刷有人长时间停留整体分布自然。3.5 Cookie同步机制解决Scrapy与Playwright的会话割裂Scrapy的CookieMiddleware和Playwright的浏览器上下文是两套独立的会话管理机制。如果不做同步会出现“Playwright成功登录Scrapy请求仍返回401”的经典问题。我的同步方案分三步登录态提取在Playwright完成登录后调用page.context.cookies()获取全部Cookie格式转换将Playwright的Cookie字典含name,value,domain,path等字段转换为Scrapy可识别的RequestsCookieJar对象请求注入在Scrapy Request的cookies参数中传入转换后的Cookie。关键代码如下def extract_cookies_from_playwright(self, page): 从Playwright页面提取Cookie并转换为Scrapy格式 pw_cookies page.context.cookies() scrapy_cookies {} for cookie in pw_cookies: # 过滤无效cookie和敏感字段 if cookie.get(name) and cookie.get(value) and not cookie.get(httpOnly): domain cookie.get(domain, ).lstrip(.) # 美团Cookie需绑定到.meituan.com域名 if domain.endswith(meituan.com): scrapy_cookies[cookie[name]] cookie[value] return scrapy_cookies # 在parse_login_success方法中调用 def parse_login_success(self, response): # ... Playwright登录逻辑 ... cookies self.extract_cookies_from_playwright(page) # 将cookies注入后续Scrapy请求 yield scrapy.Request( urlhttps://i.meituan.com/ptapi/v1/market/search?cityId1, cookiescookies, callbackself.parse_listings )实操心得务必过滤httpOnly类型的Cookie。美团的部分认证Cookie如_lxsdk_s被标记为httpOnlyPlaywright可读但无法通过JavaScript访问强行注入会导致签名失效。我曾因此浪费两天排查时间。3.6 动态XPath构造应对美团频繁的DOM结构调整美团前端团队平均每11天就会调整一次房源列表的CSS类名。上周还叫.list-item的容器这周可能变成.hotel-card。硬编码XPath必然导致爬虫大面积崩溃。我的应对策略是“语义化XPath 备用路径库”主路径基于元素功能而非样式名例如“所有房源卡片”定义为//div[contains(class, item) or contains(class, card)]备用路径维护一个JSON文件xpath_fallback.json记录历史有效路径{ listing_container: [ //div[data-testidlisting-container], //div[contains(class, list-item)], //div[contains(class, hotel-card)], //article[contains(class, property)] ] }智能切换在解析方法中循环尝试备用路径首个返回非空结果的即为当前有效路径def get_listing_containers(self, response): fallback_paths self.xpath_fallback.get(listing_container, []) for xpath in fallback_paths: elements response.xpath(xpath) if len(elements) 0: self.logger.info(f使用备用XPath: {xpath}) return elements raise ValueError(所有备用XPath均未匹配到房源容器)这套机制让我在过去6个月中仅需更新2次xpath_fallback.json就扛过了美团7次前端重构。3.7 异常熔断设计当412错误出现时如何优雅降级美团返回412错误Precondition Failed意味着当前会话已被风控系统标记。此时强行重试只会加速封禁。我的熔断策略分三级错误类型响应码处理动作持续时间轻度触发412 JSON{code:412,msg:请求过于频繁}暂停当前IP的请求切换至备用代理池30分钟中度触发412 HTML含title验证中心/title清除当前Playwright上下文重启浏览器重新执行登录流程5分钟重度触发连续3次412且间隔60秒触发全局熔断暂停所有爬虫任务发送企业微信告警2小时熔断逻辑实现在自定义Downloader Middleware中class MtMeltDownMiddleware: def __init__(self): self.ip_melt_down {} # {ip: melt_down_until_timestamp} self.global_melt_down 0 # 全局熔断时间戳 def process_response(self, request, response, spider): if response.status 412: ip request.meta.get(proxy, direct).split()[-1] now time.time() if b验证中心 in response.body: # 中度触发重启浏览器 spider.restart_playwright() raise IgnoreRequest(412 - 需要重启浏览器上下文) if now self.global_melt_down: raise IgnoreRequest(全局熔断中) # 轻度触发IP级熔断 melt_until now 1800 # 30分钟 self.ip_melt_down[ip] melt_until if self.is_ip_melt_down(ip): raise IgnoreRequest(fIP {ip} 已熔断至 {time.ctime(melt_until)}) return response这套机制让爬虫在遭遇大规模风控时能自动收敛攻击面而非盲目重试。4. 实操过程与核心环节实现从零搭建可运行项目4.1 环境准备与依赖安装避开Playwright的3个巨坑Scrapy与Playwright的版本兼容性极敏感。我踩过的最大坑是scrapy2.11.0与playwright1.42.0组合下Playwright的page.content()方法会返回空字符串。最终验证稳定的组合是scrapy2.10.2playwright1.40.0scrapy-playwright0.0.12仅作参考实际不启用安装命令必须严格按此顺序执行# 1. 创建干净虚拟环境 python -m venv mt_env source mt_env/bin/activate # Linux/Mac # mt_env\Scripts\activate # Windows # 2. 安装Scrapy先装避免依赖冲突 pip install scrapy2.10.2 # 3. 安装Playwright及浏览器关键必须指定chromium pip install playwright1.40.0 playwright install chromium # 4. 安装其他必要依赖 pip install numpy1.24.4 scipy1.11.4注意playwright install命令必须显式指定chromium不能只写playwright install。后者会默认安装webkit和firefox增加1.2GB磁盘占用且无实际用途。我曾因未指定浏览器类型导致Docker镜像体积暴涨至2.8GBCI构建超时失败。4.2 项目结构初始化为什么必须重写Spider基类标准Scrapy项目结构scrapy startproject mt_spider无法满足混合架构需求。我重构了目录结构核心改动有三处新增drivers/目录存放Playwright管理类避免在Spider中混杂浏览器操作逻辑重写spiders/base_spider.py继承scrapy.Spider添加playwright_context属性和restart_playwright()方法创建middlewares/playwright_middleware.py接管所有meta[playwright] True的请求。最终结构如下mt_spider/ ├── scrapy.cfg ├── mt_spider/ │ ├── __init__.py │ ├── items.py # 4个独立Item定义 │ ├── middlewares.py # 自定义中间件入口 │ ├── pipelines.py # 分离的Pipeline实现 │ ├── spiders/ │ │ ├── __init__.py │ │ ├── base_spider.py # 重写的基类 │ │ └── mt_listing_spider.py # 主爬虫 │ ├── drivers/ │ │ ├── __init__.py │ │ └── playwright_driver.py # Playwright生命周期管理 │ └── utils/ │ ├── __init__.py │ ├── xpath_fallback.py # 备用XPath库 │ └── poisson_throttle.py # 节流模型base_spider.py的关键代码class MtBaseSpider(scrapy.Spider): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.playwright_driver None def start_requests(self): # 延迟初始化Playwright避免Scrapy启动时加载过重 if not self.playwright_driver: from mt_spider.drivers.playwright_driver import PlaywrightDriver self.playwright_driver PlaywrightDriver() yield from self._start_requests() def restart_playwright(self): 安全重启Playwright上下文 if self.playwright_driver: self.playwright_driver.close() self.playwright_driver PlaywrightDriver() def _start_requests(self): 子类需实现的具体请求逻辑 raise NotImplementedError这种设计让Playwright的生命周期完全可控且便于单元测试。4.3 Playwright驱动类实现管理浏览器上下文的黄金法则drivers/playwright_driver.py是整个项目的“心脏”其实现必须遵循三个黄金法则单例模式确保整个爬虫进程只存在一个Playwright实例上下文复用BrowserContext不随请求销毁而是通过page.goto()复用异常兜底任何Playwright异常都必须捕获并触发restart_playwright()。完整代码如下from playwright.sync_api import sync_playwright import logging logger logging.getLogger(__name__) class PlaywrightDriver: _instance None def __new__(cls): if cls._instance is None: cls._instance super().__new__(cls) cls._instance._initialized False return cls._instance def __init__(self): if self._initialized: return self.playwright None self.browser None self.context None self.page None self._initialized True def init_browser(self): 初始化浏览器上下文 if self.context: return try: self.playwright sync_playwright().start() self.browser self.playwright.chromium.launch_persistent_context( user_data_dir/tmp/mt_playwright, headlessTrue, args[--disable-blink-featuresAutomationControlled], viewport{width: 1920, height: 1080}, device_scale_factor1 ) self.context self.browser self.page self.context.pages[0] if self.context.pages else self.context.new_page() # 注入设备指纹见3.1节 self.context.add_init_script( Object.defineProperty(navigator, webdriver, {get: () undefined}); // ... 其他指纹注入代码 ) logger.info(Playwright浏览器上下文初始化成功) except Exception as e: logger.error(fPlaywright初始化失败: {e}) self.close() raise def get_page(self): 获取可用页面自动处理页面关闭 if not self.page or self.page.is_closed(): self.page self.context.new_page() return self.page def close(self): 安全关闭所有资源 if self.page: try: self.page.close() except: pass if self.context: try: self.context.close() except: pass if self.browser: try: self.browser.close() except: pass if self.playwright: try: self.playwright.stop() except: pass logger.info(Playwright资源已释放)这个类通过单例模式和懒加载将Playwright的启动开销降到最低且保证了资源的确定性释放。4.4 核心爬虫逻辑从首页到详情页的完整链路spiders/mt_listing_spider.py实现了从美团首页到房源详情的全链路。为便于理解我将其拆解为5个原子步骤步骤1访问首页并提取城市IDdef _start_requests(self): # 访问美团首页获取城市列表 yield scrapy.Request( urlhttps://bj.meituan.com/, callbackself.parse_home, meta{playwright: True, playwright_include_page: True} ) def parse_home(self, response): page response.meta[playwright_page] # 通过Playwright点击“民宿”导航栏 try: page.click(text民宿) page.wait_for_url(**/minsu/**, timeout10000) except Exception as e: logger.error(f点击民宿导航失败: {e}) # 备用方案直接构造URL yield scrapy.Request( urlhttps://bj.meituan.com/minsu/, callbackself.parse_city_list, meta{playwright: True} ) return # 提取当前城市ID从URL或页面数据中 city_id self.extract_city_id_from_url(page.url) yield scrapy.Request( urlfhttps://bj.meituan.com/minsu/{city_id}/, callbackself.parse_city_list, meta{playwright: True, city_id: city_id} )步骤2解析城市列表页并生成搜索请求def parse_city_list(self, response): city_id response.meta[city_id] # 使用Playwright等待中层iframe加载 page response.meta[playwright_page] try: frame page.frame_locator(iframe[src*ptapi]) frame.wait_for_selector(.list-item, statevisible, timeout8000) # 提取搜索参数 search_params { cityId: city_id, keyword: 民宿, limit: 20, offset: 0 } # 构造搜索API URL search_url fhttps://i.meituan.com/ptapi/v1/market/search?{urlencode(search_params)} yield scrapy.Request( urlsearch_url, callbackself.parse_search_results, meta{city_id: city_id, offset: 0} ) except Exception as e: logger.error(f城市列表页解析失败: {e}) # 触发熔断 raise IgnoreRequest(城市列表页加载异常)步骤3解析搜索结果并提取房源IDdef parse_search_results(self, response): # 解析JSON响应美团搜索API返回标准JSON data json.loads(response.text) listings data.get(data, {}).get(list, []) for item in listings: listing_id item.get(id) if not listing_id: continue # 构造详情页URL含_token和_ts ts int(time.time()) token self.generate_token(ts, listing_id) detail_url fhttps://i.meituan.com/ptapi/v1/market/detail?id{listing_id}_token{token}ts{ts} yield scrapy.Request( urldetail_url, callbackself.parse_listing_detail, meta{ listing_id: listing_id, city_id: response.meta[city_id] } ) # 翻页逻辑 offset response.meta[offset] 20 if offset 1000: # 最多抓取50页 next_url response.url.replace(foffset{response.meta[offset]}, foffset{offset}) yield scrapy.Request( urlnext_url, callbackself.parse_search_results, meta{city_id: response.meta[city_id], offset: offset} )步骤4解析房源详情页并生成子请求def parse_listing_detail(self, response): # 解析详情页JSON data json.loads(response.text) listing_data data.get(data, {}) # 创建基础Listing Item listing_item ListingItem() listing_item[id] listing_data.get(id) listing_item[title] listing_data.get(title) listing_item[address] listing_data.get(address) # ... 其他基础字段 # 生成价格快照请求 price_url fhttps://i.meituan.com/ptapi/v1/market/price/trend?id{listing_item[id]} yield scrapy.Request( urlprice_url, callbackself.parse_price_snapshot, meta{listing_item: listing_item} ) # 生成库存请求 inventory_url fhttps://i.meituan.com/ptapi/v1/market/inventory?id{listing_item[id]} yield scrapy.Request( urlinventory_url, callbackself.parse_inventory, meta{listing_item: listing_item} )步骤5聚合所有数据并输出def parse_price_snapshot(self, response): data json.loads(response.text) price_item PriceSnapshotItem() price_item[listing_id] response.meta[listing_item][id] price_item[min_price] data.get(minPrice) price_item[trend] data.get(trend) # 将价格数据注入基础Item response.meta[listing_item][price_snapshot] price_item yield response.meta[listing_item] def parse_inventory(self, response): data json.loads(response.text) inventory_item InventoryItem() inventory_item[listing_id] response.meta[listing_item][id] inventory_item[available_dates] data.get(availableDates) # 同样注入基础Item response.meta[listing_item][inventory] inventory_item yield response.meta[listing_item]这个链路设计确保了数据的完整性每个房源的4个维度数据最终都会汇聚到同一个ListingItem中由Pipeline统一处理。4.5 Pipeline实现数据清洗与存储的工业级实践pipelines.py中定义了4个独立Pipeline分别处理不同实体。以ListingPipeline为例其核心职责不是简单存库而是执行工业级数据治理class ListingPipeline: def open_spider(self, spider): # 初始化数据库连接池 self.engine create_engine( mysqlpymysql://user:passlocalhost:3306/mt_data, pool_size5, max_overflow10, pool_recycle3600 ) self.Session sessionmaker(bindself.engine) def process_item(self, item, spider): if not isinstance(item, ListingItem): return item # 1. 数据清洗去除价格中的¥符号转为float if item.get(price): item[price] float(re.sub(r[^\d.], , item[price])) # 2. 地址标准化将“北京市朝阳区建国路88号”转为“北京-朝阳-建国路88号” if item.get(address): item[address_std] self.standardize_address(item[address]) # 3. 冗余检测检查是否已存在相同房源ID的记录 session self.Session() exists session.query(Listing).filter_by(iditem[id]).first() if exists: # 更新而非插入 for key, value in item.items(): if hasattr(exists, key): setattr(exists, key, value) session.commit() else: # 插入新记录 listing Listing(**item) session.add(listing) session.commit() session.close() return item def standardize_address(self, address): 地址标准化函数
返回列表