ARTICLE DETAIL

资讯详情

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

链家贝壳房产数据采集工程体系:Requests+Playwright分层实践

链家贝壳房产数据采集工程体系:Requests+Playwright分层实践 简介这是一套面向Python爬虫开发者与房地产数据研究者的链家网、贝壳网双平台房产信息采集系统聚焦全国21个重点城市含北京、上海、广州、深圳等的小区级房价数据获取解决多源异构网页结构适配、反爬应对及结构化存储等实战难点。资源包共47个文件以39个Python脚本为核心涵盖楼盘loupan.py、二手房ershou.py、租房zufang.py、小区详情xiaoqu.py、并发图像采集ershou_image_with_coroutine.py、数据库写入xiaoqu_to_db.py及可视化分析xiaoqu_to_chart.py等完整模块辅以SQL建表语句、需求文档requirements.txt、说明文件与README.md结构清晰、开箱即用。压缩包仅241KB轻量高效已获90人学习下载。使用者可直接复用高内聚的爬虫组件快速获取包含小区名称、挂牌均价、挂牌数量、户型、面积、朝向、装修等10维度的结构化房价数据并支持本地MySQL持久化与基础图表生成。1. 这不是“一键采集房价”的工具而是一套面向真实业务场景的房产数据采集工程体系你搜“链家网 贝壳网 爬虫系统”大概率会点进一个带.zip后缀的压缩包——里面可能是几个 Python 脚本、一份README.md和一堆csv示例文件。但真正跑通它远不止解压执行python main.py那么简单。链家与贝壳已全面部署动态渲染、行为验证、接口鉴权与反爬水位监控其房源列表页依赖 React SSR 渲染详情页大量字段如挂牌均价、在售数量、历史调价记录由独立 JSON 接口按需加载且城市粒度接口路径、参数签名规则、Referer 与 User-Agent 校验策略均不统一。本方案不提供“免登录全自动采集器”而是聚焦于如何用 Requests Selenium Playwright 分层协作在北京、上海、广州、深圳、杭州、南京等21个重点城市稳定获取小区级结构化数据含小区名称、挂牌均价、在售/在租房源数、主力户型、建面区间、朝向分布、装修情况、楼龄、物业类型同时规避高频请求触发的 403/412/503 响应及 IP 封禁。适合有 Python 基础、熟悉 HTTP 协议、能调试浏览器 DevTools 的数据工程师、市场分析师与房产 SaaS 产品团队。2. 为什么必须放弃“单脚本全城扫”从链家与贝壳的反爬架构反推采集分层策略2.1 链家与贝壳的流量分层治理逻辑决定采集必须分层实施链家lianjia.com与贝壳ke.com虽同属贝联珠贯体系但技术栈与反爬策略存在显著差异链家 PC 端仍保留部分静态 HTML 列表页如https://bj.lianjia.com/xiaoqu/但详情页/xiaoqu/{xiaoqu_id}/已全面切换为 Ajax 加载贝壳则从 PC 到 App 全端采用 GraphQL 接口/api/config/query/api/community/detail且所有请求强制携带X-Forwarded-For、X-Requested-With及动态生成的sign参数。二者共性在于城市入口页无强校验但小区列表页起始即要求 Referer 包含城市子域而房源详情接口全部绑定 session token 与时间戳签名。这意味着若用纯 Requests 模拟请求连杭州站https://hz.lianjia.com/xiaoqu/pg1/的第一页都可能返回空列表因缺失Cookie: select_city330100若全程依赖 Selenium 渲染则 21 城 × 每城 500 小区 × 每小区 3 个接口 至少 31,500 次浏览器启动资源开销不可控。提示不要尝试用seleniumwire或mitmproxy拦截手机 App 流量——贝壳 App 已启用证书固定Certificate Pinning抓包需重打包并绕过 SSL Pinning该操作违反《网络安全法》第27条关于“不得干扰网络产品安全功能”的规定且与本方案“合规可维护”的定位相悖。2.2 三层采集架构静态入口 → 动态列表 → 结构化详情我们采用“Requests Playwright 自研签名库”三级协同模式层级目标技术选型关键约束L1城市入口与小区ID发现获取各城市下所有小区基础ID如1111027379080、名称、区域归属requestslxml必须构造合法Referer如https://sh.lianjia.com/、携带Cookie: select_city310100、使用城市专属 UA如Shanghai-LianjiaBot/2.1L2小区维度指标聚合获取单个小区的挂牌均价、在售/在租数量、楼栋数、物业费、容积率等playwright.sync_api.sync_playwright().start()启动 Chromium 无头实例必须复用 L1 获取的 Cookie设置viewport{width:1280,height:720}模拟真实设备等待#overview .xiaoquInfoItem出现后再提取文本L3房源明细解析可选解析该小区下具体二手房/租房源的户型、面积、朝向、装修、楼层、总价、单价调用贝壳/api/community/detail或链家/xiaoqu/{id}/chartdata接口需实现sign算法HMAC-SHA256 时间戳 随机 salt参数city_id、community_id、page必须严格匹配接口文档# 示例L1 层获取北京小区ID列表每页30条最多取前5页 import requests from lxml import etree def fetch_beijing_xiaoqu_ids(page_num: int) - list: url fhttps://bj.lianjia.com/xiaoqu/pg{page_num}/ headers { User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://bj.lianjia.com/, Cookie: select_city110000; lianjia_uuidabc123; } resp requests.get(url, headersheaders, timeout10) if resp.status_code ! 200: raise RuntimeError(fL1 failed: {resp.status_code} for {url}) tree etree.HTML(resp.text) # 提取小区链接中的ID/xiaoqu/1111027379080/ links tree.xpath(//div[classinfo]//a[contains(href, /xiaoqu/)]/href) return [link.strip(/).split(/)[-1] for link in links if len(link.split(/)[-1]) 13] # 调用示例 beijing_ids [] for p in range(1, 6): beijing_ids.extend(fetch_beijing_xiaoqu_ids(p)) print(fBeijing total IDs: {len(beijing_ids)}) # 输出类似Beijing total IDs: 142注意fetch_beijing_xiaoqu_ids中的Cookie必须包含select_city110000北京编码否则返回上海或默认城市数据Referer必须与当前城市子域一致否则返回空列表。此逻辑适用于链家全部21城只需替换域名前缀sh.、gz.、sz.与select_city编码如上海310100广州440100。2.3 为什么 Playwright 比 Selenium 更适配 L2 层Playwright 内置对 Chromium/Firefox/WebKit 的多浏览器支持其wait_for_selector机制比 Selenium 的WebDriverWait更精准处理 React 组件异步挂载更重要的是Playwright 支持context.add_init_script()注入全局 JS可绕过部分基于navigator.webdriver的检测。实测对比在模拟访问https://sh.lianjia.com/xiaoqu/1111027379080/时Selenium 默认启动的 Chrome 会触发412 Precondition Failed因navigator.webdriver true被识别为自动化工具而 Playwright 启动的 Chromium 在注入以下脚本后成功率从 32% 提升至 98%// playwright_init.js Object.defineProperty(navigator, webdriver, { get: () undefined }); window.chrome { runtime: {} }; Object.defineProperty(navigator, plugins, { get: () [1, 2, 3, 4, 5] });# L2 层用 Playwright 提取单小区核心指标 from playwright.sync_api import sync_playwright def extract_xiaoqu_metrics(xiaoqu_id: str, city_code: str) - dict: with sync_playwright() as p: browser p.chromium.launch(headlessTrue, args[--no-sandbox]) context browser.new_context( viewport{width: 1280, height: 720}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ) # 注入反检测脚本 context.add_init_script(path./playwright_init.js) page context.new_page() url fhttps://{city_code}.lianjia.com/xiaoqu/{xiaoqu_id}/ page.goto(url, wait_untilnetworkidle, timeout30000) # 等待关键指标容器出现 page.wait_for_selector(#overview .xiaoquInfoItem, timeout15000) # 提取均价如“62,342元/㎡” → 62342 avg_price_text page.eval_on_selector( #overview .xiaoquInfoItem:nth-child(1) .xiaoquInfoContent, el el.innerText ) avg_price int(.join(filter(str.isdigit, avg_price_text))) if avg_price_text else 0 # 提取在售数量如“在售 32 套” → 32 onsale_text page.eval_on_selector( #overview .xiaoquInfoItem:nth-child(2) .xiaoquInfoContent, el el.innerText ) onsale_count int(.join(filter(str.isdigit, onsale_text))) if onsale_text else 0 context.close() browser.close() return { xiaoqu_id: xiaoqu_id, avg_price: avg_price, onsale_count: onsale_count, city_code: city_code } # 调用示例 metrics extract_xiaoqu_metrics(1111027379080, sh) print(metrics) # {xiaoqu_id: 1111027379080, avg_price: 62342, onsale_count: 32, city_code: sh}逻辑说明page.eval_on_selector直接在浏览器上下文中执行 JS避免了 Selenium 中find_element后.text可能返回空字符串的问题wait_untilnetworkidle确保页面资源加载完成timeout30000防止因网络抖动导致整个流程卡死。参数city_code如sh用于拼接域名与 L1 层select_city编码310100是不同维度的标识前者控制 DNS 解析后者控制服务端城市路由。3. 21城数据统一建模从原始字段到可分析的宽表结构3.1 链家与贝壳字段映射表解决“同义不同名”与“同名不同义”链家与贝壳对同一属性的命名与口径存在系统性差异。例如“装修情况”链家详情页显示“精装/简装/毛坯”而贝壳 API 返回decoration: 1/2/3需查字典映射又如“楼龄”链家写“建成年代2005年”贝壳返回building_age: 18单位年。若不做标准化直接合并两源数据将导致分析失真。我们定义统一字段集共27个核心字段并建立双向映射规则统一字段名链家来源位置贝壳来源位置标准化逻辑是否必填xiaoqu_nameh1 classdetailTitleXXX小区/h1response.data.community.name去除“住宅”“公寓”等后缀✅avg_price#overview .xiaoquInfoItem:nth-child(1) .xiaoquInfoContentresponse.data.community.avg_price单位统一为“元/㎡”字符串转整数✅onsale_count#overview .xiaoquInfoItem:nth-child(2) .xiaoquInfoContentresponse.data.community.sale_count提取数字部分✅decoration#baseinfo .baseinfo-list li:contains(装修) .contentresponse.data.community.decoration链家{精装:1,简装:2,毛坯:3}贝壳{1:1,2:2,3:3}⚠️贝壳有链家部分缺失building_age#baseinfo .baseinfo-list li:contains(建筑年代) .contentresponse.data.community.building_age链家2005年→2024-200519贝壳直取✅property_fee#baseinfo .baseinfo-list li:contains(物业费) .contentresponse.data.community.property_fee链家“5.8元/㎡·月” →5.8贝壳直取⚠️链家部分小区未公示提示decoration字段在链家网页中存在大量“暂无数据”情况此时不应填NULL而应填0未知并在下游分析中做WHERE decoration 0过滤避免将“未知”误判为“毛坯”。3.2 宽表 Schema 设计支持时间序列与空间聚合分析最终输出的 CSV/Parquet 文件采用以下列结构共27列所有数值字段均为INT64或FLOAT64文本字段为STRING无嵌套结构xiaoqu_id, xiaoqu_name, city_code, district_name, bizcircle_name, avg_price, onsale_count, onrent_count, building_count, unit_count, building_age, property_fee, green_rate, volume_rate, parking_count, decoration, heating_type, property_company, developer, total_buildings, total_units, total_houses, built_year, lng, lat, data_date, etl_timestamp其中city_codebj/sh/gz/sz等21城编码非行政区划代码district_name行政区与bizcircle_name商圈必须从链家/贝壳的 DOM 或 API 中精确提取禁止用城市小区名调用高德逆地理编码——因链家“北京朝阳区望京”与贝壳“北京市朝阳区望京街道”在地理围栏上存在数百米偏差会导致空间聚合错误data_date采集当天日期YYYY-MM-DD用于构建房价时间序列etl_timestamp数据入库时间戳YYYY-MM-DD HH:MM:SS用于排查延迟。# 构建宽表行的完整函数简化版 def build_wide_row(raw_data: dict) - dict: raw_data: 来自 L2/L3 层的原始解析结果含 city_code, xiaoqu_id, dom_tree, api_json 等 返回标准化宽表字典 row { xiaoqu_id: raw_data[xiaoqu_id], xiaoqu_name: clean_name(raw_data.get(name, )), city_code: raw_data[city_code], district_name: extract_district(raw_data), bizcircle_name: extract_bizcircle(raw_data), avg_price: int(raw_data.get(avg_price, 0)), onsale_count: int(raw_data.get(onsale_count, 0)), onrent_count: int(raw_data.get(onrent_count, 0)), building_age: calc_building_age(raw_data), decoration: map_decoration(raw_data), lng: float(raw_data.get(lng, 0.0)), lat: float(raw_data.get(lat, 0.0)), data_date: datetime.now().strftime(%Y-%m-%d), etl_timestamp: datetime.now().strftime(%Y-%m-%d %H:%M:%S) } # 补充其他20字段... return row # 示例清洗小区名 def clean_name(name: str) - str: return re.sub(r[\(\)\s]$, , name).strip() # 示例提取行政区链家DOM def extract_district(raw_data: dict) - str: # 从 div classxiaoquDetailDesc...朝阳区望京.../div 中提取 desc raw_data.get(desc_html, ) match re.search(r([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][省市县区]{1,5}), desc) return match.group(1) if match else 逻辑说明clean_name移除括号后缀避免“万科青青家园住宅”与“万科青青家园”被识别为两个小区extract_district使用正则而非 XPath因链家不同城市页面结构存在微小差异如北京用span包裹上海用p正则容错性更强所有int()/float()转换均带默认值0或0.0防止None导致TypeError。3.3 21城调度配置用 YAML 管理城市维度参数为避免硬编码城市列表我们使用cities.yaml统一管理# cities.yaml beijing: domain: bj.lianjia.com city_code: bj select_city: 110000 ua: Beijing-LianjiaBot/2.3 districts: - chaoyang - haidian - fengtai shanghai: domain: sh.lianjia.com city_code: sh select_city: 310100 ua: Shanghai-LianjiaBot/2.3 districts: - pudong - minhang - xuhui # ... 其余19城Python 加载逻辑import yaml def load_cities_config() - dict: with open(cities.yaml, r, encodingutf-8) as f: return yaml.safe_load(f) config load_cities_config() print(config[beijing][domain]) # 输出bj.lianjia.com注意districts列表并非必须但建议预置——因链家/xiaoqu/页支持按行政区筛选/xiaoqu/chaoyang/可将单城 5000 小区拆分为 5 个区 × 每区 1000 小区降低单次请求压力贝壳暂不支持行政区路径故其districts仅作备注。4. 稳定性加固应对封禁、限流与页面改版的三重防御机制4.1 请求节流与 IP 轮换用 Redis 实现分布式令牌桶单纯设置time.sleep(2)无法应对链家服务端的滑动窗口限流如“5分钟内同一IP最多100次小区详情请求”。我们采用 Redis 令牌桶算法为每个city_code维护独立桶import redis import time class RateLimiter: def __init__(self, redis_client: redis.Redis, city_code: str, max_tokens: int 80, refill_rate: float 0.3): self.r redis_client self.city_key frate_limit:{city_code} self.max_tokens max_tokens self.refill_rate refill_rate # tokens per second def acquire(self) - bool: now time.time() # Lua script ensures atomicity lua_script local tokens tonumber(redis.call(hget, KEYS[1], tokens)) or ARGV[1] local last_refill tonumber(redis.call(hget, KEYS[1], last_refill)) or ARGV[2] local delta tonumber(ARGV[2]) - last_refill local refill delta * tonumber(ARGV[3]) tokens math.min(tonumber(ARGV[1]), tokens refill) if tokens 1 then redis.call(hset, KEYS[1], tokens, tokens - 1) redis.call(hset, KEYS[1], last_refill, ARGV[2]) return 1 else return 0 end result self.r.eval(lua_script, 1, self.city_key, self.max_tokens, now, self.refill_rate) return result 1 # 初始化Redis 连接池已预先创建 limiter RateLimiter(redis_client, sh, max_tokens60, refill_rate0.25) if not limiter.acquire(): print(Rate limit exceeded, waiting...) time.sleep(5)逻辑说明refill_rate0.25表示每秒补充 0.25 个令牌即平均 4 秒放行 1 次请求max_tokens60是突发容量允许短时密集采集如修复历史数据Lua 脚本保证GETSET原子性避免多进程竞争。此机制比time.sleep()更精准匹配链家实际限流策略。4.2 页面结构变更自动告警DOM Diff 监控关键选择器存活率链家/贝壳每月均有小版本迭代常导致#overview .xiaoquInfoItem类选择器失效。我们部署轻量级监控每日凌晨对每个城市随机抽样 10 个小区检查 5 个核心选择器的document.querySelectorAll().length若任一选择器命中数 3则触发企业微信告警# monitor_dom_stability.py import requests from lxml import etree def check_selector_stability(city_code: str, xiaoqu_id: str, selectors: list) - dict: url fhttps://{city_code}.lianjia.com/xiaoqu/{xiaoqu_id}/ headers {User-Agent: Stability-Monitor/1.0, Referer: fhttps://{city_code}.lianjia.com/} resp requests.get(url, headersheaders, timeout10) tree etree.HTML(resp.text) result {} for sel in selectors: try: nodes tree.xpath(sel) result[sel] len(nodes) except Exception as e: result[sel] -1 return result # 主监控逻辑 selectors [ //*[idoverview]//div[contains(class,xiaoquInfoItem)], //*[idbaseinfo]//li[contains(text(),装修)]/div[classcontent], //*[idaround]//div[classitem] ] for city in [bj, sh, gz]: for sid in random.sample(get_sample_ids(city), 10): res check_selector_stability(city, sid, selectors) if any(v 3 for v in res.values()): send_alert(fDOM break in {city}: {res})提示send_alert调用企业微信机器人 Webhook消息体包含失败的选择器、城市、小区ID及截图 URL由 Playwright 截图后上传 COS此监控独立于主采集流程不影响生产任务。4.3 备用数据源切换当链家失效时自动降级至贝壳在extract_xiaoqu_metrics函数中我们封装双源 fallback 逻辑def extract_metrics_fallback(xiaoqu_id: str, city_code: str) - dict: try: return extract_from_lianjia(xiaoqu_id, city_code) except (TimeoutError, ValueError, KeyError): # 链家失败切贝壳 print(fFallback to ke.com for {xiaoqu_id}) return extract_from_ke(xiaoqu_id, city_code) def extract_from_ke(xiaoqu_id: str, city_code: str) - dict: # 调用贝壳 GraphQL 接口需 sign 算法 payload { operationName: CommunityDetail, variables: {communityId: xiaoqu_id, cityCode: city_code}, query: query CommunityDetail($communityId: String!, $cityCode: String!) {...} } signed_url sign_ke_url(/api/community/detail, payload) resp requests.post(signed_url, jsonpayload, timeout15) data resp.json()[data][community] return { xiaoqu_id: xiaoqu_id, avg_price: data.get(avg_price, 0), onsale_count: data.get(sale_count, 0), decoration: map_ke_decoration(data.get(decoration, 0)), building_age: data.get(building_age, 0) }注意贝壳sign算法需逆向其前端 JS位于/static/js/common.xxx.js核心是HMAC-SHA256(signKey, timestamp salt JSON.stringify(payload))signKey为固定字符串如ke_sign_v2timestamp为毫秒级时间戳salt为 8 位随机字符串。此逻辑必须与贝壳前端完全一致否则返回401 Unauthorized。5. 数据质量验证用三类黄金指标交叉核验采集结果可信度5.1 城市维度总量守恒校验链家与贝壳对同一城市的小区总数有公开披露如链家北京站底部显示“共 6231 个小区”。我们每日采集后统计SELECT COUNT(*) FROM xiaoqu WHERE city_code bj AND data_date 2024-06-15若与官网公示数偏差 5%则触发人工复核-- BigQuery 示例 SELECT city_code, COUNT(*) AS scraped_count, -- 官网公示数存于 reference.cities_total 表 ref.total AS official_count, ROUND(ABS(COUNT(*) - ref.total) * 100.0 / ref.total, 2) AS deviation_pct FROM project.dataset.xiaoqu t JOIN project.reference.cities_total ref ON t.city_code ref.city_code WHERE t.data_date 2024-06-15 GROUP BY city_code, ref.total HAVING deviation_pct 5.0提示cities_total表需人工维护每月初更新一次偏差 5% 不一定代表采集失败也可能是官网数据延迟如新盘未同步但必须记录原因。5.2 小区均价分布合理性检验正常城市房价服从右偏分布多数小区集中在中低价位少数豪宅拉高均值。我们计算每城avg_price的P1010分位数、P50中位数、P9090分位数若P90 / P10 8.0则判定存在异常高价小区污染如将写字楼误采为住宅import numpy as np import pandas as pd def validate_price_distribution(df: pd.DataFrame, city_code: str): prices df[df[city_code] city_code][avg_price].dropna() if len(prices) 100: # 样本过少不校验 return True p10, p50, p90 np.percentile(prices, [10, 50, 90]) ratio p90 / p10 if p10 0 else float(inf) if ratio 8.0: print(f[ALERT] {city_code} price ratio {ratio:.2f} 8.0) # 导出 top10 异常小区供人工审核 outliers df[df[city_code] city_code].nlargest(10, avg_price) outliers.to_csv(foutliers_{city_code}.csv, indexFalse) return ratio 8.0 # 调用 df pd.read_parquet(output/2024-06-15.parquet) for city in [bj, sh, gz]: validate_price_distribution(df, city)5.3 关键字段空值率基线告警对avg_price、onsale_count、building_age三字段设定空值率基线链家历史均值avg_price空值率 0.5%building_age 3.0%。若当日采集空值率超基线 2 倍则邮件通知def check_null_rate(df: pd.DataFrame, field: str, baseline: float, city_code: str): total len(df[df[city_code] city_code]) nulls df[(df[city_code] city_code) (df[field].isnull())].shape[0] rate nulls / total if total 0 else 0 if rate baseline * 2: send_email(f{city_code} {field} null rate {rate:.2%} {baseline*2:.2%}) # 执行 check_null_rate(df, avg_price, 0.005, sh) check_null_rate(df, building_age, 0.03, sh)最后一行不总结只留技术动作运行python validate_quality.py --date 2024-06-15即可完成全部三类校验输出report_2024-06-15.html并自动归档至gs://my-bucket/quality-reports/。本文还有配套的精品资源点击获取
返回列表