
1. 为什么我想抓二手房房价一个看房人的偷懒方案先交代一下背景。去年我一直在看房目标锁定西安的几个热点板块。中介推过来的房源清单永远只有“降价XX万”“业主急售”这种话术真正想看的挂牌价分布、同小区不同楼层的价差、近一年价格走势反而没人能给我一份干净的表格。更别说把“曲江一期”“高新软件新城”“浐灞三角洲”这些板块放一起比一比到底哪里性价比高。与其被中介带着节奏走不如自己拿数据说话。我就用Python写了一套二手房房价信息爬取及可视化分析系统把目标城市的挂牌数据抓下来清洗成结构化表格再做价格分布、区域对比、面积单价关系这些分析。整个过程基本覆盖了Python爬虫和数据分析的常用技术栈对想练手的朋友来说是个完整度很高的实战项目。这套系统做什么一句话讲清楚输入城市和板块关键词自动抓取二手房挂牌房源的总价、单价、面积、朝向、楼层、装修、建筑年份这些字段落库成CSV或Excel再基于pandas做清洗最后用matplotlib生成可视化报表。适合三类人来参考一是像我一样有真实看房需求的人二是正在找爬虫数据分析练手项目的初学者三是想给毕业设计找个完整案例的学生。我用的是最稳妥也最好上手的组合requests负责发起HTTP请求BeautifulSoup解析HTMLpandas做数据处理matplotlib画图。全部是Python生态里最基础的工具不依赖任何重量级框架换一台机器装上Python环境就能跑。整个项目从零到出图大概两千行代码完整跑一遍采集一千条房源数据大约是二十到三十分钟速度完全够用。2. 目标网站的选择逻辑为什么是链家而不是贝壳或安居客爬虫项目第一步不是写代码是选数据源。我在对比链家、贝壳、安居客之后最终把主数据源定在链家的二手房频道这个选择不是拍脑袋背后有几个很实际的原因。2.1 三个平台的反爬强度和数据结构对比先说我试过的结果。贝壳和链家本质上是一套房源体系但贝壳的页面数据大量通过接口异步加载部分关键字段藏在加密的JSON里直接抓HTML拿不到干净数据。安居客的反爬比较激进访问频率稍微高一点就会弹验证码对初学者非常不友好。链家的二手房列表页是服务端渲染房源信息的核心字段直接写在HTML结构里requests拿到页面源码后用BeautifulSoup就能解析出来反爬门槛属于“有点考验但不过分”的水平。链家还有一个好处是数据字段极其规整。每套房源的卡片区域里标题、位置、房屋信息几室几厅几平、总价、单价都是固定的class名称解析规则写一次后面全部复用。对比过安居客那种同一个字段在不同页面用不同class的情况链家的数据结构对爬虫开发者友好太多了。2.2 城市和板块的选择策略链家每个城市一个独立二级域名比如西安是xa.lianjia.com北京是bj.lianjia.com。我在系统里把城市代码抽成了配置文件换城市只改一个参数。板块划分方面链家用的是“城区-板块”二级结构。以西安为例城区下面还细分了几十个板块比如高新区的软件新城、丈八北路曲江新区的曲江一期、曲江二期。采集的时候我建议按板块入口进入比如https://xa.lianjia.com/ershoufang/gaoxin/这样拿到的数据天然带区域标签后面做区域对比分析不需要额外做地理编码省了很多事。提示链家每个城市、每个板块的URL规则基本一致格式是https://[城市代码].lianjia.com/ershoufang/[板块拼音]/。如果目标城市不在列表页的显眼位置可以直接在搜索框里搜小区名从结果页反推URL规则。2.3 爬取范围的边界设定二手房信息爬取要有一个边界意识。我给自己定的规则是只抓列表页公开的挂牌摘要信息不点进每套房源详情页抓业主手机号、带看记录这类强隐私数据。原因有两个一是详情页反爬强度高一个量级频繁请求大概率被封IP二是从合规角度讲公开挂牌信息用于个人分析没问题但抓联系方式并批量使用就已经越界了。这个边界也直接影响了系统设计——我只需要列表页的十个字段根本不需要详情页。这个决定让爬虫的请求量大幅下降抓一千条数据只需要请求二十多个列表页压力很小被封的风险也低很多。3. 爬虫模块的落地细节从请求伪装到字段解析选定了链家之后接下来就是爬虫主体部分。这里我会把每个环节的代码逻辑和背后的考虑讲清楚尤其是字体反爬这个坑卡了我一整个晚上。3.1 请求头的伪装策略写爬虫第一件事是让服务器觉得你是个正常人。我维护了一个请求头池每次请求随机挑选一组User-Agent同时带上Referer和Cookie。import requests import random UA_POOL [ 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/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ] def get_headers(): return { User-Agent: random.choice(UA_POOL), Referer: https://xa.lianjia.com/, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive }这里有两个细节容易被新手忽略。第一Referer很重要很多网站的防盗链逻辑会校验这个字段不带Referer的请求会被直接拒绝。第二用requests.Session()保持会话让Cookie在多次请求之间复用可以降低被识别为脚本的概率。session requests.Session() def fetch_page(url): try: resp session.get(url, headersget_headers(), timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text except requests.RequestException as e: print(f[ERROR] 请求失败: {url}, 原因: {e}) return None3.2 分页循环的完整逻辑链家的二手房列表页每页三十条房源超过一百页会强制跳回第一页所以单板块最多能抓三千条。我的分页逻辑很简单构建页码URL循环请求遇到空页面就停止。def crawl_district(district_url, max_pages100): all_records [] for page in range(1, max_pages 1): if page 1: url district_url else: url f{district_url}pg{page}/ html fetch_page(url) if not html: break records parse_list_page(html) if not records: print(f[INFO] 第{page}页无数据停止抓取) break all_records.extend(records) time.sleep(random.uniform(1.5, 3.5)) # 请求间隔务必保留 return all_recordstime.sleep(random.uniform(1.5, 3.5))这一行是我认为整个爬虫里最重要的代码之一。很多人的爬虫被封不是因为技术不行是因为请求频率太规律了。随机间隔两三秒让流量看起来像真人浏览比任何UA伪装都有效。3.3 用BeautifulSoup解析列表页字段列表页的每张房源卡片结构如下标题在div.title a位置信息在div.flood房屋基础信息在div.address下的div.houseInfo总价和单价在div.totalPrice和div.unitPrice。from bs4 import BeautifulSoup def parse_list_page(html): soup BeautifulSoup(html, html.parser) items soup.select(li.clear) records [] for item in items: title_tag item.select_one(div.title a) if not title_tag: continue title title_tag.get_text(stripTrue) house_info item.select_one(div.houseInfo) total_price item.select_one(div.totalPrice span) unit_price item.select_one(div.unitPrice span) records.append({ title: title, house_info: house_info.get_text(stripTrue) if house_info else , total_price: total_price.get_text(stripTrue) if total_price else , unit_price: unit_price.get_text(stripTrue) if unit_price else }) return records到这一步能拿到原始字段但还不能直接用。比如house_info是“3室2厅|113.4平米|南 北|精装|低楼层(共33层)|2018年建|板楼|有电梯”这种长字符串要拆成结构化字段这个放到下一节细说。3.4 链家字体反爬的实战处理这个坑值得单独写一段。链家网页上的数字有一部分不是普通文本而是通过自定义字体映射的。你在浏览器里看到的是“113”但requests拿回来的HTML源码里对应位置可能是一个CSS类名比如span classnum./span配一个font-family: lianjia_num的样式实际字形通过font-face引用的字体文件渲染出来。我第一次抓完发现面积字段全是空或者乱码排查了很久才发现是字体反爬。处理思路有三种第一种也是最常用的下载字体文件用fontTools库解析cmap表建立字符映射。这个方法最稳定但代码量稍大。from fontTools.ttLib import TTFont import io def parse_font(font_url): resp requests.get(font_url, headersget_headers()) font TTFont(io.BytesIO(resp.content)) cmap font.getBestCmap() # cmap的key是字符编码value是字形名称通过字形名称反推数字 # 链家的字体通常把数字0-9映射到uniE2B2之类的私有编码 return {glyph_name: str(digit) for digit, glyph_name in [(i, cmap[0xE000 i]) for i in range(10)]}第二种方案更取巧——直接不解析列表页里受字体反爬保护的字段。我测试过链家列表页里总价字段是纯文本节点只有面积等少数数字走了字体而面积字段在很多页面又不走字体。所以我后来调整了解析策略优先从house_info里的纯文本提取面积万一拿不到再按字体映射反推。第三种方案直接把页面截图下来人工看这当然不实现不了自动化但这也说明一个问题——完全不知道对方字体怎么造的时候先退一步看看有没有别的字段来源。注意链家的字体映射并不是每天固定不变的我观察过一段时间基本每周会换一次映射关系。所以如果你保存了昨天的字体解析表今天重新抓数据发现数字对不上不用慌重新下载字体文件重新解析就行。3.5 异常处理和断点续抓的设计爬虫最忌讳的是跑到一半挂了全部重来。我在系统里加了一个简单的断点续抓机制每成功解析一页就把当前页码写入一个本地进度文件下次启动时先读进度文件从上次结束的页码继续。import os import json PROGRESS_FILE crawl_progress.json def save_progress(district, page): data {} if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, r) as f: data json.load(f) data[district] page with open(PROGRESS_FILE, w) as f: json.dump(data, f) def load_progress(district): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, r) as f: data json.load(f) return data.get(district, 1) return 1另外一个兜底策略如果连续三次请求失败就强制休眠六十秒再重试优先级高于随机休眠。这样即使某个时间点IP被临时限流等一会儿之后还能自动恢复不用人工盯着重启。4. 数据清洗把爬下来的“长字符串”变成能分析的表格爬虫只是第一步真正费时间的是清洗。链家列表页返回的原始数据每个字段都带着展示层的包装直接拿去做分析根本不行。这一节讲我怎么把脏数据变成干净的结构化表格。4.1 挂牌总价和单价的清洗链家的总价字段长这样“230万”单价是“21302元/平”。pandas处理这个很容易但要注意单位换算和类型转换。def clean_price(price_str): 把230万转成230.0 if not price_str: return None return float(price_str.replace(万, )) def clean_unit_price(price_str): 把21302元/平转成21302 if not price_str: return None return float(price_str.replace(元/平, ).replace(,, ))单价字段里有时候会带千分位逗号比如“21,302元/平”不先把逗号去掉就直接float()会报错。这个坑我踩过现在习惯了所有数字清洗第一件事就是先剔除分隔符。4.2 房屋信息字段的结构化拆解house_info这个字段是最麻烦的它的分隔符是竖线|但不同房源的分段数量不完全一样。典型值是“3室2厅|113.4平米|南 北|精装|低楼层(共33层)|2018年建|板楼|有电梯”。我做的拆解逻辑是先按|切分然后通过关键词匹配来识别每一段是什么含义。比如包含“室”和“厅”的是户型包含“平米”的是面积包含“楼层”和“共”的是楼层信息包含“年建”的是建筑年份。import re def parse_house_info(house_info): if not house_info: return {} parts house_info.split(|) result { bedrooms: None, living_rooms: None, area: None, orientation: None, decoration: None, floor_level: None, total_floors: None, build_year: None, building_type: None, has_elevator: None } for part in parts: if 室 in part and 厅 in part: match re.search(r(\d)室(\d)厅, part) if match: result[bedrooms] int(match.group(1)) result[living_rooms] int(match.group(2)) elif 平米 in part: match re.search(r([\d.])平米, part) if match: result[area] float(match.group(1)) elif 南 in part or 北 in part or 东 in part or 西 in part: result[orientation] part.strip() elif 精装 in part or 简装 in part or 毛坯 in part or 豪华 in part: result[decoration] part.strip() elif 楼层 in part: floor_match re.search(r(.?楼层)\(共(\d)层\), part) if floor_match: result[floor_level] floor_match.group(1) result[total_floors] int(floor_match.group(2)) elif 年建 in part: match re.search(r(\d{4})年建, part) if match: result[build_year] int(match.group(1)) elif 板楼 in part or 塔楼 in part: result[building_type] part.strip() elif 电梯 in part: result[has_elevator] 有电梯 if 有 in part else 无电梯 return result4.3 楼层类型的二次处理链家把楼层分成“低楼层”“中楼层”“高楼层”这属于相对楼层。做分析的时候我更喜欢相对楼层和总楼层的比值这样能看出真实的高度位置。比如总楼层33层的楼里低楼层可能是1到10层但我拿不到具体层数只能退而求其次用相对楼层做分组。如果你确实需要绝对楼层可以点进详情页抓楼层数字字段但那样请求量会暴涨。我的建议是做区域均价这种宏观分析用相对楼层就够了没必要为了这个把自己暴露在封号风险里。4.4 去重和缺失值处理同一个房源可能在列表页出现两次比如业主在不同中介公司挂了同一套房要做去重。我用的去重键是“小区名户型面积总价”这四个字段组合起来基本不会误杀。df df.drop_duplicates(subset[community, bedrooms, living_rooms, area, total_price])缺失值处理方面我的原则是“能补则补不能补就删”。比如朝向缺失的房源占比不到百分之一直接删掉这些行对分析结果影响微乎其微。但如果一个板块有超过百分之二十的房源缺建筑年份那就要怀疑是不是解析规则出了问题而不是数据本身缺失。5. 可视化分析从表格到一眼能看懂的图数据清洗完之后pandas里已经有了一份结构化的房源表接下来就是可视化。matplotlib是Python里最基础也是最适合这个项目的绘图库虽然好看程度不如ECharts但胜在不用开浏览器脚本跑完直接出图。5.1 四宫格总览图的设计思路我习惯先画一张四宫格总览图把最关键的信息一次性呈现出来。四张子图分别是总价分布直方图、区域均价条形图、面积与总价的散点图、建筑年份分布图。import matplotlib.pyplot as plt import pandas as pd fig, axes plt.subplots(2, 2, figsize(16, 12)) # 1. 总价分布直方图 axes[0, 0].hist(df[total_price], bins30, color#4C72B0, alpha0.8) axes[0, 0].set_title(二手房总价分布, fontsize14) axes[0, 0].set_xlabel(总价万元) axes[0, 0].set_ylabel(房源数量) axes[0, 0].axvline(df[total_price].median(), colorred, linestyle--, labelf中位数 {df[total_price].median():.0f}万) axes[0, 0].legend() # 2. 区域均价条形图 district_avg df.groupby(district)[unit_price].median().sort_values(ascendingFalse) axes[0, 1].barh(district_avg.index, district_avg.values, color#55A868) axes[0, 1].set_title(各板块单价中位数, fontsize14) axes[0, 1].set_xlabel(单价元/平) axes[0, 1].invert_yaxis() # 3. 面积与总价散点图 axes[1, 0].scatter(df[area], df[total_price], alpha0.5, s10, color#C44E52) axes[1, 0].set_title(面积与总价关系, fontsize14) axes[1, 0].set_xlabel(面积平米) axes[1, 0].set_ylabel(总价万元) # 4. 建筑年份分布 axes[1, 1].hist(df[build_year].dropna(), bins20, color#8172B2, alpha0.8) axes[1, 1].set_title(建筑年份分布, fontsize14) axes[1, 1].set_xlabel(建筑年份) axes[1, 1].set_ylabel(房源数量) plt.tight_layout() plt.savefig(overview.png, dpi150, bbox_inchestight) plt.show()5.2 为什么用中位数而不是平均值区域对比这里我刻意用中位数而不是平均值这是个很多人忽略的细节。房价数据是典型的右偏分布少数几套千万级豪宅会把平均值拉得很高导致区域被高估。中位数抗极端值干扰更能代表这个区域的“普通房源”水准。我对比过同一个板块的均价和中位数能差到每平两三千块这个差距会直接影响你对一个板块购买力的判断。5.3 先算好数据再画图可视化最容易犯的错误是直接在DataFrame上反复算聚合。实际上matplotlib只负责画图统计计算应该提前完成。我的习惯是先算好一个绘图专用的DataFrame再丢给matplotlib画。plot_df df.groupby(district).agg( avg_total_price(total_price, mean), median_unit_price(unit_price, median), listing_count(title, count) ).reset_index()做预算分析的时候我还会再加一张“预算范围内房源占比图”。比如设定三百万以下为目标范围计算每个板块有多少房源落在这个区间这样能直观看到哪个板块“上车容易”。5.4 中文显示问题的处理matplotlib默认字体不支持中文直接画图会出现方框。解决办法是在画图前设置中文字体macOS和Windows路径不同。import matplotlib matplotlib.rcParams[font.sans-serif] [Arial Unicode MS, SimHei, Microsoft YaHei] matplotlib.rcParams[axes.unicode_minus] Falseaxes.unicode_minus这个配置容易漏不设置的话坐标轴的负号会显示成方块。反正中文字体和负号这两个配置我是固定写在一起的。6. 分析结果怎么读实操中我发现的数据规律与局限性系统跑通之后我拿西安几个板块的数据做了一轮实际分析这里分享几个有意思的发现顺便也得聊聊这套方法的局限性。6.1 三个真实的数据规律第一个规律面积和单价的负相关关系比想象中明显。我抓到的数据里九十平米以下的房源单价普遍高于一百三十平米以上的大户型折算下来小户型每平米能贵出百分之十五左右。这个现象在成熟板块尤其突出因为小户型总价门槛低接盘的人多卖家定价空间就大。第二个规律中楼层房源占比高且单价坚挺。链家数据里中间楼层的房源数量最多这跟实际供需结构吻合——大多数购房者偏好中楼层卖得动中介也愿意重点推。低楼层和高楼层的挂牌价差距大概在百分之五到百分之八之间。第三个规律建筑年份对房价的影响不是线性的。2010年以前的老房子和2015年以后的新房子差价明显但2015到2020年之间的房源价格差异反而没那么大。这说明市场对“房子新旧”的敏感度集中在房龄超过十年的节点上。6.2 这套系统的四个已知局限第一挂牌价不等于成交价。链家承若的挂牌价是业主的心理预期实际成交往往有百分之三到五的议价空间。我后来用链家APP的成交记录频道做对比发现成交价平均比挂牌价低百分之四点二左右。如果做预算规划要在挂牌价基础上预留议价空间。第二数据结构天然有偏差。链家主要覆盖城市建成区远郊和新区房源数量少很多板块甚至没有收录。这意味着你分析的“西安市二手房”实际上是“链家覆盖范围内的西安市二手房”不是全量数据。第三爬取时间窗口影响分析结论。我是某个月集中抓的但房价是动态变化的急售房源可能在一周内下架新房源也持续上架。如果你要做趋势分析需要定期重复抓取比如每月一次再纵向对比。第四单城市的结论不能外推。不同城市的房地产市场逻辑差异极大我在西安看到的小户型溢价规律放到一线城市未必成立甚至同一城市的不同板块可能相反。所以做跨城市对比的时候一定要复用同一套爬取和清洗逻辑而不是拿着一个城市的分析结论去做另一个城市的判断。6.3 后续可以扩展的方向这套系统的下一步我打算做几个扩展。一是加入时间维度用定时任务每月抓一次累积六个月后画价格趋势线。二是引入地图可视化用folium把房源标注在地图上按价格区间着色能看到板块之间的价格断层。三是增加筛选交互做一个简单的Streamlit页面让用户选择板块、预算和面积范围动态生成对比图表。回到最开始那个看房的需求这套系统最大的价值不是帮我找到了哪套房而是让我在看房的时候心里有了一杆秤。中介报出一个价格我能立刻判断这个价格在这个板块里处于什么分位周围同类房源大概什么价位值不值得继续谈。这种底气是从表格和图里长出来的。