
简介这是一份基于Python的链家二手房信息爬取与数据库存储设计源码面向需要批量获取房产数据的技术学习者、数据分析与房地产研究人群。系统通过Python脚本抓取链家二手房列表页中的标题、价格、地理位置、房屋详情等关键信息并将爬取到的图片保存至本地同时配合SQL脚本完成数据表结构创建与数据导入实现抓取、存储、备份一体化流程。压缩包共33个文件包含30张jpg房源图片、1个Python爬虫脚本、1个SQL数据库脚本和1个readme.txt说明文档整体体积约816KB目录结构清晰便于对照学习。目前该资源已有174人学习下载readme对安装步骤、运行方法及使用说明做了详细标注可帮助快速部署。借助该项目使用者能掌握页面解析、数据清洗、图片存储以及数据库落库的完整实现思路为二手房市场信息整理或自定义爬虫开发提供可扩展的基础范本。1. 链家二手房爬虫一个能把爬虫和数据库一起练熟的实战项目做爬虫最怕什么不是网站封你而是你爬完了数据不知道往哪放。很多人学爬虫到Requests库就停了JSON打印出来看一眼就完事下一周全忘了。链家二手房这个目标其实是个“教科书级”的训练场页面结构规整、列表页和详情页分离、数据字段足够复杂价格、面积、朝向、装修、挂牌时间而且有真实的反爬压力——不处理UA和Cookie几十个请求就会撞上验证码。基于Python的链家二手房信息爬取与数据库存储设计源码核心思路是用Requests或Scrapy把列表页的房源链接捞出来再进详情页解析结构化字段最后批量写入MySQL或MongoDB。适合刚学完Python基础、想用一个完整项目把网络请求、HTML解析、数据清洗、SQL建表和事务提交串起来的人。这篇文章会给你一条能直接照走的路径。2. 先想清楚要存什么数据结构设计决定爬虫怎么写2.1 链家二手房的字段划分列表页和详情页各拿什么链家的房源数据分布在两级页面上。列表页https://bj.lianjia.com/ershoufang/pg1/每套房源是一个li classclear LOGCLICKDATA节点里面有标题、位置、总价、单价和详情页链接。详情页/ershoufang/{房源编号}.html才有完整的房屋信息小区名、户型、面积、朝向、装修、楼层、年代、产权年限、挂牌时间、带看次数。我建议的字段划分是列表页只拿“房源编号、标题、总价、单价、详情页URL”五个字段用来做去重和待抓取队列详情页拿剩下的字段。不要指望列表页一次给全链家有些字段只在详情页渲染。CREATE TABLE if NOT EXISTS lianjia_house ( house_id VARCHAR(32) PRIMARY KEY COMMENT 链家房源编号, title VARCHAR(255) COMMENT 标题, total_price DECIMAL(10,2) COMMENT 总价(万元), unit_price DECIMAL(10,2) COMMENT 单价(元/平米), community VARCHAR(100) COMMENT 小区名称, layout VARCHAR(50) COMMENT 户型, area DECIMAL(8,2) COMMENT 面积(平米), orientation VARCHAR(20) COMMENT 朝向, decoration VARCHAR(20) COMMENT 装修, floor VARCHAR(50) COMMENT 楼层, build_year INT COMMENT 建筑年代, listing_date DATE COMMENT 挂牌时间, visit_count INT COMMENT 带看次数, detail_url VARCHAR(255) COMMENT 详情页URL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表SQL的要点是house_id设为主键而不是用自增ID因为链家的房源编号本身全局唯一拿它做主键天然具备“幂等插入”的条件——同一个房源重复抓取时可以直接用INSERT ... ON DUPLICATE KEY UPDATE做更新而不是插入新行。total_price和unit_price用DECIMAL而不是FLOAT是为了避免浮点误差后面做均价统计时你会感谢这个决定。created_at自动记录抓取时间方便后面排查数据时效性问题。2.2 为什么我推荐MySQL而不是MongoDB链家二手房数据是典型的结构化数据每条房源字段固定关系明确一个房源一行记录而且你大概率后面要做“按区域统计均价”、“按户型看挂牌周期”这类SQL聚合分析。MySQL在这里是更顺手的选择。MongoDB的优势在字段不固定、需要快速迭代数据模型的场景但链家房源字段基本稳定用文档数据库反而会增加查询成本。如果你非要用MongoDB我建议理由只能是一个你要同时存HTML源码快照做追溯。那是另一个场景不在这个项目的默认范围内。import pymysql db_config { host: localhost, port: 3306, user: root, password: your_password, database: lianjia, charset: utf8mb4, cursorclass: pymysql.cursors.DictCursor } conn pymysql.connect(**db_config) cursor conn.cursor()参数说明charset必须用utf8mb4而不是utf8因为链家的小区名和标题里会出现生僻字和特殊符号比如“壹瓶”、“C·TOWN”这种MySQL的utf8只支持基本多语言平面遇到补充平面的字符会直接报错或存成乱码。cursorclass用DictCursor这样查出来的每行是一个字典字段名直接对应列名后面写业务代码时比元组好用得多。2.3 建索引和唯一约束别等数据量大了才后悔链家一个城市的二手房挂牌量在几万到十几万条之间这个量级对MySQL来说毫无压力但如果没有索引WHERE community 回龙观这种查询照样会全表扫描。ALTER TABLE lianjia_house ADD INDEX idx_community (community); ALTER TABLE lianjia_house ADD INDEX idx_listing_date (listing_date); ALTER TABLE lianjia_house ADD INDEX idx_total_price (total_price);索引策略的思考方式先想清楚你后面会怎么查这批数据。按小区筛选是最常见的动作idx_community必须建按挂牌时间看新增房源是另一个常用维度idx_listing_date建上如果要跑“总价在300-500万之间的房源”这类统计idx_total_price能显著加速范围查询。至于area和orientation这种区分度不高的字段没必要建索引建了也是浪费空间。3. 爬虫核心实现Requests BeautifulSoup 还是 Scrapy3.1 选型依据小规模抓取和分布式抓取的分界线先说结论纯学习目的、单机跑、数据量在几万条以内用 Requests BeautifulSoup 足够要跑全量城市、要断点续爬、要并发控制上 Scrapy。Requests方案的优势是直观每一个步骤都是显式的发请求、拿响应、解析、存库出错了你能清楚地知道是在哪一步翻车的。Scrapy的优势是框架帮你把调度、去重、并发、中间件、Pipeline全串好了但框架的学习曲线会叠加在爬虫本身的难度上如果对异步和Twisted不熟排错时会很痛苦。我一般这样建议第一次做这个项目用 Requests跑通了再考虑要不要迁到 Scrapy。3.2 用 Requests 抓列表页UA 伪装和超时设置import requests from bs4 import BeautifulSoup 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, Referer: https://bj.lianjia.com/, Accept-Language: zh-CN,zh;q0.9, } def fetch_list_page(city: str, page: int) - str: url fhttps://{city}.lianjia.com/ershoufang/pg{page}/ try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text except requests.exceptions.Timeout: print(f[超时] 第{page}页请求超时) return except requests.exceptions.HTTPError as e: print(f[HTTP错误] 第{page}页: {e}) return 这段代码看起来短但有两个关键点一是Referer必须伪装成链家首页链家的反爬校验会检查来源页面空Referer的请求很容易被拦二是timeout10必须设不设的话某个IP被限流后请求会一直挂着程序卡死不动你去看日志才发现已经停了一小时。resp.encoding resp.apparent_encoding这句是拿实际内容推断编码链家的页面是UTF-8但加了这行能防一手意外情况。3.3 解析列表页BeautifulSoup 定位与提取import re from bs4 import BeautifulSoup def parse_list_page(html: str) - list: soup BeautifulSoup(html, lxml) house_list [] items soup.select(ul.sellListContent li.clear) for item in items: link_tag item.select_one(a[href*.html]) if not link_tag: continue detail_url link_tag.get(href, ) house_id re.search(r/(\d)\.html, detail_url).group(1) title item.select_one(.title a).text.strip() total_price item.select_one(.totalPrice span).text.strip() unit_price item.select_one(.unitPrice span).text.strip() house_list.append({ house_id: house_id, title: title, total_price: float(total_price) if total_price else 0, unit_price: float(unit_price.replace(元/平米, ).replace(,, )) if unit_price else 0, detail_url: detail_url, }) return house_list选择器用.开头表示CSS类选择器ul.sellListContent li.clear是链家列表页房源项的标准路径。注意re.search提取的house_id是详情页URL里的数字部分这是后续去重的关键标识。total_price的文本是一个价格数字跨度里的内容去掉单位后直接转float。unit_price的文本类似“58,500元/平米”要先去掉逗号和单位再转数值。3.4 详情页抓取与字段补齐def fetch_detail_page(detail_url: str) - dict: resp requests.get(detail_url, headersHEADERS, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) info {} # 基本属性 info[community] soup.select_one(.communityName a).text.strip() info[layout] soup.select_one(.room .mainInfo).text.strip() info[area] float(soup.select_one(.area .mainInfo).text.strip().replace(平米, )) info[orientation] soup.select_one(.type .mainInfo).text.strip() info[decoration] soup.select_one(.decoration .mainInfo).text.strip() info[floor] soup.select_one(.floor .mainInfo).text.strip() info[build_year] int(soup.select_one(.buildYear .mainInfo).text.strip()[:4]) # 交易属性 transaction_info {} for item in soup.select(.transaction li): label item.select_one(span).text.strip() value item.select_one(.labelValue).text.strip() transaction_info[label] value info[listing_date] transaction_info.get(挂牌时间, ) info[visit_count] int(transaction_info.get(带看次数, 0).replace(次, )) return info详情页的结构相对复杂每个属性块是一个div包着span和span的结构.mainInfo提取的是属性值。交易属性在一个.transaction容器里用labelValue类提取注意挂牌时间可能为空带看次数可能不在页面上链家会隐藏部分数据所以用.get()加默认值兜底。3.5 数据入库批量插入与去重更新def save_to_db(records: list): if not records: return sql INSERT INTO lianjia_house (house_id, title, total_price, unit_price, community, layout, area, orientation, decoration, floor, build_year, listing_date, visit_count, detail_url) VALUES (%(house_id)s, %(title)s, %(total_price)s, %(unit_price)s, %(community)s, %(layout)s, %(area)s, %(orientation)s, %(decoration)s, %(floor)s, %(build_year)s, %(listing_date)s, %(visit_count)s, %(detail_url)s) ON DUPLICATE KEY UPDATE total_price VALUES(total_price), unit_price VALUES(unit_price), visit_count VALUES(visit_count) cursor.executemany(sql, records) conn.commit()executemany是批量执行的重点一次提交一批记录比逐条execute快一个数量级。ON DUPLICATE KEY UPDATE的作用是同一套房源如果被重复抓取价格和带看次数有变化就更新标题和小区名这些不变字段不覆盖。这样既能保证数据新鲜度又不会产生重复行。注意conn.commit()必须在executemany之后显式调用忘记提交是整个项目里最常见的“数据无故丢失”原因。4. 数据库存储优化事务、连接池和重复数据处理4.1 连接池要不要上数据量到了十万条再说爬虫程序是短连接密集型的操作每次pymysql.connect()都走一次TCP握手和MySQL认证几百条数据感觉不出来但爬到几万条的时候连接建立的时间会占到总耗时的30%以上。from dbutils.pooled_db import PooledDB pool PooledDB( creatorpymysql, maxconnections10, mincached2, maxcached5, blockingTrue, ping1, hostlocalhost, port3306, userroot, passwordyour_password, databaselianjia, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor ) conn pool.connection() cursor conn.cursor()PooledDB参数里maxconnections10是连接池上限超过这个数的请求会阻塞等待mincached2是启动时预建的连接数避免第一个请求就现场建连ping1表示每次从池子里拿连接时检查一下连接是否还活着MySQL的wait_timeout默认8小时会断开空闲连接没有这行会拿到死连接然后报错。连接池的意义一句话把“建立连接”这个昂贵动作前置和复用让爬虫的每一秒都花在请求和解析上。4.2 批量插入的batch_size设置executemany虽然是一次API调用但MySQL端还是要一条条执行。batch_size太大会撑爆内存太小又体现不出批量优势。def batch_insert(records: list, batch_size: int 100): for i in range(0, len(records), batch_size): batch records[i:i batch_size] save_to_db(batch) print(f已入库 {i len(batch)} / {len(records)} 条)batch_size100是一个稳妥的经验值单条记录在1KB左右时100条的事务体量是100KBMySQL的max_allowed_packet默认是64MB完全不会触发包体限制。如果你的记录包含详情页大段HTML源码建议把batch_size降到20-30。另外每批次单独commit能避免一个大事务失败后全部回滚定位问题也更容易。4.3 重复数据的三种处理策略链家爬虫的重复数据主要来自三种情况程序中断后重新跑、多线程重复抓取、列表页和详情页的数据交叉。处理策略从上到下分别是-- 策略一主键冲突时更新变动字段 INSERT INTO ... ON DUPLICATE KEY UPDATE total_price VALUES(total_price); -- 策略二先查后插低并发时用 SELECT COUNT(*) FROM lianjia_house WHERE house_id %s; -- 策略三直接忽略重复适合第一次全量抓取 INSERT IGNORE INTO lianjia_house (...) VALUES (...);策略一适合日常增量抓取策略二适合调试阶段能打印出“这条已存在”的日志策略三适合全量初始化第一次爬的时候不需要更新老数据。三种策略各有适用场景别指望一种吃遍天下。5. 避坑手册链家爬虫最常见的五个翻车点5.1 IP被限制验证码页面不是解析失败现象爬到200-300条后解析结果突然全是空列表或者BeautifulSoup选择器定位不到元素。原因链家对同一IP的请求频率做了限流触发后返回的不是正常列表页而是一个验证码页面你的选择器自然什么都选不到。解决检测响应内容里的关键词如果出现“验证码”或verify字样立即停止当前请求进入退避状态。退避时间是30秒到5分钟的随机值别用固定值。if 验证码 in resp.text or verify in resp.text: sleep_time random.randint(30, 300) print(f检测到验证码暂停 {sleep_time} 秒) time.sleep(sleep_time) return fetch_list_page(city, page) # 重试一次为什么要用随机退避而不是固定延时链家的风控系统会识别固定间隔的请求模式固定等60秒和等0秒在它看来都是“机器行为”随机化是让请求间隔在宏观上呈现人类浏览的聚集特征。5.2 字段解析为NoneBeautifulSoup的text属性报错现象AttributeError: NoneType object has no attribute text。原因某个房源条目缺少你选择器对应的标签链家偶尔会把“车位”、“地下室”这类特殊房源混入列表页它们的DOM结构不完整。解决所有.text调用前加None检查或者用select_one的可选链式调用。def safe_text(element) - str: return element.text.strip() if element else 这是血泪经验一个页面里只要有一条特殊房源整个爬虫就会中断。加了这个兜底函数后脏数据最多记成空字符串不会让程序崩溃。5.3 数据库写入乱码utf8和utf8mb4的坑现象入库后查出来的小区名是???或者混京这类乱码。原因MySQL数据库或数据表的字符集是utf8不支持四字节的字符如部分生僻字和emoji。解决建库时显式指定字符集别用默认值。CREATE DATABASE lianjia DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;同时注意连接串里的charsetutf8mb4只是告诉客户端用这个编码通信如果表本身是utf8写入时照样会转码出错。三处要一致库、表、连接串。5.4 分页爬到一半就停URL拼接的边界现象爬到第20页时返回的页面数据和第19页完全一样。原因链家最多展示100页部分城市是99页超过上限后服务端不会返回404而是把请求重定向到最后一页。解决判断当前页和上一页的首条house_id是否相同相同就终止爬取。5.5 程序在半夜断了没有断点续爬机制现象爬了3万条凌晨5点程序崩了早上一看才跑到一半又得从头爬。原因没有把已完成的页码或已入库的house_id持久化到本地。解决用一个简单的progress.json记录已完成的页码重启后从记录的页码继续。import json, os def save_progress(city: str, page: int): progress_file fprogress_{city}.json with open(progress_file, w) as f: json.dump({last_page: page}, f) def load_progress(city: str) - int: progress_file fprogress_{city}.json if os.path.exists(progress_file): with open(progress_file, r) as f: return json.load(f).get(last_page, 0) return 06. 验证与进阶数据可视化分析和增量爬取爬完一万条链家二手房数据后别急着换项目。先用SQL验证数据的合理性和覆盖面再跑几个聚合查询确认数据质量-- 按区域统计挂牌均价 SELECT community, ROUND(AVG(total_price), 2) AS avg_price, COUNT(*) AS cnt FROM lianjia_house GROUP BY community HAVING cnt 10 ORDER BY avg_price DESC LIMIT 20;这个查询能做两件事一是确认数据量够不够分析某个小区只有两三条记录统计出来的均价没有统计意义二是验证单价和总价之间的关系是否合理如果出现总价300万、面积50平米、单价却标出10万一平米的记录说明详情页解析时单位没处理对。更进一步可以写个简单的可视化脚本用Matplotlib画出价格分布直方图、面积和总价的散点图或者用WordCloud对小区名做词频分析看哪些板块是挂牌热点。这些都是在同一张MySQL表上做的分析不用重新抓数据。如果你还想把这个项目做完做深可以考虑增量爬取策略每天定时抓一次链家新增房源用house_id做主键去重只更新价格变动的条目。这样你的表就成了一个“二手房价格时间序列”后面能做的分析比如“某小区挂牌价一周涨跌了多少”会超出大部分课程设计的深度。我做这个项目时最后悔的一件事是没在第一天就把listing_date这个字段的解析做对导致后来想做挂牌周期分析时有三分之一的数据这个字段是空的只能重新补爬。所以前期建表时想清楚你要问数据什么问题比拿到数据后绞尽脑汁想怎么分析要重要得多。希望帮到你。本文还有配套的精品资源点击获取