尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python爬虫实战:链家贝壳网21城房价数据采集与解析

Python爬虫实战:链家贝壳网21城房价数据采集与解析 简介这是一套面向数据分析初学者与房地产行业研究者的Python爬虫实战项目专为高效采集链家网、贝壳网两大平台的全国21个重点城市含北上广深二手房、出租房及新房房价数据而设计。资源共41个文件包含35个结构清晰的Python脚本覆盖请求调度、XPath解析、多源数据清洗、多格式写入等核心模块、2个可视化PNG图表小区热度TOP榜、2个说明类txt文档、1个SQL建表脚本及.gitignore等辅助文件压缩包仅301KB轻量易部署。已有219人学习下载适合快速掌握反爬应对、分布式爬虫逻辑拆解与多数据库存储实践。代码全面兼容Python 2/3注释详尽内置CSV/Excel/JSON/MySQL/MongoDB五种导出通道并提供xiaoqu_to_chart.py等可视化入口脚本开箱即可运行并生成趋势图表是房产数据采集与教学演示的高复用性参考方案。 前阵子一直在做房产相关的数据项目最常被问到的就是这套“基于Python的链家网贝壳网全国21城房价数据爬虫”。链家网和贝壳网这两个平台基本覆盖了国内二手房市场最完整的挂牌数据而“21城”这个范围也不是随便定的它对应的是国内一二线重点城市里二手房市场最活跃的那批区域。这篇文章不打算讲太多虚的直接把我当初怎么设计这套爬虫的完整思路和源码细节拆给你看从URL构造、页面解析、反爬应对、并发抓取到数据存储每个环节都会落实到代码级别方便你直接照着改造和复用。这套方案适合谁主要是这几类人想拿真实房价数据做区域分析的研究者、做房产类产品原型需要批量样本的开发者、以及刚开始学爬虫但想直接从“带反爬的真实项目”入手的Python学习者。我会尽量把每行代码背后的原因也讲清楚而不是只丢一段能跑的脚本给你。1. 项目整体设计与架构思路1.1 为什么选链家网和贝壳网作为数据源做房价数据爬虫第一步不是写代码而是选对数据源。我在项目启动前调研过市面上几个主流房源平台最终锁定链家网和贝壳网原因是这两家平台的房源数据结构化程度非常高。先说链家网。它的二手房列表页URL规则很规整城市子域名加固定路径再加分页参数每条房源标题、小区名、户型、面积、朝向、装修、楼层、总价、单价这些字段都放在一个结构稳定的HTML节点里非常适合用XPath或CSS选择器做批量解析。更重要的是链家网的数据更新频率高同一套房源从挂牌到成交的轨迹能追踪到这对我们后来做增量抓取和价格波动分析很关键。贝壳网和链家在数据源上是同源的但在部分城市、部分小区信息上互有补充。链家主要覆盖链家自营和合作经纪人的房源贝壳则聚合了更多品牌经纪公司的挂牌信息覆盖范围更广、小区字典更全。所以我在实际项目中会把这两个平台的数据交叉去重、互相补全比如某个小区在链家只有5套在售但贝壳可能有12套取并集后样本量明显更充足。1.2 全国21城的范围如何确定21城不是随便列的我对标的是一线城市北京、上海、广州、深圳、新一线城市成都、杭州、重庆、武汉、西安、苏州、天津、南京、郑州、长沙、东莞、青岛、沈阳、合肥、佛山等里二手房成交量靠前的区域同时也兼顾了城市域名的稳定性——链家和贝壳在以上城市都有独立子域名URL规则统一爬虫代码可以一套跑通不需要为某个城市单独写适配逻辑。每个城市抓取的页面路径是/ershoufang/pg{n}/其中{n}是页码。默认每页30条房源如果每个城市抓前100页就是3000条样本21城加起来约63000条。这个量级对个人电脑上的多进程爬虫来说非常轻松跑一遍大概20到40分钟完全在可控范围内。1.3 整体架构设计与模块划分我不会把这套爬虫写成一个大而全的脚本而是拆成几个高内聚低耦合的模块这样方便后续单独替换某一层逻辑比如换解析库、换存储方式而不影响其他部分。整体架构分四层请求层负责构造请求、携带Headers和Cookies、处理重试和异常使用requests库实现。解析层负责从HTML中提取结构化房源数据使用lxml配合XPath。存储层负责把解析结果写入CSV或SQLite数据库去重逻辑也在这层做。调度层负责多进程分城抓取、任务队列调度、断点续跑。模块之间通过简单的函数调用衔接不引入重型框架。为什么不用Scrapy说实话这个项目的数据源只有两个、页面结构稳定、爬取量级不大用Scrapy反而多了一层学习和配置成本。requests multiprocessing lxml这套组合拳在这种场景下是最直接、最容易调试的。2. 核心模块实现与关键细节2.1 请求层的封装Headers、超时与重试很多新手写爬虫会直接在requests.get里裸奔一个URL被反爬拦截了才去补Headers。这套项目的请求层我从一开始就做了比较健壮的封装重点处理三件事请求头伪装、超时控制、异常重试。请求头最关键的是User-Agent。链家虽然不像某些平台那样风控严格但UA随机化是基础操作。这里我用了fake_useragent库来随机轮换UA实测下来能显著降低被拦截的概率。同时还需要带上Referer、Accept-Language这些常规字段模拟真实浏览器的请求特征。超时和重试比较容易忽略但实际跑起来特别重要。链家的响应速度并不稳定高峰期单个请求可能要3到5秒如果不设超时某个请求卡住了整个进程都会挂住。我一般设置timeout(3, 7)connect超时3秒、read超时7秒然后配合retry机制连续失败3次就放弃这条URL把异常记入日志不阻塞主流程。import requests from fake_useragent import UserAgent from tenacity import retry, stop_after_attempt, wait_random class LianJiaClient: def __init__(self): self.ua UserAgent() self.session requests.Session() self.session.headers.update({ Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, }) retry(stopstop_after_attempt(3), waitwait_random(min1, max3)) def get(self, url): headers {User-Agent: self.ua.random} resp self.session.get(url, headersheaders, timeout(3, 7)) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text2.2 页面解析策略XPath提取房源字段解析层是整个爬虫的核心也是最容易踩坑的地方。链家二手房列表页的HTML结构相对稳定房源信息在ul.sellListContent下的li节点里一条房源对应一个li.clear。我从HTML里要提取的字段包括房源标题、小区名称、位置区域、户型、面积、朝向、装修、楼层、总价、单价、关注人数、发布时间。这里有个经验点能不写正则就不要写正则正则表达式对HTML结构变化太敏感一个标签属性顺序变了就可能匹配失败。XPath在结构解析上容错性更好表达也更直观。以下是我实际使用的解析代码完整字段都提取出来了。注意楼层的原始文本是类似“低楼层/共33层”这样的格式我会拆成楼层位置和总层数两个字段单价文本是类似“每平米35000元”这样的格式我会用正则提取数字部分方便后续做数值运算。from lxml import etree import re def parse_listing_page(html): tree etree.HTML(html) li_list tree.xpath(//ul[classsellListContent]/li[classclear]) items [] for li in li_list: item {} title_node li.xpath(.//div[classtitle]/a/text()) item[title] title_node[0].strip() if title_node else None house_info li.xpath(.//div[classaddress]/div[classhouseInfo]/text()) if house_info: info_parts house_info[0].split(|) # 格式示例: 2室1厅 | 89.32平米 | 南 北 | 精装 | 低楼层(共33层) | 2018年建 if len(info_parts) 5: item[layout] info_parts[0].strip() item[area] float(re.sub(r[^\d.], , info_parts[1])) item[orientation] info_parts[2].strip() item[decoration] info_parts[3].strip() floor_match re.search(r(\D)(\d)层, info_parts[4]) if floor_match: item[floor_position] floor_match.group(1) item[total_floors] int(floor_match.group(2)) total_price_node li.xpath(.//div[classtotalPrice]/span/text()) item[total_price] float(total_price_node[0]) if total_price_node else None unit_price_node li.xpath(.//div[classunitPrice]/span/text()) if unit_price_node: unit_text unit_price_node[0] item[unit_price] float(re.sub(r[^\d.], , unit_text)) # 关注人数和发布时间 follow_node li.xpath(.//div[classfollowInfo]/text()) if follow_node: follow_parts follow_node[0].split(/) item[follow_count] follow_parts[0].strip() item[publish_time] follow_parts[1].strip() if len(follow_parts) 1 else None items.append(item) return items2.3 数据存储设计CSV落地与SQLite去重存储层我做了两层原始数据全部落到CSV方便直接用pandas做分析同时维护一个SQLite数据库专门做URL去重和增量更新判断。CSV的存储策略是按城市分文件文件名带日期标记这样每次抓取的数据不会互相覆盖后续做时间序列分析也方便。比如data/北京_20231028.csv的格式。CSV表头我统一按字段顺序写死不会因为某次抓取缺失某个字段就把表头搞乱。SQLite去重表的结构很简单核心就是存一个URL的哈希值CREATE TABLE IF NOT EXISTS url_log ( url_hash TEXT PRIMARY KEY, city TEXT, page INTEGER, crawled_at TEXT DEFAULT (datetime(now, localtime)) );在写数据之前先计算当前URL的md5哈希查一下表里有没有有就直接跳过。这样如果某个城市从第50页中断了下次重新跑时会自动从没抓过的页码继续不需要手动记录进度。3. 实操过程从0到1实现全国21城房价爬虫3.1 环境准备Python版本与依赖安装我实际开发用的Python版本是3.9但3.7以上都可以正常运行。依赖库不多五个就够requests、fake-useragent、lxml、pandas、tenacity。其中tenacity是重试库如果不喜欢额外依赖也可以用递归或者while循环自己写重试逻辑但tenacity的装饰器写法更简洁。pip install requests fake-useragent lxml pandas tenacity有些老机器上lxml可能需要编译如果装不动可以换成pip install lxml --only-binary :all:强制用预编译的wheel包。操作系统这块我主力开发机是macOS同时也在Ubuntu服务器和Windows上跑过代码都是跨平台的没有用任何系统相关的库。3.2 城市与URL构造一套模板跑通21城链家的城市子域名是拼音缩写比如北京是bj、上海是sh、广州是gz、深圳是sz、成都是cd、杭州是hz。贝壳网的城市域名也遵循类似规则部分城市在ke.com下的子域名和链家是一样的。我在代码里维护了一个城市映射表把城市中文名映射到子域名。新增加一个城市只需要在字典里加一行其他代码一点不用动。CITY_DOMAIN { 北京: bj, 上海: sh, 广州: gz, 深圳: sz, 成都: cd, 杭州: hz, 重庆: cq, 武汉: wh, 西安: xa, 南京: nj, 天津: tj, 苏州: su, 郑州: zz, 长沙: cs, 东莞: dg, 青岛: qd, 沈阳: sy, 合肥: hf, 佛山: fs, 福州: fz, 厦门: xm, } def build_url(city, page): domain CITY_DOMAIN[city] return fhttps://{domain}.lianjia.com/ershoufang/pg{page}/这里有个细节链家在部分城市做了页面跳转限制如果请求头里的Referer不属于该城市域名可能会被302重定向到首页。所以我构造URL时会同时带上对应城市的Referer保持请求上下文的一致。3.3 多进程并发抓取速度提升10倍的关键单线程逐页抓取21城总共2100页按每页1.5秒的均速算要跑将近一个小时。用上多进程后我实测可以在5到8分钟内完成全量抓取速度提升非常明显。为什么用多进程而不是多线程因为requests库是阻塞式IO操作在等待网络响应的时候CPU基本是空闲的。多线程虽然也能并发但在Python的GIL限制下多线程对IO密集型任务确实有效不过进程间隔离性差、异常不好排查。多进程的优势是每个进程独立、稳定、可控配合multiprocessing.Process手动管理或者concurrent.futures.ProcessPoolExecutor自动管理都很方便。我实际用的是concurrent.futures.ProcessPoolExecutor原因是API更简洁不需要手动维护进程队列和返回值。from concurrent.futures import ProcessPoolExecutor, as_completed def crawl_city_page(args): city, page args url build_url(city, page) try: html client.get(url) items parse_listing_page(html) save_to_csv(city, items) mark_url_done(url) print(f[OK] {city} 第{page}页, 抓取{len(items)}条) return len(items) except Exception as e: print(f[FAIL] {city} 第{page}页: {e}) return 0 def run_all_cities(cities, max_pages100): tasks [] for city in cities: for page in range(1, max_pages 1): tasks.append((city, page)) total_count 0 with ProcessPoolExecutor(max_workers8) as executor: future_map {executor.submit(crawl_city_page, t): t for t in tasks} for future in as_completed(future_map): total_count future.result() return total_count进程数我推荐设置为CPU核心数的2倍以内。个人电脑4核8线程就设8个进程8核16线程就设10到12个不要盲目开满。因为请求的目标是同一个网站开太多进程会导致请求频率过高反而更容易触发反爬也会给对方服务器造成压力。3.4 请求频率控制君子协定式限速虽然开了多进程并发但不等于每秒钟发出几十个请求。我给这套爬虫设置了一层软性限速每个进程在发起请求前随机sleep 0.3到1秒这样8个进程并发时整体QPS大概在8到15之间既保证了效率也不会让服务器压力过大。随机延迟的目的是让请求时间间隔没有固定规律降低被模式识别拦截的风险。固定sleep 1秒反而容易被识别因为真实用户点页面的间隔不可能均匀精确。import time import random def crawl_with_velocity(args): city, page args # 随机限速避免请求频率过于规整 time.sleep(random.uniform(0.3, 1.0)) return crawl_city_page(args)3.5 主流程串联从启动到落库的完整管道主函数负责把以上所有环节串联起来。启动时的逻辑顺序是先加载城市映射表、初始化数据目录、建表、然后提交所有任务到进程池、等所有任务完成后打印汇总信息。中断后的续跑逻辑是依赖SQLite里的url_log去重所以直接重新执行一遍主函数也能安全跳过已抓页面。if __name__ __main__: init_database() os.makedirs(data, exist_okTrue) cities list(CITY_DOMAIN.keys()) start_time time.time() total run_all_cities(cities, max_pages100) elapsed time.time() - start_time print(f抓取完成: 共处理{total}条房源数据, 耗时{elapsed:.2f}秒)注意在Windows上使用ProcessPoolExecutor时if __name__ __main__保护是必须的否则会无限递归创建子进程。macOS和Linux不需要但也建议写上保持代码跨平台。4. 常见问题与排查技巧实录4.1 遇到“城市列表页无法访问”的排查方法最常见的问题直接浏览器打开链家城市列表页没问题但爬虫请求返回的不是列表页内容而是首页或者一个空白的验证页面。我最初的排查思路是看Response的状态码和最终URL——如果状态码是302且最终URL跳到了www.lianjia.com说明被重定向了大概率是请求头缺少或不匹配。排查步骤我整理成了一套体系把爬虫请求时使用的Headers完整复制到浏览器的开发者工具里用curl命令发一次请求对比。检查Cookie链家有些城市首次访问会种一个lianjia_uuid的Cookie如果没有携带这个Cookie请求可能被拦截。检查Referer某些城市要求Referer必须是同城市域名下的页面否则拒绝访问。解决办法也不复杂。在Session第一次启动时先GET一次对应城市的首页让服务器种下Cookie后续再请求列表页就正常了。同时构造请求时把Referer指向对应城市首页。4.2 数据解析不到内容先看HTML结构是否变化链家的HTML结构改版过好几次特别是2023年下半年之后部分城市在小区的挂牌详情页上做了字段位置调整。遇到这类问题我建议直接在本地把响应HTML保存到文件里用浏览器打开后通过开发者工具的Copy XPath功能重新提取路径。另外推荐一个小技巧把抓取到的原始HTML保存一份到本地这样即使后来页面改版了你也能用旧数据重新解析不需要重新抓一遍。我在存储层加了一个debug模式开启后每页保存一个HTML快照磁盘空间占用不大但排查问题时非常有用。4.3 数据去重与字段清洗的坑去重方面容易出现两个问题一是同一套房源在不同平台挂牌时标题和价格完全相同但URL不同直接按URL去重会漏掉重复数据二是同一套房源在链家上改过一次价格后URL不变但内容变了按URL去重又会错过数据更新。我的处理策略是把去重粒度从URL细化到“小区户型面积朝向”维护一个房源指纹字段。指纹的计算方式是把这几个字符串拼接后做md5这样就算URL换了也能识别同一套房源。价格变化作为独立字段存储不影响指纹去重这样还能追踪到同一套房源的价格变化轨迹。import hashlib def gen_fingerprint(item): raw f{item.get(title)}|{item.get(layout)}|{item.get(area)}|{item.get(orientation)} return hashlib.md5(raw.encode(utf-8)).hexdigest()4.4 请求失败重试与断点续跑实际耗时最长的问题不是反爬而是网络不稳定导致的Request异常。每个页面都去重试3次在21城全量跑的时候会因为个别请求反复失败拖慢整体进度。我的优化方案是把失败URL单独记录到一个文件里跑完主流程后再单独启动一个重试任务去处理失败列表。同时每次成功解析完一个页面就把页码状态写入SQLite这样即使整个进程被kill了重启后也只补跑未成功的页面不用从头再来。FAILED_URLS_FILE data/failed_urls.txt def save_failed_url(url): with open(FAILED_URLS_FILE, a, encodingutf-8) as f: f.write(url \n) def retry_failed(): with open(FAILED_URLS_FILE, r, encodingutf-8) as f: urls [line.strip() for line in f if line.strip()] for url in urls: try: html client.get(url) items parse_listing_page(html) # 找出城市名和页码重新保存 print(f[RETRY OK] {url}) except Exception as e: print(f[RETRY FAIL] {url}: {e})4.5 数值字段解析异常面积字段在链家上的格式大致是“89.32平米”但有些房源面积是“89.32㎡”或者“89.32平”正则[^\d.]只保留数字和小数点基本能覆盖绝大多数情况。楼层信息的坑更多有些是“低楼层/共33层”有些是“地下室(共2层)”我专门写了不同分支来解析。单价字段还有个特殊情况部分豪宅房源页面不显示单价只显示总价和“价格待定”之类的文案。这种情况我在清洗时会把单价置为None而不是0避免后续做均价统计时把待定房源当真计算进去。5. 项目扩展与实际应用从数据到决策5.1 数据可视化21城房价横向对比爬虫只是第一步拿到结构化数据后最有价值的事情是分析。我把抓下来的CSV加载到pandas里做了一套简单的城市维度聚合逻辑按城市分组计算挂牌均价、在售套数、户型分布、面积段分布。然后用pyecharts生成交互式地图和柱状图在综合对比页面里能直观看到北京、上海、深圳的均价明显高于其他城市而沈阳、长沙、郑州的房源套数虽然多但均价相对温和。import pandas as pd df pd.read_csv(data/北京.csv) avg_price df.groupby(position_area)[unit_price].mean().sort_values(ascendingFalse)其中一个比较有意思的发现是很多城市核心城区的单价和郊区单价差距能达到3倍以上而同一个小区的楼层差价、朝向差价也在10%到15%之间。这些都是爬虫数据直接可量化呈现的信息比中介嘴里说的“这房好采光好”要客观得多。5.2 增量爬取每天定时更新同一批城市如果只想跑一次数据就够用全量爬一次就行。但我做这套项目的时候目标是一套可以长期运行的采集服务所以又单独实现了一个增量模式每天定时执行一次每次只抓取每个城市前10页然后通过小区名户型面积指纹去重只保留新增房源和价格变动过的房源。增量模式的好处是延迟低每天脚本跑完不到5分钟就能得到最新数据。我配合crontab在凌晨2点执行避开了白天和晚高峰的网络拥堵和服务器压力。5.3 数据合规边界爬虫不能碰的几条红线爬虫技术本身是中性的但用的时候有几个边界一定得清楚。我在这套项目的README里特别标注了以下约定只抓取公开挂牌数据不抓取任何需要登录后才能看到的用户隐私信息。抓取频率控制在合理范围不针对任何页面做并发压力测试。抓取数据仅限个人学习和研究使用不用于任何商业用途不转售不传播。数据展示时会注明来源。另外我会定期检查robots.txt中的爬虫约定。虽然国内很多网站并没有严格执行但从伦理和合规角度讲爬到数据不是目的有节制地采集才是长期可运行的前提。6. 一些实操心得这套21城房价爬虫做完前后用了大概两周时间其中真正写代码只用了三天剩下时间都在处理各种边界情况和结构变化。我个人体会最深的一点是做爬虫项目写代码能力只占三成剩下七成是对目标网站结构的理解能力和对异常情况的容忍能力。最后分享一个调试的小技巧很多人喜欢直接用requests.get去拉页面然后立刻print但在页面结构复杂的地方你根本不知道哪一步出了问题。我建议每次都先把HTML保存到本地文件再对文件做解析调试这样可以把“网络请求问题”和“解析问题”完全隔离开排查效率翻倍。这个习惯我一直保留着后续做任何爬虫项目都用得上。本文还有配套的精品资源点击获取
返回列表