
简介这是一份基于Python的深圳链家二手房数据采集与分析项目适合爬虫入门、数据分析实践及毕业设计参考。项目围绕链家二级域名组装二手房链接完成页面筛选条件抓取、数据保存至MySQL并提供分析模块代码经过完整测试可作为课程设计或毕设扩展基础。压缩包共27个文件以18个Python脚本为核心涵盖采集、存储与分析流程另含JSON数据文件、配置文件、README说明及开源协议整体大小仅1.54MB结构清晰便于按模块学习。已有90人在线学习资源内包含运行说明与中间数据读者可结合代码理解从URL构建、HTML解析到数据库写入及可视化分析的全流程也可基于现有逻辑更换城市或增加维度进行二次开发。1. 基于Python的深圳二手房数据分析爬虫、MySQL、可视化一条链路拿到这份基于Python实现的深圳房地产二手楼盘数据分析源代码时我最先翻的是House-500.json和House.json这两个文件——它们是已经真实跑过的抓取结果不是演示用的假数据。项目用Scrapy从链家深圳站抓二手房列表和详情落进MySQL再做清洗和统计整条链路完整闭合这是它最值钱的地方。适合两类人一类是课程设计、毕设需要“从爬虫到分析”完整作品的同学另一类是想搭自己的城市房价监控脚本、但不想从零趟反爬门槛的从业者。下文按我实际拆这个项目时的顺序把爬虫调度、入库配置、分析套路和踩过的坑一次讲透。2. 项目结构与Scrapy调度从城市入口到每一套房源是怎么被安排的2.1 文件布局速览爬虫、配置、数据、分析各自在哪这个项目的根目录结构不复杂但分清职责会省很多时间。我从下载包里抽出实际存在的文件按用途分成四组分组文件/目录一句话职责爬虫核心HouseData/Scrapy工程主体包含spider、pipelines、settings启动入口start.py、start副本.py手动触发爬虫的脚本跑之前先看这个已抓数据House.json、House-500.json、House-all-id.json、House-node.json跑通后落盘的JSON中间结果分析阶段直接用分析/展示Analysis/、Dash/数据清洗聚合、可视化看板工程说明README.md、scrapy.cfg、.gitignore、LICENSE运行顺序、Scrapy配置、版本控制忽略项拿到包先读README.md是硬习惯。这个项目的README开头就写了运行顺序我第一次跑就是照着它走的避免了自己瞎猜spider名称和输出路径。2.2 start_requests 和列表页调度动态组装 ershoufang 链接项目的抓取入口很有意思不是写死深圳一个站而是“通过任意一个链家网址进入先抓二级域名再组装二手房链接”。这意味着想抓北京、上海、广州只需要换一个入口URLspider本身不用改。逻辑上分三步拿二级域名如sz.lianjia.com、拼二手房频道路径/ershoufang/、再按页码翻列表。start.py里最常见的做法是这样# start.py —— 爬虫启动入口 import scrapy class HouseSpider(scrapy.Spider): name house def start_requests(self): # 链家各城市二级域名换城市就改这里 base_urls [ sz.lianjia.com, # 深圳 bj.lianjia.com, # 北京 sh.lianjia.com, # 上海 ] for city in base_urls: # pg1 是第一页后续页码在 parse_list 里取 next 链接拼 url fhttps://{city}/ershoufang/pg1/ yield scrapy.Request( urlurl, callbackself.parse_list, meta{city: city}, # 通过 meta 把城市名带到下一层 dont_filterTrue )重点在meta{city: city}。深圳和北京的房源结构一样但入库时要区分城市这个meta会一路传到详情页解析函数。dont_filterTrue是避免Scrapy默认去重把不同城市的相同URL误杀实际跑的时候深圳和北京偶尔会出现同一个房源的链接参数相似的情况。列表页解析里核心是把每一套房子的详情URL提取出来再交给详情解析函数def parse_list(self, response): city response.meta.get(city) # 链家列表页每个房源卡片的标题链接 # 用 scrapy shell https://sz.lianjia.com/ershoufang/ 实测选择器最快 detail_urls response.css(li.ershoufang-list-img a.title::attr(href)).getall() for detail_url in detail_urls: yield scrapy.Request( urlresponse.urljoin(detail_url), callbackself.parse_detail, meta{city: city, list_url: response.url} ) # 翻页链家分页器里下一页的链接 next_page response.css(div.page-box a.next::attr(href)).get() if next_page: yield scrapy.Request( urlresponse.urljoin(next_page), callbackself.parse_list, meta{city: city} )两个细节要注意response.urljoin处理相对路径链家列表里的详情链接是/ershoufang/xxx.html这种必须拼成完整URL翻页判断用的是a.next这个class链家页面结构里下一页按钮的class稳定是这个但如果哪天改版了爬虫最先挂在这里。2.3 详情页解析与Item设计字段怎么提、怎么传详情页是整个数据质量的关键。链家的房源详情页主要分几个信息区标题、总价、单价、小区名、位置、户型楼层面积、关注人数。Item里对应把字段定义好解析函数填充后交给Pipeline# items.py —— 定义统一的数据结构 import scrapy class HouseItem(scrapy.Item): city scrapy.Field() # 城市二级域名区分数据来源 title scrapy.Field() # 房源标题 community scrapy.Field() # 小区名 position scrapy.Field() # 区域-商圈如 罗湖-布心 total_price scrapy.Field() # 总价万 unit_price scrapy.Field() # 单价元/平米 house_info scrapy.Field() # 户型/面积/楼层原始字符串 follow_info scrapy.Field() # 关注人数/带看次数 url scrapy.Field() # 详情页链接去重键 crawl_time scrapy.Field() # 抓取时间# spider中的详情解析 def parse_detail(self, response): item HouseItem() item[city] response.meta.get(city) item[url] response.url # 标题在 h1class 通常为 main item[title] response.css(h1.main::text).get() # 总价div.total-price span取数字部分 total response.css(div.total-price span::text).get() item[total_price] total.strip() if total else None # 单价链家详情页单价在 div.unitPrice 里 unit response.xpath(//div[contains(class, unitPrice)]//span/text()).get() item[unit_price] unit.strip() if unit else None # 小区名、位置、户型等 item[community] response.css(a.info::text).get() item[position] response.xpath(//div[contains(class, houseInfo)]/text()).get() yield item逻辑说明parse_detail只做一件事——把HTML转成结构化的Item。字段为什么用contains(class, unitPrice)而不是精确匹配因为链家不同城市的详情页class可能有unitPrice和unitPriceWrapper的差异contains兜底更稳。h1.main::text取的是标题如果返回None后面Pipeline里可以标记为“缺失”。参数说明total_price和unit_price先当字符串抓不急着转int。因为链家页面有时会带“万”或“元/平”这类单位字符清洗动作放到Pipeline和Analysis阶段统一处理spider只负责原样提取。这个分离思路在数据工程里很重要——抓取层越“笨”越可靠。3. 数据落库与管道配置把抓到的房源干净地写进MySQL3.1 Pipeline入库实现连接参数、去重策略与事务提交Scrapy的Pipeline是item离开spider之后的必经一站。项目里MySQL入库就是在这里完成的。先看核心代码# pipelines.py —— MySQL 入库管道 import pymysql class MysqlPipeline: def open_spider(self, spider): # 按项目的实际配置改密码和库名 self.conn pymysql.connect( hostlocalhost, # MySQL 地址 port3306, # 默认端口 userroot, passwordyour_password, # 改成你自己的密码 databasehouse_db, # 提前建好 charsetutf8mb4 # 中文与emoji都靠它 ) self.cursor self.conn.cursor() def process_item(self, item, spider): # url 做幂等键第一次插入后续重复只更新价格和关注数 sql INSERT INTO house (city, title, community, position, total_price, unit_price, house_info, follow_info, url, crawl_time) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) ON DUPLICATE KEY UPDATE total_price VALUES(total_price), unit_price VALUES(unit_price), follow_info VALUES(follow_info) try: self.cursor.execute(sql, ( item.get(city), item.get(title), item.get(community), item.get(position), item.get(total_price), item.get(unit_price), item.get(house_info), item.get(follow_info), item.get(url), )) self.conn.commit() except pymysql.err.IntegrityError as e: # 主键或唯一索引冲突时回滚并记录日志 self.conn.rollback() spider.logger.warning(fduplicate or error: {e}) return item def close_spider(self, spider): self.cursor.close() self.conn.close()逻辑说明ON DUPLICATE KEY UPDATE依赖url字段的唯一索引。链家房源的URL本身就带唯一房源ID比如/ershoufang/123456789.html用它做UNIQUE KEY天然适合。这样爬虫中途断掉重跑不会制造重复行只会把已有的房源价格更新成最新的等于顺手做了一个增量更新机制。参数说明charsetutf8mb4是硬性要求。链家部分房源描述里有特殊字符utf8mb4才能存下四字节的字符如果只写utf8某些房源会直接入库失败。commit()放在循环内还是外面要看数据量——这里写循环内是为了保证每一条都能落盘缺点是慢一点如果一天抓几千条完全够用。3.2 settings.py的核心参数延迟、UA、管道开关怎么调Scrapy的settings.py是调爬虫“脾气”的地方。项目跑通的关键参数集中在三处参数建议值作用与坑DOWNLOAD_DELAY1.0 ~ 2.0每次请求间隔秒数太小会被链家限流ROBOTSTXT_OBEYFalse链家的robots.txt不友好但学习用途可以关掉DEFAULT_REQUEST_HEADERS带真实浏览器UA默认Scrapy UA会被直接拦截ITEM_PIPELINES{house.pipelines.MysqlPipeline: 300}300是执行顺序数字越小越早执行FEED_EXPORT_ENCODINGutf-8导出JSON时中文不乱码的关键# settings.py 关键片段 BOT_NAME house DOWNLOAD_DELAY 1.5 ROBOTSTXT_OBEY False DEFAULT_REQUEST_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: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8,en-US;q0.6, } ITEM_PIPELINES { house.pipelines.MysqlPipeline: 300, } FEED_EXPORT_ENCODING utf-8参数说明DOWNLOAD_DELAY1.5是我跑链家常用的值。低于0.5基本几分钟内就会触发验证码高于3又太慢500套房源要抓半小时。1.5是稳定性和速度的平衡点。DEFAULT_REQUEST_HEADERS里的UA要尽量贴近真实浏览器的完整UA字符串只填Mozilla/5.0这种残缺UA和没填一样容易被识别。3.3 中间JSON的作用House.json与House-all-id.json并不是临时文件项目里有几个JSON文件容易被人当成缓存随手删其实它们是分层抓取策略的一部分。我的理解是House-all-id.json存所有目标房源的ID列表相当于“待抓清单”。先抓列表页收集ID再按ID逐个进详情页断点续爬时只需看这个文件就知道哪些还没抓。House-node.json存节点维度数据可能是区域/商圈/小区这类层级信息做分析时用于聚合分组。House.json最终完整清洗后的房源数据Analysis目录直接读它做分析。House-500.json抽取的500条样本子集适合先在本地验证分析代码不用每次全量加载。这种“分步落盘”的设计比全放MySQL更灵活分析阶段可以不完全依赖数据库直接读JSON就能跑。我在做其他爬虫项目时也沿用这个思路——每个阶段落一份文件坏了能回退不用重爬。4. 数据分析从收盘JSON到区域均价、总价分布、关注度结论4.1 加载与清洗把JSON变成干净的DataFrame爬虫跑完拿到数据接下来才进入真正出“见解”的阶段。Analysis目录下的脚本第一步几乎都是加载JSON并清洗# analysis/load_data.py 示例 import json import pandas as pd with open(House.json, r, encodingutf-8) as f: raw json.load(f) df pd.DataFrame(raw) # 关键一步字符串转数值 df[total_price] pd.to_numeric(df[total_price], errorscoerce) df[unit_price] pd.to_numeric(df[unit_price], errorscoerce) # 删除总价/单价缺失的行并打出丢弃比例避免结论失真 before len(df) df df.dropna(subset[total_price, unit_price]) print(f丢弃 {before - len(df)} 条占比 {(before - len(df)) / before:.1%})逻辑说明errorscoerce会把无法转换的值变成NaN比如页面里偶尔出现的“价格待定”这种脏数据。这里为什么丢而不是填因为房价分析里价格是核心变量用均值或中位数填充会扭曲分布宁可丢弃。dropna之后打印丢弃比例是重要习惯——如果丢掉了30%的数据说明爬虫阶段有字段解析问题得回去修spider而不是硬着头皮继续分析。4.2 聚合分析区域梯队、总价分布与户型结构清洗干净之后第一个值得做的是区域维度聚合。链家的position字段一般长这样罗湖-布心前半是行政区后半是商圈。用split拆出区域后groupby# 按行政区聚合均价和挂盘量 df[district] df[position].str.split(-).str[0] region_stats ( df.groupby(district) .agg( 平均单价(unit_price, mean), 最高总价(total_price, max), 房源数(total_price, count), ) .sort_values(平均单价, ascendingFalse) ) print(region_stats.head(10))看聚合结果能直接回答几个高频问题哪个区均价最高、哪个区挂盘量最大、各区总价上限差异。第二个值得做的分析是总价区间分布判断市场主力价格段# 总价分布分桶统计 bins [0, 200, 400, 600, 800, 1000, 1500, 2000, 5000] labels [0-200, 200-400, 400-600, 600-800, 800-1000, 1000-1500, 1500-2000, 2000] df[price_band] pd.cut(df[total_price], binsbins, labelslabels) band_counts df[price_band].value_counts().sort_index() print(band_counts)参数说明pd.cut的bins要按当地房价水平调深圳总价800万以内的房源占比通常最高放到三四线城市这个分桶就不适用得把区间整体下调。和静态分析相比这个分桶的价值在于快速定位市场结构——是刚需盘主导还是改善盘主导。关注度分析也值得做。链家详情页的follow_info字段包含关注人数它反映房源的被关注热度。把关注人数和总价放在一起看能发现性价比房源——价格不高但关注度异常高往往是即将成交的标的# 关注度与价格的关系 df[follow_count] ( df[follow_info] .str.extract(r(\d)人关注) .astype(float) ) hot_cheap ( df.sort_values(unit_price) .head(50) .sort_values(follow_count, ascendingFalse) )注意用.str.extract(r(\d)人关注)从原始字符串里抠数字而不是直接强转。链家的follow_info格式常见为“12人关注 / 56次带看”正则提取只取关注人数干净利落。4.3 可视化与输出matplotlib画图的两张模板分析结果要让人信服可视化比表格更直观。项目里最常用的两张图是区域均价条形图和总价分布直方图import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] # 中文字体Windows 常用 plt.rcParams[axes.unicode_minus] False # 解决负号显示为方块 # 图1区域均价 Top10 条形图 region_stats[平均单价].head(10).plot(kindbar, figsize(10, 6)) plt.title(深圳各行政区二手房平均挂牌单价元/平) plt.ylabel(单价) plt.xticks(rotation45) plt.tight_layout() plt.savefig(region_price.png, dpi150) # 图2总价分布直方图 df[total_price].plot(kindhist, bins30, figsize(10, 6)) plt.title(深圳二手房总价分布) plt.xlabel(总价万) plt.tight_layout() plt.savefig(total_price_dist.png, dpi150)逻辑说明plt.rcParams里的SimHei字体是中文不乱码的前提Linux服务器上没有这个字体会报错可以改成Noto Sans CJK SC。figsize和dpi影响出图清晰度保存成PNG比直接show更适合写进课设文档。这两张图出来数据分析部分的“硬货”就有了答辩时两页图比一百行代码更有说服力。5. 实测排错与避坑爬虫、入库、分析三个环节的高频翻车记录5.1 爬虫段验证码页面与“列表能开、详情全空”现象跑起来之后控制台里没有报错但抓回来的item全是一个标题——“访问验证”。一看response.status是302实际落到一个验证码页面。原因Scrapy默认的UA太明显链家反爬直接判定为机器人。或者DOWNLOAD_DELAY设成了0.2请求频率过高。解决第一步把DEFAULT_REQUEST_HEADERS里的UA换成完整浏览器UA第二步把DOWNLOAD_DELAY提到1.5以上第三步给settings里加一个RETRY_TIMES 3部分偶发302会在重试时恢复正常。如果这三步做完还频繁验证码就在本地浏览器先手动访问一次目标页面把Cookie复制到请求头里临时有效。现象列表页正常翻页详情URL也都提取到了但parse_detail产出的item里community、total_price全是None。原因列表页和详情页的HTML结构不是一套模板。我遇到过链家深圳正常、但切到某二线城市后详情页的class多了一个后缀的情况。解决对某个具体URL用scrapy shell https://sz.lianjia.com/ershoufang/xxxx.html实测在shell里试选择器直到能取到内容再改spider。这是写爬虫最笨但最有效的方法猜选择器猜三次不如shell里试一次。5.2 入库段中文乱码与重复数据现象MySQL里看数据中文全部变成“??”或一堆问号英文和数字正常。原因数据库连接时charset没指定默认用了latin1或者建库时没指定字符集库的排序规则是latin1_swedish_ci。解决建库时用CREATE DATABASE house_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;连接参数里固定写charsetutf8mb4。如果已经建好库用ALTER DATABASE house_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;补救然后重建表。这个坑我踩过一次之后现在写任何入库代码第一行就是charsetutf8mb4。现象同一个房源在表里出现两条一条价格550万一条560万过几天重跑又变成三条。原因列表页的分页排序会变化同一套房可能在不同批次被重复入队。表里没有唯一约束Pipeline的INSERT每次都当成新数据插入。解决给url建唯一索引再配合ON DUPLICATE KEY UPDATE让重复抓到的数据走更新而不是插入。建索引SQLALTER TABLE house ADD UNIQUE KEY uk_url (url);如果url字段太长先确认它是VARCHAR(255)且内容里没有超长的情况。5.3 分析段字段类型与数据缺失对结论的干扰现象groupby之后排序结果错乱“南山”均价显示成8万“龙岗”显示成3万排列毫无规律。原因total_price在DataFrame里是object类型字符串排序走的是字符串比较500万 5000万字符串比的是逐位字符的数值当然乱。解决任何聚合之前先pd.to_numeric把价格列转成float。这个动作看起来简单但漏掉的人最多。我一般写完加载代码立刻打印df.dtypes检查一遍类型成了肌肉记忆。现象均价计算结果明显偏高比如深圳平均单价15万每平。原因链家部分房源详情页没有展示单价字段unit_price大面积为NaN。如果分析时只选了unit_price非空的数据样本就偏向“展示单价的房源”而这类房源偏向高端盘。解决用总价除以面积估算缺失单价面积从house_info字段里正则提取提取不到的再丢弃。同时在分析说明里注明“缺失率X%”让结论的适用范围透明。不透明才是分析报告最大的坑。6. 一个能立刻上手的技巧把项目改造成多城市房价监控6.1 三个改动点完成城市切换这个项目的设计本身就留了多城市扩展的口子我在本地把它改成了“深北广沪”四城监控全程只动了三处十分钟内能搞定。第一处是start_requests里的base_urls列表把目标城市的二级域名加进去。第二处是Item和Pipeline确保city字段全程传递并在入库时写入city列——这一步项目原本已经做了只是很多人没意识到它是为多城市设计的。第三处是分析脚本在groupby之前先按city分组对比城市间差异# 多城市对比分析片段 city_stats ( df.groupby([city, district]) .agg(平均单价(unit_price, mean), 房源数(unit_price, count)) .sort_values(平均单价, ascendingFalse) ) # 只看深圳罗湖、北京朝阳这类跨城区域梯队 print(city_stats.loc[sz.lianjia.com].head(10))第二处有个细节值得单独说不同城市链家页面结构并非100%一致。我切武汉时发现详情页的houseInfo区少了一个字段list的规格也不同这时不要在spider里堆if city 分支而是做成配置化的CSS选择器字典每个城市一份# 城市选择器配置示例 CITY_SELECTORS { sz.lianjia.com: { title: h1.main::text, unit_price: //div[contains(class, unitPrice)]//span/text(), }, wh.lianjia.com: { title: h1.main::text, unit_price: //div[contains(class, unitPrice)]/span/text(), }, }这样新增城市只是加一条配置不用改解析逻辑。跑新城市之前先用scrapy shell验证该城市的结构差异再决定是复用还是新增配置项。从那以后我每拿到一个Scrapy项目第一件事就是检查它的入口是不是支持多城市或多数源扩展——这个项目的好处就在这入口不写死、meta传参到位、入库带城市维度扩展成本极低。改完记得重跑一次House-500.json对应的样本量确认识别率没有下降再全量跑。这个习惯能让你少交好多学费希望帮到你。本文还有配套的精品资源点击获取