☰
链家二手房爬虫实战:requests与BeautifulSoup数据采集全解析
2026/10/5 8:22:23 网站建设 项目流程

1. 链家二手房爬虫项目概述

1.1 为什么选择链家作为爬取目标

链家是目前国内房源信息最规范、数据最透明的房产平台之一,它跟很多信息聚合平台不一样,每套房源的挂牌价、建筑面积、户型、朝向、楼层、装修情况、年代、关注人数这些字段,基本都有统一的展示规范。尤其是“单价”这个字段,链家按建筑面积计算后直接显示在房源卡片和详情页里,省去了自己去算的麻烦。

对于想练手爬虫的人来说,链家的反爬强度属于“中等偏上”,既不像一些政府网站那样完全不设防,也不像电商平台那么变态。它主要靠 User-Agent 校验、请求频率限制、验证码这三种手段做防护,正好适合用来学习 requests + BeautifulSoup 这套最常见的爬虫组合。我见过很多新手一上来就去爬淘宝、拼多多,结果被各种滑块验证码、签名参数折磨到怀疑人生,最后连数据长什么样都没见到。相比之下,链家这套页面结构简洁、接口稳定,爬到数据的那一刻能获得很实在的正反馈。

这篇文章我会从零开始,把整个爬取思路、代码实现、字段说明、常见坑全部过一遍。你只要照着敲,能把链家北京、上海、深圳、成都这类城市在售的二手房列表信息完整抓下来,存成 CSV 或 Excel,后续做价格分析、行情趋势、板块对比都没问题。

1.2 爬虫的合规边界与使用说明

写爬虫之前,有几个底线问题必须先讲清楚。链家的《用户协议》明确禁止未经许可的批量抓取行为,所以这个项目只能用于个人学习、技术研究、数据分析练习,不能拿去商用,也不建议用高并发去攻击对方的服务器。

爬取过程中要注意三个原则:第一,控制请求频率,我下面代码里会加time.sleep()随机延时,每抓一页停留 1 到 3 秒,这是对目标网站的起码尊重;第二,只抓公开页面数据,不碰需要登录才能看的信息,不提交表单,不遍历接口漏洞;第三,爬下来的数据不要公开传播原始数据集,自己做做分析没问题,发到 GitHub 上最好只放代码不放数据。

说白了,爬虫本身是中性技术,关键是用的人怎么用。我平时做数据采集项目,最核心的一条经验就是:把对方服务器当成自己的服务器来对待,你不想自己的服务被人狂刷,那就别去狂刷别人的。

2. 技术选型与抓取思路拆解

2.1 requests + BeautifulSoup 还是 Scrapy

爬虫技术栈的选择,往往决定了整个项目的复杂度和维护成本。链家二手房信息爬取这个问题,我强烈建议新手从 requests + BeautifulSoup 入手,而不是一上来就上 Scrapy。

原因很简单:链家的数据不需要执行 JavaScript 渲染,所有房源信息都直接写在 HTML 里,属于典型的服务端渲染页面。你打开链家二手房列表页,右键“查看网页源代码”,能直接看到房源标题、价格、户型这些文本。这种场景下 requests 发一个 GET 请求拿到 HTML,然后用 BeautifulSoup 做解析就够了。Scrapy 虽然功能强大、自带并发和管道机制,但它的学习曲线比较陡,项目结构对新手来说有点重,等你把 pipelines、middlewares、settings 这些概念搞明白,半小时都过去了。

等以后遇到需要登录、需要携带复杂 Cookie、需要分布式采集的项目,再迁移到 Scrapy 也不迟。当前阶段,简单直接就是最大的优势。

2.2 链家页面结构与 URL 规律分析

以我这次爬取的链家二手房列表为例,每个城市的房源列表页 URL 规律非常清晰。北京的在售房源列表是:

https://bj.lianjia.com/ershoufang/pg2/

bj是城市拼音缩写,ershoufang是二手房频道,pg2表示第 2 页。其他城市只要替换城市缩写就行,比如上海是sh、广州是gz、成都是cd,规则完全统一。第一页的 URL 可以不加pg1,直接写https://bj.lianjia.com/ershoufang/。

每个城市在售房源数量不一样,北京一般在一万套左右,按每页 30 条计算,总共三百多页。链家最多开放 100 页的翻页限制,所以实际能爬到的最多就是 3000 条左右,再往后的页码会返回空白或者直接跳到错误页。这个限制我们后面会说如何应对。

打开任意一个列表页,每套房源的信息都在一个div标签里面,class 为info。这里面包含标题、小区名称、位置、户型、面积、朝向、装修、楼层、年代、总价、单价、关注人数这些关键字段。我用浏览器开发者工具逐个定位了每个字段的 CSS 选择器,后面代码部分会详细展示。

2.3 反爬机制与应对策略

链家的反爬目前主要有三道关卡。

第一道是 User-Agent 校验。如果你不带浏览器标识直接请求,服务器会返回 403 或者一个空页面。解决办法是构造一个常见的 Chrome 浏览器 UA 放进请求头里,让请求看起来像真实浏览器发出的。

第二道是 IP 频率检测。同一 IP 短时间大量请求,会触发验证码或者 IP 封禁。应对方案是控制抓取速度,每页间隔 1-3 秒随机延时,尽量模拟人的浏览节奏。如果需要大批量抓取,可以考虑代理池轮换,但对链家这个项目来说,单 IP 加上合理延时足够完成任务,没必要一上来就上代理。

第三道是验证码。触发频率检测后,页面会跳转到验证码页面,输入正确的验证码才能继续访问。遇到这种情况,最有效的办法不是去搞 OCR 识别,而是立刻停止程序、休息一段时间(建议至少 30 分钟),检查是否请求太快了。

3. 完整代码实现与讲解

3.1 依赖安装与项目结构

开始写代码之前,先准备好运行环境。我用的 Python 版本是 3.9,如果是 3.7 及以上都没有问题。需要安装的第三方库只有两个:requests 和 beautifulsoup4。用 pip 安装就行:

pip install requests beautifulsoup4

如果你想把数据存成 Excel 而不是 CSV,顺手装一个 pandas:

pip install pandas

整个项目结构非常简单,我习惯把文件组织成下面这样:

lianjia_spider/ ├── spider.py # 主爬虫脚本 ├── data/ # 存放爬取结果的目录 └── requirements.txt # 依赖清单

requirements.txt内容:

requests>=2.28.0 beautifulsoup4>=4.12.0

3.2 定义请求头和爬虫主类

我现在直接给出一个完整的链家二手房爬虫代码,代码里每一步都有注释。先定义请求头和主类:

import csv import random import time import requests from bs4 import BeautifulSoup HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", }

关于请求头的构造,有几个细节值得说。User-Agent字符串最好完整地从真实浏览器复制,不要随便简写。有些网站的防护逻辑会检查 UA 的完整性和版本号,新版 Chrome 的 UA 长得跟旧版不一样,你要是拿一个 Chrome 80 的 UA 去访问,有些服务器也会怀疑。另外Accept-Language这项,访问国内网站时可以设置成zh-CN,zh;q=0.9,这会让服务器返回中文页面,解析的时候更省事。

3.3 编写页面下载与解析函数

接下来是获取页面和解析数据的核心函数:

class LianJiaSpider: def __init__(self, city, max_pages=100): self.city = city self.city_code = CITY_CODES.get(city, "bj") self.base_url = f"https://{self.city_code}.lianjia.com/ershoufang" self.max_pages = max_pages self.session = requests.Session() self.session.headers.update(HEADERS) def fetch_page(self, page): """获取指定页码的列表页 HTML""" if page == 1: url = f"{self.base_url}/" else: url = f"{self.base_url}/pg{page}/" try: resp = self.session.get(url, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text except requests.RequestException as e: print(f"[错误] 第{page}页请求失败: {e}") return None

这里有个容易被忽视的小知识点:resp.encoding = "utf-8"。requests 库有时候会根据响应头猜测编码,如果猜错了,页面上中文会变成乱码。我建议对链家这种明确声明 UTF-8 编码的网站,手动指定一下编码格式,解析的时候就稳了。

解析函数是整个爬虫的灵魂,链家每个房源卡片的结构是有规律可循的。我拆开来说:

def parse_page(self, html): """解析列表页,提取所有房源信息""" soup = BeautifulSoup(html, "html.parser") houses = [] # 每个房源卡片所在的容器 li_list = soup.select("ul.sellListContent li") for li in li_list: # 标题信息 title_elem = li.select_one(".title a") title = title_elem.get_text(strip=True) if title_elem else "" # 房源信息:小区名、户型、面积、朝向、装修、楼层、年代 house_info_elem = li.select_one(".houseInfo") house_info = house_info_elem.get_text(strip=True) if house_info_elem else "" info_parts = house_info.split("|") if house_info else [] # 位置信息:城区、板块 position_elem = li.select_one(".positionInfo") position_info = position_elem.get_text(strip=True) if position_elem else "" # 总价 total_price_elem = li.select_one(".totalPrice") total_price = total_price_elem.get_text(strip=True) if total_price_elem else "" # 单价 unit_price_elem = li.select_one(".unitPrice") unit_price = unit_price_elem.get_text(strip=True) if unit_price_elem else "" # 关注人数 follow_elem = li.select_one(".followInfo") follow_info = follow_elem.get_text(strip=True) if follow_elem else "" houses.append({ "标题": title, "小区": info_parts[0] if len(info_parts) > 0 else "", "户型": info_parts[1] if len(info_parts) > 1 else "", "面积": info_parts[2] if len(info_parts) > 2 else "", "朝向": info_parts[3] if len(info_parts) > 3 else "", "装修": info_parts[4] if len(info_parts) > 4 else "", "楼层": info_parts[5] if len(info_parts) > 5 else "", "年代": info_parts[6] if len(info_parts) > 6 else "", "位置": position_info, "总价": total_price, "单价": unit_price, "关注情况": follow_info, }) return houses

3.4 数据存储与主流程控制

解析完成后的数据,需要写到 CSV 文件里。这里有个细节:直接用 Python 内置的 csv 模块写入时,如果直接用open()而不指定encoding="utf-8-sig",生成的 CSV 文件用 Excel 打开会出现中文乱码。utf-8-sig会在文件开头写入 BOM 头,Excel 就能正确识别编码了:

def save_to_csv(self, all_data, filename): """将数据保存为 CSV 文件""" if not all_data: print("[提示] 没有数据需要保存") return fieldnames = [ "标题", "小区", "户型", "面积", "朝向", "装修", "楼层", "年代", "位置", "总价", "单价", "关注情况", ] with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(all_data) print(f"[完成] 数据已保存至 {filename},共 {len(all_data)} 条") def run(self): all_data = [] for page in range(1, self.max_pages + 1): print(f"[抓取] 正在抓取第 {page} 页...") html = self.fetch_page(page) if html is None: continue # 简单检查是否为验证码页面 if "验证码" in html: print(f"[警告] 第 {page} 页触发验证码,程序停止,建议休息一会再试") break page_data = self.parse_page(html) if not page_data: print(f"[提示] 第 {page} 页没有解析到数据,可能已经超出可访问的页码范围,结束抓取") break all_data.extend(page_data) # 随机延时,模拟真实用户浏览 delay = random.uniform(1, 3) time.sleep(delay) self.save_to_csv(all_data, "data/lianjia_houses.csv")

主流程里有两个关键的保护机制。检查"验证码" in html是为了及时止损,一看到验证码就退出,而不是继续硬刚导致 IP 被拉黑。另一个是if not page_data主动结束循环,因为链家很多城市最多只开放前 100 页,超过范围的页面是空的,继续请求纯属浪费时间,甚至可能触发风控。

3.5 城市代码映射与入口函数

最后补上城市代码映射表和入口函数:

# 城市代码映射表 CITY_CODES = { "北京": "bj", "上海": "sh", "广州": "gz", "深圳": "sz", "成都": "cd", "杭州": "hz", "武汉": "wh", "南京": "nj", "天津": "tj", "重庆": "cq", } def main(): city = input("请输入城市名称(如:北京、上海、广州、深圳): ").strip() max_pages = input("请输入要抓取的页数(默认 50 页): ").strip() if city not in CITY_CODES: print("[错误] 暂时不支持该城市,或者城市名称输入有误") return if max_pages.isdigit(): max_pages = int(max_pages) else: max_pages = 50 spider = LianJiaSpider(city=city, max_pages=max_pages) spider.run() if __name__ == "__main__": main()

运行起来的效果大概是这样的:

pandas is installed? # 不用管这个,直接跑 请输入城市名称(如:北京、上海、广州、深圳): 北京 请输入要抓取的页数(默认 50 页): 20 [抓取] 正在抓取第 1 页... [抓取] 正在抓取第 2 页... [抓取] 正在抓取第 3 页... ... [完成] 数据已保存至 data/lianjia_houses.csv,共 600 条

4. 数据字段说明与清洗经验

4.1 链家字段的真实含义

爬到数据只是第一步,理解每个字段的真实含义才是做分析的关键。我刚开始爬链家的时候,看到“单价”和“总价”直接拿来用,后来做数据可视化时发现有些房源单价高得离谱,仔细看才发现是“车位”或者“别墅”混在里面。链家的字段其实有它自己的口径,搞清楚之后再分析才有意义。

我整理了一下关键字段的实际含义:

字段含义与说明
标题房源的挂牌标题,通常包含小区名和户型关键词
小区房源所属小区名称,来自houseInfo第一段
户型如“3室2厅”,表示房间数和客厅数
面积建筑面积,单位是平方米,文本里带“平米”字样
朝向如“南 北”,表示主要采光方向
装修如“精装”“简装”“毛坯”
楼层如“低楼层/共6层”,包含所在层和总层数
年代建筑年代,如“2008年建”
位置城区和板块,如“朝阳 望京”
总价挂牌总价,万为单位,如“728万”
单价按建筑面积计算的均价,单位是元/平米,如“56000元/平米”
关注情况“100人关注 / 50次带看”这类数据,反映房源热度

这里面最需要清洗的是“面积”“总价”“单价”这三个字段,因为它们是字符串类型,需要转成数值才能做计算和可视化。

4.2 数据清洗的必备代码

写一段简单的数据清洗代码,把字符串转成数值类型:

import re import pandas as pd df = pd.read_csv("data/lianjia_houses.csv") # 提取面积数值 df["面积数值"] = df["面积"].str.extract(r"([\d.]+)").astype(float) # 提取总价数值(万) df["总价数值"] = df["总价"].str.extract(r"([\d.]+)").astype(float) # 提取单价数值(元/平米) df["单价数值"] = df["单价"].str.extract(r"([\d.]+)").astype(float) # 关注人数 df["关注人数"] = df["关注情况"].str.extract(r"(\d+)人关注").astype(float) # 朝向处理:统一格式 df["朝向"] = df["朝向"].str.replace(" ", "/", regex=False) # 去掉面积异常值(小于5平米的一般是车位或杂物间) df_clean = df[df["面积数值"] >= 5] print(df_clean[["标题", "小区", "面积数值", "总价数值", "单价数值", "关注人数"]].head())

清洗之后,你可以直接按板块算均价、按小区统计挂牌量、按户型分布看市场结构。比如用 pandas 的groupby做个简单统计:

# 按区域板块统计平均单价 avg_price = df_clean.groupby("位置")["单价数值"].mean().sort_values(ascending=False) print(avg_price.head(10))

这就是爬虫带来的乐趣——把网上散落的信息变成结构化的数据,再用自己的方式去解读它。

5. 常见问题与排查技巧实录

5.1 爬虫运行中的高频问题速查表

我把实际爬取过程中遇到的问题,按发生概率排了个序,做成一张速查表:

问题现象可能原因解决方案
返回 403 ForbiddenUser-Agent 缺失或异常检查请求头,使用完整的浏览器 UA
中文乱码编码声明缺失设置resp.encoding = "utf-8"
触发验证码请求频率太高停止程序,休息 30 分钟以上,增大请求间隔
翻页到第 100 页之后无数据平台限制最大访问页数接受该限制,或通过其他城市的子区域 URL 细分抓取
解析得到的列表为空页面结构变化重新用开发者工具检查选择器,链家会不定期调整 class 名
CSV 用 Excel 打开乱码编码不是utf-8-sig写入文件时指定encoding="utf-8-sig"
KeyError: "title"某个房源卡片结构异常用select_one+ 判断空值的方式避免直接报错

5.2 我踩过的几个典型坑

第一个坑是链家改版导致的选择器失效。某次我的爬虫突然一个数据都解析不出来了,排查了半天发现链家把房源卡片的 CSS class 从sellListContent改成了别的名字。这种问题没有根治的办法,只能说爬虫本身是个维护型工具,页面结构一变,爬虫代码就得跟着改。常用的排查手段是在fetch_page后加一行print(resp.text[:500]),看看返回的 HTML 长什么样,再对着开发者工具调试选择器。

第二个坑是houseInfo字段的分割问题。这个字段里面的内容是用竖线|分隔的,正常情况下依次是小区、户型、面积、朝向、装修、楼层、年代。但我碰到过有些房源缺少“年代”信息,导致info_parts长度为 6,直接按索引取第 6 个元素就会IndexError。解决方法是像上面代码那样,每次都用if len(info_parts) > n做防护,或者用try...except包裹。

第三个坑是关于翻页限制的应对。链家很多城市只允许访问前 100 页,约 3000 条数据。如果你想抓某个城市全量房源,可以换个思路:链家还有区域筛选、户型筛选、价格筛选这些维度,通过组合筛选条件把大集合拆成多个小集合,每个小集合的页数都在限制以内,就能绕过 100 页的上限。实际做法是构造带筛选参数的 URL,比如:

https://bj.lianjia.com/ershoufang/pg2rs%E6%9C%9B%E4%BA%AC/

这里rs参数后面跟的是区域名的 URL 编码,望京编码后是%E6%9C%9B%E4%BA%AC。这种方式既不算攻击,也在平台允许的范围之内。

5.3 如何优雅地处理请求失败

网络请求不可能每次都成功,所以我建议在爬虫里加一个简单的重试机制。下面这段代码放在fetch_page里,请求失败后最多重试 3 次,每次重试间隔递增:

def fetch_page(self, page, retries=3): if page == 1: url = f"{self.base_url}/" else: url = f"{self.base_url}/pg{page}/" for attempt in range(retries): try: resp = self.session.get(url, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text except requests.RequestException as e: wait = 2 ** attempt print(f"[错误] 第{page}页第{attempt + 1}次请求失败:{e},{wait}秒后重试...") time.sleep(wait) print(f"[失败] 第{page}页在 {retries} 次重试后仍然失败,跳过该页") return None

重试的等待时间用指数退避策略,第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,这样可以避免连续重试把对方服务器打爆。

6. 结果分析与后续扩展思路

6.1 数据分析示例:北京二手房行情初探

爬完数据存进 CSV,就可以开始做有意思的分析了。我之前用这份数据做过一个简单的北京二手房行情统计,代码很短,但结论挺直观的:

import pandas as pd import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 解决中文乱码 df = pd.read_csv("data/lianjia_houses.csv") # 清洗 df["面积数值"] = df["面积"].str.extract(r"([\d.]+)").astype(float) df["单价数值"] = df["单价"].str.extract(r"([\d.]+)").astype(float) df["总价数值"] = df["总价"].str.extract(r"([\d.]+)").astype(float) # 按城区分析平均单价 df["城区"] = df["位置"].str.split(" ").str[0] city_avg = df.groupby("城区")["单价数值"].mean().sort_values(ascending=False) plt.figure(figsize=(12, 6)) city_avg.plot(kind="bar") plt.title("各城区平均单价(元/平米)") plt.xticks(rotation=45) plt.tight_layout() plt.savefig("beijing_avg_price.png")

从结果能明显看出不同城区之间的价差结构,比如核心城区的均价远超外围区域,这个信息对了解市场很有帮助。你也可以按总价区间统计房源分布,或者算一下“关注人数”和“单价”之间的相关性,看看是不是越便宜的房源关注度越高。

6.2 从列表页到详情页的进阶方向

列表页的数据已经能满足很多分析需求,但如果你想知道每套房源更详细的信息,比如房屋产权年限、抵押情况、周边配套、历史挂牌记录,那就需要进一步爬取房源的详情页。

详情页的 URL 规律也很清晰,列表页每一个房源卡片的标题链接里就包含了详情页的完整地址,比如https://bj.lianjia.com/ershoufang/101123456789.html。你可以在parse_page里把title_elem["href"]也抓下来,存到 DataFrame 里,后面再批量访问详情页。

这里有个更稳妥的做法:解析到详情页链接后不要立刻请求,而是先存起来,等列表页全部抓完,再用另一个脚本、放慢速度去请求详情页。这样能避免列表和详情请求混在一起,导致触发频率检测。详情页的数据结构比列表页复杂,涉及更多标签嵌套,用soup.select按字段逐个提取就好,核心思路和列表页完全一致。

6.3 数据可视化与图表展示技巧

爬虫和数据可视化是天然的一对,数据抓回来不分析就是一堆数字,分析之后做成图才真正有价值。我常用的可视化方向有:

  • 面积-总价散点图:看整体市场价位分布,面积和总价的关系是否线性
  • 户型占比饼图:真实反映市场主力户型结构
  • 城区均价柱状图:横向对比各区域差异
  • 单价分布直方图:看价位段集中度,了解大多数房源分布在什么价格区间
  • 关注人数 Top20 排行榜:找出热门房源,分析它们的共同特征

用 matplotlib 或 pyecharts 都能实现。做这些图之前,确保数据清洗做好了,单位统一了,否则出来的图表会带节奏。

7. 写在最后的实操心得

链家二手房爬虫这个项目,是我带过很多朋友入门爬虫的经典案例,原因在于它具备了“完整爬虫项目”的所有要素:有真实的需求、有明确的反爬挑战、有清晰的数据结构、有可验证的成果。跑通这个项目,你对 HTTP 请求、HTML 解析、数据存储、异常处理这四件事就有了整体概念,再去看别的爬虫项目,基本都能举一反三。

有几个我自己的使用习惯,分享一下:第一,爬虫脚本最好用虚拟环境管理依赖,别直接往系统 Python 里装一堆包,后面项目多了会乱;第二,跑完的 CSV 数据文件名里带上日期,比如lianjia_houses_20250115.csv,方便留存和分析,因为房产数据跟时间强相关,过两周再看同一个小区价格已经变了;第三,抓取中途程序崩了不要慌,设计成“断点续跑”的思路,比如通过参数指定从第几页开始抓,而不是总从头来。

如果你想把链家爬虫项目再往上走一步,扩展方向也挺多:用threading或asyncio提升并发效率,用APScheduler做定时任务每日自动抓取,用pymysql把数据存进 MySQL,配合 Grafana 做一个房源价格走势看板。这些都是顺手的事,关键在于先把最基础的 requests + BeautifulSoup 这套搞扎实。

最后再叮嘱一句:爬虫写得好不如用得稳,频率克制,数据留念,把项目当学习而不是薅羊毛。祝你们都能顺利跑通这个项目,拿到自己想要的数据。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询