简介:这是一份基于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 链接
项目的抓取入口很有意思:不是写死深圳一个站,而是“通过任意一个链家网址进入,先抓二级域名,再组装二手房链接”。这意味着想抓北京、上海、广州,只需要换一个入口URL,spider本身不用改。
逻辑上分三步:拿二级域名(如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 = f"https://{city}/ershoufang/pg1/" yield scrapy.Request( url=url, callback=self.parse_list, meta={"city": city}, # 通过 meta 把城市名带到下一层 dont_filter=True )重点在meta={"city": city}。深圳和北京的房源结构一样,但入库时要区分城市,这个meta会一路传到详情页解析函数。dont_filter=True是避免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( url=response.urljoin(detail_url), callback=self.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( url=response.urljoin(next_page), callback=self.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 # 标题在 h1,class 通常为 "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. 数据落库与管道配置:把抓到的房源干净地写进MySQL
3.1 Pipeline入库实现:连接参数、去重策略与事务提交
Scrapy的Pipeline是item离开spider之后的必经一站。项目里MySQL入库就是在这里完成的。先看核心代码:
# pipelines.py —— MySQL 入库管道 import pymysql class MysqlPipeline: def open_spider(self, spider): # 按项目的实际配置改密码和库名 self.conn = pymysql.connect( host="localhost", # MySQL 地址 port=3306, # 默认端口 user="root", password="your_password", # 改成你自己的密码 database="house_db", # 提前建好 charset="utf8mb4" # 中文与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(f"duplicate 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天然适合。这样爬虫中途断掉重跑,不会制造重复行,只会把已有的房源价格更新成最新的,等于顺手做了一个增量更新机制。
参数说明:charset="utf8mb4"是硬性要求。链家部分房源描述里有特殊字符,utf8mb4才能存下四字节的字符;如果只写utf8,某些房源会直接入库失败。commit()放在循环内还是外面要看数据量——这里写循环内是为了保证每一条都能落盘,缺点是慢一点,如果一天抓几千条完全够用。
3.2 settings.py的核心参数:延迟、UA、管道开关怎么调
Scrapy的settings.py是调爬虫“脾气”的地方。项目跑通的关键参数集中在三处:
| 参数 | 建议值 | 作用与坑 |
|---|---|---|
DOWNLOAD_DELAY | 1.0 ~ 2.0 | 每次请求间隔秒数,太小会被链家限流 |
ROBOTSTXT_OBEY | False | 链家的robots.txt不友好,但学习用途可以关掉 |
DEFAULT_REQUEST_HEADERS | 带真实浏览器UA | 默认Scrapy UA会被直接拦截 |
ITEM_PIPELINES | {"house.pipelines.MysqlPipeline": 300} | 300是执行顺序,数字越小越早执行 |
FEED_EXPORT_ENCODING | utf-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/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.8,en-US;q=0.6", } ITEM_PIPELINES = { "house.pipelines.MysqlPipeline": 300, } FEED_EXPORT_ENCODING = "utf-8"参数说明:DOWNLOAD_DELAY=1.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", encoding="utf-8") as f: raw = json.load(f) df = pd.DataFrame(raw) # 关键一步:字符串转数值 df["total_price"] = pd.to_numeric(df["total_price"], errors="coerce") df["unit_price"] = pd.to_numeric(df["unit_price"], errors="coerce") # 删除总价/单价缺失的行,并打出丢弃比例,避免结论失真 before = len(df) df = df.dropna(subset=["total_price", "unit_price"]) print(f"丢弃 {before - len(df)} 条,占比 {(before - len(df)) / before:.1%}")逻辑说明:errors="coerce"会把无法转换的值变成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("平均单价", ascending=False) ) 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"], bins=bins, labels=labels) 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", ascending=False) )注意用.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(kind="bar", figsize=(10, 6)) plt.title("深圳各行政区二手房平均挂牌单价(元/平)") plt.ylabel("单价") plt.xticks(rotation=45) plt.tight_layout() plt.savefig("region_price.png", dpi=150) # 图2:总价分布直方图 df["total_price"].plot(kind="hist", bins=30, figsize=(10, 6)) plt.title("深圳二手房总价分布") plt.xlabel("总价(万)") plt.tight_layout() plt.savefig("total_price_dist.png", dpi=150)逻辑说明: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;,连接参数里固定写charset="utf8mb4"。如果已经建好库,用ALTER DATABASE house_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;补救,然后重建表。这个坑我踩过一次之后,现在写任何入库代码第一行就是charset="utf8mb4"。
现象:同一个房源在表里出现两条,一条价格550万,一条560万,过几天重跑又变成三条。
原因:列表页的分页排序会变化,同一套房可能在不同批次被重复入队。表里没有唯一约束,Pipeline的INSERT每次都当成新数据插入。
解决:给url建唯一索引,再配合ON DUPLICATE KEY UPDATE,让重复抓到的数据走更新而不是插入。建索引SQL:ALTER 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("平均单价", ascending=False) ) # 只看深圳罗湖、北京朝阳这类跨城区域梯队 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对应的样本量,确认识别率没有下降再全量跑。这个习惯能让你少交好多学费,希望帮到你。
本文还有配套的精品资源,点击获取