☰
链家二手房爬虫实战:requests+BeautifulSoup高效采集房源数据
2026/10/5 4:10:13 网站建设 项目流程

1. 项目概述与爬虫设计思路

做爬虫这行有个朴素的真理:真正难的不是写代码,而是确定“要什么”和“怎么要”。链家二手房这个项目,算是我接触过的所有爬虫练习里性价比最高的一个——页面结构规整、字段丰富、数据量充足,而且自带反爬机制,非常适合拿来练手。我当初做这个项目的原因很简单,帮一个做房产研究的朋友整理某个城市的二手房挂牌数据,需要连续抓取几个月的小区均价变动。

先说结论:链家二手房信息爬取的核心价值在于,它能帮你拿到结构化的真实房源数据,包括小区名称、户型面积、朝向装修、楼层年代、总价单价等十几个字段。这些数据不管是做市场分析、价格监控,还是写量化策略的输入特征,都非常有用。整个项目用到的技术栈也很干净:Python 3 + requests + BeautifulSoup + csv,不需要 Selenium 这类重型工具就能跑通。

适合谁来参考?如果你正在学爬虫,想要一个“能完整跑通、有真实数据落地”的项目;或者你是做数据分析的,需要一份可靠的房价数据源;又或者你只是想在 GitHub 上找一个能改改就能用的代码骨架,这个项目都很合适。我会把完整代码、踩过的坑、以及背后“为什么要这么写”的逻辑全部讲清楚。

1.1 核心需求解析

在动笔写代码之前,我先列了一张需求清单。这是整个项目中最重要的环节——很多人一上来就写 requests.get,结果爬到一半发现要么被反爬拦截,要么解析出来的字段对不上,其实就是需求梳理没做透。

我的核心需求拆解下来是这样的:

  • 数据源:链家二手房在售房源列表页(city.lianjia.com/ershoufang/pg{n}/)
  • 目标字段:小区名称、所在区域、户型、面积、朝向、装修情况、楼层、建筑年代、总价、单价,共 10 个关键字段,另外顺手把房源标题也抓下来做文本分析用
  • 翻页逻辑:链家二手房每页 30 套房源,单城市总页数在几十到上百页之间,需要遍历全部页码
  • 数据存储:本地 CSV 文件,方便直接用 Excel 或 pandas 做后续分析
  • 频率控制:每请求一页后随机休眠 1-3 秒,避免给目标服务器造成压力

这个需求清单看似简单,但每一个点背后都有对应的技术决策。比如为什么选 BeautifulSoup 而不是正则表达式?因为链家的 HTML 层级结构比较规整,用 CSS 选择器提取比写十几个正则要可维护得多。为什么用 requests 而不是 Scrapy?因为 Scrapy 的爬虫框架对新手来说有点重,这个项目用 requests 加循环就完全够用,代码量短一半,逻辑也更直观。

1.2 链家页面的数据特征

我花了一点时间人工浏览了链家的二手房页面,这里分享几个对写代码有直接影响的观察结果。

第一个特征是列表页的信息密度很高。链家的列表页每套房源都展示在一个 class 为“info”的 div 里,里面有标题、位置信息、房屋信息、关注人数、发布时间、总价、单价。其中“房屋信息”是一整段文本,包含户型、面积、朝向、装修、楼层、年代,用换行符和竖线分隔——这一步给解析提供了很大的便利,但也埋了一个坑:不同房源的字段顺序并非完全一致,单纯按位置索引取值会出问题。

第二个特征是分页结构很清楚。链家网页底部有一个分页条,每一页用 href 方式跳转,URL 里带 pg2、pg3 这样的页码参数。有个隐藏规则需要注意:链家的页面上会显示总共有多少套房源,我实测下来每页 30 条,总页数等于总房源数除以 30 向上取整。但链家对最大翻页深度有限制,超过某个页码后继续翻页会拿到重复数据或直接返回空内容。

第三个特征是反爬机制虽然存在但不算极端。链家主要通过 User-Agent 检测和访问频率控制来识别爬虫。如果你用默认的 Python-requests UA,大概率第一次请求就会撞上验证码页面。但只要设置了正常的浏览器 UA,再加上每次请求之间的随机延迟,基本可以稳定爬取。另外,IP 封禁也是存在的,不过链家的封禁策略相对宽松,我之前用单个 IP 连续爬取几百页也没有被永久封禁,最多是中途出现几次验证码,过几分钟自己就恢复了。

2. 技术选型与核心实现方案

这个项目的技术选型是我反复权衡过的,这里详细讲讲每个领域的决策逻辑,方便你在类似项目里直接套用。

2.1 为什么用 requests + BeautifulSoup 组合

现代爬虫圈子里,工具的选择多到让人眼花缭乱:requests、httpx、aiohttp、Scrapy、Selenium、Playwright、Pyppeteer……每一个都有自己的适用场景,但不代表每个项目都要上最复杂的工具。

我选择 requests + BeautifulSoup 的原因有三层:

第一层,目标网页是服务端渲染。链家的二手房列表页是典型的服务端渲染页面,所有房源数据都直接写在 HTML 源码里。这意味着不需要浏览器执行 JavaScript,不需要 Selenium,也不需要渲染引擎。既然 requests 就能把完整 HTML 拿回来,为什么要用重量级工具增加稳定性和性能负担?有个很简单的判断标准:打开网页后右键“查看页面源代码”,如果能在源码里搜到你要的数据,那用 requests 就够了;如果搜不到,才考虑后续的渲染方案。

第二层,解析复杂度适中。BeautifulSoup 的 CSS 选择器对链家这种层级清晰的页面来说,写起来非常顺手。举个例子,要提取每套房的标题,只需一行代码:soup.select('div.info a.title')。如果是正则,你可能得写一长串带各种分组和转义的模式,还要考虑换行符和大小的差异,维护成本高得多。

第三层,学习曲线和排错成本都很低。requests 的 API 就那十几个方法,BeautifulSoup 的核心概念就四个(Tag、NavigableString、BeautifulSoup、Comment)。出问题时,网上能搜到的资料最多。Scrapy 虽然功能强大,但对于这种单站单页面的小项目,它的中间件、管道、Item 定义等概念会变成不必要的负担。

2.2 基于规则解析与字段提取策略

爬虫解析页面有两种大方向:一种是把页面当作文本来处理,用正则表达式或者字符串操作抽取信息;另一种是把页面当作树形结构,用 DOM 解析库(如 BeautifulSoup、lxml)来定位节点。这个项目我选择了后者的“CSS 选择器 + 结构化提取”策略。

我的具体做法是先把每个房源卡片看作一个独立的 div 节点,然后在每个节点内部按 class 名称提取子节点。这种做法的好处在于,即使某个字段在个别的房源上缺失,也不会影响其他字段的解析——因为节点定位是相对独立的。这一点比“用列表去按位置索引字段”要稳健得多。

字段提取还有一个关键细节:原始数据清洗。链家房源信息里有很多零碎文本,例如“3室1厅·96.7平米·南 北·简装·低楼层(共6层)·2003年建”。直接把这个字符串存进 CSV 也能用,但如果要做数据分析,最好还是拆分成单独的结构化字段。我在代码里用了 split() 和 strip() 的组合,再配合几个条件判断来处理不同字段的边界。这部分的完整实现代码,下面第三章会单独展示。

2.3 常见架构误区:避免把全部代码塞进一个函数

我在看别人写的爬虫代码时,最常见的问题就是把所有逻辑都堆在 main 函数里:发请求、解析、翻页、存数据全在一起,代码是能跑,但改起来要命。这个项目我希望做一个稍微工程化一点的示范,所以按职责拆分了四个函数:

  • fetch_listing_page(url):负责请求列表页并返回 BeautifulSoup 对象
  • parse_listing_card(card):负责从单个房源卡片中提取结构化字段
  • parse_page(soup):负责遍历一整页的所有房源卡片,返回列表
  • save_records(records):负责把数据写入 CSV

这种拆分的好处是显而易见的。每个函数只需关注一件事,出了问题直接定位到具体函数。比如拿到的一页数据里全是空的,那就先看 parse_listing_card 里的选择器是否匹配;如果请求被拦截,那就检查 fetch_listing_page 的请求头。四个函数加一个主循环,整个代码 150 行以内就能写完,还有一个额外的好处,就是后续如果要增加新字段,只需要改 parse_listing_card 内部的几行,不需要动其他函数。

3. 完整代码实现与逐段解析

现在进入正题,我把完整代码贴出来,然后逐段解析里面的关键逻辑。代码结构遵循上面提到的四个函数加一个主循环,全部用 Python 标准库加第三方库实现,依赖项只有 requests 和 beautifulsoup4。

3.1 环境准备与依赖安装

在写任何代码之前,先确保你的 Python 环境是干净的。我推荐用 Python 3.8 以上的版本,因为 f-string 和类型注解在这之后的版本里才比较好用。创建虚拟环境不是必须的,但在做爬虫项目时我强烈建议用,因为爬虫经常会装 lxml、pandas 这类重依赖,不隔离环境容易把系统 Python 弄乱。

安装依赖只需要两行命令:

pip install requests beautifulsoup4 pip install lxml # 可选,BeautifulSoup 使用 lxml 作为解析器时速度更快

这里有个小建议:requests 和 beautifulsoup4 都有纯 Python 的依赖(urllib3、soupsieve 等),安装速度很快。lxml 是一个 C 扩展库,编译安装有时会遇到坑,在 Windows 上推荐直接从 pip 下载预编译的 wheel,在 macOS 上用 Homebrew 的 Python 通常也没问题。

3.2 核心代码:requests 请求模块

import random import time import csv 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/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Referer': 'https://www.lianjia.com/' }

这一段定义了一个请求头字典。这里的核心是 User-Agent 和 Referer。UA 直接决定了服务器认为你是一个“真人浏览器”还是“Python 脚本”。Chrome 120 这个版本信息是我在写代码那段时间实测能生效的版本,UA 的具体版本号其实没那么重要,关键是格式要符合浏览器的特征。如果哪天链家升级了 UA 检测策略,换一个最新的 Chrome UA 就行。

Referer 也很关键。链家有些页面会校验来源,直接从别的域名跳转过来的请求会算作盗链。我虽然不确认链家是否启用了 Referer 校验,但从经验出发,带上 Referer 总比不带好。在爬虫领域,低成本地模仿真人的行为细节,能规避掉很大一部分反爬策略。

def fetch_listing_page(url, retries=3): for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=15) if resp.status_code == 200: resp.encoding = 'utf-8' return BeautifulSoup(resp.text, 'lxml') else: print(f'请求失败,状态码:{resp.status_code}') except requests.RequestException as e: print(f'请求异常(第 {attempt+1} 次尝试):{e}') time.sleep(2 + random.random() * 3) return None

fetch_listing_page 这个函数包含了三个工程化细节,值得单独说明。

第一是 retries 重试机制。网络请求的失败率远比你想象得高,超时、连接被重置、临时断网都可能导致请求失败。我设置 3 次重试,并且每次重试之间等待 2 到 5 秒的随机时长,这能有效避免因为瞬时网络抖动导致整个任务崩溃。

第二是 timeout=15。requests 库如果不在请求时指定 timeout,默认是永不超时。这意味着如果链家服务器接收了连接但没有返回数据,你的脚本会一直挂在那里,既不报错也不往下跑。设一个 15 秒的超时时间,请求超过 15 秒就主动放弃,然后走重试逻辑。

第三是 resp.encoding = 'utf-8'。链家页面的 charset 是 utf-8,但有时候 requests 会从响应头里没有获取到编码信息,默认使用 ISO-8859-1 来解码,导致中文变成一坨乱码。强制指定 utf-8 可以稳妥地避免这个问题。

3.3 核心代码:HTML 解析与字段提取模块

def parse_listing_card(card): title_tag = card.select_one('div.title a') position_tag = card.select_one('div.positionInfo a') house_info_tag = card.select_one('div.houseInfo') total_price_tag = card.select_one('div.totalPrice span') unit_price_tag = card.select_one('div.unitPrice span') follow_info_tag = card.select_one('div.followInfo') title = title_tag.get_text(strip=True) if title_tag else '' position = position_tag.get_text(strip=True) if position_tag else '' follow_info = follow_info_tag.get_text(strip=True) if follow_info_tag else '' total_price = total_price_tag.get_text(strip=True) if total_price_tag else '' unit_price = unit_price_tag.get_text(strip=True) if unit_price_tag else '' # 房屋信息:3室1厅·96.7平米·南 北·简装·低楼层(共6层)·2003年建 house_text = house_info_tag.get_text(' ', strip=True) if house_info_tag else '' parts = [p.strip() for p in house_text.split('·')] return { 'title': title, 'position': position, 'house_type': parts[0] if len(parts) > 0 else '', # 户型 'area': parts[1] if len(parts) > 1 else '', # 面积 'orientation': parts[2] if len(parts) > 2 else '', # 朝向 'decoration': parts[3] if len(parts) > 3 else '', # 装修 'floor': parts[4] if len(parts) > 4 else '', # 楼层 'year': parts[5] if len(parts) > 5 else '', # 年代 'total_price': total_price, 'unit_price': unit_price, 'follow_info': follow_info, }

这一部分是整个爬虫的核心逻辑。第一步是初始化 CSS 选择器定位各个子元素。链家页面上每个房源卡片是一个 div,默认有 class 为“info”,卡片内各字段的 class 名称我在注释里标清楚了。这里面最容易搞混的就是 totalPrice 和 unitPrice,前者是“总价 620 万”,后者是“单价 58000 元/平米”,都是一样的 class 前缀但不同层级,用 select_one('div.totalPrice span') 和 select_one('div.unitPrice span') 正好分别定位。

第二步是拆解房屋信息。链家的 houseInfo 是一整段文本,格式很规整但不固定。我实测中发现大多数房源是 6 个字段,但偶尔有 5 个的(没有装修或没有年代的)。直接用 split('·') 分割然后按索引取值,有可能会出现错位。所以我在这里做了一个容错:用列表 parts 保存分割结果,然后靠索引去取,但默认值设为空字符串,确保即使 6 个字段不全,程序不会报 IndexError。这部分可以做得更细,例如对“高楼层/中楼层/低楼层”做归一化,这里先留一个可扩展的空间。

第三步是 get_text 的参数。BeautifulSoup 的 get_text() 默认会返回所有子节点的文本,但如果标签内部有换行,返回的字符串可能带很多空白字符。传一个分隔符参数 ' ' 可以统一用空格连接多个文本节点,然后 strip 掉首尾空白,解析出的内容就干净很多。

def parse_page(soup): cards = soup.select('div.info') records = [] for card in cards: try: record = parse_listing_card(card) records.append(record) except Exception as e: print(f'解析房源卡片失败:{e}') continue return records

parse_page 这个函数很简单,就是遍历一页里所有 class 为 info 的 div,对每个卡片调用解析函数。我用 try-except 把单个卡片的异常拦下来并 continue,这样即使某个卡片因为特殊格式解析出错,也不会中断整页的处理。

3.4 核心代码:CSV 存储模块与主循环

def save_records(records, filename='lianjia_ershoufang.csv'): if not records: return fieldnames = ['title', 'position', 'house_type', 'area', 'orientation', 'decoration', 'floor', 'year', 'total_price', 'unit_price', 'follow_info'] write_header = not os.path.exists(filename) with open(filename, 'a', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) if write_header: writer.writeheader() writer.writerows(records)

存储这一层,我选择了 append 模式而不是一次性覆盖写入。原因很实际:爬虫任务跑几十甚至上百页,如果中途挂了,已经抓到的数据不应该丢失。只需要给 CSV 文件追加内容,下次再运行时数据就会接在后面。如果是第一次运行,文件不存在,就先用 os.path.exists 判断一下,再写入表头。

编码这里有个细节,UTF-8 编码写入后再用 Excel 打开会乱码,因为 Excel 需要 BOM 头。Python 里把编码设置为 utf-8-sig,写入的 CSV 就能直接在 Excel 里正常显示中文。这是很多人踩过坑之后才知道的细节。

def main(): csv_filename = 'lianjia_ershoufang.csv' base_url = 'https://bj.lianjia.com/ershoufang/pg{page}/' start_page = 1 end_page = 50 for page in range(start_page, end_page + 1): url = base_url.format(page=page) print(f'正在抓取第 {page} 页...') soup = fetch_listing_page(url) if soup is None: print(f'第 {page} 页抓取失败,跳过') continue records = parse_page(soup) if not records: print(f'第 {page} 页未解析到任何房源,可能是反爬拦截或页面结构变化') continue save_records(records, filename=csv_filename) print(f'第 {page} 页解析完成,共 {len(records)} 条记录') time.sleep(random.uniform(1, 3)) print('全部抓取完成') if __name__ == '__main__': main()

主循环的代码非常直白:构造 URL、抓取页面、解析数据、保存数据、随机休眠。这里我设置了 1 到 50 页,对应大约 1500 套房源,作为演示足够了。如果你想爬全量数据,可以先把首页解析一下,从页脚拿到总页数,再动态调整 end_page。

休眠这一步很重要。random.uniform(1, 3) 的意思是每次请求之间等待 1 秒到 3 秒之间的随机时长。这个随机区间考虑了防盗和任务耗时之间的平衡——间隔太短容易被封,间隔太长整个任务要跑很久。对于中小规模的采集来说,1 到 3 秒是比较合理的区间。

3.5 完整代码汇总

为了方便大家直接抄作业,我把上面的所有函数拼接成一个完整的脚本。具体的各函数注释和说明已经分散在前面,这里就直接把全量代码贴出来:

import os import csv import time import random 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/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Referer': 'https://www.lianjia.com/' } def fetch_listing_page(url, retries=3): for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=15) if resp.status_code == 200: resp.encoding = 'utf-8' return BeautifulSoup(resp.text, 'lxml') else: print(f'请求失败,状态码:{resp.status_code}') except requests.RequestException as e: print(f'请求异常(第 {attempt+1} 次尝试):{e}') time.sleep(2 + random.random() * 3) return None def parse_listing_card(card): title_tag = card.select_one('div.title a') position_tag = card.select_one('div.positionInfo a') house_info_tag = card.select_one('div.houseInfo') total_price_tag = card.select_one('div.totalPrice span') unit_price_tag = card.select_one('div.unitPrice span') follow_info_tag = card.select_one('div.followInfo') title = title_tag.get_text(strip=True) if title_tag else '' position = position_tag.get_text(strip=True) if position_tag else '' follow_info = follow_info_tag.get_text(strip=True) if follow_info_tag else '' total_price = total_price_tag.get_text(strip=True) if total_price_tag else '' unit_price = unit_price_tag.get_text(strip=True) if unit_price_tag else '' house_text = house_info_tag.get_text(' ', strip=True) if house_info_tag else '' parts = [p.strip() for p in house_text.split('·')] return { 'title': title, 'position': position, 'house_type': parts[0] if len(parts) > 0 else '', 'area': parts[1] if len(parts) > 1 else '', 'orientation': parts[2] if len(parts) > 2 else '', 'decoration': parts[3] if len(parts) > 3 else '', 'floor': parts[4] if len(parts) > 4 else '', 'year': parts[5] if len(parts) > 5 else '', 'total_price': total_price, 'unit_price': unit_price, 'follow_info': follow_info, } def parse_page(soup): cards = soup.select('div.info') records = [] for card in cards: try: record = parse_listing_card(card) records.append(record) except Exception as e: print(f'解析房源卡片失败:{e}') continue return records def save_records(records, filename='lianjia_ershoufang.csv'): if not records: return fieldnames = ['title', 'position', 'house_type', 'area', 'orientation', 'decoration', 'floor', 'year', 'total_price', 'unit_price', 'follow_info'] write_header = not os.path.exists(filename) with open(filename, 'a', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) if write_header: writer.writeheader() writer.writerows(records) def main(): csv_filename = 'lianjia_ershoufang.csv' base_url = 'https://bj.lianjia.com/ershoufang/pg{page}/' start_page = 1 end_page = 50 for page in range(start_page, end_page + 1): url = base_url.format(page=page) print(f'正在抓取第 {page} 页...') soup = fetch_listing_page(url) if soup is None: print(f'第 {page} 页抓取失败,跳过') continue records = parse_page(soup) if not records: print(f'第 {page} 页未解析到任何房源,可能是反爬拦截或页面结构变化') continue save_records(records, filename=csv_filename) print(f'第 {page} 页解析完成,共 {len(records)} 条记录') time.sleep(random.uniform(1, 3)) print('全部抓取完成') if __name__ == '__main__': main()

到这里,一个完整可运行的链家二手房爬虫已经完成了。接下来我重点讲一下我在这个项目里遇到的实际问题和排查过程。

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

任何爬虫项目,跑起来和跑好是两个层次的事。这里我把自己在实际运行中遇到的高频问题、排查路径、以及最终解决方案整理成速查表,方便你快速对照解决。

4.1 问题速查表

现象可能原因解决方案
返回状态码 302需要登录或访问被重定向检查是否被跳转到验证码页,加入 Referer 和更完整的请求头
页面返回但无数据页面结构变动或列表选择器失效手动打开页面审查元素,用浏览器开发者工具重新定位 class 名
中文乱码默认编码判断错误强制设置 resp.encoding = 'utf-8'
部分字段为空该房源缺失对应信息用三目运算保证每个字段至少返回空字符串,不要抛异常
请求异常超时目标临时限流或网络问题加入重试机制和随机延迟,避免高频请求
被封 IP请求频率太高延长休眠时间,或使用代理池(注意合规)
CSV 打开后中文乱码编码没有 BOM 头写入时使用 utf-8-sig
爬了几页后全是重复数据触发了分页限制检查页面是否返回了同一页内容,增加请求头 Cookie 字段

这 8 个问题基本覆盖了我个人在爬取链家时遇到的 90% 的异常场景。下面挑几个典型的详细说明排查过程。

4.2 验证码与访问拦截的应对策略

第一次跑脚本时,我碰到最多的就是验证码问题。具体表现是:前几页数据正常,到后面突然某一页返回的不是房源列表,而是一个验证码页面。BeautifulSoup 在解析验证码页面时自然找不到任何 div.info,于是该页返回空列表。

排查思路是这样的:先在浏览器里手动打开对应页码,看看网页是否正常展示。如果浏览器正常而 requests 不行,说明不是页面本身的问题,而是请求被识别了。然后检查请求头,对比浏览器请求和爬虫请求的差异。我最终发现,UA 和 Referer 都带上之后,验证码出现的频率从“每 5 页一次”降到“每 100 页不到一次”。

另一个实用技巧是处理已经出现的验证码。如果某次请求返回了验证码页面,可以打印出页面标题或者 URL,让脚本识别到异常状态,然后暂停更长时间(比如 60 秒)再重试。代码里加一段判断:

if '验证码' in resp.text or resp.url.startswith('https://captcha'): print('触发验证码,等待 60 秒后重试...') time.sleep(60) continue

这个策略很朴素但很有效。验证码拦截本质上是频率问题,主动降速是可以解决的。但要注意降速是暂时的,如果长时间不停地爬,IP 还是可能被临时封禁。真被封了也没事,停个十几分钟再继续,链家的封禁时长通常是可控的。

4.3 页面结构变化带来的解析失败

爬虫最怕的不是被反爬,而是网站改版。一旦目标页面的 HTML 结构发生变化,之前写的 CSS 选择器全部失效,脚本就会返回空数据。

我遇到过两次这样的情况,每次的排查路径都类似。第一步,用浏览器打开链家二手房页面,按 F12 打开开发者工具,选中任意一个房源卡片,查看它现在用的 class 是什么。第二步,把新的 class 名替换到代码里的选择器中。第三步,手动解析一页看看输出是否正常。

这种改版通常是小规模的,比如把 div.info 改成了 div.infoWrapper,或者把某个价格从 span 换成了 div。真正大规模改版很少见,所以只要定期关注爬虫日志,发现连续多页空数据时立刻检查页面结构,基本都能快速修复。

4.4 关于动态加载页面的一些思考

这里有一个需要澄清的点。有些读者可能会问:链家二手房是不是动态加载的?为什么我用 requests 拿到的是空页面?

我的实测结果是:链家二手房列表页是服务端渲染,即使关闭浏览器 JavaScript,页面依然能展示完整的房源信息。所以 requests 可以直接拿到数据。但如果你用的是链家的“地图找房”或者其他交互式页面,那就是另一回事了——那些页面依赖 JavaScript 动态渲染,requests 拿到的 HTML 里确实没有数据。

针对动态渲染页面,通常的解决方案有几种:一是分析浏览器发出的 XHR/AJAX 请求,找到真实的数据接口,直接用 requests 去请求接口(这是最高效的方式);二是使用 Selenium 或 Playwright 这类无头浏览器,直接渲染出完整页面再解析;三是使用 pyppeteer 这类异步化的浏览器工具。但这些都已经超出了本项目的范围,属于进阶话题,以后可以单独写。

5. 合规、道德与风险控制

每次写爬虫相关的内容,我都必须专门拿出一章来聊合规和道德问题。这不是套话,而是因为爬虫天然游走在灰色地带,规则不明确,边界不清晰。

5.1 robots.txt 与数据使用边界

先看 robots 协议。链家网站的 robots.txt 明确写有对爬虫的访问限制,比如禁止爬取部分路径。虽然 robots.txt 在法律上没有强制约束力,但作为从业者,我认为尊重站点的爬虫策略是基本功。本项目仅做个人学习与技术演示用途,爬取数据量控制在很小的规模,且不对目标站点造成明显压力,这是我给自己设定的红线。

数据使用边界更重要。爬下来的链家房源信息,包含小区名称、户型、价格、经纪人联系方式等,这些数据涉及企业数据权益和个人信息保护。用于个人学习分析是一回事,用于商业变现是另一回事,后者的法律风险要高得多。我在实际项目中,只把数据用于内部市场趋势分析,数据也基本是匿名的,都不涉及任何个人信息。

5.2 控制频率与负责任爬取

我在整个代码里,最看重的就是请求频率的控制。很多爬虫出问题的根源并不是代码写得不好,而是频率太高。你想想,链家的服务器每天要服务多少真实用户?你每秒发 10 个请求,和正常用户的使用模式完全不符,被拦截是必然的。

负责任爬取的具体实践包括:

  • 单线程访问,不搞并发协程(这个项目本身就不需要)
  • 每次请求后随机休眠 1 到 3 秒
  • 单次运行不超过几百页
  • 不在高峰期(如节假日促销活动时)爬取
  • 抓到数据就停,不反复抓同一页

这套行为准则,比我写过的任何一个爬虫代码都重要。它决定了你的 IP 会不会被封,也决定了目标网站是否愿意继续开放数据给所有人。做技术的人应该有这个自觉。

5.3 后续可以怎么扩展

这个项目做完之后,我根据自己的实际需求做了两个方向的扩展,可以给大家参考。

第一个方向是定时抓取加增量更新。把主循环包在一个 while True 里面,每天凌晨运行一次,只抓取前一天新上架的房源,然后合并到历史数据里。这样就能构建一份时间序列数据,用于分析某个小区的挂牌价走势。这个功能的关键在于去重:链家同一套房源的链接是稳定的,可以以链接指纹为 key 去重。

第二个方向是加上数据可视化。把 CSV 读进 pandas,按区域做均价排行,画出箱线图或者热力图,能直观看出哪个区域的价格波动大、哪个小区的性价比高。如果你在做房产相关的研究,这些图表会比单个表格有价值得多。

我这里也想过要不要再加一套代理池,但仔细想过之后决定不加。原因有两个:一是链家对这个项目的访问压力不大,单 IP 加上频率控制完全够用;二是免费代理池的质量参差不齐,很多代理 IP 本身就被目标站点标记了,用了反而更慢,还可能带来数据污染。真要稳定使用,付费代理又是一笔成本,对学习项目来说不划算。

6. 写在最后的一点体会

这个项目看似简单,但对我来说每一次重写都有新收获。最早我写的爬虫版本,把所有逻辑都堆在 main 函数里,请求失败不会重试,编码不对就乱码,字段不对就报错。后来慢慢地学会了重试机制、异常处理、数据清洗、CSV 编码这些细节,才发现写爬虫其实是在写工程,不是在写一次性脚本。

有一个经验想特别分享:判断一个爬虫写得好不好,不是看它能不能把数据抓下来,而是看它在各种意外情况下能不能自己恢复。链家这个项目里,如果没有 retries、没有字段容错、没有随机休眠,一百页跑下来大概率会在中途挂掉。加了这些机制之后,我跑完整套流程几乎没有人为干预。

如果你正准备动手跑这个项目,我给你三个建议:第一,先把单页解析做到完美再批量跑,不要急着循环一百页发现全是空数据;第二,第一次运行时把 end_page 设小一点,比如 5 到 10 页,确认 CSV 文件内容正常再放大量;第三,数据抓下来花点时间看看,里面有很多有趣的细节,比如同一个小区的单价差异、不同朝向的均价差,这些比代码本身更能让你理解数据的价值。

技术是工具,数据是资源,怎么用好它们,才是我们真正要思考的问题。

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

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

立即咨询