
简介面向数据分析与爬虫初学者的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;q0.9, } def get_list_page(page): if page 1: url https://sz.lianjia.com/ershoufang/ else: url fhttps://sz.lianjia.com/ershoufang/pg{page}/ resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) return soup这里有个容易被新手忽略的点resp.encoding utf-8必须写在解析之前。链家页面虽然有 meta 声明 utf-8但 requests 有时会根据响应头猜编码导致乱码。直接强制指定 utf-8 能避免后续所有字段解析出来的中文变成乱码这是血泪经验换来的别省这一行。列表页返回的 BeautifulSoup 对象里房源卡片都在div[classsellListContent]下的li[classclear]中。每张卡片上有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(stripTrue) link card.select_one(div.title a)[href] house_info card.select_one(div.address .houseInfo).get_text(stripTrue) total_price card.select_one(div.totalPrice span).get_text(stripTrue) unit_price card.select_one(div.unitPrice span).get_text(stripTrue) position_info card.select_one(div.positionInfo).get_text(stripTrue) 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 itemspositionInfo这个字段的值看起来像一长串比如 “2室1厅 | 67.54平米 | 东南 | 0层(共8层) | 2008年建”实际上链家把朝向、楼层、年代、是否有电梯都塞进了这一个字符串。后面清洗阶段要按竖线分割这里先原样存进 CSV避免在采集阶段做太激进的拆分导致解析错误。一页卡片大约 30 条所以每页能拿到的数据量是固定的翻页到 100 页之后基本就到头了整个深圳链家在售二手房大概在一万套上下。2.2 把列表页采集器跑成一整套带断点续跑的命令上面两个函数只解决了单页抓取和解析真正要落地还得加一个调度循环和异常处理。链家对高频请求的容忍度不算高连续请求几十页之后可能被要求输入验证码所以每一页之间要 sleep 一段时间。请求失败不能直接中断整个程序——第一页请求失败后停止服务可以但爬到第 87 页失败就退出等于前面的 2600 条数据全白跑了。常见做法是把数据边抓边写每解析完一页就追加到 CSV 文件而不是攒在内存里最后一次性落盘。这样即使断在某一页下次从断点页码继续跑也不会丢数据。断点怎么记我一般用一个叫progress.txt的文件存当前页码每成功一页就覆写一次重跑时先读这个文件。import os def crawl_list(max_page100, start_page1): all_items [] current start_page while current max_page: print(fcrawling page {current}) try: soup get_list_page(current) items parse_list_page(soup) if not items: print(fpage {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, modea, headernot os.path.exists(shenzhen_lianjia_raw.csv), indexFalse, encodingutf-8-sig, ) except Exception as e: print(fpage {current} error: {e}, retry after 10s) time.sleep(10) continue current 1 time.sleep(2) # 页面间隔控制请求频率 return all_itemsencodingutf-8-sig在这里是第二个关键点。带 BOM 的 CSV 用 Excel 打开不会乱码用 pandas 读取时指定encodingutf-8-sig也能正常解析。如果你用默认的utf-8写文件Excel 双击打开全是乱码在电商公司打分时这一条就可能被直接扣分。写文件的格式是追加模式所以第二次跑程序时不需要删掉旧数据但前提是数据结构没变如果中间改过字段名老数据的列对不上这种情况就要先手动清理旧文件再重新抓。列表页抓完后得到的是全深圳在售二手房的线索表但每条记录还缺小区建造年代、产权年限、梯户比这些详情字段。下一步就是拿link列表去请求详情页在详情页的div[classintroContent]里能找到完整的十个左右属性项。def get_detail_page(url): resp requests.get(url, headersHEADERS, timeout10) 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(stripTrue) 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, encodingutf-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], inplaceTrue)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(errorscoerce)强制转换转不了的变成 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, ascendingFalse) 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], bins40, color#4C72B0, edgecolorwhite) axes[0].set_xlabel(总价 (万元)) axes[0].set_ylabel(挂牌房源数) axes[0].set_title(深圳二手房挂牌总价分布) axes[1].hist(df[unit_price], bins40, color#DD8452, edgecolorwhite) axes[1].set_xlabel(单价 (元/平米)) axes[1].set_title(深圳二手房挂牌单价分布) plt.tight_layout() plt.savefig(price_distribution.png, dpi150)Matplotlib 画中文图时字体配置是必踩的坑。Windows 下 SimHei 通常可用但如果你在 Mac 或 Linux 服务器上跑同一份代码SimHei 不存在会显示一堆方框。更稳妥的做法是统一指定系统自带的中文字体比如 Mac 用Arial Unicode MSLinux 用Noto Sans CJK SC或者用matplotlib.font_manager.FontProperties指定字体文件路径。我在实际项目里会把字体选择写成一个函数检测操作系统动态加载避免换环境跑脚本时图表中文全部变方块。直方图的 bins 数量不建议用默认值默认 bins10 对总价分布来说太粗了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, ascendingFalse) 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(datadf, xdirection, yunit_price, order[南, 南北, 东南, 西南, 东, 西, 北, 不详], paletteBlues) ax.set_ylim(0, 150000) # 截掉极端高价豪宅聚焦主流区间 ax.set_xlabel(朝向) ax.set_ylabel(单价 (元/平米)) ax.set_title(深圳二手房不同朝向的单价分布) plt.savefig(direction_price_box.png, dpi150)箱线图的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], alpha0.4, s20, labeldistrict, colorcolors[district]) ax.set_xlabel(楼龄 (年)) ax.set_ylabel(总价 (万元)) ax.set_title(深圳二手房楼龄与总价关系按区分色) ax.legend() plt.savefig(age_price_scatter.png, dpi150)散点图上一眼能看出的结论是龙岗和宝安的老房子总价明显低于南山福田同龄房源而南山的新房总价是龙岗同龄新房的 2 倍以上。这证明深圳房价的地段因素强过楼龄因素楼龄分析必须嵌套在行政区或板块内才有意义。做这个散点图时alpha0.4很重要一万个点如果不设透明度重叠区域会变成纯黑块看不出密度分布。点的大小s20也要控制太大会让不同区的数据点互相覆盖。散点图之后的进阶分析是算楼龄和单价的相关系数用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 时用了encodingutf-8Excel 默认用 GBK 打开或者写入时没问题但读取时 pandas 用了错误编码。链家页面本身是 utf-8但 Windows 中文版 Excel 对无 BOM 的 utf-8 支持差是历史问题。解决写文件统一用encodingutf-8-sig读文件统一指定encodingutf-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], errorscoerce)做安全转换转换不了的变成 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, encodingutf-8-sig) df_yesterday pd.read_csv(shenzhen_lianjia_clean.csv, encodingutf-8-sig) df_today[record_id] df_today.apply( lambda r: generate_record_id(r[link], r[unit_price], r[total_price]), axis1 ) df_yesterday[record_id] df_yesterday.apply( lambda r: generate_record_id(r[link], r[unit_price], r[total_price]), axis1 ) 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 上用crontabWindows 上用计划任务每天凌晨 2 点跑一次最合适——链家每天的数据更新基本在零点前完成凌晨抓到的就是前一天的完整快照。增量更新的代码要单独维护别和全量脚本混在一起。全量脚本负责初始化数据增量脚本负责日常更新两个脚本共用同一个解析函数模块这样可以避免改了解析逻辑只更新了一个脚本、另一个还在用旧规则的问题。把公共函数抽到lianjia_parser.py里两个入口脚本crawl_full.py和crawl_incremental.py都import它源的变更只需要改一处。做增量更新的同时建议顺手做数据质量监控每天跑完后输出一个简单的统计摘要包括今日房源总数、较昨日增减、分区的挂牌量变化存成daily_report.txt。几周下来你能从中看出深圳各区的挂牌节奏——龙岗是永远的天量供给区南山福田挂牌量波动大这个摘要本身就是一张很有价值的市场信号表。我自己的习惯是每周看一下这五条摘要链家数据有没有抓漏一眼就能发现不需要等分析跑出来才发现数据坑了。这个项目做到这个程度就不再是一个交差的作业而是一个能持续运转、有数据积累的分析基础设施了。回看整个方案最核心的教训其实很简单链家二手房分析这个方向技术难点不在分析算法而在拿数据的过程里能不能忍受琐碎和不确定。但正是这些琐碎步骤——编码设置、请求限速、字段拆分——决定了你后续所有分析的可靠性。把这几层做扎实换到北京、上海、广州的链家数据也不过是改几个 URL 参数的事。希望这套思路和代码能帮你在自己的数据分析项目里少踩几个坑。本文还有配套的精品资源点击获取