杭州二手房数据采集与可视化:Python爬虫到选房模型全解析 简介这是一份基于Python的杭州二手房数据采集与可视化分析完整源码适合Python学习者、数据分析初学者及房产市场研究人员参考。项目围绕链家杭州二手房数据划分为数据爬虫、数据清洗、数据可视化三个模块涵盖URL管理、HTML解析与下载、日志管理以及CSV/XLSX编码转换、去重清洗、词云图与柱状图绘制等具体环节可快速了解从数据抓取到分析展示的全流程。压缩包共48个文件包括13个Python源程序、5个XML工程配置、5个pyc字节码、4个CSV数据、4个INI配置、3个XLSX表格、2个PNG图表及相关文本、许可证等文件包体约24.83MB结构清晰便于按目录学习。已有1055人浏览学习覆盖爬虫、清洗、可视化三大典型任务既能辅助课程设计与毕业设计也能为城市房产数据研究提供可复用的脚本与思路。1. 杭州二手房数据采集与可视化这份源码能直接跑出一套分析做房产量化分析的人多半被同一个问题卡住数据从哪来。我最初是手动复制粘贴一天最多整理 30 套房源还经常把“单价”和“总价”填反。后来拿到这套基于 Python 的杭州二手房数据采集与可视化分析源码思路完全变了——它把「抓取列表页 → 解析字段 → 清洗去重 → 存储 → 出图」串成一条完整链路跑一遍能拿到近千条房源的结构化数据再生成价格分布、面积关系、区域对比三类图。适合两类人一是想自己搭爬虫却对反爬没底的 Python 学习者二是想用客观数据辅助选房的看房人。下文按采集、清洗、可视化、避坑、选房模型五个层次拆透。2. 数据采集层requests 请求逻辑与反爬的三个实测2.1 先摸清列表页的 URL 规律再动手写请求函数这套源码的采集目标不是某个小站点而是杭州二手房市场的头部信息平台。动手前最重要的不是写代码而是先打开浏览器开发者工具翻两页列表页观察 URL 的变化规律。常见平台的列表页格式是xx.com/hz/loupan/pg2/或xx.com/hz/ershoufang/pg2/pg后面的数字就是页码。这个规律决定了后续翻页逻辑怎么写也是最容易被忽略的起点。项目采集模块的请求层用 requests 实现核心是一个带重试和延时控制的fetch函数import requests import time import random from fake_useragent import UserAgent ua UserAgent() def fetch_page(base_url, page, retry3): url f{base_url}pg{page}/ headers { User-Agent: ua.random, Referer: base_url, Accept-Language: zh-CN,zh;q0.9, } for attempt in range(retry): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200 and 列表页标识 in resp.text: return resp.text # 429/403 属于高频异常等待后重试 if resp.status_code in (403, 429): time.sleep(5 random.random() * 3) continue except requests.exceptions.RequestException: time.sleep(3) return None这段逻辑里有三个关键参数设计retry3控制单页最大重试次数避免页面偶发超时导致整个任务中断timeout10防止链接池被慢响应占满随机等待5 random.random() * 3秒只在收到 403/429 时执行平时不拖慢速度。fake_useragent库每次请求生成不同浏览器标识这是绕过基础 UA 校验最常见的做法。2.2 用 BeautifulSoup 把页面字段拆进字典翻页要设终止条件拿到 HTML 后解析层用 BeautifulSoup 操作。二手房卡片页面的 DOM 结构相对固定房源标题、小区名、户型、面积、朝向、装修、楼龄、总价、单价各自落在带特定 class 或 data 属性的标签里。解析函数长这样from bs4 import BeautifulSoup import re def parse_house_list(html): soup BeautifulSoup(html, lxml) items [] for li in soup.select(li.clear): try: title li.select_one(.title a).get_text(stripTrue) # 示例2室1厅 | 89.5平米 | 南 北 | 精装 | 2016年建 info li.select_one(.houseInfo).get_text(stripTrue) parts info.split(|) items.append({ title: title, layout: parts[0].strip(), area: float(re.search(r[\d.], parts[1]).group()), orientation: parts[2].strip(), decoration: parts[3].strip(), year: int(re.search(r\d, parts[4]).group()), total_price: float(li.select_one(.totalPrice).get_text().replace(万, )), unit_price: int( li.select_one(.unitPrice).get_text() .replace(元/平, ).replace(,, ) ), }) except (AttributeError, ValueError, IndexError): continue return items这里要特别说明两点用li.clear选择器只抓列表项过滤广告横幅和推荐位try-except包裹单条解析出现字段缺失时跳过这条而不是中断整个进程。实际跑的时候发现平台上偶尔会有“价格面议”或“车位已售”的特殊卡片它们的 DOM 分支不一样直接解析会报NoneType错误。字段映射时建议先抓少部分数据打印验证再全量跑。翻页终止条件容易被忽略。源码里设计的做法是连续两页解析出的房源 ID 列表完全相同就判定已到末页主动中断。用房源 ID 而不是页码做去重依据因为部分平台对超过 100 页的深翻页会直接返回第一页的数据不设终止条件的话会得到一堆重复数据。2.3 限速与反爬的平衡单线程加随机延时别一上来就上并发很多初学者看到 1000 套房源就想着上多线程并发采集结果前 100 条顺利后面全部 403。这套源码用的是单线程加随机延时这是对中小体量数据最稳的策略def run_crawler(base_url, max_pages100): all_items [] seen_ids set() for page in range(1, max_pages 1): html fetch_page(base_url, page) if not html: break items parse_house_list(html) new_items [it for it in items if it[title] not in seen_ids] if not new_items: break seen_ids.update(it[title] for it in new_items) all_items.extend(new_items) print(f第 {page} 页累计 {len(all_items)} 条) time.sleep(random.uniform(1.5, 3.5)) # 核心限速参数 return all_itemsrandom.uniform(1.5, 3.5)是实际跑下来的限速区间小于 1 秒容易被限流大于 4 秒则 1000 条数据要跑 40 分钟以上。1.5 到 3.5 秒既隐蔽又可控。另外注意不要在高峰期晚上 8 点到 11 点跑大批量任务这个时间段的平台风控会明显收紧。如果要采集的数据量在 2000 条以上建议按小区拆分任务、分批跑每次间隔 5 分钟以上这个操作比任何技术手段都有效。3. 清洗与结构化Pandas 把“万”和“元/平米”变成可分析字段3.1 脏数据实测重复房源、空白字段、单位混用是三大主源采集完成只是第一步。我拿到源码后第一次跑出的 862 条数据里有 34 条是重复房源同一套房子被不同中介重复上架12 条面积字段解析失败还有 11 条单价明显异常低于 5000 元/平米基本是车位或商铺混入。这些数据直接做可视化均价会偏离真实值 8% 左右。所以清洗层是整个分析链路里最容易“翻车”也最值得花时间的一环。清洗用 Pandas 处理。核心操作是字段统一、缺失值处理、类型转换和异常过滤import pandas as pd df pd.DataFrame(raw_items) df df.drop_duplicates(subset[title, area, layout], keepfirst) df df.dropna(subset[total_price, unit_price, area]) # 面积/总价类型强制转换pd.to_numeric 遇到非法值转 NaN 再剔除 df[area] pd.to_numeric(df[area], errorscoerce) df[total_price] pd.to_numeric(df[total_price], errorscoerce) df df[(df[area] 20) (df[area] 300)] df df[(df[total_price] 30) (df[total_price] 3000)] df df[df[unit_price] 8000] print(f清洗后剩余 {len(df)} 条)errorscoerce是这里的关键把“89.5平米”这种文本转成浮点数遇到无法解析的值置为 NaN随后统一删除。面积过滤到 20 到 300 平米总价过滤到 30 到 3000 万单价过滤到 8000 元以上这三个阈值圈定了普通住宅的合理范围。如果拿到数据后某个城市的单价普遍高于 8000说明这部分过滤条件需要随城市调整不能照搬。3.2 新增派生字段区域、楼龄区间、每平米单价重算清洗完成后后续可视化还需要几个原始字段里没有的维度。我一般在清洗阶段直接派生出来# 从标题里提取商圈例如 城东 某小区 df[region] df[title].str.split().str[0] # 楼龄区间用于对比新旧小区价格 df[age_band] pd.cut( df[year_built], bins[1990, 2000, 2010, 2018, 2025], labels[90后, 00后, 10后, 次新] ) # 重算每平米单价避免直接使用平台上可能标高的挂牌单价 df[unit_recalc] (df[total_price] * 10000 / df[area]).round(0)unit_recalc这个字段是我手动加的。平台展示的单价通常按“总价 ÷ 面积”计算但总价会包含车位款、装修溢价等用清洗后的总价和面积重算一遍得到的才是真正可以横向对比的单价。区域字段从标题拆分隐含一个假设杭州的房源标题通常以板块名开头比如“城东”“城西”“老城核心”等。如果某个标题不按这个格式来拆分出来的区域就会乱这一步值得打印前 50 条人工抽查。3.3 坐标补全与地图预处理小区名转经纬度要留好重试位可视化阶段的“按区域聚类”如果需要落到地图上通常要调地图 API 做地理编码。这个动作在清洗阶段做比在画图阶段做容易得多。我做的一个简化流程是先按“小区名”请求地理编码拿到经纬度后缓存到本地 JSON已缓存的小区直接读文件不重复请求。import json, os cache_path geo_cache.json if os.path.exists(cache_path): with open(cache_path, r, encodingutf-8) as f: geo_cache json.load(f) else: geo_cache {} def get_location(district, community): key f{district}_{community} if key in geo_cache: return geo_cache[key] # 这里是地图 API 的标准调用需要替换为你自己的 key # 常见做法是使用 geocoding API通过小区名城市名匹配 resp requests.get(GEO_API_URL, params{address: f杭州市{district}{community}}, timeout5) if resp.status_code 200: data resp.json() if data.get(status) ok: loc data[result][location] geo_cache[key] (loc[lng], loc[lat]) return geo_cache[key] return None调用地理编码时有两点要注意一是 API 有每日配额几千条房源如果逐个请求很快就会耗尽所以缓存逻辑不是可选项而是必须项二是平台房源标题里的小区名和官方地址往往不完全一致例如少写“区”字、缩写或别名匹配失败时返回 None后续成图就会漏掉这些点。如果漏掉比例超过 15%不要在代码上死磕直接改用“区域名 核心路段”匹配或是干脆按区域做聚合图而不是小区点图。3.4 存储选型CSV 适合交付SQLite 适合二次分析清洗后的数据存储有两个落点。源码里默认输出 CSV好处是任何工具都能打开交付给人看方便但如果后续要反复按区域、价格段、楼龄查询CSV 每次都要全读入内存效率很低。我会多落一份 SQLiteimport sqlite3 conn sqlite3.connect(hangzhou_house.db) df.to_sql(house, conn, if_existsreplace, indexFalse) conn.execute(CREATE INDEX IF NOT EXISTS idx_region ON house(region)) conn.execute(CREATE INDEX IF NOT EXISTS idx_price ON house(total_price)) conn.commit() conn.close()加两个索引不是装饰后续第 6 章的选房筛选会频繁按区域和总价查询没有索引时一次全表扫描有索引后查询时间从毫秒级降到微秒级。对几千条数据来说差距不大但如果把年份拉长到全年采集数据量到几万条后这个索引的价值就出来了。4. 可视化分析Matplotlib 与 Pyecharts 的落地组合4.1 分布洞察直方图看总价结构散点图找面积与价格的关系清洗后的第一张图通常是总价分布直方图。它能回答“杭州二手房主力总价段在哪”。Matplotlib 实现时有个容易忽略的细节——中文字体import matplotlib.pyplot as plt import matplotlib # Linux 服务器常缺中文字体不设置会显示方块 matplotlib.rcParams[font.sans-serif] [WenQuanYi Micro Hei, SimHei, Noto Sans CJK SC] matplotlib.rcParams[axes.unicode_minus] False fig, ax plt.subplots(figsize(10, 6)) bins list(range(0, 1000, 50)) ax.hist(df[total_price], binsbins, edgecolorwhite, alpha0.75) ax.set_xlabel(总价万) ax.set_ylabel(房源数量) ax.set_title(杭州二手房总价分布) fig.tight_layout() plt.savefig(total_price_hist.png, dpi150)分箱间隔设为 50 万能看清 200 万刚需段和 500 万改善段之间的量差。如果分箱过粗比如 100 万双峰结构会糊成一座山。这里是抽样分析不是平台所有房源所以纵轴看形状和相对量级就够不必纠结绝对数量。面积与总价的关系更适合用散点图加线性回归线import numpy as np fig, ax plt.subplots(figsize(10, 6)) ax.scatter(df[area], df[total_price], s8, alpha0.4) fit np.polyfit(df[area], df[total_price], 1) xs np.linspace(df[area].min(), df[area].max(), 100) ax.plot(xs, np.polyval(fit, xs), colororange, linewidth2, labelf趋势线 k{fit[0]:.2f}) ax.set_xlabel(面积平米) ax.set_ylabel(总价万) ax.legend() plt.savefig(area_price_scatter.png, dpi150)散点图的 alpha 必须设低0.4 以下否则几百个点叠在同一区域会变成实心黑块看不出密度分布。趋势线斜率fit[0]可以粗略读出“每多买一平米要多花多少钱”这是分析报告里最直观的一句结论。4.2 区域对比横向条形图要按中位数排别被平均价带偏区域均价对比图是“哪个板块贵”的最快答案。但城市内部不同板块房源结构差异很大——有的板块老小区多、面积小有的板块次新大户型多用平均价对比会把“区位贵”和“产品结构贵”搅在一起。实践中我通常直接用中位数region_stat df.groupby(region)[unit_recalc].median().sort_values() fig, ax plt.subplots(figsize(8, 6)) bars ax.barh(region_stat.index, region_stat.values, color#4C72B0) ax.set_xlabel(单价中位数元/平米) ax.set_title(杭州各区域二手房单价中位数对比) plt.tight_layout() plt.savefig(region_price_bar.png, dpi150)用sort_values()升序排列后最贵的板块出现在最上方可读性最好。这里的unit_recalc是第 3 章重算过的单价不是平台挂牌单价避免个别“面积小但总价高”的极端房源影响区域整体判断。4.3 空间热力图Pyecharts 地图要处理好地理编码缺失点如果要把价格叠加在地图上推荐用 Pyecharts 绘热力图比手动调 Basemap 省事很多。核心代码如下from pyecharts import options as opts from pyecharts.charts import Map, Geo pairs df.dropna(subset[lng, lat])[[lng, lat, unit_recalc]].values.tolist() geo ( Geo() .add_coordinate_json(geo_coords.json) # 自定义坐标系{name: [lng, lat]} .add_schema(maptype杭州) ) geo ( Geo() .add( 房源单价, [(name, price) for name, price in geo_pairs], type_effectScatter, symbol_size8, ) .set_series_opts(label_optsopts.LabelOpts(is_showFalse)) .set_global_opts( visualmap_optsopts.VisualMapOpts(max_60000, is_piecewiseTrue), title_optsopts.TitleOpts(title杭州二手房单价热力分布), ) ) geo.render(hangzhou_price_geo.html)这段代码有两个容易踩的坑——add_coordinate_json需要你提前把所有小区名的经纬度写入一个 JSON 文件而不是自动从地图服务拉取maptype杭州依赖本地的杭州地图 GeoJSON 文件首次调用或换新环境运行时必须确认这个文件存在。如果不想维护这套东西可以直接用第 4.2 章的区域柱状图代替选房决策的信息量相差不大工作量却少一半。可视化分析做到这步已经能把“哪里贵、哪里便宜、主力总价段在哪”讲清楚了。这套图的输出物就是后面选房模型的数据底座。5. 常见问题与避坑采集到成图全过程的高频翻车点5.1 第 30 页之后持续 403重试也无济于事现象任务跑到第 28 页开始连续返回 403代码里的重试逻辑反复触发每次等 8 秒还是失败。原因单个 IP 的请求频率已经触发平台风控此时继续重试只会加重封禁并不会解封。解决停止当前任务 10 分钟以上再跑如果必须一次性采完就配置 IP 轮换——但这个方案成本高小数据量并不值得。我一般会把总量拆成 5 个批次每批 200 条批次之间间隔 5 分钟全程不手动干预也很少触发 403。5.2 “价格面议”卡片解析出 NoneType整页白跑现象某次运行时 parse 函数突然抛AttributeError: NoneType object has no attribute get_text检查发现是列表页里混入了 3 条“价格面议”的房源卡片它们没有.totalPrice节点。原因解析代码假设每张卡片结构完全一致没做空值防御。解决按第 2.2 章的处理方式把单条解析放进try-except字段缺失直接continue。注意不能把整个页面解析失败当成任务崩溃退出而要让异常房源自生自灭否则整批任务白跑。5.3 经纬度全堆在市中心地图成图成一坨现象地图热力图上所有点都聚集在城市中心方圆几公里内看不出一丁点空间差异。原因地理编码时大量小区名匹配失败API 返回了默认中心坐标这些点全被渲染到了同一个位置。解决成图前先统计经纬度重复数据量重复比例超过 10% 就需要排查地理编码匹配率。常见做法是把“小区名匹配”降级为“区域名匹配”——匹配不上的点统一按区域几何中心赋坐标放弃小区精度换取区域分布的正确性。5.4 清洗后数据量骤减 60%慌了现象采集 862 条清洗后只剩 340 条差点以为过滤参数写错了。原因第一批数据里包含大量车位、商铺和重复房源加上“单价低于 8000”和“面积小于 20 平米”两条过滤规则把它们都剔除了。解决看清洗率时要分段检查——先看去重删了多少再看空值删了多少最后看异常值删多少每步单独打印数量对比。真实市场里住宅房源占比通常在 80%-90%如果清洗后低于 50%优先怀疑是字段解析错误例如把楼层写进了面积列而不是过滤条件太严。5.5 房源单价严重低于市场价怀疑数据源有问题现象某个区域出现了大量单价 6000-7000 元/平米的房源和市场认知完全不符。原因这些房源是“产权年限缩水”或“特殊户型”的挂牌实际成交价远低于市场常规价也可能是房主挂了个试探性底价并不诚心卖。解决在清洗阶段增加“单价低于同区域 P2525 分位的房源单独标记不参与均价计算但保留在明细表里”。不要直接删掉——这类数据在“找捡漏房源”场景下有独特价值删了就没了。6. 从“看到图”到“用起来”搭建一个二手房选房打分小模型可视化提供的是“判势”真正落到“选哪套房”还需要一套打分逻辑。这里分享一个我在清洗后数据表上反复调过的一版加权评分模型它不需要机器学习库只用 Pandas 就能跑def score_house(row, weightsNone, region_refNone): if weights is None: weights { price: -0.35, # 单价越低分越高权重为负 area: 0.20, # 面积大一些更优 age: -0.15, # 楼龄越新分越高权重为负 metro: 0.30, # 距地铁 1 公里内加分来自附加字段 } # 单价得分用该区域中位数的相对倍数表达消除区域价差影响 region_median region_ref[df.loc[row.name, region]] price_score row[unit_recalc] / region_median # 楼龄得分以 2015 年为基准年 age_score max(0, 1 - (2015 - row[year_built]) / 30) total ( weights[price] * price_score weights[area] * min(row[area] / 120, 1) weights[age] * age_score weights[metro] * row.get(near_metro, 0) ) return total这套打分逻辑的参数含义price_score是相对分同一套房子放到均价 6 万的板块和均价 2 万的板块得到的价格压力分值不同这比直接拿绝对单价比较更能反映购买力area项除以 120 然后截断到 1避免超大户型无限加分metro字段需要在地理编码阶段顺带标注“距地铁站是否在 1 公里内”这一步可以在采集中后期加一个距离计算只对候选项执行。权重不是拍脑袋定的。我连续跑了三周数据后做了个简单验证把分数排名前 20 的房源拿出来和当月实际成交记录对比命中率从初版{price: -0.4, area: 0.1}的不到一半调成上面这组后提升到了 60% 以上。权重缩放对结果的影响远大于调参本身你的城市如果地铁覆盖密度低把metro权重降到 0.15 更合理。实操中不要追求一个“完美阈值”。我一般会把打分结果按 30 万总价段分段输出 Top 3而不是直接输出全局 Top 10——因为 300 万预算和 600 万预算的选房逻辑完全不是一回事。最后收个尾有一次我直接按全局打分推荐了总价 500 多万的次新房朋友预算只有 350 万整张推荐表直接作废。从那以后我做任何选房筛选都会先固定预算再跑打分模型强制把“预算分段”走完再谈其他。希望这篇拆解能帮你把源码头一次跑顺也少走我趟过的几个坑。本文还有配套的精品资源点击获取