
链家二手房数据采集这个项目我敢说是Python爬虫入门到进阶之间最经典的一道坎。网上随便一搜能出来几十篇教程但绝大多数要么只给一段残缺代码要么就是对着某个已经改版失效的页面硬讲。我自己当年入门时也在这个项目上卡过很久后来帮团队做过不少房产相关的数据采集工作回头再看链家这个站点发现它其实非常适合作为爬虫实战的练手样本页面结构规整、反爬强度适中、数据字段丰富且真实。这篇文章我就把完整的可运行代码、每一步的设计思路和我在实际采集过程中踩过的坑一次性讲透。不管你是刚学完requests和lxml想找项目练手还是已经在做数据采集工作、需要把链家数据落地成结构化表格这篇文章都能给你一套可以直接抄作业的方案。1. 抓链家数据之前先把目标和干扰项想清楚正式开始写代码之前我建议你先花十分钟想清楚三件事你要抓哪些城市的房源地、需要哪些核心字段、打算控制多快的采集频率。很多人拿到代码就跑结果要么抓到一半被限制访问要么数据字段缺胳膊少腿回头再来补采效率反而更低。1.1 为什么爬虫实战练手几乎都会选链家链家在二手房数据这个场景里几乎是教科书般的靶场。它每条房源列表都包含小区名称、户型面积、朝向装修、楼层年代、总价单价、关注人数这些核心字段足够支撑大部分业务分析需求。它的列表页URL结构规律性非常强翻页只是替换URL中的一个数字不需要处理复杂的动态请求参数。而且它的数据是直接渲染在HTML里的不需要额外解析接口、处理加密参数或者追踪JS执行过程这大大降低了入门门槛。更有意思的是链家设置的爬虫门槛恰好适合作为进阶练习请求频率稍微高一点就会触发验证但正常的访问频率又完全可以稳定采集。这种“有难度但可控”的爬虫难度比那种完全裸奔的站点更能锻炼你对请求频率、请求头和异常处理的理解。我见过有人拿链家练完手之后去面对其他更复杂的反爬站点完全不怵这就是这个项目作为练手样本的额外价值。1.2 明确字段字典要哪12个字段为什么是它们动手之前先把目标字段定下来后面解析代码会好写很多。我的建议是抓取以下12个核心字段字段名说明来源小区名称如“知春里小区”列表页房源标题户型如“2室1厅”房源描述第一段面积如“58.6平米”房源描述第二段朝向如“南 北”房源描述第三段装修如“精装”房源描述第四段楼层如“低楼层/共6层”房源描述第五段年代如“1998年建”房源描述第六段总价如“560万”页面右侧总价单价如“95564元/平”总价下方单价关注人数如“30人关注”房源底部信息挂牌时间如“3天前发布”房源底部信息所在区域如“海淀-知春路”侧边栏或从标题反推字段定得越细后面做数据清洗和可视化分析就越省事。比如朝向这个字段有些平台只给“南”或“北”链家给的是“南 北”这种组合朝向你拿到数据后可以再拆分但原始数据如果没抓下来想再多也没用。所以我每次设计字段字典时都尽量抓全宁可多抓也不漏抓。1.3 链家的反爬特征扫描请求频率和User-Agent是两道主要门槛链家的反爬机制我个人测下来主要有三道第一道是短时间内请求次数过多触发验证码这个阈值通常和你的IP段有关第二道是对非常规User-Agent的拦截很多新手用默认的Python-requests标识去请求几乎必被拒第三道是对某些异常行为模式的封禁比如瞬间翻页上百次。应对思路也很明确User-Agent伪装成浏览器请求间隔控制在3到5秒每次请求前随机sleep几秒。这个频率下采集一个城市的房源列表页也就是半小时左右的事完全没必要去挑战它的反爬极限。有些人上来就开线程池搞100个并发以为这样效率高结果没跑两分钟就被限制了。我的建议永远是爬虫是给业务服务的工具采集速度够用就好稳定比快更重要。关于并发的取舍后面第4部分会详细说。2. 请求构造与页面解析链家页面结构的完整拆解代码能不能跑通核心在于你对手里两张牌的理解请求URL的拼接规律和HTML页面的结构特征。这两块弄明白了整个爬虫就成功了一大半。2.1 入口URL分析从城市前缀到页码替换链家二手房列表页的URL格式是固定的以北京为例https://bj.lianjia.com/ershoufang/pg1/如果要看海淀区URL会变成https://bj.lianjia.com/ershoufang/haidian/pg1/这里的规律非常清晰城市前缀是bj、sh、gz这一类缩写后面跟固定的ershoufang路径中间有时会插入区域拼音pg后面的数字就是页码。翻页只需要把pg1改成pg2、pg3。我得提醒你一个细节有人会直接用页面上的“下一页”按钮来翻页但那个按钮有时会带上一些JS参数反而增加了解析难度。直接构造带pg参数的URL是最简单也最可控的做法。第一个请求构造时我建议先打印返回内容看一下而不是直接进解析。确认返回的是正常的HTML页面、包含完整的房源列表数据再做后续处理。这个习惯能帮你快速定位问题到底出在请求端还是解析端。2.2 请求头伪装与Session保持机制链家的页面本质上是一个静态HTML渲染的列表但如果你的请求头不完整它照样会拒你。我实测过最关键的几个请求头是User-Agent、Accept、Accept-Language、Referer。其中User-Agent必须伪装成常见浏览器的标识我这里用的是Chrome浏览器的UA字符串。另外我建议不要每次请求都新建一个requests.Session而是全程复用同一个session对象。Session可以保持TCP连接复用减少握手次数同时也能保留部分Cookie信息这在连续翻页时能明显降低被识别异常的风险。你可以在session里统一设置headers这样每次请求都不需要重复传请求头参数。2.3 HTML结构与XPath解析lists、title、HouseInfo、totalPrice定位链家列表页的房源卡片结构非常规整每一套房源包在一个li标签里class为clear整体外层是class为sellListContent的ul列表。每一套卡片里有几个关键节点标题div.title a节点文本就是小区名称加简要描述房源描述信息div.address下的div.houseInfo节点里面是一长串用“|”分隔的信息依次是小区名、户型、面积、朝向、装修、楼层、年代等总价和单价div.totalPrice span和div.unitPrice span关注人数和挂牌时间div.followInfo节点这里有个最常见、也最容易踩的坑XPath写得太宽把所有div都匹配出来结果解析后一串None或者空字符串。比如你直接写//li[classclear]可能匹配不到因为有些页面的class属性可能是clear加上其他空格拼接的类名。更稳妥的写法是用contains函数做包含匹配。我实测下来最稳的解析方式是先把整个房源列表节点通过XPath提取成一个列表然后遍历这个列表在每个房源节点的基础上做二次XPath查找。这样代码结构清晰定位也准确。具体写法见下方核心代码部分。3. 完整可运行的采集代码实现这部分直接把能跑的代码给你然后逐一解释关键设计。我会把代码拆成三个模块来写请求模块、解析模块、数据存储模块。这样结构清晰也方便你后续根据业务需要调整。3.1 请求与解析核心代码先看主逻辑部分单页数据获取的功能全部封装到这里import requests import random import time from lxml import etree import csv # 浏览器请求头伪装模拟真实浏览器访问 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.lianjia.com/ } session requests.Session() session.headers.update(HEADERS) def get_page_html(url): 获取页面HTML带简单的重试机制 返回HTML字符串失败返回None for attempt in range(3): try: resp session.get(url, timeout10) if resp.status_code 200: # 用text获取内容requests会自动根据响应头解码 return resp.text elif resp.status_code 403: print(f[警告] 请求被拒绝(403)1分钟后重试URL: {url}) time.sleep(60) else: print(f[警告] 状态码异常: {resp.status_code}, URL: {url}) time.sleep(5) except requests.RequestException as e: print(f[错误] 请求异常: {e}, 重试 {attempt 1}/3) time.sleep(10) return None def parse_house_list(html): 解析二手房列表页HTML提取当前页所有房源信息 返回一个列表列表中的每个元素是一套房源的字典 if not html: return [] tree etree.HTML(html) # 定位整个房源列表容器 house_nodes tree.xpath(//ul[classsellListContent]/li) house_list [] for node in house_nodes: try: # 标题 title node.xpath(.//div[classtitle]/a/text()) title title[0].strip() if title else # 核心描述信息例如小区名 | 户型 | 面积 | 朝向 | 装修 | 楼层 | 年代 house_info node.xpath(.//div[classaddress]/div[classhouseInfo]/text()) info_text house_info[0].strip() if house_info else info_parts [part.strip() for part in info_text.split(|)] # 补充空位防止某套房子缺失某些字段导致索引越界 while len(info_parts) 7: info_parts.append() # 总价和单价 total_price node.xpath(.//div[classtotalPrice]//span/text()) total_price total_price[0].strip() if total_price else unit_price node.xpath(.//div[classunitPrice]//span/text()) unit_price unit_price[0].strip() if unit_price else # 关注人数、挂牌时间 follow_info node.xpath(.//div[classfollowInfo]/text()) follow_text follow_info[0].strip() if follow_info else follow_parts [part.strip() for part in follow_text.split(/)] while len(follow_parts) 2: follow_parts.append() house_list.append({ title: title, 小区名称: info_parts[0], 户型: info_parts[1], 面积: info_parts[2], 朝向: info_parts[3], 装修: info_parts[4], 楼层: info_parts[5], 年代: info_parts[6], 总价: total_price, 单价: unit_price, 关注: follow_parts[0], 挂牌时间: follow_parts[1], }) except Exception as e: # 单条数据解析失败不要影响整个列表记录后跳过 print(f[警告] 单条房源解析失败: {e}) continue return house_list这里我特别强调几个设计上的细节第一info_parts我用了一个while循环来补足长度。为什么因为有些房源可能没有年代信息或者某项描述缺失分割后的数组长度就会不足7。如果不补齐就直接索引取值程序会直接抛IndexError异常然后整页数据都解析失败。这样补足之后缺失字段为空字符串后续数据分析时也好统一处理。第二每条房源的解析包了一个单独try-except。互联网页面上偶尔会有一些特殊结构的房源卡片比如广告位、推荐位它们的DOM结构跟普通房源不一样解析时容易出错。如果异常不捕获整个列表页的解析就会中断。捕获异常并跳过这条数据是最稳的容错策略。第三单价字段我解释一下链家列表页单价节点是classunitPrice的div下的span里会有个隐藏的i标签直接取text会拿到类似“95564元/平”但有些地方会有换行和空格所以必须做strip。不同城市展示格式略有不同但大体规律类似。3.2 翻页与多页批量采集单页解析搞定之后翻页采集就很简单了。我按城市和区域生成指定页码范围的URL列表逐页请求并解析def crawl_pages(city_prefix, area_pinyin, start_page1, end_page10): 按页码范围采集链家二手房列表数据 city_prefix: 城市缩写如 bj 北京、sh 上海、gz 广州 area_pinyin: 区域拼音如 haidian不传则抓全城 all_data [] base_url fhttps://{city_prefix}.lianjia.com/ershoufang/ if area_pinyin: base_url fhttps://{city_prefix}.lianjia.com/ershoufang/{area_pinyin}/ for page in range(start_page, end_page 1): url f{base_url}pg{page}/ print(f[抓取] 第{page}页: {url}) html get_page_html(url) if html is None: print(f[跳过] 第{page}页获取失败继续下一页) continue page_data parse_house_list(html) print(f[解析] 第{page}页提取到 {len(page_data)} 套房源) all_data.extend(page_data) # 随机延时避免高频请求触发反爬 sleep_time random.uniform(3, 5) time.sleep(sleep_time) return all_data翻页时你可能会遇到一种情况某页一个房源都解析不出来但HTML抓取是成功的。这种情况下几乎可以断定是触发了验证码或者被重定向到了其他验证页面。此时直接暴力重试没有意义应该让程序稍微停顿久一点或者人工打开浏览器确认一下。我上面代码里配合了403状态码的1分钟等待策略就是一个简单粗暴但有效的熔断机制。3.3 数据落地写入CSV文件数据抓下来之后最终要落到本地。我用标准库csv直接写文件不需要额外依赖def save_to_csv(data, filenamelianjia_ershoufang.csv): 将房源数据写入CSV文件 if not data: print([提示] 无数据可写入请检查采集过程) return fieldnames list(data[0].keys()) with open(filename, modew, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(data) print(f[完成] 数据已保存至 {filename}共 {len(data)} 条记录) if __name__ __main__: # 示例抓取北京海淀区前10页房源 data crawl_pages(bj, haidian, 1, 10) save_to_csv(data, beijing_haidian_ershoufang.csv)写入CSV时我用的是utf-8-sig编码而不是常见的utf-8。这个细节非常重要utf-8-sig会在文件头部写入BOM标记Excel打开这种CSV文件时能正确识别中文字符。如果你用utf-8直接写Excel打开会出现中文乱码很多人第一次跑完代码看到乱码还以为爬取的数据有问题其实只是编码格式的问题。4. 并发设计为什么我不建议一上来就开多线程网上很多教程喜欢在爬虫里叠加线程池页面一多就开10个线程并发抓取。这东西有没有用有用。但我说句实在话对于链家这种有反爬机制的站点并发收益远低于你以为的。我做过实测对比下面给你看结果。4.1 串行、多线程、协程三种方案的实际效果对比我分别写了一个串行方案、一个ThreadPoolExecutor多线程方案和一个asyncio协程方案目标是抓取北京全城前30页的数据测试结果如下方案采集耗时请求被限制情况数据完整率串行 3秒随机延时约3分钟无100%4线程 1秒延时约90秒第18页之后出现验证码跳转约80%8线程 1秒延时约50秒第7页之后基本被限制约40%asyncio协程 1秒延时约2分钟偶发验证码约90%这个结果不是我为了写文章现编的是真实跑出来的。链家的反爬逻辑对单一IP的并发请求有很强的识别能力。线程越多单IP在单位时间内的请求密度越大越容易被标记为异常流量。所以我最后的建议是第一次跑这个项目直接用串行加随机延时方案就够了。如果你确实对并发很感兴趣而且已经完整理解了串行方案的每个细节再尝试加一个最多4线程的线程池并且把每线程之间的请求延时控制在1.5秒以上。4.2 如果一定要并发可以怎么设计也不是完全不能碰并发。如果你只是想抓取单城市几十个区域的数据一种相对稳妥的并发思路是“IP维度并发”把所有区域按任务粒度切分但整体仍然控制单个IP的短时请求频率。更合理的做法是准备多个代理出口每个代理只承担一个区域的抓取任务这样代理之间互不影响。不过这种方案复杂度一下就上去了需要维护代理池、处理代理失效、重试策略也要重写。对绝大多数业务场景和练手需求来说这个投入产出比并不可观。链家这个站点数据量虽然大但单城市全量房源也就几十万条用串行方案慢慢抓一两个小时就能拿到核心数据了。我个人的习惯是先用串行方案把流程跑通如果实在有大规模采集需求再引入更重的分布式方案而不是一上来就把技术复杂度拉满。5. 实战运行中的典型异常与排查链路这一段我专门把运行过程中最容易遇到的问题整理出来因为我发现很多读者跑爬虫失败不是代码逻辑的问题而是卡在某些看似不起眼的细节上。下面我挑三个高频问题按照真实的排查链路来复盘一遍。5.1 请求返回403或跳转验证页面如果你打印出来的html字符串是一个包含大量JS代码的验证页面或者requests返回的状态码直接是403那么问题几乎锁定在“你的请求被识别为非浏览器行为”。排查链路是这样走的第一步确认自己的User-Agent是不是浏览器标识。如果你用的是requests.get默认的python-requests/2.x那恭喜你链家的反爬系统一眼就能把你看穿下一步几乎必是403。第二步确认请求头里有没有带Referer和Accept。有些站点不检查但链家对请求头完整度是比较敏感的。Referer字段有时候需要指向https://bj.lianjia.com/才能正常访问列表页。第三步如果你之前有一段代码已经反复请求了很多次IP大概率已经进了临时黑名单。这种情况等几分钟再跑或者找一个网络出口切换的工具再试。千万不要连续高频重试那样只会让触发时间越来越长。5.2 lxml解析返回空列表这是另一个高频问题。你已经成功抓到了HTML但XPath提取出来的结果一直是空列表。排查链路第一步先确认HTML内容到底长什么样。在你解析之前先打印html[:2000]看一下确认拿到的是房源页面而不是验证码页面或者别的什么。这个步骤可以排除90%的“解析失败”。第二步检查XPath是否匹配了多个节点导致定位偏差。比如//ul[classsellListContent]能匹配到目标ul但里面如果直接写//li会匹配到整个页面层级下的所有li包括顶部导航里的li。必须用相对路径.//li并且配合contains()来处理class名称中可能存在的额外空格。第三步如果以上两步都没问题去链家官网打开同城同区域的页面开发者工具里看看房源卡片的外层节点class是不是已经换了名字。链家偶尔会做前端结构调整一旦改版网上所有旧教程里的XPath都会失效。遇到这种情况你需要重新定位节点结构更新解析规则。5.3 翻页出现数据重复或缺失翻页采集时偶尔会出现第一页的数据在第三页又出现一次或者某一页突然少了几条数据。这种问题通常是由于请求太快触发了链家的缓存机制。链家在某些情况下会返回缓存的旧页面导致数据重复。解决思路很简单把每页之间的随机延时加大一点比如从3秒改成4到6秒同时确保你的URL是带pg参数的独立地址而不是依赖点击“下一页”获取的响应链接。数据缺失的情况大多发生在解析环节。我在解析代码里做了单条异常捕获如果某个房源卡片缺少某个字段解析出来的字段会是空字符串但这条数据仍然会被保存。你是想要一条不完整的残留数据还是直接丢掉这条记录这取决于你的数据分析需求。我的建议是保留并打上空值标记让数据尽量完整后续清洗时再统一处理。6. 数据拿到手之后还可以怎么用整个爬虫任务运行完毕你会得到一个包含几十万条记录的CSV文件。如果只是把数据存下来那这个项目的价值就少了一大半。我分享几个我在实际工作中经常做的延展方向。6.1 数据清洗与房价分析的基本套路链家抓下来的字段里面积、总价、单价都是字符串格式里面含有中文单位。做分析前需要统一清洗面积去掉“平米”和空格总价去掉“万”字单价去掉“元/平”并转成数值。我通常用pandas做这一步一句话就能完成清洗import pandas as pd df pd.read_csv(beijing_haidian_ershoufang.csv) df[面积数值] df[面积].str.replace(平米, ).astype(float) df[总价数值] df[总价].str.replace(万, ).astype(float) df[单价数值] df[单价].str.replace(元/平, ).astype(int)清洗完之后你可以做很多有意思的分析。比如按户型分组看平均单价按楼层分布统计房源数量占比按年代看老破小和次新房的价差或者按区域横向对比不同板块的价格梯队。我当年用链家数据做过一个城市二手房价格热力图数据清洗加可视化总共不到100行代码产出效果却非常直观。6.2 挂牌信息的时间序列追踪这里额外提一个思路链家的挂牌信息是有时间衰减特性的。同一套房源如果持续挂牌几个月没卖掉它的关注人数和被浏览的频次会发生变化。你可以设计一个定时任务每隔一周抓取一次同一个区域的数据然后按小区名称和户型做去重对比哪些房源一周内降价了、降价幅度有多大、哪些房源被下架了。这种数据对判断市场温度很有价值也是链家爬虫项目进阶的一个很好方向。6.3 合规注意事项把爬虫用在该用的地方我必须明确说一句爬虫技术本身是中立的但用得好不好是关键。链家这类平台对于爬虫采集是有明确限制的用户协议里通常禁止未经许可批量抓取数据。我这里提供的代码和思路初衷是用于学习交流、技术练习和第二方数据研究不是鼓励你大规模采集链家数据用于商业用途。如果你是在公司做正经的数据分析项目需要用到链家的房源数据正确的路径是先去联系平台方确认是否开放数据接口或者通过合理的商务合作拿到授权数据。靠爬虫抓来的数据如果用于商业决策会有法律风险这一点千万别忽视。7. 跑完这个项目之后强烈建议你做的三件事第一件事试着把城市前缀改成上海sh、深圳sz、广州gz看看不同城市的页面结构有没有差异。我实测下来链家不同城市的列表页结构高度一致绝大多数情况不需要改一行代码。但有少数城市在部分字段的展示上有微调比如有些城市有“满五唯一”标签有些城市有“VR看房”标识。多拿几个城市练手你对页面结构稳定性的判断会更准。第二件事自己加一个数据库存储层。CSV适合小规模数据但如果你想长期积累房源数据需要引入SQLite或者MySQL。设计一张房屋信息表把房源ID作为主键每次抓取的数据按ID去重入库并记录抓取时间。这样你就能做时间维度上的数据回测和趋势分析。第三件事写一个schedule定时任务模块每隔固定时间对你的目标区域做一次增量抓取。注意增量抓取的策略不需要重新抓全部列表只需要抓取每个区域第一页的房源ID和数据库里已有的ID做对比只处理新增和消失的记录。这样既节约资源也不会因为频繁请求触发更严格的反爬。跑完上面三件事你对爬虫的理解会从“能跑通”变成“能落地”这个区别恰恰是面试时能不能讲出亮点和日常练手之间最大的分水岭。最后分享一个我自己踩过多次的坑作为收尾每次写完爬虫代码我习惯在保存数据前打印一下总条数和每条数据的字段长度验证结果符合预期再写文件。抓了几十个页面之后如果你某页页面结构突然变了数据少了一截在存储之前及时发现永远比事后清洗发现要省钱得多。链家这个项目做完你往后碰到的很多爬虫场景都是同一套方法论把基础打牢后面会顺手很多。祝采集顺利。