☰
用Python爬取Q房网二手房数据:requests与BeautifulSoup实战记录
2026/10/10 19:28:05 网站建设 项目流程

做房产数据分析的朋友隔三差五就找我拉二手房数据,一开始是几套几套地手动复制,后来干脆把一个区域的挂牌房源清单甩给我:能不能写个脚本,把小区名、户型、面积、总价、单价这些字段全抓下来,存成表格?

于是就有了这篇Q房网二手房数据爬虫的实现记录。Q房网在广州、深圳、东莞这些华南城市的二手房挂牌数据比较全,字段包含小区名称、户型、面积、朝向、楼层、所在区域和商圈、总价、单价,页面又是服务端直接渲染好的HTML,用Python的requests加BeautifulSoup就能搞定,完全不需要上Scrapy或者Selenium这种重家伙。这篇文章会从真实的数据需求出发,把列表页的解析逻辑、翻页规则、字段清洗、CSV落地,以及我实际运行中踩过的几个坑完整讲一遍。适合已经用Python写过简单爬虫、想跑通一个完整采集流程然后拿数据做分析的读者参考,文章最后我也会附上完整的代码结构。

1. 为什么盯上Q房网:一个真实的数据需求驱动

1.1 需求场景还原

朋友要的其实是这么个东西:某个二线城市近一年的二手房挂牌趋势,具体到"哪个区最贵""哪个面积段供应量最多""总价300万以内的房子集中在哪"。这种分析如果靠人工去房源App里一页页翻,一天能记录一百套就算效率高了,而且容易漏、容易重。我的想法很简单——把挂牌数据先全量抓下来,再做聚合分析。数据源的质量直接决定了后续分析靠不靠谱,所以第一步并不是写代码,而是选数据平台。

1.2 为什么选Q房网而不是别的平台

当时对比了几个常见渠道。有些头部房产平台的页面数据是通过接口异步加载的,直接请求HTML拿不到房源信息,必须逆向接口签名,这对只想拿数据做分析的人来说成本太高;有些平台的房源里掺杂了大量重复或者已经下架的无效数据,清洗时很痛苦。Q房网的好处是页面结构老派且规整,房源列表是服务端渲染的完整HTML,每个房源卡片的信息密度也高,不用进详情页就能拿到大部分关键字段,特别适合用静态页面爬虫来搞。

当然,这并不代表Q房网的数据一定比别家“准”,但做趋势分析本来也不需要精确到每一套房,只要数据字段完整、覆盖量大、更新时间连续即可。另外,挂牌价本身就是中介对外展示的公开信息,抓取公开页面数据用于个人学习研究,这是我一直坚持的底线——不搞登录态、不碰验证码、不批量下载图片,更不会把数据拿去做商业用途。

1.3 要抓哪些字段,抓到什么程度算够用

开工之前我先把字段清单列了出来,避免写代码时东加一个西加一个。最终列出来的核心字段有九个:小区名称、户型、建筑面积、朝向、楼层情况、所在区域、所在商圈、总价、单价。这几个字段组合在一起,已经足够回答区域均价、户型占比、面积段分布、总价区间分布这几个核心问题了。

字段范围一旦确认,就不要在中途频繁改动,尤其是爬虫跑到一半改字段名,后面清洗代码全部要跟着动。我吃过这个亏,当时是临时想加一个“关注人数”字段,结果前面的数据已经落盘了,新旧字段对不齐,最后只能清掉重跑。所以我的建议是:第一版老老实实按核心需求做,等数据跑通、分析框架定了,再决定要不要扩展。

2. 开工前的技术选型与页面摸底

2.1 技术栈确定:requests + BeautifulSoup 还是 Selenium

技术选型这件事,我一直的观点是:能用最简单的方案就别上重框架。Q房网的二手房列表页是纯服务端渲染的HTML,用浏览器打开后直接查看源码就能看到房源文字信息,这意味着不需要执行任何JavaScript,所以requests完全够用。

  • requests:负责发HTTP请求、拿HTML源码。
  • BeautifulSoup(配合lxml解析器):负责从HTML里提取房源字段。
  • pandas:负责数据清洗、去重、统计和落盘。
  • time + random:控制请求频率。

很多初学者一上来就非Scrapy不可,其实Scrapy虽然功能强,但学习曲线和项目结构成本都不低。如果只是单机、单任务、几千条数据量级的采集,requests加BeautifulSoup反而是最舒服的,排查问题也直观。等哪天需要分布式采集、需要增量爬取、需要调度器了,再迁移到Scrapy也不迟。Selenium就更不用想了,一个纯静态页面用无头浏览器去渲染,简直是拿牛刀杀鸡,既慢又占内存。

2.2 先看 robots.txt 和页面源码再动手

写爬虫之前先做两件事。第一件事是看目标网站的 robots.txt,也就是站点对爬虫的“行为规范说明”,请求一下网站根路径下的 robots.txt 文件,里面会明确哪些路径不允许抓取。如果 robots.txt 里直接禁止了目标路径,那这个方向就该放弃了;如果没禁止,也要严格控制请求频率。这是底线问题,不值得为了几万条公开数据去冒险。

第二件事是打开一个列表页,右键查看源码,确认房源数据是不是真的在HTML里。这一步我能确认Q房网列表页的房源卡片确实在HTML源码中,并且每个房源卡片都包含了标题、区域、价格等字段。有些房源卡片只在列表页显示了部分字段,比如朝向和楼层需要进详情页才能看到,这个也集中记下来,后面解析时单独处理。

2.3 摸清列表页 URL 的翻页规律

数据在HTML里还不够,还要搞明白翻页URL是怎么变的。这一步千万别靠猜,我直接把列表页往下滚到第二页,复制第二页的URL和第一页对比,就能看到页码参数长什么样。Q房网不同城市的域名前缀不同,比如深圳是 sz.qfang.com,广州是 gz.qfang.com,二手房列表的路径一般是 /sale/,翻页参数通常是 page 或者 pn 这种形式,也有可能是形如 /sale/page/2/ 这样的路径参数。

这里有个容易被忽略的细节:有些网站第一页URL和其他页URL不统一,第一页可能是 /sale/,第二页就变成了 /sale/page/2/。写翻页函数的时候,必须对第一页单独处理,不要让第一页也带上 page=1 参数,否则部分站点会返回404或者重定向。我实际写代码时会把第一页URL单独生成,从第二页开始循环拼接页码。

3. 核心实现:列表抓取、详情补充与翻页逻辑

3.1 带重试和随机延时的基础请求模块

基础的请求模块是整个爬虫的地基,地基不稳后面全白搭。网络请求最典型的问题就是超时、连接重置、偶发403。我的做法是封装一个 fetch_html 函数,内置三次重试、随机User-Agent、解码方式自动识别、每次请求后固定随机延时。

import requests import time import random from bs4 import BeautifulSoup UA_LIST = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", ] def get_headers(): return { "User-Agent": random.choice(UA_LIST), "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", } def fetch_html(url, retries=3): for i in range(retries): try: resp = requests.get(url, headers=get_headers(), timeout=15) if resp.status_code == 200: resp.encoding = resp.apparent_encoding return resp.text print(f"[{i + 1}/{retries}] 非200状态码: {resp.status_code}, URL: {url}") except requests.RequestException as e: print(f"[{i + 1}/{retries}] 请求异常: {e}, URL: {url}") time.sleep(random.uniform(3, 6)) return None

几个关键点:

  • 超时时间设成15秒是有原因的,太短容易被慢网络误杀,太长会让整个爬虫卡在某个坏请求上无法推进。
  • resp.encoding = resp.apparent_encoding 这行很重要。中文网站有的用UTF-8,有的用GBK,如果不做这步,解析出来的小区名和商圈名会直接乱码。requests库的默认编码推断有时候不准,apparent_encoding 是通过分析字节内容得出的更可靠结果。
  • 重试之间的随机延时不要固定死,随机数能避免每次失败后的等待时长一模一样,减少被识别为脚本行为的概率。

3.2 列表页房源卡片解析:主战场在HTML结构里

解析是整个爬虫里最需要耐心的部分。我打开浏览器开发者工具,选中一个房源卡片,观察它的HTML结构。每套房源卡片就是一个独立容器,里面有小区名、户型、面积、楼层、朝向、区域商圈、总价、单价这些字段,完整得像一张小表格。

def parse_list_page(html): soup = BeautifulSoup(html, "lxml") items = [] # 下面这个选择器只是示例,实际使用时要根据当前页面结构调整 cards = soup.select("div.house-list-item") for card in cards: try: record = {} title_node = card.select_one("a.title") record["title"] = title_node.get_text(strip=True) if title_node else "" # 区域和商圈通常在某个段落里的多个span中 area_tags = card.select("p.area span") record["district"] = area_tags[0].get_text(strip=True) if len(area_tags) > 0 else "" record["business"] = area_tags[1].get_text(strip=True) if len(area_tags) > 1 else "" # 户型、面积、朝向、楼层可能用逗号或空格分隔在同一个标签里 desc_node = card.select_one("p.des") desc_raw = desc_node.get_text(strip=True) if desc_node else "" parts = [part.strip() for part in desc_raw.split("|")] # 这里用位置索引有一个前提:字段顺序固定。如果顺序变了就会串位。 record["layout"] = parts[0] if len(parts) > 0 else "" record["area"] = parse_area(parts[1]) if len(parts) > 1 else 0.0 record["orientation"] = parts[2] if len(parts) > 2 else "" record["floor"] = parts[3] if len(parts) > 3 else "" price_node = card.select_one("span.price") record["total_price"] = parse_total_price(price_node.get_text(strip=True)) if price_node else 0 unit_price_node = card.select_one("span.unit-price") record["unit_price"] = parse_unit_price(unit_price_node.get_text(strip=True)) if unit_price_node else 0 items.append(record) except Exception as e: print(f"解析单条房源失败: {e}") continue return items

这段代码能跑通的前提是页面结构符合我写的示例,一旦网站改版,选择器就要跟着换。我给读者一个最重要的建议:不要背选择器,要把“打开开发者工具、定位节点、提取文本”这个流程练熟。网站改版是家常便饭,今天能用的选择器,明天可能就失效了。

3.3 翻页逻辑:别写死页码,先观察URL变化

翻页逻辑本身不复杂,复杂的是确认URL规律。我建议在做翻页函数之前,先用浏览器手动翻三页,把三个URL放在一起比对,找出变化的数字在第几层路径还是查询参数里。以下是我写的通用翻页函数骨架:

def build_page_urls(city_prefix="sz", start_page=1, end_page=30): urls = [] base_url = f"https://{city_prefix}.qfang.com/sale" for page in range(start_page, end_page + 1): if page == 1: urls.append(base_url) else: # 实际翻页参数以浏览器观察到的为准,下面只是常见写法 urls.append(f"{base_url}/page/{page}/") return urls

我在实际抓取时没有贪多,单次运行只跑30页,每页大约20套房源,一次采集在600套左右。这个量级对于做市场价格分析已经够用了。如果想扩大范围,可以从区域筛选入口进入,比如每个行政区单独跑一遍,每个区抓30页,这样采集到的数据在区域维度上分布更均匀,不会因为默认全城列表页里某个区的房源特别多而扭曲统计结果。

3.4 字段缺失时的容错处理

解析过程中最常遇到的其实是字段缺失。同一个列表页里,有的房源没有标注朝向,有的户型字段是空的,有的楼层信息写得很随意。这些缺失字段如果不处理,后面清洗阶段就会很痛苦。

我的处理思路是:先不丢弃这条记录,而是在字段提取失败的环节给一个空值或者默认值,把问题放到清洗阶段统一处理。例如 parse_area 函数,如果传入的字符串无法转成浮点数,就返回一个明显的负数标记 -1,清洗时统一剔除。这样做比在解析阶段直接丢记录更稳,因为你永远不知道一个字段解析失败是页面差异还是真实缺失,过早丢弃可能会把有分析价值的房源误杀。

def parse_area(text): if not text: return -1 import re m = re.search(r"(\d+(\.\d+)?)", text.replace("㎡", "").replace("平米", "").strip()) return float(m.group(1)) if m else -1

4. 数据清洗、去重与结构化落地

4.1 价格、面积、朝向这几个字段的脏数据长什么样

从HTML里提取出来的原始字符串肯定不会直接可用。我整理了一下实际出现的脏数据形态:

数据项原始形态目标形态常见问题
总价"345万"3450000单位混用,有的房源是"万",极少见"亿"
单价"38764元/㎡"38764字符串里有"元/㎡",需要剥离
面积"89.5㎡"89.5有的写"89.5平米",有的带标签混合文本
朝向"南"、"东南"、"南北"字符串有空值,有"暂无数据"字样
楼层"低层(1-5层)"字符串格式不统一,有的写"低楼层",有的写"共28层"
区域"深圳南山""南山"文本里有下划线或者空格

总价转成整数后,单位统一为元,这样后续做总价区间统计时才不会出现“万”和“元”混在一起的情况。面积转成浮点数,朝向和楼层保持字符串但清理空白和特殊符号。区域字段的清理我用的是一个简单的 replace 加 strip 组合,只要把空格、下划线、换行符去掉就行。

4.2 用 pandas 完成清洗、去重和透视

所有列表页解析完成后,把全部记录汇成一个list,再转成DataFrame。清洗步骤按顺序来:先去空白、再补缺失、再转类型、再去重、最后做几个统计透视。

import pandas as pd df = pd.DataFrame(all_records) # 1. 去空白和特殊字符 df["district"] = df["district"].str.replace("_", "", regex=False).str.strip() # 2. 剔除面积异常或缺失的记录 df = df[df["area"] > 0] # 3. 去重:同一小区、同样户型、同样总价的房源视为同一条挂牌 df = df.drop_duplicates(subset=["title", "layout", "area", "total_price"], keep="first") # 4. 按行政区统计平均单价和挂牌量 grouped = df.groupby("district", as_index=False).agg( avg_unit_price=("unit_price", "mean"), avg_total_price=("total_price", "mean"), listing_count=("title", "count") ) grouped = grouped.sort_values("avg_unit_price", ascending=False) print(grouped.head(10))

去重这一步特别值得说一下。同样是“阳光花园 3室2厅 89.5㎡ 345万”,可能在搜索列表里出现两次,一次是因为中介重复上架,一次是因为页面分页边界重复。用 title、layout、area、total_price 四个字段联合去重,基本能把重复项杀干净。注意不要只用title去重,因为同一个小区同名但不同户型的房源本来就该同时存在。

4.3 落盘为 CSV,Excel 打开不乱码

数据清洗完成后就涉及落盘。说到CSV,多数人第一次踩的坑就是:用 Python 默认的 utf-8 编码写入CSV,然后用 Excel 打开,发现全是乱码。Excel 对 UTF-8 的兼容性一直是老大难问题,所以我在写入时要带 BOM 头,也就是编码用 utf-8-sig。

df.to_csv("qfang_ershoufang.csv", index=False, encoding="utf-8-sig")

这一行代码能让Excel直接正常显示中文。如果后续要在多个Python脚本之间传递这个文件,读的时候指定 utf-8-sig 就行,pandas 会自己把 BOM 头处理掉。除了CSV,我有时候也会顺手存一份 Excel 格式的中间结果,方便朋友用Excel继续做透视表。

5. 实测结果、限速策略与踩坑复盘

5.1 500 条数据跑完的整体耗时与效果

我用默认参数跑了一次深圳南山区的二手房列表,30页一共解析出580多条房源,清洗去重后剩下543条有效数据。整个跑完大约花了8分钟,平均每页请求间隔在10秒左右。这个速度听起来不快,但这是故意的——把频率压下来,既是对目标站点的尊重,也是让自己数据采集周期拉长一点,避免短时间高频请求触发限流。对于做市场分析来说,8分钟能拿到500多条真实挂牌数据,比人工记录快了两个数量级,完全够用。

聚合结果也很直观:南山区的平均挂牌单价、总价区间分布、户型占比这些统计都能直接做出来。比如我用户型字段画了个饼图,3室2厅的挂牌量占比最高,这个信息放在买房决策或者市场报告里都很有说服力。

5.2 被临时限流的典型表现和处理方式

爬虫跑的过程中最怕的不是数据解析失败,而是请求被限流。当时遇到的表现是:前面几页都正常,跑到第20页左右,连续几个请求返回403,页面内容变成一屏乱码或者访问控制提示。这种基本就是频率太高被临时限制访问了。

我的应对方式是:

  • 立刻停止运行,等待5到10分钟再继续。
  • 把延时从随机2到5秒提高到随机5到8秒。
  • 单次运行的页数从30页降到15页。
  • 拆成多次运行,每两次运行之间至少间隔半小时。

这种“认怂”策略在礼貌爬虫的框架下是完全合理的。我的原则是:不挑战目标网站的防护底线,数据拿不到就换时段、减速、缩小范围,而不是去想绕过办法。爬虫技术本身是中性的工具,能控制欲望、守得住边界,才能长期稳定地使用它。

5.3 三处最容易翻车的地方

第一次完整跑下来,我在三个地方分别翻过车,这里集中复盘一下,帮读者避开。

第一处是编码问题。第一次跑的时候没有设置 resp.encoding,导致解析出来的仍然是乱码。后来检查发现,列表页的部分内容使用GBK编码,requests默认按ISO-8859-1去解码,自然就乱了。加上 apparent_encoding 之后问题消失。

第二处是字段分隔符判断错误。我一开始以为户型、面积、朝向、楼层是用空格分隔的,结果发现有的卡片用“|”分隔,有的用“,”分隔,还有的直接挤在一起。最后我改成先整体提取文本,再用正则把面积先挖出来,剩下的字段用 split 处理,才稳定下来。这也解释了为什么解析函数里我要把每个字段单独封装成一个小函数,而不是在一个表达式里全搞定——每个字段的脏数据形态都不同,单独处理才方便排查。

第三处是分页数据的重复。我发现第一页URl是 /sale/,但第二页URL带页码参数后,返回的列表里面有一部分和第一页末尾重复的房源。这种边界重复在技术上很难完全避免,所以去重步骤必须放在清洗阶段而不是解析阶段,等所有页面的数据都汇总后再统一去重。

5.4 后续可以扩展的方向

跑通一遍之后,这个项目其实就搭好了一个通用架子。后续扩展的方向很多,我简单列几个:

  • 加一个详情页解析模块,列表页缺失的房屋编号、挂牌时间、看房记录可以去详情页补全,但这会明显增加请求量,需要把延时进一步拉大。
  • 把数据存到SQLite或者MySQL,方便做增量采集,每天只抓新增和变价房源。
  • 定时每天或每周跑一次积攒历史数据,用来做时间序列分析,比如区域挂牌价的周度变化。
  • 把代码封装成命令行工具,接收城市、区域、页数参数,方便换城市跑。

数据采集只是第一步,真正有价值的是后续的分析。等历史数据攒够三个月,就能画出挂牌价走势曲线,那时候再来回看这套爬虫带来的价值,就远远不只是省了几个小时手动复制的时间。

我个人在实际操作里还有个体会:这类项目最值得沉淀的不是某个网站的解析规则,而是“拿到任何静态列表页都能在半小时内拆出字段、跑通流程”的方法。这次写Q房网的爬虫是这样,下次换一个站,流程依然是:确认数据在HTML里、梳理URL规律、写基础请求模块、解析卡片、清洗落盘、控制频率。把这套流程变成肌肉记忆,以后遇到什么数据需求都不会慌。

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

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

立即咨询