简介:这份源码面向房地产数据分析初学者与爬虫开发者,提供基于Python的链家二手房及租房在线数据采集方案,可解决房产市场信息获取效率低、数据结构化处理门槛高的问题。压缩包共20个文件,约10.85MB,以8个Python脚本为核心,涵盖爬虫主逻辑、数据模型定义、配置管理及链家站点交互库;另含CSV与文本数据文件、PNG可视化截图、Jupyter笔记本、Markdown说明及开源许可文件,结构清晰便于二次开发。已有706人学习下载,适合作为课程设计或市场研究的实战参考。读者可从中掌握请求构造、反爬应对、字段解析与数据落盘思路,并借助笔记本完成二手房与租房数据的探索性分析,快速搭建可复用的采集分析流程。
1. 链家二手房与租房数据抓取:从页面结构到可运行爬虫的完整路径
链家二手房和租房页面看起来只是普通的房源列表,但真正动手抓的时候,很多人会在第一页就卡住:列表页只渲染前几页,详情页字段散落在多个 DOM 节点里,翻页参数还带加密。这个标题讲的就是用 Python 把这条链路打通——从列表页拿到房源 ID,再进详情页补齐总价、单价、户型、楼层、朝向、挂牌时间等字段,最后落成结构化数据。它适合两类人:一类是刚学完 requests 和 xpath、想找一个真实站点练手的 Python 爬虫入门者;另一类是做房产数据分析、需要稳定拿到区域维度房源样本的从业者。核心不是“能不能抓”,而是“怎么抓得稳、字段对得上、翻车了知道看哪里”。
2. 抓链家之前先把三件事定下来:目标字段、请求节奏、存储结构
2.1 先定字段再写代码,别反过来
很多人一上来就写requests.get,结果抓了三千条发现没有“挂牌时间”,又回头重跑。链家二手房列表页能直接拿到的字段有限,真正有价值的字段大多在详情页。我一般先列一张字段表,明确哪些来自列表页、哪些必须进详情页,再决定请求次数。
| 字段 | 来源 | 说明 |
|---|---|---|
| 房源 ID | 列表页 | 详情页 URL 里的数字串,唯一键 |
| 小区名称 | 列表页 | 列表卡片标题里 |
| 区域/商圈 | 列表页 | 面包屑或卡片副标题 |
| 总价 | 列表页 | 单位万元,注意“万”字要剥离 |
| 单价 | 详情页 | 元/平米,列表页偶尔缺失 |
| 户型 | 详情页 | 如 2室1厅1卫 |
| 建筑面积 | 详情页 | 平米 |
| 楼层 | 详情页 | 含“中楼层/共18层”这类复合信息 |
| 朝向 | 详情页 | 详情页基础属性区 |
| 挂牌时间 | 详情页 | 部分城市展示,部分不展示 |
| 租房月租 | 列表页 | 租房频道单独一套解析逻辑 |
这张表决定了你的请求量级:如果只抓列表页,一个区域 30 页就结束;如果要详情页字段,请求数会放大 30 倍以上,节奏控制必须提前设计。
2.2 请求节奏与合规边界
链家对高频访问有风控,表现为返回验证页、302 跳转或直接空数据。常见做法是:单 IP 每秒不超过 1 次请求,详情页之间随机 sleep 1.5 到 3 秒,列表页翻页 sleep 2 到 4 秒。不要用多线程硬怼,先跑通单线程再考虑加并发。请求头里User-Agent用真实浏览器串,Referer带上列表页地址,Accept-Language设成zh-CN,zh;q=0.9。这些不是玄学,是让服务端把你当普通浏览器而不是脚本。
提示:抓取前先看目标站点的 robots.txt 和服务条款,控制频率、不抓个人隐私字段、不对外传播原始数据,这是长期能跑的前提。
2.3 存储结构:先用 SQLite 跑通,再换 MySQL
新手最容易犯的错是直接上 MySQL,结果环境配半天,代码没写几行。我一般先用 SQLite 建一张宽表,字段和上面表格对齐,跑通后再迁移。建表语句如下:
CREATE TABLE lianjia_house ( house_id TEXT PRIMARY KEY, title TEXT, community TEXT, region TEXT, business_area TEXT, total_price REAL, unit_price REAL, layout TEXT, area REAL, floor TEXT, orientation TEXT, listed_date TEXT, rent_price REAL, house_type TEXT, crawl_time TEXT );house_id做主键,重复抓取时用INSERT OR REPLACE更新,避免同一房源反复入库。crawl_time记录抓取时间,方便后续做价格变化分析。house_type区分二手房和租房,一张表就能覆盖两个频道。
3. 用 requests + lxml 跑通列表页与详情页的最小闭环
3.1 列表页请求与房源 ID 提取
先装依赖:pip install requests lxml。列表页 URL 形如https://bj.lianjia.com/ershoufang/pg1/,其中pg后面是页码。下面是最小可运行代码:
import requests from lxml import etree import time import random 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", "Referer": "https://bj.lianjia.com/ershoufang/", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_list_page(city="bj", page=1): url = f"https://{city}.lianjia.com/ershoufang/pg{page}/" resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code != 200: print(f"列表页 {page} 状态码 {resp.status_code}") return [] html = etree.HTML(resp.text) # 列表卡片里的详情链接,取 href 中的数字 ID links = html.xpath('//ul[@class="sellListContent"]/li//a[@class="noresultRecommend"]/@href') house_ids = [] for link in links: # 链接形如 https://bj.lianjia.com/ershoufang/12345678.html hid = link.strip("/").split("/")[-1].replace(".html", "") if hid.isdigit(): house_ids.append(hid) return house_ids if __name__ == "__main__": ids = fetch_list_page("bj", 1) print(f"第 1 页拿到 {len(ids)} 个房源 ID") time.sleep(random.uniform(2, 4))这段代码做了三件事:拼列表页 URL、发请求、用 xpath 取详情链接并抽 ID。xpath里的//ul[@class="sellListContent"]/li//a[@class="noresultRecommend"]/@href是链家列表页常见的结构,如果页面改版,class 名会变,这时候用浏览器开发者工具重新定位即可。timeout=10防止请求挂死,random.uniform(2, 4)是翻页间隔。
3.2 详情页字段解析
拿到 ID 后拼详情页 URL:https://bj.lianjia.com/ershoufang/{house_id}.html。详情页字段分两块:标题区和基础属性区。
def parse_detail(house_id, city="bj"): url = f"https://{city}.lianjia.com/ershoufang/{house_id}.html" resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code != 200: return None html = etree.HTML(resp.text) def get_text(xpath_expr): result = html.xpath(xpath_expr) return result[0].strip() if result else "" data = { "house_id": house_id, "title": get_text('//h1[@class="main"]/text()'), "total_price": get_text('//span[@class="total"]/text()'), "unit_price": get_text('//span[@class="unitPriceValue"]/text()'), "community": get_text('//div[@class="communityName"]/a[@class="info"]/text()'), "region": get_text('//div[@class="areaName"]/span[@class="info"]/a[1]/text()'), "layout": get_text('//div[@class="room"]/div[@class="mainInfo"]/text()'), "area": get_text('//div[@class="area"]/div[@class="mainInfo"]/text()'), "orientation": get_text('//div[@class="type"]/div[@class="mainInfo"]/text()'), "floor": get_text('//div[@class="floor"]/div[@class="mainInfo"]/text()'), "listed_date": get_text('//div[@class="transaction"]//li[1]/span[2]/text()'), } return dataget_text封装了“取第一个结果并 strip”的逻辑,避免每个字段都写一遍判断。total_price取到的是“520”这种纯数字,单位在页面别处,入库时统一按万元存。listed_date在部分城市详情页的“交易属性”区,如果取不到就留空,不要硬编造。
3.3 把列表页和详情页串起来
主流程就是“翻页拿 ID → 逐个进详情 → 入库”。下面这段把前面两步接上,并写入 SQLite:
import sqlite3 def save_to_db(records, db_path="lianjia.db"): conn = sqlite3.connect(db_path) cur = conn.cursor() for r in records: cur.execute(""" INSERT OR REPLACE INTO lianjia_house (house_id, title, community, region, total_price, unit_price, layout, area, orientation, floor, listed_date, crawl_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) """, ( r["house_id"], r["title"], r["community"], r["region"], r["total_price"], r["unit_price"], r["layout"], r["area"], r["orientation"], r["floor"], r["listed_date"] )) conn.commit() conn.close() def crawl(city="bj", max_pages=3): all_records = [] for page in range(1, max_pages + 1): ids = fetch_list_page(city, page) print(f"第 {page} 页拿到 {len(ids)} 个 ID") for hid in ids: detail = parse_detail(hid, city) if detail: all_records.append(detail) time.sleep(random.uniform(1.5, 3)) time.sleep(random.uniform(2, 4)) save_to_db(all_records) print(f"共入库 {len(all_records)} 条") if __name__ == "__main__": crawl("bj", max_pages=3)max_pages=3是保守起步,先跑三页确认字段完整、没有触发风控,再逐步加页。INSERT OR REPLACE保证重复运行不会报主键冲突。datetime('now')用 SQLite 内置函数写时间,省去 Python 侧格式化。
4. 租房频道与二手房频道的差异处理
4.1 租房列表页结构不同,解析要单独写
租房 URL 是https://bj.lianjia.com/zufang/pg1/,列表卡片 class 和二手房不一样,价格单位是“元/月”。如果直接把二手房解析逻辑套上去,会拿到空列表。常见做法是写一个fetch_rent_list,xpath 换成租房卡片对应的 class,价格字段单独处理。
def fetch_rent_list(city="bj", page=1): url = f"https://{city}.lianjia.com/zufang/pg{page}/" resp = requests.get(url, headers=HEADERS, timeout=10) html = etree.HTML(resp.text) items = html.xpath('//div[@class="content__list"]/div[@class="content__list--item"]') results = [] for item in items: link = item.xpath('.//a[@class="content__list--item--aside"]/@href') price = item.xpath('.//span[@class="content__list--item-price"]/em/text()') if link: hid = link[0].strip("/").split("/")[-1].replace(".html", "") results.append({ "house_id": hid, "rent_price": price[0] if price else "", "house_type": "rent" }) return resultscontent__list--item是租房列表卡片的容器 class,content__list--item-price里的em是月租数字。租房详情页字段比二手房少,通常列表页就能拿到大部分信息,是否进详情页取决于你要不要“入住时间”“看房方式”这类字段。
4.2 二手房和租房共用一张表的字段映射
前面建的表里house_type区分类型,rent_price存月租,二手房该字段留空。total_price对租房无意义,也留空。这样一张表既能做二手房价格分析,也能做租房租金分析,查询时加WHERE house_type = 'rent'即可。如果两个频道字段差异继续扩大,再拆成两张表也不迟,但起步阶段一张宽表最省事。
5. 避坑与排查:链家爬虫最容易翻车的五个地方
5.1 现象:列表页返回 200 但 xpath 取到空列表
原因通常是页面结构改版,class 名变了,或者请求被重定向到验证页但状态码仍是 200。解决:先把resp.text存成文件,用浏览器打开看实际返回内容。如果是验证页,说明频率过高,降速并换请求头;如果是结构变了,用开发者工具重新定位 class。
5.2 现象:详情页字段时有时无,同一字段有的房源有值有的为空
原因:链家不同城市、不同房源类型的详情页模板不完全一致,部分字段只在特定条件下渲染。解决:get_text返回空字符串时不要抛异常,入库允许 NULL;后续分析时按字段覆盖率决定是否使用该字段。不要为了“填满”而用相邻字段硬凑。
5.3 现象:跑了几十页后突然全部返回空数据
原因:触发风控,IP 被限。解决:立即停止,不要继续重试。降低单 IP 请求频率,列表页间隔拉到 5 秒以上,详情页间隔 3 秒以上。如果确实需要更大数据量,考虑分布式爬虫方案,但分布式的前提是单机已经跑稳,否则只是把翻车放大。
5.4 现象:价格字段带“万”“元/平”等单位,直接入库后无法计算
原因:页面展示带单位,代码没剥离。解决:入库前统一清洗,total_price去掉“万”转 float,unit_price去掉“元/平”转 float,rent_price去掉“元/月”转 float。清洗逻辑写在parse_detail里,不要留到分析阶段再处理。
5.5 现象:翻页到某一页后 URL 拼出来是 404
原因:链家列表页有最大页数限制,超出后返回 404 或空页。解决:在fetch_list_page里判断返回内容是否包含房源卡片,如果没有就提前终止翻页,不要硬翻到max_pages。同时记录实际抓到的页数,方便判断数据覆盖范围。
6. 进阶:用字段覆盖率验证抓取质量,而不是只看条数
跑完爬虫后,很多人只看“抓了多少条”,但条数多不代表数据能用。我一般会算每个字段的覆盖率:非空记录数除以总记录数。覆盖率低于 60% 的字段,要么是解析 xpath 写错了,要么是该字段本身在目标城市不展示。下面这段代码直接查 SQLite:
import sqlite3 def field_coverage(db_path="lianjia.db"): conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute("SELECT COUNT(*) FROM lianjia_house") total = cur.fetchone()[0] fields = ["title", "community", "region", "total_price", "unit_price", "layout", "area", "orientation", "floor", "listed_date"] for f in fields: cur.execute(f"SELECT COUNT(*) FROM lianjia_house WHERE {f} IS NOT NULL AND {f} != ''") cnt = cur.fetchone()[0] print(f"{f}: {cnt}/{total} = {cnt/total:.1%}") conn.close() if __name__ == "__main__": field_coverage()跑出来如果listed_date覆盖率只有 20%,说明大部分房源详情页没有这个字段,分析时就不能拿它做时间维度。如果unit_price覆盖率 95% 以上,说明解析稳定。这个方法比“抓了五千条”有用得多,因为它告诉你哪些字段能信、哪些字段要放弃。
另一个进阶技巧是增量抓取:每次运行前先查库里已有的house_id,列表页拿到的 ID 如果在库里且crawl_time在 24 小时内,就跳过详情页请求。这样日常更新只需要抓新上架房源,请求量能降一个数量级。实现上就是在crawl里加一个existing_ids集合,parse_detail前判断一下。
我自己踩过最深的坑是早期不看字段覆盖率,抓了两万条数据,做价格分析时才发现area字段有一半是空的,原因是详情页面积字段的 xpath 在部分模板里位置不同。后来养成习惯:每换一个城市或跑完一批,先跑覆盖率,再决定这批数据能不能进分析流程。希望帮到你。
本文还有配套的精品资源,点击获取