引言
“同一套房子,在链家上挂牌520万,在贝壳上显示505万,在安居客上又变成了498万——到底哪个价格是真的?”这是无数购房者在看房过程中都会遇到的困惑。
三大房产平台的房源数据各有侧重:链家/贝壳同源但展示维度不同,安居客聚合了更多中小中介的房源。同一小区、同一户型,不同平台的挂牌价可能相差数万甚至数十万。如果能同时爬取三个平台的房源数据,进行交叉对比,就能看清真实的价格区间,识别“虚高挂牌”和“低价引流”的套路。
然而,三个平台的反爬机制各不相同,技术方案无法“一套代码通吃”。本文将系统介绍如何设计适配层架构,突破各平台的反爬体系,实现跨平台房价横向对比。
免责声明:本文仅限技术学习与交流,请勿将爬虫技术用于商业用途或对目标网站造成过大压力。请遵守各平台的robots.txt协议及相关法律法规。
一、三大平台反爬特征对比
在动手写代码之前,必须先搞清楚三个平台分别“防什么”——它们的反爬策略各有侧重,需要区别对待。
1.1 链家/贝壳:JS动态渲染 + 字体反爬
链家与贝壳同属一个体系,数据互通但前端展示不同。其核心反爬手段包括:
动态渲染:房源列表和详情页的核心数据通过JavaScript异步加载,直接请求HTML只能拿到空壳骨架。
字体反爬:部分关键字段(如租金、面积)使用自定义字体映射加密,直接提取DOM文本会得到乱码。
IP频率限制:单IP高频请求会触发封禁,且平台会检测请求行为是否像真人。
行为验证:过于规律的请求间隔、缺少鼠标移动事件、固定User-Agent等,都可能触发滑块验证码。
1.2 安居客:请求头校验 + 动态Cookie
安居客的反爬策略与链家/贝壳有显著差异:
请求头完整性要求高:User-Agent、Referer、Accept-Language等字段缺失或不合法会被直接拒绝。部分接口要求traceparent、X-K-HEADER等签名参数。
动态Cookie:核心接口需要携带__lg_stoken__、user_trace_token等动态参数,这些参数由前端JS实时生成。
IP频率限制:单IP的请求频率阈值比链家更低,密集请求更容易触发封禁。
页面结构频繁调整:安居客的HTML结构会不定期变化,昨天能用的CSS选择器今天可能就失效了。
1.3 反爬对比速览
| 维度 | 链家/贝壳 | 安居客 |
| 核心反爬 | 字体加密 + JS渲染 | 动态Token + 请求头校验 |
| IP封禁严格度 | 极严 | 极严 |
| 数据加载方式 | Ajax异步 | Ajax异步 |
| 签名参数 | 部分接口 | __lg_stoken__等 |
| 页面稳定性 | 相对稳定 | 频繁调整 |
| 破解难度 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
二、技术架构设计:适配器模式
面对三个平台差异巨大的反爬策略,一套“大一统”的爬虫代码是不现实的。正确的做法是分层架构 + 平台适配器。
2.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ 调度层(Scrapy-Redis) │
│ 任务队列、去重、分布式扩展 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 平台适配层(Adapter Pattern) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 链家适配器 │ │ 贝壳适配器 │ │ 安居客适配器 │ │
│ │ Playwright │ │ Playwright │ │ requests+ │ │
│ │ +字体解密 │ │ +字体解密 │ │ Token管理 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 代理层(站大爷隧道代理) │
│ 自动IP轮换、地域选择、故障自愈 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 数据层(清洗 + 标准化) │
│ 统一数据模型、跨平台房源匹配、SQLite存储 │
└─────────────────────────────────────────────────────────────┘
核心优势:各平台采集逻辑相互隔离,修改一个不影响另一个;代理层统一管理IP,所有平台的请求共享同一个隧道代理出口。
2.2 统一数据模型
跨平台对比的关键在于数据标准化。三个平台的字段命名和格式各不相同,需要映射到统一的数据模型:
# 统一房源数据模型
standard_house = {
"platform": "lianjia" | "beike" | "anjuke",
"house_id": "平台房源ID",
"title": "房源标题",
"community": "小区名称", # 跨平台匹配的关键字段
"district": "所属区域",
"total_price": 520.0, # 总价(万元)
"unit_price": 58000, # 单价(元/平米)
"area": 89.5, # 面积(平米)
"layout": "3室2厅", # 户型
"floor": "中楼层", # 楼层
"orientation": "南北", # 朝向
"decoration": "精装", # 装修
"build_year": 2015, # 建成年份
"collected_at": "2026-09-15 10:00:00"
}
跨平台对比的核心不是“爬”,而是对齐——同一套房源在三个平台的ID不同、标题写法不同、面积精度不同,需要通过小区名称 + 户型 + 面积的组合进行模糊匹配。
三、爬虫实战:三平台适配器实现
3.1 环境准备
pip install playwright scrapy scrapy-redis requests pandas pillow
playwright install chromium
3.2 链家/贝壳适配器(Playwright方案)
链家/贝壳的核心数据通过JS动态渲染,使用Playwright驱动真实浏览器是最可靠的方案。
from playwright.sync_api import sync_playwright
import time
import random
import pandas as pd
class LianjiaAdapter:
"""链家/贝壳平台适配器"""
def __init__(self, use_proxy=True):
self.use_proxy = use_proxy
if use_proxy:
self.proxy_config = {
"server": "http://用户名:密码@tps.zdaye.com:8080"
}
else:
self.proxy_config = None
self.user_agents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
]
def scrape_city(self, city_url, max_pages=10):
"""采集指定城市的二手房数据"""
results = []
with sync_playwright() as p:
browser = p.chromium.launch(
headless=False,
proxy=self.proxy_config
)
context = browser.new_context(
user_agent=random.choice(self.user_agents),
viewport={"width": 1440, "height": 900}
)
context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
""")
page = context.new_page()
for page_num in range(1, max_pages + 1):
url = f"{city_url}/pg{page_num}/"
print(f"[链家] 正在采集第{page_num}页...")
page.goto(url, wait_until="networkidle")
time.sleep(random.uniform(2, 4))
items = page.query_selector_all(".sellListContent li")
for item in items:
try:
title = item.query_selector(".title a").inner_text()
total_price = item.query_selector(".totalPrice span").inner_text()
unit_price = item.query_selector(".unitPrice span").inner_text()
house_info = item.query_selector(".houseInfo").inner_text()
position = item.query_selector(".positionInfo").inner_text()
results.append({
"platform": "lianjia",
"title": title.strip(),
"total_price": float(total_price.replace("万", "")),
"unit_price": int(unit_price.replace("元/平米", "").replace(",", "")),
"house_info": house_info.strip(),
"position": position.strip()
})
except:
continue
time.sleep(random.uniform(2, 5))
browser.close()
return results
3.3 安居客适配器(requests + Token方案)
安居客的接口需要携带动态Token,通过抓包分析可以复用部分固定参数。
import requests
import time
import random
class AnjukeAdapter:
"""安居客平台适配器"""
def __init__(self, cookie, use_proxy=True):
self.session = requests.Session()
self.cookie = cookie
self.use_proxy = use_proxy
if use_proxy:
self.proxies = {
"http": "http://用户名:密码@tps.zdaye.com:8080",
"https": "http://用户名:密码@tps.zdaye.com:8080"
}
else:
self.proxies = None
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://www.anjuke.com/",
"Cookie": cookie,
"Accept": "application/json, text/plain, */*"
}
def scrape_city(self, city_url, max_pages=10):
"""采集指定城市的二手房数据"""
results = []
for page_num in range(1, max_pages + 1):
url = f"{city_url}/p{page_num}/"
print(f"[安居客] 正在采集第{page_num}页...")
for attempt in range(3):
try:
resp = self.session.get(
url,
headers=self.headers,
proxies=self.proxies,
timeout=15
)
if resp.status_code == 200:
# 解析HTML提取房源信息
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, 'html.parser')
items = soup.find_all('div', class_='property-content')
for item in items:
try:
title = item.find('h3').text.strip()
price = item.find('span', class_='property-price-total-num')
total_price = float(price.text) if price else None
results.append({
"platform": "anjuke",
"title": title,
"total_price": total_price,
"raw_html": str(item)[:500]
})
except:
continue
break
elif resp.status_code in [403, 429]:
time.sleep(3 * (attempt + 1))
except Exception as e:
print(f"请求异常: {e}")
time.sleep(3)
time.sleep(random.uniform(2, 5))
return results
3.4 统一采集调度
class HouseCompareTool:
"""跨平台房价对比工具"""
def __init__(self, lianjia_cookie=None, anjuke_cookie=None):
self.lianjia = LianjiaAdapter(use_proxy=True)
self.anjuke = AnjukeAdapter(anjuke_cookie, use_proxy=True)
self.all_data = []
def collect_all(self, city_config):
"""采集所有平台的房源数据"""
# 链家
lianjia_data = self.lianjia.scrape_city(
city_config["lianjia_url"],
max_pages=5
)
self.all_data.extend(lianjia_data)
# 安居客
anjuke_data = self.anjuke.scrape_city(
city_config["anjuke_url"],
max_pages=5
)
self.all_data.extend(anjuke_data)
# 保存
df = pd.DataFrame(self.all_data)
df.to_csv("house_compare.csv", index=False, encoding="utf-8-sig")
print(f"共采集 {len(self.all_data)} 条房源数据")
return df
# 使用示例
if __name__ == "__main__":
tool = HouseCompareTool(anjuke_cookie="你的Cookie")
config = {
"lianjia_url": "https://bj.lianjia.com/ershoufang",
"anjuke_url": "https://beijing.anjuke.com/sale"
}
df = tool.collect_all(config)
3.5 跨平台房源匹配
同一套房源在三个平台的标题和描述不同,需要通过小区名称 + 户型 + 面积的组合进行模糊匹配:
import re
from difflib import SequenceMatcher
def extract_community(title):
"""从标题中提取小区名称"""
# 实际规则需根据平台标题格式调整
patterns = [
r'^([^\s]+小区)',
r'^([^\s]+花园)',
r'^([^\s]+苑)',
]
for pattern in patterns:
match = re.search(pattern, title)
if match:
return match.group(1)
return title.split()[0] if title else ""
def match_houses(df):
"""跨平台匹配同一房源"""
matched = []
# 按小区分组
df["community"] = df["title"].apply(extract_community)
for community, group in df.groupby("community"):
if len(group) < 2:
continue
# 同小区内按面积匹配
for _, row_a in group.iterrows():
for _, row_b in group.iterrows():
if row_a["platform"] == row_b["platform"]:
continue
# 面积差异在5%以内视为同一房源
area_a = extract_area(row_a.get("house_info", ""))
area_b = extract_area(row_b.get("house_info", ""))
if area_a and area_b:
diff = abs(area_a - area_b) / max(area_a, area_b)
if diff < 0.05:
matched.append({
"community": community,
"platform_a": row_a["platform"],
"price_a": row_a["total_price"],
"platform_b": row_b["platform"],
"price_b": row_b["total_price"],
"price_diff": row_a["total_price"] - row_b["total_price"]
})
return pd.DataFrame(matched)
四、站大爷隧道代理:跨平台采集的基础设施
4.1 为什么跨平台比价必须用代理?
三个平台都对单IP的请求频率有严格限制。跨平台采集意味着请求量是单平台的3倍——同样的采集任务,IP被封的风险成倍增加。
更棘手的是,不同平台的风控系统可能通过IP关联识别爬虫行为。如果同一个IP在短时间内同时访问链家、贝壳、安居客,平台的风控系统很容易判定这是“批量采集行为”而非“正常用户比价”。
站大爷隧道代理是解决这一问题的关键。它支持按请求自动切换IP,让每个平台的请求都从不同的IP发出,从根本上规避跨平台关联识别。
4.2 站大爷隧道代理的核心优势
以站大爷隧道代理为例,其在房产数据采集场景中的关键特性包括:
- 自动切换无感知:每次请求自动更换出口IP,无需手动干预
- 地域覆盖广泛:覆盖全国99%地域,支持300+城市IP精准定位,方便采集不同城市的房价数据
- 高可用性:连续7天24小时压力测试,连接成功率稳定在99.3%
- 三种鉴权模式:白名单、用户名密码/白名单、用户名密码+白名单
- 高并发支持:分布式爬虫架构下稳定运行
4.3 在Playwright中集成站大爷隧道代理
# 站大爷隧道代理配置
PROXY_CONFIG = {
"server": "http://用户名:密码@tps.zdaye.com:8080"
}
browser = p.chromium.launch(
headless=False,
proxy=PROXY_CONFIG
)
4.4 在requests中集成站大爷隧道代理
import requests
PROXIES = {
"http": "http://用户名:密码@tps.zdaye.com:8080",
"https": "http://用户名:密码@tps.zdaye.com:8080"
}
def fetch_with_tunnel(url, headers, retry=3):
for attempt in range(retry):
try:
resp = requests.get(
url,
headers=headers,
proxies=PROXIES,
timeout=15
)
if resp.status_code == 200:
return resp.text
if resp.status_code in [403, 429]:
print(f"请求被拒绝,隧道代理自动切换IP重试...")
time.sleep(3 * (attempt + 1))
except Exception as e:
time.sleep(3)
return None
对于需要精细控制IP更换周期的场景,站大爷隧道代理支持通过period参数自定义:
# 每300秒更换一次IP
PROXIES = {
"http": "http://用户名-period-300:密码@tps.zdaye.com:8080",
"https": "http://用户名-period-300:密码@tps.zdaye.com:8080"
}
五、房价横向对比分析
5.1 同小区价格差异分析
import pandas as pd
import matplotlib.pyplot as plt
# 加载匹配后的数据
matched = pd.read_csv("matched_houses.csv")
# 价格差异分布
plt.figure(figsize=(10, 6))
matched["price_diff"].hist(bins=30, color="skyblue", edgecolor="black")
plt.title("跨平台同一房源价格差异分布")
plt.xlabel("价格差异(万元)")
plt.ylabel("房源数量")
plt.savefig("price_diff_distribution.png")
# 统计
print(f"平均价格差异: {matched['price_diff'].abs().mean():.2f}万元")
print(f"最大价格差异: {matched['price_diff'].abs().max():.2f}万元")
print(f"价格差异超过10万的房源占比: {(matched['price_diff'].abs() > 10).mean()*100:.1f}%")
5.2 区域均价对比
# 各平台在不同区域的平均单价对比
df = pd.read_csv("house_compare.csv")
df["district"] = df["position"].str.extract(r'([^\s]+区)')
district_price = df.groupby(["district", "platform"])["unit_price"].mean().unstack()
print("各区域不同平台平均单价对比:")
print(district_price.round(0))
# 可视化
district_price.plot(kind="bar", figsize=(14, 6))
plt.title("各区域不同平台二手房平均单价对比")
plt.ylabel("单价(元/平米)")
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig("district_price_comparison.png")
5.3 从对比数据到洞察
通过跨平台房价横向对比,可以获得以下有价值的洞察:
识别价格虚高:如果某房源在链家/贝壳上的挂牌价显著高于安居客同小区同类房源,可能存在“虚高挂牌”的情况。
发现低价房源:部分房源可能只在某一个平台挂出,且价格明显低于市场均价,可能是“捡漏”机会。
平台数据差异:如果某平台的整体均价持续高于其他平台,可能反映该平台的房源结构偏向高端,或者存在系统性的“价格美化”。
六、常见问题与避坑指南
6.1 链家/贝壳字体反爬怎么破?
链家/贝壳的字体加密需要下载字体文件进行映射。建议使用Playwright等待页面完全渲染后提取,或者直接调用平台的价格接口获取明文数据。
6.2 安居客Token过期怎么办?
安居客的动态Token有效期较短。建议每次运行前从浏览器重新获取Cookie,或使用Playwright自动登录并获取最新Token。
6.3 IP被封后如何快速恢复?
- 使用站大爷隧道代理自动切换IP
- 控制请求频率,建议单平台请求间隔不低于2秒
- 不同平台使用不同的IP出口,避免跨平台关联
6.4 跨平台房源匹配准确率低怎么办?
- 优化小区名称提取规则,结合区域信息进行匹配
- 使用面积 + 户型 + 楼层的组合进行多维度匹配
- 对于匹配不上的房源,可以手动审核部分样本
6.5 法律与合规提醒
- 爬取前阅读各平台的robots.txt及用户协议
- 控制请求频率,避免对目标服务器造成过大压力
- 仅将数据用于学习和研究目的,不得用于商业用途
- 遵守《网络安全法》《数据安全法》《个人信息保护法》等相关法律法规
七、总结
本文从三大房产平台的反爬特征差异入手,系统介绍了跨平台房价横向对比的完整方案。核心要点可以概括为:
| 挑战 | 解决方案 |
| 链家/贝壳JS渲染 + 字体反爬 | Playwright有头模式 + 等待渲染 |
| 安居客动态Token + 请求头校验 | Cookie复用 + 完整请求头 |
| 跨平台IP关联识别 | 站大爷隧道代理,每个平台独立IP出口 |
| 数据字段不统一 | 统一数据模型 + 字段映射 |
| 房源跨平台匹配 | 小区名称 + 户型 + 面积模糊匹配 |
| 房价对比分析 | 价格差异分布 + 区域均价对比 |
技术选型速览:推荐“Playwright(链家/贝壳)+ requests(安居客)+ 站大爷隧道代理”的组合方案。各平台适配器隔离,隧道代理统一管理IP,数据层做标准化和匹配。
三大平台的房价数据各有侧重,单独看任何一家都可能“管中窥豹”。通过跨平台横向对比,可以看清真实的价格区间,识别“虚高挂牌”和“低价引流”的套路,为购房决策提供更全面的数据支撑。
最后需要提醒的是:数据采集行为应当遵循平台规则,控制请求频率以避免对目标服务器造成过载。请遵守相关法律法规,仅将爬虫技术用于学习和研究目的。希望本文能帮助你在房产数据分析的道路上迈出坚实的一步。