☰
链家二手房爬虫实战:从requests解析到MySQL存储全流程
2026/9/28 12:06:56 网站建设 项目流程

简介:基于Python的链家二手房信息爬取与数据库存储设计源码,是一套集网页数据抓取、图片自动下载与数据库持久化于一体的实战项目。项目以链家二手房列表页为切入点,自动解析房源标题、价格、地理位置及房屋详情等核心字段,并将整理后的数据按预定格式导入MySQL等数据库,而抓取的房源图片则同步保存到本地。资源包共含33个文件,包括30张爬取所得的jpg图片、1个Python主脚本、1个SQL表结构脚本和1份安装运行说明,压缩包约816KB,结构紧凑。该项目已累计有174人学习下载,适合正在学习爬虫、数据库设计或对房产数据采集有需求的开发者与研究者。通过参考源码及文档,可快速复现整套采集流程,并为后续的二手房市场数据分析、备份管理提供有力支撑。

1. 链家二手房爬虫与数据库存储:先看清这个项目的真实边界

如果你搜过“链家爬虫源码”,大概率会看到一堆跑不通的旧代码。这个标题真正想解决的问题,不是“把网页抓下来”,而是从房源列表页一路到 MySQL 表里的完整链路:解析字段、清洗文本、设计表结构、处理重复数据和反爬。项目的复杂度不高,但坑集中在数据标准不统一和请求频率控制上——链家的列表页是服务端渲染的,requests 直接拿 HTML 就能用,不需要 Selenium,这是先决条件。适合有 Python 基础、想完整走一遍“爬虫 + 数据库存储”全流程的从业者,也适合做二手房价数据分析的人拿来当数据管道的地基。下面按我实际做过的方案拆开讲。

2. 选型与最小骨架:requests 加 BeautifulSoup 为什么够用

2.1 链家列表页是服务端渲染,先确认这一点再动手

判断一个站点能不能用 requests 硬抓,不要凭经验猜,打开浏览器开发者工具看 Network 面板。链家二手房列表页(https://xx.lianjia.com/ershoufang/)返回的 HTML 里直接带有房源标题、单价、总价、小区名、户型、面积、朝向等完整字段,不需要等待异步接口返回。这意味着 requests.get 拿到的响应文本就是最终结果,解析成本最低。

我见过有人一上来就上 Selenium 模拟浏览器,结果被资源加载拖慢,还更容易触发风控。正确顺序是:先用 curl 或 requests 打印响应状态码和前 500 字,确认页面结构是服务端渲染再开始写解析。链家部分字段做过字体反爬,比如有的页面房源标题里的数字会用自定义字体混淆,但列表页的核心字段——总价、单价、面积——目前是明文,可以放心用 CSS 选择器提取。

另一个需要确认的点是编码。响应头里 charset 是 utf-8,但偶尔会因为压缩传输产生乱码。requests 的 Response.apparent_encoding 可以兜底,不过更稳定的做法是在请求头里显式声明 Accept-Encoding: gzip, deflate,让 requests 自动解压,避免拿到压缩流后解析出错。

2.2 从列表页到结构化字典:最小可用代码骨架

下面这段是抓取单页列表并解析出房源核心字段的最小实现,字段覆盖了后续数据库设计的大多数列。

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", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://www.lianjia.com/", } def fetch_page(url: str) -> str | None: """抓取列表页 HTML,失败返回 None。""" try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200: return resp.text if resp.status_code == 403: print(f"[警告] {url} 被拒绝,可能触发了频率限制") return None except requests.RequestException as exc: print(f"[错误] 请求 {url} 异常: {exc}") return None def parse_detail_page(html: str) -> list[dict]: """从列表页 HTML 中抽取房源字典列表。""" soup = BeautifulSoup(html, "html.parser") items = [] for li in soup.select("ul.sellListContent li.clear"): title_tag = li.select_one(".title a") if title_tag is None: continue title = title_tag.get_text(strip=True) link = title_tag.get("href", "") # 位置信息: 小区 / 商圈 / 行政区,用 split 拆更稳 position = li.select_one(".positionInfo") if position: pos_text = position.get_text(separator="|", strip=True) parts = [p for p in pos_text.split("|") if p] else: parts = [] # 房屋信息: 户型 / 面积 / 朝向 / 装修 / 楼层 house_info = li.select_one(".houseInfo") if house_info: house_text = house_info.get_text(separator="|", strip=True) h_parts = [p for p in house_text.split("|") if p] else: h_parts = [] # 总价与单价 total_price = li.select_one(".totalPrice") unit_price = li.select_one(".unitPrice") items.append({ "title": title, "link": link, "district": parts[0] if len(parts) > 0 else "", "area_name": parts[1] if len(parts) > 1 else "", "total_price": total_price.get_text(strip=True) if total_price else "", "unit_price": unit_price.get_text(strip=True) if unit_price else "", "house_info": house_info.get_text("|", strip=True) if house_info else "", "h_parts": h_parts, }) return items

这段代码里有几个位置值得说明。第一,User-Agent用的是 Chrome 的完整 UA 字符串,而不是简写的python-requests,后者在服务器端很容易被识别为爬虫。第二,positionInfo用|做分隔符再拆,是因为链家在这个容器里把“行政区 / 商圈 / 小区”用斜杠分隔,直接get_text()会得到一长串难处理的文本,先按标签分段再拆成功率更高。第三,totalPrice和unitPrice取到的字符串带“万”和“元/平”单位,入库前必须在清洗阶段转成浮点数,这件事放到数据库设计一章一起处理。

fetch_page里的超时设为 10 秒,链家正常响应通常在 300-800 毫秒,超过 10 秒大概率是网络异常或被限流,没必要继续等。如果返回 403,说明 IP 已被临时限制,这时应该停止抓取而不是加重试频率,等 5-10 分钟再继续。这个判断逻辑直接影响后面调度器的设计。

2.3 分页游标:看懂链家列表页的翻页规则

链家二手房列表页的 URL 长这样:/ershoufang/pg2/。第一页没有pg段,第二页开始用pg{n}/标识页码。这个规律意味着可以用一个简单的计数器拼接 URL,不需要解析下一页的链接。

def build_page_url(city_domain: str, page_num: int) -> str: """生成列表页 URL。城市域名形如 'https://bj.lianjia.com'。""" if page_num <= 1: return f"{city_domain}/ershoufang/" return f"{city_domain}/ershoufang/pg{page_num}/" def crawl_pages(city_domain: str, max_pages: int): """顺序爬取前 max_pages 页,遇到 403 直接中断。""" all_items = [] for page in range(1, max_pages + 1): url = build_page_url(city_domain, page) html = fetch_page(url) if html is None: break items = parse_detail_page(html) if not items: print(f"[停止] 第 {page} 页无数据,可能是页数越界或反爬介入") break all_items.extend(items) print(f"[完成] 第 {page} 页,累计 {len(all_items)} 条") return all_items

max_pages的上限要看目标城市的房源总量。北京链家二手挂牌量通常在 4 万套左右,每页 30 条,全量也就 1300 多页,但你大概率不需要抓全量。按区域筛选 URL(比如/ershoufang/chaoyang/pg2/)可以只抓某个商圈,更适合做定向分析。这里的关键参数是max_pages和请求间隔——fetch_page里目前没有 sleep,生产环境必须加,否则第 30 页左右就会触发验证码,这部分在避坑章细说。

3. 数据库存储设计:字段怎么拆、表怎么建,才不用返工

3.1 从房源详情反推字段清单:先建模再建表

很多爬虫项目死在第一步:抓下来是乱糟糟的字典,往数据库里灌的时候发现要么字段不够,要么格式冲突。正确做法是拿到一条完整房源记录后,先人工把字段列出来,再设计表结构。链家二手房列表页能提供的字段包括:标题、链接、行政区、商圈、小区名、户型、面积、朝向、装修情况、楼层、总价、单价,以及houseInfo里混装的其他描述。详情页还能补充挂牌时间、房本年限、电梯、学区等,但那是二级爬虫的事。

对于第一版,我建议把表设计成两个部分:核心字段单列成列,不确定的杂项全部塞进extra_json。理由很直接——链家的展示字段会变,今天多了个“车位配比”,明天改了“供暖方式”,如果每个都加列,迁移成本太高;存成 JSON 文本,查询时用 JSON 函数解析,对第一版完全够用。

核心表设计如下:

CREATE TABLE IF NOT EXISTS lianjia_house ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, house_code VARCHAR(64) NOT NULL COMMENT '房源唯一编码, 取自链接尾部', title VARCHAR(255) NOT NULL, city VARCHAR(32) NOT NULL DEFAULT '未知', district VARCHAR(32) NOT NULL DEFAULT '', bizcircle VARCHAR(32) NOT NULL DEFAULT '', community_name VARCHAR(128) NOT NULL DEFAULT '', layout VARCHAR(64) NOT NULL DEFAULT '' COMMENT '户型, 如 2室1厅1卫', area_sqm DECIMAL(8, 2) NOT NULL DEFAULT 0.00 DEFAULT 0 COMMENT '建筑面积, 单位平米', orientation VARCHAR(32) NOT NULL DEFAULT '' COMMENT '朝向', decoration VARCHAR(16) NOT NULL DEFAULT '' COMMENT '装修: 精装/简装/毛坯', floor_desc VARCHAR(64) NOT NULL DEFAULT '' COMMENT '楼层描述原文', total_price_wan DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT '总价, 单位万', unit_price_yuan DECIMAL(10, 0) NOT NULL DEFAULT 0 COMMENT '单价, 单位元/平米', link VARCHAR(255) NOT NULL DEFAULT '', extra_json TEXT COMMENT '其余字段的JSON快照', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_house_code (house_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='链家二手房房源表';

几个字段设计上的取舍说明。house_code是从详情链接尾部提取的房源编号,比如https://bj.lianjia.com/ershoufang/101234567890.html里的数字串,它是天然的业务主键,比自增 id 更适合做去重。total_price_wan用 DECIMAL(10,2) 而不是 INT,是因为链家显示“589万”,清洗后是 589.00,但有的房源可能有小数点,比如“289.5万”;unit_price_yuan用 DECIMAL(10,0) 是因为单价显示为整数“38914元/平”,直接保留整数即可。area_sqm保留两位小数,链家面积有时显示为“89.6㎡”,浮点误差用 DECIMAL 避免。

extra_json这个设计在改动频繁的爬虫项目里是后悔药。链家列表页的houseInfo字段有时带装修、有时带入户年份,与其为每个可能出现的字段建列,不如以 JSON 形式原样存一份,需要哪个字段再单独解析。这不影响后续做分析——MySQL 8.0 的JSON_EXTRACT函数可以直接查询。

3.2 清洗规则:单位、空白、脏文本一次处理完

网页抓下来的字符串不可能直接入库。我一般写一个独立的clean_house_record函数,把解析出的原始字典清洗成符合表结构的字段。

import re import json def clean_price(price_text: str) -> float | None: """把 '589万' 或 '289.5万' 转成浮点数, 失败返回 None。""" if not price_text: return None # 只保留数字和小数点, 兼容 '589万' 与 '总价589万' 等变体 match = re.search(r"(\d+(?:\.\d+)?)", price_text.replace(",", "")) return float(match.group(1)) if match else None def clean_unit_price(unit_text: str) -> int | None: """把 '38914元/平' 转成整数。""" if not unit_text: return None match = re.search(r"(\d+(?:\.\d+)?)", unit_text.replace(",", "")) return int(float(match.group(1))) if match else None def clean_area(area_text: str) -> float | None: """把 '89.6㎡' 转成浮点数。""" if not area_text: return None match = re.search(r"(\d+(?:\.\d+)?)", area_text.replace(",", "")) return float(match.group(1)) if match else None def extract_house_code(link: str) -> str: """从详情页链接中提取房源编号, 用作去重键。""" match = re.search(r"/ershoufang/(\d+)\.html", link) return match.group(1) if match else link def clean_house_record(raw: dict, city: str) -> dict: """把解析字典转换成插入数据库的干净记录。""" h = raw.get("h_parts", []) # h_parts 典型形如: ['2室1厅1卫', '89.6㎡', '南 北', '精装', '中楼层/共28层'] layout = h[0] if len(h) > 0 else "" area = clean_area(h[1]) if len(h) > 1 else None orientation = h[2].replace(" ", "/") if len(h) > 2 else "" decoration = h[3] if len(h) > 3 else "" floor_desc = h[4] if len(h) > 4 else "" return { "house_code": extract_house_code(raw.get("link", "")), "title": raw.get("title", ""), "city": city, "district": raw.get("district", ""), "bizcircle": raw.get("area_name", ""), "community_name": raw.get("community_name", ""), "layout": layout, "area_sqm": area or 0.00, "orientation": orientation, "decoration": decoration, "floor_desc": floor_desc, "total_price_wan": clean_price(raw.get("total_price", "")) or 0.00, "unit_price_yuan": clean_unit_price(raw.get("unit_price", "")) or 0, "link": raw.get("link", ""), "extra_json": json.dumps({"house_info_raw": raw.get("house_info", "")}, ensure_ascii=False), }

清洗函数里最值得留意的是正则写法。(\d+(?:\.\d+)?)匹配整数或带小数的数字,replace(",", "")处理链家偶尔展示的千分位分隔符,比如“38,914元/平”这种格式。把清洗逻辑独立成函数而不是在解析时顺手做,是为了后续接入详情页爬虫时能复用——详情页返回的字段更多,但清洗规则是一致的。

3.3 批量插入与去重:INSERT IGNORE 还是 ON DUPLICATE KEY UPDATE

数据入库的阶段,最常犯的错是一条一条 insert。链家一个城市全量几万条,逐条插入要几十分钟,批量插入只要几秒。但批量插入必须处理主键冲突,这里有个选型问题:第一次全量抓取时,用INSERT IGNORE最简单,重复的house_code直接丢弃;后续增量更新时,应该用ON DUPLICATE KEY UPDATE更新价格等易变字段。

import pymysql def batch_insert(records: list[dict], use_upsert: bool = False) -> int: """批量写入房源表, use_upsert 控制是否更新已有记录。""" if not records: return 0 config = { "host": "127.0.0.1", "port": 3306, "user": "root", "password": "your_password", "database": "house_db", "charset": "utf8mb4", } conn = pymysql.connect(**config) try: with conn.cursor() as cursor: keys = [ "house_code", "title", "city", "district", "bizcircle", "community_name", "layout", "area_sqm", "orientation", "decoration", "floor_desc", "total_price_wan", "unit_price_yuan", "link", "extra_json", ] placeholders = ", ".join(["%s"] * len(keys)) sql = f"INSERT INTO lianjia_house ({', '.join(keys)}) VALUES ({placeholders})" if use_upsert: sql += " ON DUPLICATE KEY UPDATE total_price_wan=VALUES(total_price_wan), " \ "unit_price_yuan=VALUES(unit_price_yuan), update_time=NOW()" values = [ [r.get(k, "") for k in keys] for r in records ] affected = cursor.executemany(sql, values) conn.commit() return affected finally: conn.close()

参数说明:executemany预处理后批量执行,相比逐条execute能快一个数量级。use_upsert参数控制增量场景的行为——全量抓取传 False,用 INSERT IGNORE 的语义跳过重复;每日增量传 True,已存在的房源只更新总价和单价,其余静态字段如户型、朝向不变。注意这里没有把ON DUPLICATE KEY UPDATE写成INSERT IGNORE,是因为二手房的价格是动态变化的,今天挂牌 589 万,下周可能调成 579 万,忽略掉这 10 万的价差,数据分析结果就是错的。

4. 链家爬取避坑指南:编码、脏文本、反爬的 5 条实战记录

4.1 编码乱码:控制台正常但文件里是锟斤拷

现象:用resp.text打印 HTML 正常,写入 CSV 后用 Excel 打开全是乱码,或者 MySQL 里存的中文变成“???”。

原因:链家响应头没有显式指定 charset,requests 默认按 ISO-8859-1 解码,导致中文全部变成乱码。有些人的代码在 print 时恰好被终端转码“掩盖”了问题,写入文件时才暴露。

解决:在请求返回后强制指定编码。链家页面实际是 UTF-8,所以resp.encoding = "utf-8"要在拿到响应后立刻设置,再取resp.text。另外 MySQL 连接参数必须带charset="utf8mb4",建表语句也要带DEFAULT CHARSET=utf8mb4,否则存入的 emoji 字符(比如户型里的特殊符号)会被截断。

4.2 字段缺项导致解析越界

现象:parse_detail_page偶尔报IndexError: list index out of range,或者几条房源的小区名、商圈是空的。

原因:链家列表页存在少量广告位和失效房源。广告位的li标签没有.houseInfo或.positionInfo元素,解析函数按固定下标取parts[0]时就越界。另外链家对部分房源隐藏了商圈信息,只显示到行政区级别。

解决:解析时先判断元素是否存在,再取下标。我在parse_detail_page里用if len(parts) > 0做保护,就是为了处理这种结构性缺项。更保险的做法是在clean_house_record里对空字符串给默认值,不让脏数据在插入时才崩溃。入库后也可以跑一个 SQL 查空值比例,确认是普遍现象还是个别记录异常。

4.3 请求过快触发验证码,403 后页面变成滑块

现象:连续跑 30-40 页后,fetch_page返回 403,换浏览器看同一 URL 出现滑块验证,需要人工拖动才能继续。

原因:链家对短时间高频请求有 IP 维度限流。requests 的默认行为是每次请求之间间隔极短,只要连续访问十几页,很容易触发风控。这不是封 IP,而是临时限制,几小时后自动解除。

解决:在每页请求之间加随机 sleep。我一般用time.sleep(random.uniform(1.5, 3.5)),既避免规律性间隔被识别,又能把单页请求频率控制在阈值以下。另一个有效手段是轮换User-Agent,准备一个列表随机取用,减少指纹一致性。注意不要用代理池——链家对代理 IP 的容忍度更低,尤其是数据中心 IP,命中即封。这个方案稳,但跑全量几万条需要 1-2 小时,可接受。

4.4 房源价格单位不统一:有的“万”有的“元/平”

现象:入库后total_price_wan有值,但取出来分析时发现部分记录价格少了一位数,unit_price_yuan也出现个位数的异常值。

原因:链家在列表页对总价和单价使用了不同的展示格式——总价固定是“589万”,单价“38914元/平”,看起来统一。但个别房源(比如车位或商业性质)会显示成“总价45万元”或“单价1.2万元/平”,正则(\d+(?:\.\d+)?)能抓到数字,但“万元”和“元”差了 10000 倍,不做单位换算就会出错。

解决:清洗函数里增加单位判断。clean_price检测文本是否含“万”,有则直接取数;如果有“亿”,先乘 10000 再存;单价部分如果不含“元/平”,按文本是否含“万”做二次换算。这个判断不能省,链家二手房里确实会出现总价上亿的豪宅页面。

4.5 增量更新把下架房源留在库里

现象:今天抓 500 条,明天再抓,昨天 480 条还在,但库里没标记哪些已经下架,统计挂牌量时数字虚高。

原因:二手房是增量变化的商品,房子卖掉或下架后列表页就不再展示,但数据库里旧记录还在。如果只做插入和更新,没有“失效检测”,表里的数据会随时间推移包含大量已下架房源,均价分析也就不准。

解决:每次全量抓取后,把本次出现的house_code集合与数据库已有集合做对比,找出“上次有、这次无”的记录,给它们打is_active=0标记。这比删除好——保留历史快照,分析时可以按is_active过滤当前在售房源。MySQL 更新时用UPDATE lianjia_house SET is_active=0 WHERE city=%s AND house_code NOT IN (...),注意 IN 列表长度有限制,超过 1000 项要分批处理。

5. 增量更新与限速调度:让爬虫按天跑,而不是跑一次就废

5.1 按页游标和已抓集合控制增量范围

全量爬虫写出来不难,难的是让它每天自动跑增量。链家二手房列表页的排序不是完全稳定的——同一套房源可能因为调价在列表里换位置,所以“只看第一页”的增量策略会漏数据。我采用的做法是:每天抓取每个目标区域的前 10 页(约 300 条),用house_code集合做去重。这个量级对城市级分析足够,因为真正剧烈变动的房源(调价、新上架、下架)基本都出现在排序靠前的位置。

def daily_incremental(domain: str, regions: list[str], page_limit: int = 10): """按区域跑增量任务, regions 形如 ['chaoyang', 'dongcheng']。""" all_records = [] for region in regions: for page in range(1, page_limit + 1): url = build_region_page_url(domain, region, page) html = fetch_page(url) if html is None: break items = parse_detail_page(html) if not items: break cleaned = [clean_house_record(item, city=domain.split(".")[0]) for item in items] all_records.extend(cleaned) sleep_after_request() # 直接入库, 已存在的房源走 ON DUPLICATE KEY UPDATE batch_insert(all_records, use_upsert=True) # 标记本次未出现的房源为失效 mark_inactive_records(domain, all_records)

build_region_page_url和全局列表页不同:区域页 URL 是https://{city}.lianjia.com/ershoufang/{region}/pg{n}/,第一页没有pg段,这里不再重复贴代码。增量场景下use_upsert=True的关键作用是修正价格波动——昨天这个房 589 万,今天调成 579 万,ON DUPLICATE KEY UPDATE 能让库里的价格跟随真实挂牌变动。mark_inactive_records的参数校验也很重要:如果当天任务失败或者抓到的记录数异常少,就不该做失效标记,否则会误伤大量正常房源。

5.2 限速、UA 轮换与失败重试的工程参数

增量任务挂在定时器上,每跑一次都伴随反爬风险。我常用的调度策略是:每次请求后随机休眠 1.5-3.5 秒,重试策略用指数退避——第一次失败等 5 秒重试,第二次 10 秒,第三次直接放弃该页并记录日志。这里有个容易被忽视的细节:重试前的等待不能固定,固定间隔比随机间隔更容易被判为机器行为。

import time import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) " "Gecko/20100101 Firefox/121.0", ] def get_headers() -> dict: return { "User-Agent": random.choice(UA_POOL), "Accept-Language": "zh-CN,zh;q=0.9", } def sleep_after_request() -> None: time.sleep(random.uniform(1.5, 3.5)) def fetch_page_with_retry(url: str, max_retries: int = 3) -> str | None: """带指数退避的页面抓取, 返回 None 表示彻底失败。""" for attempt in range(max_retries): resp = requests.get(url, headers=get_headers(), timeout=10) if resp.status_code == 200: return resp.text if resp.status_code in (401, 403): wait = (2 ** attempt) * 5 print(f"[限流] {url} 返回 {resp.status_code}, {wait}s 后重试") time.sleep(wait) continue time.sleep(2) return None

这段代码里,UA 池每次随机取用,避免同一个 UA 长时间高频出现;限流的重试等待是2 ** attempt * 5,即 5 秒、10 秒、20 秒,三次失败后放弃该页继续下一页。600 页的增量任务,按每页 sleep 2.5 秒算,大约 25 分钟跑完,刚好在限流阈值可控范围内。日常观察如果连续 5 页都返回 403,应该主动停止整个任务而不是单页重试——说明 IP 已经进入冷却期,再跑只会加重限制。

5.3 调度与日志:定时任务不能只有成功路径

增量爬虫跑在服务器上,挂了没有任何输出就等于白跑。我建议至少做三件事:写结构化日志到文件、把任务结果写入一张crawl_log表、失败时输出告警信息。日志字段最少包含:任务开始时间、抓取页数、新增条数、更新条数、失败页码列表。

import logging logging.basicConfig( filename="crawl.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) def log_crawl_result(task_name: str, page_count: int, total: int): logging.info(f"任务={task_name} 页数={page_count} 记录数={total}")

日志文件会告诉你限流发生的规律:如果正好在第 40 页左右开始 403,说明当前页数阈值是上限,要把每次任务的页数限制从 100 降到 60,而不是盲目加 UA。Cron 定时任务用0 3 * * *这类凌晨时段跑增量,避开白天用户访问高峰,风控阈值会放宽不少。

6. 跑完一版之后:验证数据质量并做最小可用分析

6.1 入库后先查这四条 SQL,别急着分析

数据入库只是开始,多数人栽在“分析结果异常但不知道是爬虫问题还是业务问题”。我每次跑完增量,第一件事是跑几个校验查询。缺失率要在 1% 以下;单价离群值用 3-sigma 找出来看一眼;同house_code重复记录数必须为 0;价格字段出现 0 或负数说明清洗环节有漏。

-- 1. 检查字段缺失率 SELECT COUNT(*) AS total_rows, SUM(area_sqm <= 0) AS zero_area, SUM(total_price_wan <= 0) AS zero_price, SUM(community_name = '') AS empty_community FROM lianjia_house WHERE city = 'bj'; -- 2. 检查重复 house_code SELECT house_code, COUNT(*) AS cnt FROM lianjia_house GROUP BY house_code HAVING cnt > 1; -- 3. 检查单价离群值: 偏离均值 3 个标准差 SELECT district, area_sqm, total_price_wan, unit_price_yuan FROM lianjia_house WHERE unit_price_yuan > ( SELECT AVG(unit_price_yuan) + 3 * STDDEV(unit_price_yuan) FROM lianjia_house ) LIMIT 20;

这些查询跑完,基本能定位清洗逻辑的问题。比如说zero_area比例突然从 0% 涨到 5%,大概率是链家改版了houseInfo的 DOM 结构,h_parts[1]不再是面积字段。这时候优先检查页面结构,而不是清洗函数——链家改版时,旧解析规则会集体失效,这是爬虫项目的常态。

6.2 把存储数据接一个简单的挂牌均价分析

数据落库且验证通过后,可以直接用 SQL 算分区间的挂牌均价。这个结果比第三方房价平台更新更快,也更能反映真实挂牌趋势。

SELECT district, COUNT(*) AS listing_count, ROUND(AVG(total_price_wan), 2) AS avg_total_price, ROUND(AVG(unit_price_yuan), 0) AS avg_unit_price, ROUND(AVG(area_sqm), 1) AS avg_area FROM lianjia_house WHERE city = 'bj' AND is_active = 1 GROUP BY district ORDER BY avg_unit_price DESC;

is_active = 1这个过滤条件很关键,它保证统计的是当前挂牌房源而不是历史累积。把这张表导出成 CSV,再接 Python 的 pandas 或者直接套可视化模板,就能生成区域挂牌价热力图。这套管道跑通后,每周手动触发一次增量任务,数据就在持续更新——不需要重写爬虫,只是调参和换区域。

我个人的习惯是每三个月复查一次解析规则和表结构,因为链家的页面结构平均半年会有一次调整。全文抓住一个原则:解析和存储解耦,清洗独立成函数,表设计留 JSON 扩展位,这样页面改版时只动一个模块,数据层不用跟着返工。希望这套方案能帮你在二手房数据方向少走几条弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询