
简介这是一套面向Python爬虫与数据分析学习者的实战项目源码聚焦链家广州二手房市场帮助读者掌握从数据采集到可视化呈现的完整流程。项目以requests、BeautifulSoup、lxml、pandas等库为核心覆盖网页请求、HTML解析、反爬应对、数据清洗与统计分析等环节并借助Matplotlib、Seaborn等工具绘制价格走势、区域差异、房屋特征与价格关系等图表适合具备Python基础、希望积累真实项目经验的学生与开发者。资源包共13个文件包含9个py脚本、2个md说明文档、1个csv数据文件及1个gitignore配置压缩包约146KB脚本按获取网页内容、数据清洗、均值中位数统计、总价与面积散点、朝向饼图、装修类型、小区在售数量等模块拆分便于按需查阅与二次修改。目前已有187人学习下载读者可参考完整目录结构与代码实现快速理解爬虫与可视化项目的组织方式并在此基础上迁移到其他城市或平台的数据分析场景。1. 链家广州二手房数据从页面结构到可复现的数据管道广州二手房市场的信息密度很高但真正能拿来做分析的干净数据往往不是打开网页复制粘贴就能得到的。链家作为国内房源信息较全的平台之一其广州二手房列表页包含了总价、单价、户型、面积、朝向、楼层、年代、小区、区域等字段这些字段恰好是房价分析、区域对比、户型偏好研究的基础素材。用 Python 把这条数据链路跑通意味着你可以按自己的口径筛选区域、按时间维度重复采集、把结果落到本地做可视化而不是每次依赖别人整理好的二手表格。这套方案适合两类人一是刚学完 Python 基础语法、想找一个真实项目练手的入门者二是需要定期获取区域房价样本、做市场监测或数据产品的从业者。核心难点不在写几行 requests而在于页面结构会变、反爬策略会拦、字段清洗规则要自己定下面按可复现的顺序拆开讲。2. 采集前的技术选型与页面结构拆解2.1 为什么用 requests BeautifulSoup 而不是一上来就 Selenium链家广州二手房列表页的服务端渲染程度较高房源卡片的关键信息在初始 HTML 里就能拿到这意味着用 requests 发请求、BeautifulSoup 解析 DOM 的成本最低速度也最快。Selenium 或 Playwright 更适合处理强 JavaScript 渲染、需要模拟点击翻页的场景但引入浏览器驱动后资源占用和调试复杂度都会上升。我的习惯是先用 requests 试探如果返回的 HTML 里能稳定找到房源节点就不动浏览器方案。判断方法很简单请求一次列表页把响应文本保存下来搜索房源标题或总价字段能搜到就说明服务端已经吐出了数据。import requests from bs4 import BeautifulSoup url https://gz.lianjia.com/ershoufang/pg1/ 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-Language: zh-CN,zh;q0.9, } resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) # 先确认房源卡片容器是否存在 items soup.select(ul.sellListContent li.clear) print(房源卡片数量:, len(items)) if items: first items[0] print(first.select_one(div.title a).get_text(stripTrue))这段代码做了三件事构造带 User-Agent 的请求头、设置超时、用 CSS 选择器定位房源卡片。ul.sellListContent li.clear是链家列表页常见的卡片结构但页面改版后类名可能变化所以第一步永远是打印数量验证而不是直接进入循环。timeout10防止请求悬挂resp.encoding utf-8避免中文乱码。如果返回数量为 0先检查状态码和响应长度再考虑是否被重定向到验证页。2.2 列表页字段定位与翻页规律链家广州二手房的翻页 URL 规律是pg后接页码例如pg1、pg2。每个房源卡片里标题在div.title a总价在div.totalPrice单价在div.unitPrice小区和区域信息在div.positionInfo户型、面积、朝向、楼层、年代等细节在div.houseInfo。这些字段的文本往往带有空格、换行和单位需要在提取时统一清洗。def parse_item(item): title item.select_one(div.title a).get_text(stripTrue) total_price item.select_one(div.totalPrice).get_text(stripTrue) unit_price item.select_one(div.unitPrice).get_text(stripTrue) position item.select_one(div.positionInfo).get_text( , stripTrue) house_info item.select_one(div.houseInfo).get_text( , stripTrue) return { title: title, total_price: total_price, unit_price: unit_price, position: position, house_info: house_info, } for it in items[:3]: print(parse_item(it))get_text( , stripTrue)用空格连接子节点文本避免户型、面积、朝向粘在一起。position通常包含区域和小区名后续可以按空格或固定分隔符拆成region和community。这一步不要急着写正则先把原始文本落盘观察几十条样本后再定清洗规则否则很容易在个别格式上翻车。2.3 请求频率控制与本地缓存链家对高频请求有拦截连续快速翻页容易触发验证。常见做法是每请求一页后time.sleep随机 2 到 5 秒并把已下载的 HTML 按页码保存到本地。这样即使中途被拦也能从缓存继续解析不用重新请求。import os, time, random os.makedirs(html_cache, exist_okTrue) for page in range(1, 6): cache_file fhtml_cache/pg{page}.html if os.path.exists(cache_file): continue page_url fhttps://gz.lianjia.com/ershoufang/pg{page}/ r requests.get(page_url, headersheaders, timeout10) r.encoding utf-8 with open(cache_file, w, encodingutf-8) as f: f.write(r.text) time.sleep(random.uniform(2, 5))缓存文件既是断点续爬的后悔药也是排查页面结构变化的样本。如果某天解析失败直接打开对应 HTML 对比类名比反复请求线上页面高效得多。注意random.uniform(2, 5)只是降低频率不是绕过风控的保证实际采集量建议控制在合理范围内并遵守平台 robots 协议与相关法律法规。3. 数据清洗与结构化落库3.1 从文本到数值总价、单价、面积的提取原始文本里总价常写成“350万”单价写成“42345元/平米”面积写成“89.5平米”。要参与计算和可视化必须转成数值类型。这里用正则提取数字部分并处理“暂无数据”或“暂无价格”这类缺失值。import re import pandas as pd def to_number(text, pattern): if not text: return None m re.search(pattern, text) return float(m.group(1)) if m else None records [] for page in range(1, 6): with open(fhtml_cache/pg{page}.html, encodingutf-8) as f: soup BeautifulSoup(f.read(), html.parser) for item in soup.select(ul.sellListContent li.clear): raw parse_item(item) records.append({ title: raw[title], total_price: to_number(raw[total_price], r([\d.])万), unit_price: to_number(raw[unit_price], r([\d.])元), position: raw[position], house_info: raw[house_info], }) df pd.DataFrame(records) print(df.head()) print(df.shape)to_number的第二个参数是正则模式总价匹配“万”前的数字单价匹配“元”前的数字。如果某条记录匹配不到返回None后续用dropna或填充策略处理。df.shape用来确认采集条数是否符合预期通常一页 30 条5 页约 150 条数量明显偏少就要检查选择器是否失效。3.2 户型、面积、朝向、楼层的拆分house_info字段通常形如“2室1厅 | 89.5平米 | 南 | 精装 | 中楼层(共18层) | 2010年建”。用|拆分后按位置或关键词提取各维度。不同房源可能缺少某个维度所以不能硬编码索引最好用关键词匹配。def split_house_info(info): parts [p.strip() for p in info.split(|)] result {layout: None, area: None, orientation: None, floor: None, year: None} for p in parts: if 室 in p and 厅 in p: result[layout] p elif 平米 in p: result[area] to_number(p, r([\d.])平米) elif p in (东, 南, 西, 北, 东南, 西南, 东北, 西北, 南北): result[orientation] p elif 楼层 in p: result[floor] p elif 年建 in p: result[year] to_number(p, r(\d{4})年建) return result info_df df[house_info].apply(split_house_info).apply(pd.Series) df pd.concat([df, info_df], axis1) print(df[[title, total_price, area, layout, orientation, year]].head())这里用关键词而不是固定索引是因为链家在不同城市、不同房源类型下字段顺序可能不同。apply(pd.Series)把字典展开成列再与原表拼接。如果某列大量为空先抽样看原始house_info确认是字段缺失还是匹配规则太窄。3.3 落库与去重SQLite 作为轻量存储采集和清洗后的数据建议落到 SQLite方便后续用 SQL 做聚合也避免每次分析都重新跑爬虫。建表时给标题和总价加索引去重可以用标题加总价的组合。import sqlite3 conn sqlite3.connect(lianjia_gz.db) df.to_sql(ershoufang, conn, if_existsreplace, indexFalse) # 去重示例按标题和总价保留一条 df_dedup df.drop_duplicates(subset[title, total_price]) df_dedup.to_sql(ershoufang_dedup, conn, if_existsreplace, indexFalse) print(pd.read_sql(select count(*) as cnt from ershoufang_dedup, conn)) conn.close()if_existsreplace适合每次全量重跑如果想增量追加改成append并配合唯一索引。SQLite 单文件便于携带数据量在几万条以内完全够用。注意to_sql写入前确认列类型一致混合None和float的列会被识别为REAL一般不影响分析。4. 可视化分析区域、价格与户型的交叉观察4.1 区域均价对比用 groupby 找出价格高地广州各区域均价差异明显用groupby按区域聚合单价中位数比均值更抗极端值。区域字段可以从position里取第一段或者单独在解析时拆出来。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False df[region] df[position].str.split( ).str[0] region_price df.groupby(region)[unit_price].median().sort_values(ascendingFalse) region_price.plot(kindbar, figsize(10, 5), color#4C72B0) plt.title(广州各区域二手房单价中位数) plt.ylabel(元/平米) plt.tight_layout() plt.savefig(region_price.png, dpi150) plt.show()SimHei用于显示中文Linux 环境如果没有该字体可以换成WenQuanYi Zen Hei或提前安装。median()比mean()更稳因为个别豪宅单价会拉高均值。如果区域名拆分后出现空值检查position的分隔符是否一致必要时用正则提取“区”字前的部分。4.2 总价分布与面积关系散点图看离群点总价和面积通常正相关但总价还受区域、楼层、年代影响。散点图能快速发现异常点比如面积很小但总价极高可能是别墅或数据录入问题。plt.figure(figsize(8, 6)) plt.scatter(df[area], df[total_price], alpha0.5, c#DD8452) plt.xlabel(面积平米) plt.ylabel(总价万) plt.title(广州二手房面积与总价分布) plt.tight_layout() plt.savefig(area_price.png, dpi150) plt.show() # 简单过滤离群点 df_clean df[(df[area] 10) (df[area] 500) (df[total_price] 20)] print(过滤后条数:, df_clean.shape[0])alpha0.5让重叠点可见。过滤条件按业务常识设定面积小于 10 平米或大于 500 平米、总价低于 20 万的记录要么是特殊房源要么是解析错误。这一步不要直接删除先打印出来人工确认避免误删真实数据。4.3 户型词频与朝向偏好快速统计户型字段可以直接做词频统计看哪些户型供给最多。朝向则按类别计数观察南向房源占比。layout_counts df[layout].value_counts().head(10) print(layout_counts) orientation_counts df[orientation].value_counts() print(orientation_counts) orientation_counts.plot(kindpie, autopct%1.1f%%, figsize(6, 6)) plt.title(房源朝向分布) plt.ylabel() plt.tight_layout() plt.savefig(orientation.png, dpi150) plt.show()value_counts()默认按频次降序head(10)只看前 10 个户型。饼图适合展示占比但类别过多时会拥挤朝向类别少所以合适。如果layout为空的比例高回到split_house_info检查“室厅”匹配逻辑有些房源写“2房间1卫”或“开间”需要补充规则。5. 避坑与常见问题排查5.1 请求返回 200 但解析不到房源现象requests.get状态码 200但soup.select返回空列表。原因通常是链家返回了验证页或跳转页HTML 结构完全不同。解决先把响应文本保存到文件搜索“验证”“安全”“跳转”等关键词确认是否被拦截如果是降低频率、更换请求头、增加间隔必要时改用带 Cookie 的会话。不要盲目加大请求量先确认页面内容。5.2 中文乱码或单位丢失现象标题显示为乱码或总价提取为None。原因响应编码未正确设置或页面实际编码不是 UTF-8。解决用resp.apparent_encoding检测或手动尝试gbk、utf-8单位丢失通常是正则只匹配了数字没考虑“万”“元”前后有空格或全角字符调整正则为r([\d.])\s*万并统一全角半角。5.3 翻页到后面返回重复数据现象第 5 页之后内容与第 1 页相同。原因链家对未登录或高频访问的会话限制翻页深度或者 URL 参数被忽略。解决检查返回 HTML 的标题是否与第一页一致如果是停止翻页改用区域筛选或排序参数缩小范围分多次采集。不要试图用多线程硬刷容易触发更严格的限制。5.4 可视化中文显示为方块现象图表标题和轴标签中文变成方框。原因matplotlib 默认字体不支持中文。解决设置plt.rcParams[font.sans-serif]为系统已安装的中文字体如SimHei、Microsoft YaHei、WenQuanYi Zen Hei设置后仍无效用matplotlib.font_manager查找可用字体路径或清除缓存后重启内核。5.5 数据落库后列类型混乱现象SQLite 里总价列变成文本无法用 SQL 求和。原因to_sql写入时列中混有None和字符串pandas 推断为 object。解决写入前用pd.to_numeric(df[total_price], errorscoerce)强制转数值None会变成NaNSQLite 中存为NULL或者建表时显式指定REAL类型再逐列转换。6. 把采集脚本变成可复用的数据管道走到这里你已经有一条能跑通的链路请求列表页、缓存 HTML、解析字段、清洗落库、出图。但真正让这套东西值得投入的是把它从一次性脚本变成可重复执行的数据管道。我的习惯是拆成三个文件fetch.py只负责按页码下载并缓存parse.py只负责从缓存解析并写入 SQLiteanalyze.py只负责读库出图。这样页面结构变化时只改parse.py请求策略调整时只改fetch.py分析口径变化时只改analyze.py互不干扰。验证管道是否可靠可以用一个简单方法连续跑两次fetch.py第二次应该全部命中缓存不发起新请求再跑parse.py对比两次写入的条数是否一致。如果条数波动说明解析规则不稳定需要回到样本里找原因。另一个技巧是给每条记录加crawl_date字段这样即使后续重复采集也能按日期切片观察价格随时间的变化而不是每次覆盖旧数据。import datetime df[crawl_date] datetime.date.today().isoformat() df.to_sql(ershoufang_history, conn, if_existsappend, indexFalse)append模式配合crawl_date就能积累时间序列。注意每次追加前确认表已存在否则第一次会失败可以先执行一次to_sql(..., if_existsreplace)建表后续改为append。如果数据量增长到几十万条SQLite 仍然够用但查询时记得给crawl_date和region加索引。最后说一个我踩过的坑早期我为了图快把请求间隔设成 0.5 秒结果前 3 页正常第 4 页开始全部返回验证页缓存里存了一堆无效 HTML解析出来全是空值排查了半天才发现是频率问题。后来改成随机 2 到 5 秒并且每次请求后检查页面标题是否包含“链家”不包含就丢弃并延长等待。这个习惯一直保留到现在虽然慢一点但数据质量稳定得多。希望帮到你。本文还有配套的精品资源点击获取