ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

链家二手房Python爬虫实战:从数据采集到深圳楼市分析

链家二手房Python爬虫实战:从数据采集到深圳楼市分析

简介:面向数据分析与爬虫初学者的Python实战资源,以深圳链家二手房为对象,完整实现从二级域名抓取、二手房链接组装、筛选条件获取到数据入库及分析的闭环流程,设计贴合真实项目场景。代码已测试通过,支持将爬取结果保存至MySQL,适合计算机相关专业学生用于毕设、课设或项目演示。资源包共27个文件,以18个Python脚本为核心,覆盖Scrapy爬虫、数据处理与分析模块;同时包含4个JSON数据样例、Scrapy配置、README说明、LICENSE许可等文件,整体约1.54MB,目录结构简明便于定位。目前已有90人学习下载。通过此资源可掌握爬虫框架搭建、数据持久化与基础分析思路,并可直接修改扩展实现其他城市或维度的房源分析,具有较强的实战参考价值,尤其适合作为毕业设计或课程作业的起步模板。

1. 为什么拿链家二手数据做深圳楼市分析是件划算的事

深圳楼市的数据分析项目在网上并不少见,但真正落到“二手楼盘”粒度、又有完整源代码能直接改着用的,大部分都卡在数据获取这一关。链家深圳站(sz.lianjia.com)的二手房频道恰好提供了结构化的楼盘挂牌信息:小区名、户型、面积、朝向、楼层、总价、单价、区域板块,这些字段基本覆盖了入门级房产数据分析需要的所有维度,而且页面结构相对规整,用 Python 的 requests 加 BeautifulSoup 就能稳定抓取,不需要逆向 App 接口或处理加密参数。

这个项目能解决的实际问题是:把链家页面上一万多套挂牌房源转成一张干净的 CSV 表,再从区域均价、户型分布、单价区间、楼龄结构几个角度回答“深圳哪个板块性价比高”“什么户型供应最紧张”这类问题。适合三类人:准备做数据分析和可视化作品的求职者、想用真实数据练手 pandas 的 Python 初学者、以及买房前想对目标片区做量化对比的刚需用户。整个流程下来,你拿到的不只是几张图表,而是一条从抓取到出报告的完整链路,后面换城市换数据源都能复用。

2. 数据采集层:用 requests 抓链家二手房列表页与详情页

2.1 先摸清链家深圳二手房页面的 URL 规律

链家二手房列表页的 URL 结构非常有规律,深圳站的入口是 sz.lianjia.com/ershoufang,翻页规则是在路径末尾追加 /pg{n}/。第一页没有页码后缀,第二页是 /pg2/,第三页是 /pg3/,以此类推。每个房源卡片上有两个最关键的信息:标题里的小区名和 href 里的房源详情页链接。列表页只展示总价、单价、面积、朝向、楼层这些基础字段,但真正的核心数据——小区建造年代、板楼塔楼、产权年限、附近地铁——藏在详情页里。

抓取策略上,我一般会做两层:先抓列表页拿到全部房源链接和基础字段,再对链接做去重后批量请求详情页,补齐剩余字段。两层分离的好处是列表页挂了或结构变了,详情页的数据还能保住一半;而且详情页请求量更大,单独一个模块方便控制并发和错误重试,不至于一整轮跑下来因为一个页面超时全盘翻车。

import requests from bs4 import BeautifulSoup import pandas as pd import time HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } def get_list_page(page): if page == 1: url = "https://sz.lianjia.com/ershoufang/" else: url = f"https://sz.lianjia.com/ershoufang/pg{page}/" resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") return soup

这里有个容易被新手忽略的点:resp.encoding = "utf-8"必须写在解析之前。链家页面虽然有 meta 声明 utf-8,但 requests 有时会根据响应头猜编码导致乱码。直接强制指定 utf-8 能避免后续所有字段解析出来的中文变成乱码,这是血泪经验换来的,别省这一行。

列表页返回的 BeautifulSoup 对象里,房源卡片都在div[class="sellListContent"]下的li[class="clear"]中。每张卡片上有totalPrice(总价,单位万元)、unitPrice(单价,单位元/平米)、positionInfo(楼层+朝向+年代)、houseInfo(小区名+户型+面积)。解析时用 select 方法一次性匹配,比 find 逐个查更稳。

def parse_list_page(soup): items = [] cards = soup.select('div.sellListContent li.clear') for card in cards: try: title = card.select_one('div.title a').get_text(strip=True) link = card.select_one('div.title a')['href'] house_info = card.select_one('div.address .houseInfo').get_text(strip=True) total_price = card.select_one('div.totalPrice span').get_text(strip=True) unit_price = card.select_one('div.unitPrice span').get_text(strip=True) position_info = card.select_one('div.positionInfo').get_text(strip=True) items.append({ "title": title, "link": link, "house_info": house_info, "total_price": total_price, "unit_price": unit_price, "position_info": position_info, }) except AttributeError: continue return items

positionInfo这个字段的值看起来像一长串,比如 “2室1厅 | 67.54平米 | 东南 | 0层(共8层) | 2008年建”,实际上链家把朝向、楼层、年代、是否有电梯都塞进了这一个字符串。后面清洗阶段要按竖线分割,这里先原样存进 CSV,避免在采集阶段做太激进的拆分导致解析错误。一页卡片大约 30 条,所以每页能拿到的数据量是固定的,翻页到 100 页之后基本就到头了,整个深圳链家在售二手房大概在一万套上下。

2.2 把列表页采集器跑成一整套带断点续跑的命令

上面两个函数只解决了单页抓取和解析,真正要落地还得加一个调度循环和异常处理。链家对高频请求的容忍度不算高,连续请求几十页之后可能被要求输入验证码,所以每一页之间要 sleep 一段时间。请求失败不能直接中断整个程序——第一页请求失败后停止服务可以,但爬到第 87 页失败就退出,等于前面的 2600 条数据全白跑了。

常见做法是把数据边抓边写,每解析完一页就追加到 CSV 文件,而不是攒在内存里最后一次性落盘。这样即使断在某一页,下次从断点页码继续跑也不会丢数据。断点怎么记?我一般用一个叫progress.txt的文件存当前页码,每成功一页就覆写一次,重跑时先读这个文件。

import os def crawl_list(max_page=100, start_page=1): all_items = [] current = start_page while current <= max_page: print(f"crawling page {current}") try: soup = get_list_page(current) items = parse_list_page(soup) if not items: print(f"page {current} empty, stop") break all_items.extend(items) # 每页都覆盖写进度文件,记录当前页码 with open("progress.txt", "w") as f: f.write(str(current)) # 每页完成后立刻追加写入,防止内存丢失 pd.DataFrame(items).to_csv( "shenzhen_lianjia_raw.csv", mode="a", header=not os.path.exists("shenzhen_lianjia_raw.csv"), index=False, encoding="utf-8-sig", ) except Exception as e: print(f"page {current} error: {e}, retry after 10s") time.sleep(10) continue current += 1 time.sleep(2) # 页面间隔,控制请求频率 return all_items

encoding="utf-8-sig"在这里是第二个关键点。带 BOM 的 CSV 用 Excel 打开不会乱码,用 pandas 读取时指定encoding="utf-8-sig"也能正常解析。如果你用默认的utf-8写文件,Excel 双击打开全是乱码,在电商公司打分时这一条就可能被直接扣分。写文件的格式是追加模式,所以第二次跑程序时不需要删掉旧数据,但前提是数据结构没变,如果中间改过字段名,老数据的列对不上,这种情况就要先手动清理旧文件再重新抓。

列表页抓完后,得到的是全深圳在售二手房的线索表,但每条记录还缺小区建造年代、产权年限、梯户比这些详情字段。下一步就是拿link列表去请求详情页,在详情页的div[class="introContent"]里能找到完整的十个左右属性项。

def get_detail_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") info = {} items = soup.select("div.introContent div.base li") for li in items: text = li.get_text(strip=True) if ":" in text: key, value = text.split(":", 1) info[key.strip()] = value.strip() return info

详情页里的属性名称自带中文,比如 “房屋户型:2室1厅1厨1卫”,直接作为字段名存下来有个好处,后面做数据分析时列名可读性强,不需要额外维护一份英文映射表。但要注意这里抓的是div.base里的房源基本信息,另一个div.subContent里还有交易权属、挂牌时间这些属性,二选一即可,不用全抓,字段太多后面清洗会累死。

2.3 详情页抓取的限速与失败重试策略

详情页的请求量是列表页的几十倍,一万条房源就是一万次请求,如果不做并发控制,最快也得四十分钟。链家的反爬核心就是频率控制——短时间大量请求触发验证码后,滑块会拖慢整个采集流程。两种方案比较常见:第一种是线程池加小并发,用ThreadPoolExecutor开 8 个线程,每个线程独立控制请求间隔;第二种是老老实实串行跑,每次 sleep 固定时长。

我倾向于第二种,稳是第一位的。并发抓详情页一旦触发滑块验证,失败的请求会连锁反应,最后还得重跑。串行模式下每个请求间隔 1.2 秒左右,一万个详情页大约三个半小时,睡一觉的功夫就抓完了。实在要提速,把 sleep 从 1.2 秒降到 0.5 秒就够用,别贪快到 0.2 秒以下。之前有同事图省时间把间隔设成 0.1 秒,跑了五百个请求后被强制跳验证码页面,所有解析全部落空,那批数据等于报废了,算上重跑时间反而是亏的。

失败重试也有讲究。requests的timeout必须显式指定,不设 timeout 的请求在网络抖动时会挂起非常久,线程池场景下直接卡死整个任务。每次失败捕获异常后 sleep 10 秒再重试,同一个 URL 连续失败 3 次就跳过并记录下来,最后统一补抓,比无限重试要理智。详情页数据补齐后,CSV 里每一行就是一个完整的房源记录,字段包括小区名、户型、楼层、朝向、总价、单价、面积、建造年代、产权年限等,到这一步,这个 Python 数据分析项目里最脏最累的活就干完了。

3. 数据清洗层:链家原始字段怎么变成可分析的规整表

3.1 把字符串型字段拆成数值列:面积、总价、单价、楼层

从链家抓下来的字段几乎全是字符串,比如 “67.54平米”、“430万”、“ 63643元/平米”,直接拿去做均值计算会报错。清洗的第一步就是把所有数值从字符串里剥出来并转成 float 类型。这里用 pandas 的str.extract配合正则表达式,比replace加astype更可靠,因为链家有些字段偶尔会出现 “暂无数据” 或 “价格待定” 这类脏值,正则匹配不到就直接变成 NaN,不会报异常中断流水线。

import pandas as pd import re df = pd.read_csv("shenzhen_lianjia_raw.csv", encoding="utf-8-sig") # 面积:提取数字部分,保留两位小数 df["area"] = df["house_info"].str.extract(r"([\d.]+)平米").astype(float) # 总价:从 "430万" 提取数字 df["total_price"] = df["total_price"].str.extract(r"(\d+)").astype(float) # 单价:从 "63643元/平米" 提取数字 df["unit_price"] = df["unit_price"].str.extract(r"(\d+)").astype(float) # 楼层信息:position_info 里 "0层(共8层)" 拆出所在楼层和总楼层 df["floor"] = df["position_info"].str.extract(r"(\d+)层\(共(\d+)层\)") df["floor_current"] = df["floor"].str[0].astype(float) df["floor_total"] = df["floor"].str[1].astype(float) df.drop(columns=["floor"], inplace=True)

str.extract有个好处是匹配失败时返回 NaN 而不是报错,后续再统一做缺失值处理。链家字段的格式相对固定,正则写对了提取成功率在 99% 以上,剩下的 1% 是 “暂无数据” 之类的情况,直接保留 NaN,分析时用dropna过滤掉就行。注意面积的提取用的是house_info而不是title,因为 title 里小区名可能带 “花园”“大厦” 等字样,碰上数字的概率不小,从house_info里提取能减少误匹配。

楼层拆成当前楼层和总楼层之后,可以衍生一个新特征:低层、中层、高层。这个特征在分析 “采光对价格的影响” 时很好用,后面可视化可以直接按分组画箱线图。总楼层为 7 层及以下的通常是多层住宅,没有电梯,高楼层虽然视野好但没电梯反而卖不上价;总楼层 11 层以上的属于小高层或高层,有电梯,楼层越高单价越贵。所以拆楼层不只是为了好看,它直接关系到你对深圳楼市的判断逻辑。

3.2 拆分户型字段的三种翻车场景

户型字段的原始值长这样:“2室1厅1厨1卫”。分析时一般只需要室和厅,“厨”和“卫”对房价的区分度不大,可以在拆分后直接丢弃。但实际清洗时这个字段的坑有三个:第一,链家有些房源是复式,户型可能是 “4室2厅1厨2卫” 后面带 “复式” 两个字;第二,别墅房源会写 “5室3厅” 加 “带花园”;第三,有些商住房源户型是 “1室0厅1厨1卫”,表示开放式厨房没有独立客厅。

def split_house_type(house_info): # 提取"X室X厅",匹配不到返回 (None, None) match = re.search(r"(\d+)室(\d+)厅", str(house_info)) if match: return int(match.group(1)), int(match.group(2)) return None, None df["bedrooms"], df["living_rooms"] = zip(*df["house_info"].apply(split_house_type)) df = df.dropna(subset=["bedrooms", "living_rooms"])

这一步的dropna要放在后面所有分析步骤之前。有些分析报告里户型字段缺失率不超过 5%,直接删除这一小部分数据不会对结果产生实质影响,但如果不加这个过滤,后面按卧室数分组时会出现一个 NaN 组,画图时多出一个空白分类,整个图表的美观度和可解释性都会打折扣。还有一种做法是把 “室” 缺省的数据聚成 “未知户型” 单独分析,适合户型缺失率比较高的数据源,链家的数据源质量还行,直接过滤更省事。

拆完户型后可以顺手加两个衍生字段:house_area_per_room(每间卧室平均面积)和house_type(紧凑型/舒适型/豪华型)。前者用 area 除以 bedrooms,能反映出楼盘的定位——同样 90 平的两房和 90 平的三房,居住体验完全不同;后者可以按面积区间划分,比如单套小于 70 平划为紧凑型,70-100 平划为舒适型,大于 100 平划为豪华型。衍生字段的价值在做分析时才会体现,不用在这阶段过度加工,保持数据表干净就好。

3.3 朝向字段的标准化映射与缺失值处理

链家的朝向字段值是中文组合,比如 “东南”“南北”“西南北”,理论上应该有南、北、东、西、东南、西南、东北、西北、东西、南北、东南北这几种组合,但实际抓取下来你会发现有 “东南北”“西南北” 这种三朝向房源,多出现在边户或顶层复式。分析朝向对价格的影响时,最好把多朝向转换成主朝向——同一套房子里朝南开间数量最多的方向就是主朝向。

def normalize_direction(pos_info): direction_match = re.search(r"([东西南北]+)", str(pos_info)) if not direction_match: return None raw = direction_match.group(1) if "南" in raw and "北" in raw: return "南北" if "南" in raw and "东" in raw: return "东南" if "南" in raw and "西" in raw: return "西南" if "南" in raw: return "南" if "东" in raw: return "东" if "西" in raw: return "西" if "北" in raw: return "北" return raw df["direction"] = df["position_info"].apply(normalize_direction)

“南北通透”在深圳二手房市场是个溢价标签,所以把 “南北” 单独拎出来而不是归入 “综合朝向”,后面做分组对比时才能看出这个标签到底值多少钱。三朝向的 “东南北” 和 “西南北” 直接归入 “东南”“西南”,因为在实际带看时买家关注的是主要采光面,三朝向的溢价能力不如户型本身来得实在。

朝向缺失值不能简单删除,有些房源详情页确实没写朝向信息,但数量很少,可以一律填 “不详”。真正麻烦的是position_info字段整体缺失的整行数据,这代表这条房源记录在采集时卡片解析不完整,直接删掉就可以。判断依据是:朝向缺失可以通过其他字段补,但position_info全字段缺失说明这条记录从源头就是不完整的,保留下来只会污染后续的分析结果。

3.4 建造年代与楼龄的边界处理

建造年代在链家详情页里是 “建成年代:2008年” 这样的格式,清洗后得到built_year列。楼龄不是直接用当前年份减建成年代那么简单——深圳的二手房有一个特殊概念叫 “满五唯一”,满五指的是房产证满五年,跟楼龄不是一回事,但买家讨论时会混着讲,所以做分析时一般用楼龄做参考,不做满五判断,因为满五唯一是契税层面的概念,链家详情页不一定标。

from datetime import datetime current_year = datetime.now().year df["building_age"] = current_year - df["built_year"] df["age_group"] = pd.cut( df["building_age"], bins=[0, 5, 10, 20, 100], labels=["5年以内", "5-10年", "10-20年", "20年以上"] )

pd.cut的 bins 边界要仔细核对。bins=[0, 5, 10, 20, 100] 表示区间是 (0,5]、(5,10]、(10,20]、(20,100],左开右闭,所以楼龄正好 5 年的房子会落入 “5年以内”,正好 10 年的落入 “5-10年”。如果你希望楼龄 5 年的归到 “5-10年”,就要把 bins 改成 [0, 4, 9, 19, 100],这个细节很容易翻车,分组后画图发现某一组数量特别少,先检查是不是边界写错了。

深圳有个特殊情况是楼龄超过 30 年的老小区依然在售,这类房子的贷款政策跟新房差距很大,银行对楼龄 30 年以上的房产可能拒贷或要求提高首付,所以分析时把 20 年以上的单独成组就够了,不用拆得更细。链家对房龄过老的房源有时候会标注 “房龄超40年” 而没有具体年份,这种值astype(float)会报错,要用pd.to_numeric(errors="coerce")强制转换,转不了的变成 NaN 再统一填充或删除。

清洗完的 DataFrame 列名建议整理成统一的英文格式:community、link、total_price、unit_price、area、bedrooms、living_rooms、floor_current、floor_total、direction、built_year、building_age、district,存成shenzhen_lianjia_clean.csv。到这一步,你已经把链家原始页面的非结构化文本变成了 15 个字段的结构化表格,接下来做任何分析和可视化都有了一个干净的基础。

4. 分析维度与可视化:深圳二手楼盘的五个关键观察视角

4.1 区域均价排名:用 groupby 找出六区价格梯队

深圳的行政区划在链家数据里是直接带在房源标题或详情页里的,比如 “南山-前海-” 这种前缀,通常需要先从title或district字段里提取区名。如果采集时没特意抓行政区,可以从详情页的城区字段补,但最省事的方式是用正则从房源标题里匹配,链家的标题格式统一为 “小区名 户型 面积 区-板块” 类似的结构。

district_pattern = r"([\u4e00-\u9fa5]{2,3}区)" df["district"] = df["title"].str.extract(district_pattern) area_stats = df.groupby("district").agg( avg_unit_price=("unit_price", "mean"), avg_total_price=("total_price", "mean"), listing_count=("total_price", "count"), avg_area=("area", "mean"), ).sort_values("avg_unit_price", ascending=False) area_stats["avg_unit_price"] = area_stats["avg_unit_price"].astype(int)

groupby加agg一步算完所有聚合指标,然后按单价降序排列,你就能看到深圳六区的价格梯队。南山的均价大概率最高,福田紧随其后,宝安和龙华价格接近,龙岗和坪山相对低。这份表格输出成 Markdown 或 CSV 就是你分析报告的第一张表。做区域对比时注意一点:罗湖的老破小单价不高,但总量能拉开差距,因为老小区多,挂牌量大,均价被拉低,不能简单认为罗湖房价不如龙华,要结合楼龄分布一起看。

深圳房地产的板块逻辑比行政区逻辑更精准,同一个南山区,深圳湾片区的均价能到 15 万+,而同属南山的西丽片区可能只有 7 万。如果链家详情页里有版块字段(一般在district属性里),聚合时用板块粒度比区粒度更能说明问题。这一步可以先按区出总览,再选均价前三的区做板块下钻,报告结构清晰,也方便你后续做地理分布的可视化时不会因为数据点过密而卡顿。

4.2 总价和单价的分布形态:直方图看刚需与改善盘分界线

总价分布和单价分布是两套不同的逻辑。总价反映的是购房预算结构,深圳链家在售房源中总价 300-500 万区间是绝对主力,这个区间对应的是关外两房或三房的刚需上车盘;单价分布的看点在峰值,如果某个月单价分布出现双峰,说明市场在同时消化刚需盘和豪宅,两个价格梯队互不干扰。

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 解决中文乱码 plt.rcParams["axes.unicode_minus"] = False # 解决负号显示问题 fig, axes = plt.subplots(1, 2, figsize=(14, 5)) axes[0].hist(df["total_price"], bins=40, color="#4C72B0", edgecolor="white") axes[0].set_xlabel("总价 (万元)") axes[0].set_ylabel("挂牌房源数") axes[0].set_title("深圳二手房挂牌总价分布") axes[1].hist(df["unit_price"], bins=40, color="#DD8452", edgecolor="white") axes[1].set_xlabel("单价 (元/平米)") axes[1].set_title("深圳二手房挂牌单价分布") plt.tight_layout() plt.savefig("price_distribution.png", dpi=150)

Matplotlib 画中文图时字体配置是必踩的坑。Windows 下 SimHei 通常可用,但如果你在 Mac 或 Linux 服务器上跑同一份代码,SimHei 不存在,会显示一堆方框。更稳妥的做法是统一指定系统自带的中文字体,比如 Mac 用Arial Unicode MS,Linux 用Noto Sans CJK SC,或者用matplotlib.font_manager.FontProperties指定字体文件路径。我在实际项目里会把字体选择写成一个函数,检测操作系统动态加载,避免换环境跑脚本时图表中文全部变方块。

直方图的 bins 数量不建议用默认值,默认 bins=10 对总价分布来说太粗了,300-500 万的核心区间会被合并成一两个柱子,看不出预算集中度。总价用 40 个 bins,单价用 40 个 bins,每个柱子大约对应总价 50 万和单价 2000 元的分辨率,足够的粒度能支撑后续分户型对比时看到分布形状差异。

4.3 户型占比与单位面积均价:两房三房谁是主力

户型分析的第一步是计算不同卧室数量的挂牌占比,第二步是按户型分组算单位面积均价和平均面积。两步放一起才能看出供应结构和价格的关系:如果两房占比大但单价最高,说明这个市场刚需盘供应紧张;如果三房占比大且单价跟大盘持平,说明改善型需求主导。

type_stats = df.groupby("bedrooms").agg( count=("total_price", "count"), avg_unit_price=("unit_price", "mean"), avg_area=("area", "mean"), ).sort_values("count", ascending=False) type_stats["pct"] = (type_stats["count"] / type_stats["count"].sum() * 100).round(1) type_stats["avg_unit_price"] = type_stats["avg_unit_price"].astype(int)

深圳链家实际抓下来的数据里,两房和三房通常各占三成以上,四房占比 15% 左右,一房占比不到 10%。用这个表可以回答一个经典问题:“同样的预算,买关内小两房还是关外大三房?”做法是筛选总价相同区间的房源,对比单位面积均价和房龄——单价高的那组通常楼龄更短但面积小,两边各有取舍,把这个对比做成带筛选条件的图表,比单独看户型占比要有信息量得多。

四房及以上房型的样本量如果不足,分析时可以归成一个 “四房及以上” 组,避免分组后每组数据量过小而无法得出稳定结论。还有一种做法是按面积粗分替代按卧室数分,比如面积段 60-90 平、90-120 平、120-150 平,这样能绕开 “小面积三房” 和 “大两房” 的对比难题——深圳有大量 89 平三房,也有 89 平大两房,两者总价接近但目标人群完全不同,只看卧室数会忽略面积这个变量。我一般会选择做双维度交叉表:行是卧室数,列是面积段,值是平均单价,这样一眼能看出同样的三房,面积越大单价是升还是降。

4.4 朝向对单价的影响:箱线图揭示南向溢价

朝向分析是深圳二手房市场最有意思的维度,因为深圳气候炎热潮湿,朝南和朝北的居住体验差异巨大,最终会体现在价格上。把单价按朝向分组画箱线图,你能直观看到南向单价中位数跟北向的差距,以及南北通透户型是否存在额外溢价。

import seaborn as sns fig, ax = plt.subplots(figsize=(12, 6)) sns.boxplot(data=df, x="direction", y="unit_price", order=["南", "南北", "东南", "西南", "东", "西", "北", "不详"], palette="Blues") ax.set_ylim(0, 150000) # 截掉极端高价豪宅,聚焦主流区间 ax.set_xlabel("朝向") ax.set_ylabel("单价 (元/平米)") ax.set_title("深圳二手房不同朝向的单价分布") plt.savefig("direction_price_box.png", dpi=150)

箱线图的ylim必须显式设置,否则单价 30 万一平的深圳湾豪宅会把整个图压缩得看不到中位数位置。截断值设成 15 万是经验值,主流刚需和改善盘都在这个区间里,高过 15 万的房源单独做筛选分析,别混在一张图里。南向的箱体中位数大概率在全部分组中最高,但你能从箱体跨度看出另一个现象:南向房源的数量多、价格离散度大,说明南向内部也分三六九等,靠海的南向和靠马路的南向差价能到 30%。

做朝向分析时有个隐藏变量必须控制——楼龄。同一片区里,老房子的朝向逻辑和新房不同:老小区南向房源价格优势明显,因为老房子普遍通风差,南向采光带来的居住体验提升直接反映在挂牌价上;新房多是板楼设计,南北通透是标配,朝向因素的影响被稀释了。所以严谨的做法是先按楼龄分组(5年以内、5-10年、10年以上),再在每组内做朝向对比。分组后你会发现南向溢价在老房组里更夸张,这比直接画全量箱线图更深一层。

4.5 楼龄与总价的散点图:验证 “次新房溢价” 是否成立

楼龄和总价的关系不是一个简单线性关系,次新房(楼龄 5-8 年)通常有溢价,楼龄超过 15 年后价格折价明显,但深圳核心地段的学区老房会打破这个规律。画散点图时把区作为颜色维度,能同时看到楼龄和区位两个变量的相互作用。

fig, ax = plt.subplots(figsize=(12, 6)) colors = {"南山区": "#1f77b4", "福田区": "#ff7f0e", "宝安区": "#2ca02c", "龙华区": "#d62728", "龙岗区": "#9467bd", "罗湖区": "#8c564b"} for district, group in df.groupby("district"): if district not in colors: continue ax.scatter(group["building_age"], group["total_price"], alpha=0.4, s=20, label=district, color=colors[district]) ax.set_xlabel("楼龄 (年)") ax.set_ylabel("总价 (万元)") ax.set_title("深圳二手房楼龄与总价关系(按区分色)") ax.legend() plt.savefig("age_price_scatter.png", dpi=150)

散点图上一眼能看出的结论是:龙岗和宝安的老房子总价明显低于南山福田同龄房源,而南山的新房总价是龙岗同龄新房的 2 倍以上。这证明深圳房价的地段因素强过楼龄因素,楼龄分析必须嵌套在行政区或板块内才有意义。做这个散点图时alpha=0.4很重要,一万个点如果不设透明度,重叠区域会变成纯黑块,看不出密度分布。点的大小s=20也要控制,太大会让不同区的数据点互相覆盖。

散点图之后的进阶分析是算楼龄和单价的相关系数,用df[["building_age", "unit_price"]].corr()。全量数据的相关系数可能只有 -0.15 左右,靠的其实是各区样本混合后的结果,如果按区切片后重新计算,南山的楼龄-单价负相关会强于龙岗。这个发现比散点图本身更能说明问题,因为深圳的关外区域土地供应充足,新房源源不断,老房溢价能力弱,这在数据分析报告里是个有价值的结论。

5. 实战避坑:链家爬虫与深圳楼盘数据里最常见的 5 个翻车点

5.1 列表页解析返回空列表,但浏览器能正常打开

现象:parse_list_page返回空列表,程序一页都抓不到,但用浏览器访问同一个 URL 一切正常。

原因:没有带完整的请求头。链家对默认的python-requestsUser-Agent 做了拦截,返回的是一个安全验证页面,里面没有sellListContent这个类名的节点,BeautifulSoup 匹配不到任何卡片。

解决:请求头必须带浏览器级别的 User-Agent 和 Accept-Language。我一般还会加上Referer: https://sz.lianjia.com/,有些反爬策略会校验来源页。用上面的HEADERS字典就能解决,如果还不行,把 User-Agent 换成最新版 Chrome 的完整 UA 串。这条排查顺序放在最前面:先打印resp.status_code和len(resp.text),如果是 200 但长度只有几百字节,大概率是被拦截了。

5.2 编码设置遗漏导致 CSV 里全是乱码

现象:CSV 文件用 Excel 打开后小区名变成 “馿°´å®¶å”,pandas 读回来也是乱码。

原因:写入 CSV 时用了encoding="utf-8",Excel 默认用 GBK 打开;或者写入时没问题,但读取时 pandas 用了错误编码。链家页面本身是 utf-8,但 Windows 中文版 Excel 对无 BOM 的 utf-8 支持差是历史问题。

解决:写文件统一用encoding="utf-8-sig",读文件统一指定encoding="utf-8-sig"。注意如果你在 Mac 上写 CSV 用的是utf-8,发给 Windows 同事时同样会乱,所以这个设置要固定成习惯,别等出问题再改。另外采集阶段resp.encoding = "utf-8"也不能省,否则requests自动检测编码时可能误判成 GBK,从源头就是乱码,后面怎么改文件编码都救不回文本正文。

5.3 详情页字段 “建筑年代” 写成 “暂无数据” 导致 to_numeric 崩溃

现象:清洗时df["built_year"].astype(int)直接报ValueError,查出来是字符串 “暂无数据” 混在年份里。

原因:链家部分房源详情页确实没有建成年代,显示 “暂无数据”,到 2025 年还挂着没卖出去的房源里这种比例大概在 2-3%,不是脏数据,是真实情况。

解决:先用pd.to_numeric(df["built_year"], errors="coerce")做安全转换,转换不了的变成 NaN,然后用fillna填充一个合理值。填充值怎么选?如果房源数量少,直接删除这些行影响不大;如果房源数量多,可以按同小区的其他房源中位数填充。链家数据里同小区不同期的房源建造年代通常接近,用中位数填充比全局填充更合理。

5.4 Request 被限流后出现大量 418 状态码

现象:爬到一半开始连续出现HTTP 418,页面内容变成了“请输入验证码”。

原因:请求频率过高触发了链家的频率限制机制,服务端返回 418 是它在告诉你“你被识别成机器人了”。链家的限流阈值不是按秒算的,是按分钟内的请求总量,详情页抓得越快越容易踩。

解决:立即停止程序,等 10-15 分钟让限制解除,然后降低请求频率重新跑。抓取间隔从 1.2 秒调到 2.5 秒,同时开启断点续跑从上次进度继续。不要在同一 IP 上跑两个抓取实例,线程数翻倍不代表速度翻倍,反而让限流阈值提前触发。针对 418 响应,在代码里加一个判断:如果resp.status_code == 418,直接 sleep 300 秒再重试当前 URL,而不是继续抓下一个。

5.5 面积字段提取出小区名里的数字

现象:清洗后发现的area列出现 345、89 这样的数值,比正常房源面积大几倍,而且查了下是小区名里的数字被误当面积提取了。

原因:house_info字段的格式并不是百分百统一,有些房源卡片在houseInfo没抓到的时候,代码 fallback 用了title去提取面积,而 title 里的 “XX花园三期” 里的 “三” 或 “3期” 会被正则匹配到。更隐蔽的是直接匹配house_info时,链家个别房源把面积写成了 “67.54平米” 但前面多了几个空格,正则([\d.]+)平米没受空格影响,但如果是 “约67平米” 这种描述会匹配到 “约” 后跟着的数字,总面积就错了。

解决:提取面积后加一道合理性校验:面积小于 20 平米或大于 300 平米的直接置为 NaN 并标记。深圳最小的一房一厅也有 30 平,最大的别墅或顶复才可能超过 300 平,这个范围外的基本都是提取错误。用df["area"].between(20, 300)过滤一遍,比逐个看数据快得多,只保留该范围内的样本做分析。

6. 从一次性脚本到可复用分析管道:给这个项目加一个定时增量更新

链家二手房数据是每天变化的:房源下架、价格调整、新上架,你昨天抓的数据今天就已经失去时效性。如果你不想每次都手动跑一遍全量脚本,可以做一个增量更新方案:每天定时抓一次列表页,对比今天的房源链接和昨天的差异,新增的和价格变化的才去抓详情页。对比的key用详情页链接,因为链接是房源的唯一ID,价格和状态变化不会改变链接本身。

import hashlib def generate_record_id(link, unit_price, total_price): raw = f"{link}|{unit_price}|{total_price}" return hashlib.md5(raw.encode()).hexdigest() df_today = pd.read_csv("shenzhen_lianjia_raw_today.csv", encoding="utf-8-sig") df_yesterday = pd.read_csv("shenzhen_lianjia_clean.csv", encoding="utf-8-sig") df_today["record_id"] = df_today.apply( lambda r: generate_record_id(r["link"], r["unit_price"], r["total_price"]), axis=1 ) df_yesterday["record_id"] = df_yesterday.apply( lambda r: generate_record_id(r["link"], r["unit_price"], r["total_price"]), axis=1 ) changed_records = df_today[~df_today["record_id"].isin(df_yesterday["record_id"])] print(f"新增或价格变动的房源数: {len(changed_records)}")

这个generate_record_id的妙处在于:同一套房源的链接只要有价格变动,生成的 ID 就会变化,能被识别成增量;而属性没变的房源 ID 保持不变,会自动跳过详情页请求。跑增量更新时可以只对changed_records的链接抓详情页,此时详情页的请求量通常只有几十条,非常快。定时任务在 Linux 上用crontab,Windows 上用计划任务,每天凌晨 2 点跑一次最合适——链家每天的数据更新基本在零点前完成,凌晨抓到的就是前一天的完整快照。

增量更新的代码要单独维护,别和全量脚本混在一起。全量脚本负责初始化数据,增量脚本负责日常更新,两个脚本共用同一个解析函数模块,这样可以避免改了解析逻辑只更新了一个脚本、另一个还在用旧规则的问题。把公共函数抽到lianjia_parser.py里,两个入口脚本crawl_full.py和crawl_incremental.py都import它,源的变更只需要改一处。

做增量更新的同时建议顺手做数据质量监控:每天跑完后输出一个简单的统计摘要,包括今日房源总数、较昨日增减、分区的挂牌量变化,存成daily_report.txt。几周下来你能从中看出深圳各区的挂牌节奏——龙岗是永远的天量供给区,南山福田挂牌量波动大,这个摘要本身就是一张很有价值的市场信号表。我自己的习惯是每周看一下这五条摘要,链家数据有没有抓漏一眼就能发现,不需要等分析跑出来才发现数据坑了。这个项目做到这个程度,就不再是一个交差的作业,而是一个能持续运转、有数据积累的分析基础设施了。

回看整个方案,最核心的教训其实很简单:链家二手房分析这个方向,技术难点不在分析算法,而在拿数据的过程里能不能忍受琐碎和不确定。但正是这些琐碎步骤——编码设置、请求限速、字段拆分——决定了你后续所有分析的可靠性。把这几层做扎实,换到北京、上海、广州的链家数据也不过是改几个 URL 参数的事。希望这套思路和代码能帮你在自己的数据分析项目里少踩几个坑。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进