简介:这份基于Python的链家二手房信息爬取与数据库存储设计源码,面向希望学习网络爬虫与数据持久化的Python初学者,也适合二手房产数据研究及课程设计场景。系统能够自动抓取链家网二手房列表中的标题、价格、地理位置等核心字段,将结构化数据写入SQL数据库,并把对应房屋图片保存到本地,实现从采集到存储的完整链路。压缩包共33个文件,含30张JPG图片、1个Python主脚本、1个SQL建表与导入脚本以及1个TXT说明文档,整体约816KB,结构清晰,便于直接阅读与二次修改。已有173人学习下载。通过readme说明可快速部署运行,调整SQL表结构和抓取字段即可适配不同需求;既可用于小规模二手房信息备份,也是理解requests解析、数据库交互等环节的实战参考。
1. 链家二手房爬取与数据库存储:这门数据库课程设计的正确拆法
链家二手房最近成了数据库课程设计里的高频选题,原因很直白:房源列表自带价格、小区、户型、朝向、挂牌时间这类结构化字段,入库后能直接跑聚合查询和排序;爬取难度又刚好卡在入门与进阶之间。拿豆瓣电影top250练手会觉得太静态,换懂车帝二手车要处理接口签名,直接上boss直聘还会撞上token逆向,链家拦人的方式主要是请求头校验和访问频次限制,属于调参就能过的那一档。这篇文章按“基于Python的链家二手房信息爬取与数据库存储设计源码”这个题目的常规做法走一遍:Python环境怎么配、列表页和详情页各取哪些字段、MySQL表怎么建、批量写入怎么去重和防止乱码,最后补上入库后的校验手段,整套代码可以直接搬进自己的课程设计项目。
2. Python安装与请求前置:Session复用、UA伪装和访问节奏
2.1 用VSCode配置Python虚拟环境并装齐依赖
Python版本选3.9以上即可,不需要追新版本,3.11最稳。装完Python之后打开VSCode,装好Python扩展,在项目目录里建虚拟环境并激活:
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install requests beautifulsoup4 lxml pymysql fake-useragentrequests负责HTTP请求,beautifulsoup4做页面解析,lxml是bs4背后的C解析器,pymysql用来连MySQL,fake-useragent用来生成浏览器User-Agent。这里有个常见疏漏:只装beautifulsoup4不装lxml,解析时会退回python内置解析器,链家页面本身是完整HTML,不出错但速度明显慢,所以五个依赖最好一次装齐。
虚拟环境激活后,先确认版本,避免后续因为Python或pip版本差异多绕弯:
python -V pip listpip list输出里能看到requests、beautifulsoup4、lxml就说明环境就绪。Windows终端没有grep命令,直接用pip list查看最省事。这里顺便说明一个选择:不要用scrapy起步,只做课程设计或几千条数据的小规模抓取,requests加四个轻量依赖更容易写在论文里,也更好debug。
2.2 三个必调请求参数:User-Agent、Referer和Cookie
链家对请求头的校验比普通静态站严格,但又没做到指纹识别。一个最近版本的ChromeUA、一个合理的Referer、一个完整Cookie,就能绕过绝大多数请求头层面的拦截。
| 参数 | 常用取值 | 作用 |
|---|---|---|
| User-Agent | Chrome或Safari开头的完整UA | 应付请求头校验,不能带Python-requests字样 |
| Referer | 当前城市的链家二手房列表首页 | 声明访问来源,避免被当站外采集 |
| Cookie | lianjia_uuid、select_city等 | 维持会话身份,应对访问频率限制 |
封装Session的常见做法如下:
import requests from fake_useragent import UserAgent def build_session() -> requests.Session: # fallback 参数防止 fake_useragent 的在线数据源偶发抽风 ua = UserAgent(fallback="Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0 Safari/537.36") session = requests.Session() session.headers.update({ "User-Agent": ua.random, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "keep-alive", "Referer": "https://sh.lianjia.com/ershoufang/", }) return sessionua.random每次请求随机取一个UA,配合Session的keep-alive连接复用,比手写固定UA更像浏览器。Referer这里填的是上海链家二手房首页,如果抓北京就把域名换成bj.lianjia.com。注意Session只在一次运行内复用UA,同一个UA连续请求几十页也会被识别,所以推荐在翻页循环里每十次请求重新调用一次ua.random更新header。
Cookie是最容易踩坑的一项。纯requests第一次访问链家能拿到200,翻到第三五页却突然跳验证页,多半是Cookie缺了。我一般会在浏览器无痕窗口手工打开一次链家二手房列表,F12把Application里的Cookie整体复制,直接塞进请求头:
session.headers["Cookie"] = "lianjia_uuid=xxx; select_city=310000; l2_...;"不要用requests的cookies参数逐条传,直接放header里最省事,也不会因为个别cookie的格式差异导致校验失败。
2.3 随机sleep与分页节奏:每页至少停两秒
把抓取间隔设成固定2秒并不是好习惯,请求间隔固定且数量均匀的访问,从统计上看反而更容易被识别成脚本。常见做法是设一个区间,用random.uniform生成浮动值:
import random import time def polite_wait(): # 每页之间休息,区间不必太长,但一定要随机 time.sleep(random.uniform(2.0, 5.5)) def get_page(session, url, retries=3): for attempt in range(retries): try: resp = session.get(url, timeout=10) if resp.status_code == 200 and "sellListContent" in resp.text: return resp.text except requests.RequestException: pass polite_wait() return Noneget_page返回None时不要继续翻页,直接中断这一轮的抓取。链家二手房每个城市最多能翻100页,比如上海大约3000条房源,按每页30条、每页3.5秒算,一轮抓完接近6分钟,但持续不断气的请求一定触发频控。我在翻页循环里的策略是:每页结束后立刻sleep,连续两个超时请求就结束当前城市抓取并记录日志。另外get_page里的超时参数一定要写,不写timeout的requests请求可能挂到几十分钟。
3. 链家二手房页面解析:列表页提字段,详情页补数据
3.1 房源卡片怎么定位:BS4的select与.sellListContent
列表页URL格式是https://sh.lianjia.com/ershoufang/pg2/,第一页可以省略pg1。页面上每个房源是一个<li>,挂在<ul class="sellListContent">下,每个li里有title、positionInfo、houseInfo、totalPrice四个区块。先用select把页面里所有房源li抽出,再针对每个区块解析。
from bs4 import BeautifulSoup def parse_list_html(html: str) -> list[dict]: soup = BeautifulSoup(html, "lxml") items = [] # .sellListContent 是链家列表容器,li 是单个房源卡片 for li in soup.select(".sellListContent li"): item = {} title_a = li.select_one(".title a") item["title"] = title_a.get_text(strip=True) if title_a else None # house_id 从详情链接末尾解析,这是后面去重的唯一键 item["house_id"] = None if title_a and "href" in title_a.attrs: href = title_a["href"] item["house_id"] = href.rstrip("/").split("/")[-1].replace(".html", "") # positionInfo 里通常有行政区和小区两个及以上链接 pos_links = li.select(".positionInfo a") if pos_links: text_list = [a.get_text(strip=True) for a in pos_links] item["district"] = text_list[0] item["bizcircle"] = text_list[1] if len(text_list) > 1 else None item["community"] = text_list[-1] else: item["district"] = item["bizcircle"] = item["community"] = None # houseInfo 的字段顺序之前变过,用关键词定位比固定下标稳 parts = li.select_one(".houseInfo").get_text(" ", strip=True).split("|") if li.select_one(".houseInfo") else [] def pick(keyword): return next((p.strip() for p in parts if keyword in p), None) item["layout"] = pick("卫") # 2室1厅1卫 item["area"] = pick("平米") # 88.12平米 item["floor_info"] = pick("楼层") # 中楼层/共19层 item["orientation"] = pick("南") or pick("北") or pick("东") or pick("西") # 装修单独匹配,避免和 positionInfo 里的文字混在一起 decoration = next((p.strip() for p in parts if p.strip() in {"精装", "简装", "毛坯", "豪装"}), None) item["decoration"] = decoration # 总价区域有一个 span 保存数字,单位是万 total_span = li.select_one(".totalPrice span") total_text = total_span.get_text(strip=True) if total_span else None item["total_price"] = float(total_text) if total_text else None items.append(item) return items这段代码里最关键的两个点:一是house_id从详情链接取,链家的详情链接形如https://sh.lianjia.com/ershoufang/107103842443.html,末尾那段纯数字就是全局唯一编号;二是houseInfo的字段顺序不要写死,链家改过页面结构,早年的版本是楼型开头,新版变成户型开头,用pick(keyword)按关键词搜索,字段顺序变了也不会取错。area字段建议在存入数据库前转成float,保留两位小数。
3.2 详情页需要单独抓的字段:关注人数、挂牌时间、楼栋信息
列表页能拿到房源标题、行政区、小区、户型、面积、朝向、装修、楼层、总价这九项,但数据库课程设计如果想做得更完整,通常还会补详情页里的挂牌时间和关注人数。这两个字段在列表页拿不到,需要单独请求详情页面。
import re def parse_detail_html(html: str) -> dict: soup = BeautifulSoup(html, "lxml") item = {} follow_match = re.search(r"(\d+)人关注", html) if follow_match: item["follow_count"] = int(follow_match.group(1)) # 基本信息区用 label 做键值匹配,页面结构变化时只丢单字段 for li in soup.select(".baseattribute li"): label = li.select_one(".label") content = li.select_one(".content") if not label or not content: continue key = label.get_text(strip=True).rstrip("::") value = content.get_text(strip=True) if key == "挂牌时间": item["listing_date"] = value elif key == "建筑年代": item["build_year"] = int(value) if value.isdigit() else None return itemfollow_count用正则直接从HTML源码里抓“数字+人关注”,比遍历DOM节点更快。挂牌时间和建筑年代则遍历.baseattribute li,按label的文本匹配。这里不建议用固定索引,因为详情页的明细行顺序偶尔会有差异。详情页请求频率要比列表页更保守,一个li配一个详情页会使请求量放大三十倍,我在课程设计里的做法是只采集前500条房源的详情页,其余字段留空,论文里说明采样范围即可。
3.3 find与select的取舍:页面结构变化时的兜底方案
select基于CSS选择器,写起来简洁,但链家调整HTML层级时,比如.totalPrice span改成.totalPrice > span,选择器会静默返回空。find和find_all基于标签和属性名匹配,灵活性低,但对层级关系相对宽容。
我的兜底写法是解析主流程用select,关键字段出现缺失时立刻切find验证:
if not items: # 反爬页通常没有游? .sellListContent,判定为风控后直接返回 return [] soup_check = BeautifulSoup(html, "lxml") if not soup_check.find("ul", class_="sellListContent"): return []列表页解析出全部空字段或者items长度明显小于每页30条,说明DOM选择器失效,审计日志就要记录“parse_mismatch”。另外每次运行前先手工看一眼最新版网页源码里的class名,比长期信任一段固定选择器可靠得多。
4. 数据库存储设计:MySQL表结构、批量写入与唯一键去重
4.1 按课程设计要求建表:字段、索引、唯一约束
MySQL建表是数据库课程设计的核心评审项,字段类型、主键、唯一键、索引都要能在答辩时说清楚。链家二手房抓取结果的表结构按下面这种形态设计:
CREATE DATABASE IF NOT EXISTS lianjia_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE lianjia_db; CREATE TABLE IF NOT EXISTS ershoufang ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', house_id VARCHAR(20) NOT NULL COMMENT '链家房源编号,抓取去重的唯一键', title VARCHAR(120) DEFAULT NULL COMMENT '挂牌标题', district VARCHAR(50) DEFAULT NULL COMMENT '行政区', bizcircle VARCHAR(50) DEFAULT NULL COMMENT '商圈', community VARCHAR(80) DEFAULT NULL COMMENT '小区名称', layout VARCHAR(50) DEFAULT NULL COMMENT '户型,如2室1厅1卫', area DECIMAL(6,2) DEFAULT NULL COMMENT '建筑面积,单位平米', orientation VARCHAR(20) DEFAULT NULL COMMENT '朝向', decoration VARCHAR(20) DEFAULT NULL COMMENT '装修情况', floor_info VARCHAR(60) DEFAULT NULL COMMENT '楼层描述', build_year SMALLINT UNSIGNED DEFAULT NULL COMMENT '建筑年代', total_price DECIMAL(9,2) DEFAULT NULL COMMENT '总价,单位万元', unit_price DECIMAL(7,2) DEFAULT NULL COMMENT '单价,单位元/平米', listing_date DATE DEFAULT NULL COMMENT '挂牌时间', follow_count INT UNSIGNED DEFAULT NULL COMMENT '关注人数', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近更新时间', PRIMARY KEY (id), UNIQUE KEY uk_house_id (house_id), KEY idx_district (district), KEY idx_total_price (total_price) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='链家二手房抓取结果表';| 字段 | 类型 | 约束说明 |
|---|---|---|
| house_id | VARCHAR(20) | 房源唯一编号,自动去重的核心 |
| area | DECIMAL(6,2) | 面积保留两位小数,避免浮点误差 |
| total_price | DECIMAL(9,2) | 大户型总价会到千万,9位够了 |
| create_time | DATETIME | 入库时间,自动填充 |
| update_time | DATETIME | 更新时时间,配合价格变更追踪 |
house_id设UNIQUE KEY是最关键的设计决策。每次抓取结果里同一套房源重复出现时,可以直接交给MySQL判断重复,不需要在Python里维护一个已见集合。created_time设为DEFAULT CURRENT_TIMESTAMP,每条数据落库时自动写时间,少写一行插入语句。为什么不用url做主键?链家房源短链有时带utm参数,不同tracking参数导致URL不同但房源是同一套,只有去掉参数的house_id才能稳定去重。
4.2 pymysql参数化执行与批量写入代码
pymysql是纯Python实现的MySQL驱动,课程设计环境里比mysql-connector更轻量。连接数据库时charset这一项必须传utf8mb4,不传的话中文会以latin1编码写入导致乱码。
import pymysql def save_items(items: list[dict], batch_size: int = 50) -> int: conn = pymysql.connect( host="localhost", user="root", password="your_password", database="lianjia_db", charset="utf8mb4", autocommit=False, ) # 只保留有 house_id 的数据,防止脏行入库 rows = [ ( it["house_id"], it["title"], it["district"], it["bizcircle"], it["community"], it["layout"], it["area"], it["orientation"], it["decoration"], it["floor_info"], it["build_year"], it["total_price"], ) for it in items if it.get("house_id") ] sql = """ INSERT INTO ershoufang (house_id, title, district, bizcircle, community, layout, area, orientation, decoration, floor_info, build_year, total_price) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE title = VALUES(title), total_price = VALUES(total_price), update_time = CURRENT_TIMESTAMP """ affected = 0 with conn.cursor() as cursor: for i in range(0, len(rows), batch_size): # 分批执行,避免一次提交太多参数导致SQL包过大 affected += cursor.executemany(sql, rows[i:i + batch_size]) conn.commit() conn.close() return affected参数化这里的意义是防止SQL注入,课程设计答辩被问“为什么不用字符串拼接”时,直接回答参数化能避免字段里的单引号破坏SQL结构。executemany内部会把数据分批绑定到预编译语句上,网络交互比循环execute少很多。50条一批是性能和内存的平衡点,一次几百条也可以,但超过上千条时SQL包变大,反而容易触发MySQL的max_allowed_packet限制。
这里补充一个MySQL版本相关的细节:8.0.20起VALUES()被标记为deprecated,虽然目前还能用,但答辩时如果用的是MySQL 8.0.20以上版本,建议写成INSERT ... AS new ON DUPLICATE KEY UPDATE title = new.title的新语法。MySQL 5.7则完全兼容VALUES()写法,大多数课程设计环境都没问题。
4.3 重复抓取的处理:跟价更新而不是插入新行
ON DUPLICATE KEY UPDATE在这里承担了“跟价”功能。同一套房源再次抓到,house_id已存在,MySQL执行UPDATE而不是INSERT。上面SQL里update_time = CURRENT_TIMESTAMP的写法,配合表的ON UPDATE CURRENT_TIMESTAMP效果一样,写在SQL里更明确,查询这条记录有没有在我们这次抓取中更新,直接看update_time即可。
如果课程设计要求的只是“不重复入库”,不需要跟踪价格变化,可以把ON DUPLICATE KEY UPDATE整段换成INSERT IGNORE。两者的差异是:INSERT IGNORE遇到重复键直接丢弃新数据,更新时间不变;ON DUPLICATE KEY UPDATE会覆盖旧值。做爬虫库的增量更新一般倾向后者。
pymysql事务的细节也值得注意:connect时设置autocommit=False,一批抓取全部执行后再conn.commit(),中途失败时数据不会半截入库。执行完成后affected返回值是插入和更新行数的总和,抽样核对数据量时可以直接打印这个值。
5. 链家二手房入库后的正确性校验:三条最容易被忽略的检查点
5.1 先看总数是否一致,再看峰值归属
抓完一批数据,第一步不是急着写报告,而是核对总数:
mysql -h localhost -u root -p lianjia_db -e \ "SELECT COUNT(*) AS total, COUNT(DISTINCT house_id) AS unique_count FROM ershoufang;"total和unique_count不一致说明去重逻辑失效,通常是house_id没解析出来,去检查列表页链接格式是否变化。链家一个城市最多100页约3000条房源,如果库里的总数远小于这个值,说明中途踩了风控导致提前退出,不是正常结束。
5.2 按行政区聚合,验证字段有没有串位
SELECT district, COUNT(*) AS cnt, ROUND(AVG(total_price), 2) AS avg_price, ROUND(AVG(area), 2) AS avg_area FROM ershoufang GROUP BY district ORDER BY cnt DESC;行政区和板块的平均价如果出现明显的数量级异常,比如某个商圈均价显示成几万元,多半是解析阶段字段串位,把总价和单价串了。链家列表页同时有总价和每平米单价,我见过不少人解析时把.unitPrice的文本存进total_price,导致均价和真实房价对不上。
5.3 抽样比对详情页,确认两个被忽略字段
抽查三到五套房源,连着点开线上详情页手工比对朝向、挂牌时间、跟进人数。这三个字段最容易出现解析偏差,朝向可能被拆成南北两个,挂牌时间的格式在链家内部也有“2025-01-05”和“2025.01.05”两种,入库前统一转成%Y-%m-%d格式,用DATE类型存,后面做按月份分组统计时才不会出错。比对完成后在日志文件里随机打印几条完整记录,留下审计痕迹。
链家二手房这条链路的完整度其实比大多数课程设计要求高出一截,从requests请求到BeautifulSoup解析再到pymysql入库,每个环节都有独立的错误处理,答辩时可以按“请求层—解析层—存储层”逐层展开,遇到评审问哪个字段丢了,直接说这轮以列表页为主,详情页只补了挂牌时间和关注人数,能自圆其说就算合格。
# 校验脚本最后一段,打印每条抽样房源的完整信息 import json with open("sample_check.json", "r", encoding="utf-8") as f: sample = json.load(f) for r in sample: print(r["house_id"], r["district"], r["community"], r["layout"], r["area"], r["total_price"])本文还有配套的精品资源,点击获取