简介:这是一份基于Python的链家二手房信息爬取与数据库存储源码包,面向需要快速获取二手房市场数据的研究者、投资分析人员或爬虫学习者,解决房源信息自动抓取、结构化落库与图片本地备份的问题。压缩包共33个文件,含Python爬取脚本、SQL建表入库脚本、txt使用说明以及30张jpg房源图片,整体仅816KB,结构紧凑、便于直接对照部署。系统会自动解析链家二手房列表中的标题、价格、位置与房屋详情,并按预定格式写入数据库,实现数据抓取、存储与图片保存的一体化流程。目前已有174人浏览学习,适合用于二手房信息管理、市场数据分析及数据备份场景。项目代码体现了抓取与存储紧密结合的设计思路,配合readme说明可快速上手,对希望掌握通用爬虫流程和数据库落盘方案的读者具有实际参考价值。
1. 为什么都在爬链家二手房:一张HTML页面背后的房源数据库生意
链家二手房是房产数据领域最规整的公开页面之一:列表页每一套房都带总价、单价、面积、朝向、楼层、小区名,详情页还能补齐建筑年代、挂牌时间这些字段。做区域价格监测、小区均价对比、学位房价格口径分析的人,第一反应基本都是用Python爬链家二手房信息,然后把解析结果存进数据库做统计。这篇文章按一条完整的Python爬虫实战路径来走:requests抓列表页、BeautifulSoup解析字段、SQLite存储设计,全程可以直接复现。文章后半部分把反爬触发、数据失真和分页截断这些容易翻车的细节单独列出来,避免你爬到一半回头改表结构。
2. 选型与架构:requests还是Scrapy,SQLite还是MySQL
2.1 requests + BeautifulSoup 为什么是多数人入门的首选
先说选型。链家二手房的城市挂牌量从几千到几万套不等,加上按天快照历史数据,requests同步采集完全能扛住,没必要一上来就上Scrapy。requests的编程模型是线性的一层:构造URL、发请求、收HTML、用BeautifulSoup做解析。Scrapy自带异步并发、中间件和分布式调度,能力更强,但你要先学它的Spider、Pipeline和Item类体系,调试成本高出一截;而链家这类站点对并发并不友好,Scrapy的并发优势在这种反爬环境下恰恰发挥不出来。
那么requests什么时候会不够用?两个信号:一是采集范围从单城市扩展到全国几十个城市,二是采集频率需要稳定跑在每秒十几个请求以上。出现这类需求时再迁移到Scrapy,此时你的源码里已经积累了完整的解析函数和清洗逻辑,移植过去只换请求层,解析层不动。所以给多数朋友的建议是:先用requests把流程跑通,再按实际数据量换框架。
具体到项目结构,我习惯拆成三个文件各司其职:spider.py负责请求与解析,只输出结构化的房源字典列表;db.py负责建表、批量写入和去重查询;config.py放URL模板、请求头、延时上下限、重试次数这些全局配置。这个拆法的直接好处是调试时不用在几百行代码里翻参数——改User-Agent、调sleep间隔都是高频操作,集中在config.py里改起来清爽。源码的组织方式不是玄学,骨架搭得合理,后面切框架、加字段都少遭罪。
再聊requests的会话管理。直接用requests.get每次调用会重新建立TCP连接,爬一百多页页面时握手开销被明显放大。推荐用requests.Session持有连接池,让同一域名下的请求复用底层连接:
# spider.py —— 初始化 Session 的写法 import requests from config import HEADERS def create_session() -> requests.Session: session = requests.Session() session.headers.update(HEADERS) return session逻辑说明:Session内部维护连接池,连续请求同一域名时复用TCP连接,实测能减少15%到30%的耗时;同时session.headers.update统一设置默认请求头,后续每个请求不用重复传headers参数。注意Session不是并发安全的,如果你的爬虫后面改成多线程,每个线程单独创建一个Session实例,不要共用一个。
请求异常处理也要在初期就做。requests在网络抖动时抛出的异常有好几类:ConnectionError表示连不上,Timeout表示响应超时,HTTPError表示状态码异常。绕开这个问题的常用写法是封装一个带重试的fetch函数,把重试次数和退避间隔收进config.py统一管理:
# spider.py —— 带重试的请求封装 import random import time import requests def fetch_with_retry(session, url, retries=3, timeout=10): for attempt in range(retries): try: resp = session.get(url, timeout=timeout) if resp.status_code == 200: return resp if resp.status_code >= 500: print(f"服务端错误: {resp.status_code}, 第 {attempt+1} 次重试") except (requests.ConnectionError, requests.Timeout) as e: print(f"请求异常: {type(e).__name__}") if attempt == retries - 1: raise time.sleep(random.uniform(2, 5)) raise RuntimeError(f"请求最终失败: {url}")参数说明:retries设为3表示最多尝试三次,超过就抛出异常让主流程感知;timeout=10指的是连接和读取阶段各10秒上限,避免请求挂起拖死整个爬虫。退避延时在2到5秒之间随机取值,固定延时的请求模式容易被统计特征识别,随机化处理是反爬对抗里的基础手段。这个函数后续所有页面请求都走它,不要在业务代码里散落裸的requests.get调用。
2.2 数据库选型:SQLite起步,MySQL上量的取舍边界
数据库这块,链家二手房爬虫的量级天然适合SQLite起步。单文件、零配置、Python标准库直接支持sqlite3,不需要单独安装数据库服务。一套城市房源快照就是几万条记录,SQLite在三五十万条数据量下做小区均价聚合查询仍是毫秒级返回。只有当你要做多城市并发采集、多个程序同时写库、或者面向Web服务提供查询接口时,才需要切到MySQL。切换时有个容易忽略的坑:SQLite和MySQL的SQL语法在简单查询上一致,但日期函数、布尔字段、自增主键的写法有差异,表结构设计阶段尽量把字段类型定得通用一些——VARCHAR、INTEGER、REAL、TEXT这四个类型两头都兼容,后面换库不用改字段定义。
SQLite有一个需要手动开启的参数:WAL日志模式。默认的delete模式下,写入事务会锁住整个数据库文件,如果采集程序在写库同时你还开着一个查询页面,很容易撞上“database is locked”报错。建表前对连接执行PRAGMA journal_mode=WAL,写前日志与读操作可以并行,这一个参数能解决多数SQLite并发读写的尴尬。
再补充两个在建表前推荐执行的PRAGMA配置:
PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;第一行把日志模式切成WAL,读和写互不阻塞;第二行把同步级别从FULL降为NORMAL,每个事务不再强制落盘等待,批量写入速度大约能提升20%。代价是极端断电场景可能丢最后一点点数据,对爬虫采集场景完全可以接受。这两行不是默认行为,每次新建连接都要重新执行一次,建议放在db.py的init_db函数里。
存储结构上,我建议按“小区表 + 房源表”拆两张表,不要把所有字段塞进一张大宽表。链家二手房的数据天然有层级关系:同一个小区下有多套房源,房源按天反复采集会形成历史快照,而小区的基础信息(行政区、板块、建筑年代)只有一份。拆表之后,房源表用小区ID做外键关联,按小区聚和统计的SQL写起来非常顺。反过来说,如果一开始图省事做成单表,后面加字段或重新分区就得全表重建,那种返工体验相当折磨。
3. 拆解链家二手房页面:URL规律、请求头与字段解析
3.1 从列表页到详情页:URL规律与分页构造
链家二手房的URL规律很工整,以北京站为例:列表页是https://bj.lianjia.com/ershoufang/,第n页是https://bj.lianjia.com/ershoufang/pg{n}/,按行政区过滤是https://bj.lianjia.com/ershoufang/chaoyang/pg{n}/,详情页是https://bj.lianjia.com/ershoufang/房源编号.html。这套规律有几个直接好处:第一,无需登录就能访问;第二,pg参数可以循环构造分页;第三,按区域爬时只换中间的城市代码和板块路径即可。
构造分页请求的代码可以这样写:
# config.py import time CITY = "bj" BASE_URL = f"https://{CITY}.lianjia.com/ershoufang" 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": f"https://{CITY}.lianjia.com/", "Accept-Language": "zh-CN,zh;q=0.9", } DOWNLOAD_INTERVAL = (1.0, 3.0) # 随机延时范围,单位秒 RETRY_TIMES = 3# spider.py —— 单页请求 import random import time from config import BASE_URL, HEADERS, DOWNLOAD_INTERVAL def fetch_list_page(page_no: int) -> str: url = f"{BASE_URL}/pg{page_no}/" resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" time.sleep(random.uniform(*DOWNLOAD_INTERVAL)) if resp.status_code != 200: raise RuntimeError(f"请求失败: {resp.status_code} | {url}") return resp.text逻辑说明:fetch_list_page把页码拼进URL,带上配置好的请求头发请求,拿到响应后显式指定utf-8编码——链家页面头部虽然有charset声明,但requests有时会误判成gbk,显式指定能避开中文乱码。time.sleep放在函数末尾而不是开头,每次请求完成后再等下一轮,顺序很重要,放开头会导致第一页白等一段延时。
参数说明:Request Headers里Referer同样不能省,链家部分页面会校验来源,Referer缺失的请求更早触发风控。DOWNLOAD_INTERVAL用元组表示延时区间,配合random.uniform生成1到3秒间的随机值,比固定sleep两秒的效果更好——固定间隔很容易被统计特征识别。URL末尾的斜杠不要省略,链家对无斜杠URL做301跳转,多一跳就多一次请求,还可能把自定义请求头丢掉。
有了单页函数,主循环就简单了:遍历页码、请求、解析、写入、间隔。我用一个列表收集解析结果,凑满50条就批量入库一次,避免每页开一个新事务。这种“攒批”的写法比逐页commit少很多磁盘IO,后面第四章会具体展开。每次请求失败就跳过当前页继续下一页,不要因为某一页网络抖动就把整个爬虫杀掉,失败页可以记录到日志里,跑完再回头补。
3.2 字段解析实战:标题、价格、朝向、楼层和标签怎么取
列表页HTML里的房源信息在div.sellListContent下的li元素中。每张卡片包含标题、链接、总价、单价、小区名、基础信息(户型面积朝向楼层装修年代)以及关注人数和标签。下面这段解析代码覆盖从一页HTML抽出全部主要字段的流程:
# spider.py —— 列表页解析 from bs4 import BeautifulSoup import re def parse_list_page(html: str) -> list[dict]: soup = BeautifulSoup(html, "html.parser") houses = [] for li in soup.select("div.sellListContent li.clear"): title_tag = li.select_one("div.title a") if not title_tag: continue title = title_tag.get_text(strip=True) detail_url = title_tag.get("href", "") # 总价,文本类似 "880万" total_price_tag = li.select_one("div.totalPrice span") total_price_text = total_price_tag.get_text(strip=True) if total_price_tag else "" total_price = extract_number(total_price_text) # 单价,文本类似 "99310元/平米" unit_price_tag = li.select_one("div.unitPrice span") unit_price_text = unit_price_tag.get_text(strip=True) if unit_price_tag else "" unit_price = extract_number(unit_price_text) # 基础信息,链家把小区名、户型、面积、朝向、楼层、年代拼在一段文本里 house_info_tag = li.select_one("div.houseInfo") raw_info = house_info_tag.get_text("|", strip=True) if house_info_tag else "" info_items = [i.strip() for i in raw_info.split("|")] # 小区名在houseInfo里的第一个a标签 community_a = li.select_one(".houseInfo a") community = community_a.get_text(strip=True) if community_a else "" # 关注数据,文本类似 "128人关注 / 80次带看" follow_tag = li.select_one("div.followInfo") follow_text = follow_tag.get_text(" ", strip=True) if follow_tag else "" follow_match = re.search(r"(\d+)人关注", follow_text) follow_cnt = int(follow_match.group(1)) if follow_match else 0 # 位置信息,文本类似 "朝阳 望京 城盈中心" position_tag = li.select_one("div.positionInfo") position_text = position_tag.get_text(strip=True) if position_tag else "" # 房源标签,如 ["近地铁", "满五年"] tag_elems = li.select("div.tag span") tags = [t.get_text(strip=True) for t in tag_elems] houses.append({ "title": title, "detail_url": detail_url, "total_price": total_price, "unit_price": unit_price, "area": parse_area(info_items), "layout": parse_field(info_items, r"(\d室\d厅)"), "orientation": parse_field(info_items, r"([南北东南西南东][东西南北]?[北]?)"), "floor_info": parse_field(info_items, r"(高楼层|中楼层|低楼层)\(共\d+层\)"), "build_year": parse_year(info_items), "community": community, "follow_cnt": follow_cnt, "position_info": position_text, "tags": tags, }) return houses逻辑说明:解析时优先用select_one定位单节点,拿不到就返回空并跳过该房源,这样结构异常的个别卡片不会拖垮整页。get_text("|", strip=True)先把一个标签内的多段文本用竖线拼接,再split成列表,链家不同城市的字段顺序不完全一致,先按分隔符拆开、再用正则从列表里找目标字段,比硬编码固定下标的位置要稳得多。
辅助清洗函数长这样:
# spider.py —— 字段清洗辅助函数 def extract_number(text: str) -> float: if not text: return 0.0 m = re.search(r"(\d+(\.\d+)?)", text) return float(m.group(1)) if m else 0.0 def parse_area(items: list) -> float | None: for item in items: m = re.search(r"(\d+(\.\d+)?)\s*(平米|㎡)", item) if m: return float(m.group(1)) return None def parse_field(items: list, pattern: str) -> str: for item in items: m = re.search(pattern, item) if m: return m.group(0) return ""参数说明:parse_area通配“89.5平米”和“89.5㎡”两种写法,做到入库前统一格式,避免数据库里同个单位出现两套文本风格,聚合统计时把数据切成两段。parse_field用正则做字段匹配,朝向匹配逻辑要覆盖“东南 北”这类带空格的组合,楼层字段统一提取“高楼层”“中楼层”“低楼层”和总层数两个信息,方便后面按楼层段做价格分层分析。
需要注意的一个细节:detail_url里包含房源编号,这是去重的唯一依据。链家同一套房重新挂牌时会沿用原编号,所以直接拿编号当数据库主键,后面做增量更新时天然不会重复插入。解析阶段不要对URL做任何截断处理,完整存下来再提取编号,反正详情页可能还有字段需要从这里补齐。
4. 数据库存储设计:表结构、去重与批量写入
4.1 房源表与小区表:先决定主键再谈性能
设计表结构时我先定主键,而不是先罗列字段。房源表如果用自增ID做主键,同一个房源再次爬到时会插入两条记录,聚合统计把同套房算两遍,价格中位数直接被污染。常见做法是把房源编号(详情页URL里_数字.html那段)直接当主键。房源编号在链家后台是唯一的,同一套房换房东、改价格重新上架也沿用同一个编号。用这个字段做主键做增量更新,比自增ID可靠得多。
-- db.py 中的 DDL 建表语句 CREATE TABLE IF NOT EXISTS community ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, district TEXT, bizcircle TEXT, build_year INTEGER, crawl_date TEXT ); CREATE TABLE IF NOT EXISTS house ( house_id TEXT PRIMARY KEY, title TEXT, total_price REAL, unit_price REAL, area REAL, layout TEXT, orientation TEXT, floor_info TEXT, community_id INTEGER, crawl_date TEXT, FOREIGN KEY (community_id) REFERENCES community(id) ); CREATE INDEX IF NOT EXISTS idx_house_community ON house(community_id); CREATE INDEX IF NOT EXISTS idx_house_crawl_date ON house(crawl_date);逻辑说明:community表的小区名加UNIQUE约束,写入前按名字查一次,存在就用已有ID,不存在就新插入再用lastrowid拿ID。house表以房源编号为文本主键,天然去重;crawl_date记录采集日期,让同一套房在不同时间的挂牌价保留成多条历史记录——这是做挂牌价趋势分析的关键数据。外键和索引对标的是“按小区查询”和“按日期查询”这两个高频场景,早点建索引比数据量大后再补索引省事得多。
这里有个容易忽略的点:house_id字段虽然内容是数字,但类型要写成TEXT而不是INTEGER。链家房源编号是十位以上的数字,超出部分数据库整型显示精度会丢末尾位数,统一当文本存既保留原始编号也不影响索引性能。另外total_price和unit_price用REAL类型,存的是万元和元/平米两个口径,分析时注意口径混用——总价除以面积得到的价格和页面显示单价有时差几百块,原因是链家对两个值各自做了取整,不要拿两套数字互推。
建表还有一个重要动作:SQLite默认不校验外键约束。如果你希望删除小区时把该小区房源一并清理或阻止删除,需要额外执行PRAGMA foreign_keys=ON。对于爬虫存储场景,我一般保持默认关闭,因为爬虫偶尔会先写房源后建小区,严格的外键约束反而制造麻烦。等数据进库稳定后,再用一条SQL检查孤儿记录:
SELECT COUNT(*) FROM house h LEFT JOIN community c ON h.community_id = c.id WHERE c.id IS NULL;这条查询用来定位community_id为空或无匹配的房源行,找到后回头排查是解析丢小区还是建表遗漏,别让孤儿数据在统计里悄悄拉低均价。
4.2 用INSERT OR IGNORE去重与executemany批量写入
把解析结果写入SQLite时,逐条INSERT碰上几千条数据会明显变慢,事务提交的开销被放大好几倍。常见做法是先构造参数列表,再用executemany一次写入一批,同时用INSERT OR IGNORE配合主键约束,重复房源自动跳过,省掉先SELECT再判断的额外查询。
# db.py —— 批量写入与去重 import sqlite3 import re def init_db(db_path: str) -> sqlite3.Connection: conn = sqlite3.connect(db_path) conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA synchronous = NORMAL") conn.executescript(DDL) # 上一节的两条建表语句 conn.commit() return conn def save_houses(conn: sqlite3.Connection, houses: list[dict]) -> int: # 第一步:维护小区表,建立 name -> id 的内存映射 community_ids = {} for h in houses: name = h["community"] if not name or name in community_ids: continue row = conn.execute( "SELECT id FROM community WHERE name = ?", (name,) ).fetchone() if row: community_ids[name] = row[0] else: cur = conn.execute( "INSERT INTO community (name, district, bizcircle, crawl_date) " "VALUES (?, ?, ?, ?)", (name, h.get("district", ""), h.get("bizcircle", ""), h["crawl_date"]), ) community_ids[name] = cur.lastrowid # 第二步:整理房源行,executemany 批量写 rows = [] for h in houses: m = re.search(r"/(\d+)\.html", h["detail_url"]) house_id = m.group(1) if m else h["detail_url"] rows.append(( house_id, h["title"], h["total_price"], h["unit_price"], h["area"], h["layout"], h["orientation"], h["floor_info"], community_ids.get(h["community"]), h["crawl_date"], )) cur = conn.executemany( """ INSERT OR IGNORE INTO house (house_id, title, total_price, unit_price, area, layout, orientation, floor_info, community_id, crawl_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, rows, ) conn.commit() return cur.rowcount逻辑说明:第一步先把小区名的映射建好,每套房只查一次小区ID,而不是每条房源单独触发SELECT,这一步省下的查询次数在几千套房源级别非常可观。查不到就INSERT,用lastrowid拿到自增主键存进内存映射,后面同一小区的房源直接取映射值。第二步把房源数据整理成统一元组列表,executemany一次提交,底层把这一批操作当作单个事务处理,比逐条commit快一个数量级。INSERT OR IGNORE在遇到主键冲突时静默跳过,好处是重复数据不会报错中断。
参数说明:cur.rowcount返回的是实际插入的行数,INSERT OR IGNORE中被打掉的重复行不计入,用这个返回值能直接看出当轮爬虫带来多少新数据。如果发现rowcount明显小于解析到的房源数,说明当天挂牌数据与库内快照高度重叠,正常现象。还有一点,crawl_date统一用YYYY-MM-DD格式的文本,SQLite没有独立的DATE类型,文本格式保证排序即按时间。入库前house_info_raw字段我建议保留原始串,不参与任何统计,只在需要排查解析问题时拿出来对照,这相当于给自己留了一颗后悔药。
5. 爬取与存储避坑清单:反爬、数据失真与并发翻车
5.1 爬到一半页面开始返回验证码或者空白源码
现象:前几十页都正常,翻到一半响应状态码仍然是200,但页面内容变成一段JS挑战脚本或者验证码弹窗,解析出来的房源列表为空。
原因:请求频次触发了服务端的访问风控,同一User-Agent在短时间内发起过多请求被临时限制。这类限制不是永久封禁,一般等待几分钟到半小时会自行恢复。
解决:把DOWNLOAD_INTERVAL的下限从1秒提到3秒、上限提到6秒,同时增加连续失败退避逻辑——连续5次请求失败就暂停90秒再继续。链家这类站点比很多电商网站宽松,只要频率接近人工浏览就没有大问题。退避逻辑写在主循环里:
# spider.py —— 主循环中的失败退避 fail_count = 0 for page_no in range(1, MAX_PAGE + 1): try: html = fetch_list_page(page_no) fail_count = 0 # 成功后归零 houses = parse_list_page(html) save_houses(conn, houses) except Exception as e: fail_count += 1 print(f"第{page_no}页失败: {e}") if fail_count >= 5: wait = 60 + random.randint(0, 60) print(f"连续失败{fail_count}次,暂停{wait}秒") time.sleep(wait) fail_count = 0 continue逻辑说明:fail_count在每次成功后归零,只有连续失败才触发暂停。等待时长取60到120秒的随机区间,避免多个爬虫实例同时恢复请求造成二次限流。单页失败跳过并不中断整体流程,跑完可以查看日志决定是否补采。
5.2 同一套房源反复入库,统计数字越看越离谱
现象:第一次爬完统计小区均价还有参考价值,隔天再跑数据量直接翻倍,AVG单价被拉高或拉低。
原因:建表时没有用房源编号做主键,或者主键是自增ID。同一房源编号在第二天的列表页再次出现,因为主键不同被当成新记录插入,完全绕过去重逻辑。
解决:建表阶段就用房源编号做PRIMARY KEY,写入用INSERT OR IGNORE。如果已经建错表也不用推倒重来,先用一条SQL按编号加日期分组把重复行清掉,再补唯一索引:
DELETE FROM house WHERE rowid NOT IN ( SELECT MIN(rowid) FROM house GROUP BY house_id, crawl_date ); CREATE UNIQUE INDEX IF NOT EXISTS uq_house_id_date ON house(house_id, crawl_date);注意:这个去重策略保留每组的第一行。如果你更想保留最新一次爬到的数据,先按crawl_date倒序排再取MIN(rowid),语义上有点绕但可行。我建议采集库保留历史快照,用house_id + crawl_date联合唯一键,同一房源同一天只保留一条记录,跨天数据自然形成价格时间序列。
5.3 面积和朝向解析出来一堆空值与格式混杂
现象:数据库里area字段既有89.5,也有89.5平米,还有空字符串;orientation字段既有“东南 北”也有“东南北”,聚合分类时一个朝向被拆成两个分组。
原因:链家不同城市、不同页面版本对同字段的渲染不一致。面积有的用“89.5平米”,有的用“89.5㎡”;朝向在列表页带空格、在详情页不带;甚至有些房源数据缺失导致标签为空。
解决:在解析函数和入库函数之间单独加一层清洗函数,统一用正则提取数字、全角符号转半角、空值一律填NULL而不是空字符串。之前给出的parse_area和parse_orientation就是干这个事。跑完一批后做一次质检查询,统计空值比例是否异常:
SELECT COUNT(*) AS total, SUM(CASE WHEN area IS NULL THEN 1 ELSE 0 END) AS area_null, SUM(CASE WHEN orientation = '' THEN 1 ELSE 0 END) AS orientation_empty FROM house WHERE crawl_date = (SELECT MAX(crawl_date) FROM house);如果空值占比超过2%,先回去检查是不是页面结构变了,而不是硬着头皮继续爬——解析层出错时,爬得越快错得越多。
5.4 分页爬不到100页之后的数据
现象:列表页URL的pg参数从1加到100都正常返回,但第101页开始返回空页面或直接跳回第一页,城市在售房源多时只抓到一部分。
原因:链家列表页有分页上限,单个列表最多100页。在售房源超过一定数量时,只靠全局列表页翻页抓不全。
解决:按行政区或板块拆分子列表分别抓取。先在https://bj.lianjia.com/ershoufang/pg1/的筛选导航里提取全部区域链接,再对每个区域独立循环翻页。判断分页是否到底时,不要只看请求状态码,而是比较当前页第一条房源编号和上一页最后一条,连续两页完全一样就说明已经到末尾。区域拆分还有个额外好处:每页请求量更小,风控触发概率也跟着下降。
6. 进阶:增量爬取与用SQL直接出小区均价报表
爬虫稳定跑两周之后,你会发现库里最有价值的不是最新挂牌价,而是同一套房的价格变化曲线。增量爬取的关键依然是把house_id当作自然主键:每次跑之前先查询库内已有编号,列表页解析完只对新编号走插入流程;库内旧房源如果价格变化了,用UPDATE更新当期价格并刷新crawl_date。在Python里就是初始化一个set存放现有ID,解析循环里做一次成员判断,命中就不进rows列表。
增量更新等价于给每次采集打上快照标记,三天前的价格和今天的价格在同一条数据下形成对比。做小区均价报表时,只需要关注最新快照对应的记录:
SELECT c.name AS community, ROUND(AVG(h.unit_price), 0) AS avg_price, COUNT(DISTINCT h.house_id) AS listing_cnt, MAX(h.crawl_date) AS latest_date FROM house h JOIN community c ON h.community_id = c.id WHERE h.crawl_date = (SELECT MAX(crawl_date) FROM house) GROUP BY c.name HAVING COUNT(DISTINCT h.house_id) >= 10 ORDER BY avg_price DESC LIMIT 20;这段SQL统计最新一次快照里各小区的挂牌均价,HAVING条件过滤掉样本不足10套的小区,避免一两套高价房源把均价带偏。对比两周快照中同一house_id的unit_price差值,就能筛出正在集体调价的小区,这比看单个房源价格涨跌可靠得多。我自己的使用习惯是每周跑一次这个查询,把结果导成CSV丢给图表工具做趋势看板;增量逻辑再往前一步,把小区映射缓存进内存字典,不用每页重复查询数据库。
这里有一段血泪经验:第一版没做缓存映射,爬8000套房源跑了将近20分钟,一大半时间花在反复SELECT小区ID上;改成字典缓存后,同一批数据8分钟左右跑完。先建映射再谈并发,这个顺序别搞反。希望本文这套方案和排坑思路能帮到你跑通自己的链家房源库,别在同一个地方翻第二次车。
本文还有配套的精品资源,点击获取