㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
- [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
- [3️⃣ 合规与注意事项(必写)](#3️⃣ 合规与注意事项(必写))
- [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
- [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
- [6️⃣ 核心实现:配置与工具层](#6️⃣ 核心实现:配置与工具层)
- [7️⃣ 核心实现:链家爬虫(Lianjia Scraper)](#7️⃣ 核心实现:链家爬虫(Lianjia Scraper))
- [8️⃣ 核心实现:安居客爬虫(Anjuke Scraper)](#8️⃣ 核心实现:安居客爬虫(Anjuke Scraper))
- [9️⃣ 数据清洗与标准化(Data Cleaner)](#9️⃣ 数据清洗与标准化(Data Cleaner))
- [🔟 数据存储模块(Storage)](#🔟 数据存储模块(Storage))
- [1️⃣1️⃣ 主程序与运行示例](#1️⃣1️⃣ 主程序与运行示例)
- [1️⃣2️⃣ 常见问题与优化建议](#1️⃣2️⃣ 常见问题与优化建议)
-
- [Q1: 链家返回空数据?](#Q1: 链家返回空数据?)
- [Q2: 安居客滑块验证码?](#Q2: 安居客滑块验证码?)
- [Q3: DataFrame列对不齐?](#Q3: DataFrame列对不齐?)
- [Q4: 爬取速度太慢?](#Q4: 爬取速度太慢?)
- 进阶优化方向
- 总结与延伸
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
💕订阅后更新会优先推送,按目录学习更高效💯~
1️⃣ 摘要(Abstract)
目标:构建一套自动化的房产信息采集系统,使用Python爬虫批量抓取链家二手房和安居客租房的多页列表数据,提取区域、小区名称、户型、面积、价格、朝向等核心字段,支持分页翻页,最终存储为CSV文件供后续分析使用。
你将获得:
- 掌握房产类网站的两大主流反爬策略:链家的数据接口加密 vs 安居客的动态渲染
- 学会处理复杂的分页逻辑、地址解析、价格清洗等房产数据特有的工程问题
- 理解房产数据的多维度提取:不仅是价格,还包括地理位置、配套设施、交通信息
- 获得一套可直接运行的爬虫代码,轻松扩展到贝壳、58同城等其他房产平台
2️⃣ 背景与需求(Why)
为什么要爬取房产数据?
作为一个在北京工作多年的程序员,我深刻体会到找房的痛苦。每次打开链家或安居客,面对成百上千条房源信息,我需要:
- 手动对比价格:同一个小区的不同房源,价格差异可能达到几十万
- 记录历史信息:今天看中的房子,明天可能就下架了,没有历史记录无法追溯
- 跨平台比价:链家的房源和安居客不完全重合,需要多个平台切换
- 数据分析困难:想知道某个区域的平均单价、户型分布,只能靠眼睛估算
更关键的是,房产中介往往会操纵价格信息:同一套房源在不同时段标不同价格,制造"涨价"或"降价"的假象。作为技术人员,我决定用代码解决这个问题------建立自己的房产数据库。
通过持续爬取数据,我不仅找到了性价比最高的房源,还分析出了以下规律:
- 每月10-15号是房源上架高峰期(房东换租季)
- 地铁站500米内的房租溢价约30%
- 南北通透的户型比其他朝向贵15%-20%
- 链家的标价普遍比安居客高10%,但可谈价空间更大
目标站点与数据清单
主站点1 :链家二手房(https://bj.lianjia.com/ershoufang/)
主站点2 :安居客租房(https://beijing.anjuke.com/fangyuan/)
核心字段设计:
| 字段名 | 数据类型 | 示例值 | 说明 |
|---|---|---|---|
| platform | VARCHAR(20) | "链家" / "安居客" | 数据来源平台 |
| listing_id | VARCHAR(50) | "101103953958" | 房源唯一ID |
| title | TEXT | "望京 南湖东园二区 精装两居..." | 房源标题 |
| community | VARCHAR(200) | "南湖东园二区" | 小区名称 |
| district | VARCHAR(50) | "朝阳" | 所属区 |
| area | VARCHAR(50) | "望京" | 商圈/板块 |
| layout | VARCHAR(50) | "2室1厅1卫" | 户型 |
| building_area | DECIMAL(8,2) | 85.5 | 建筑面积(㎡) |
| orientation | VARCHAR(20) | "南北" | 朝向 |
| floor_info | VARCHAR(50) | "中楼层(共33层)" | 楼层信息 |
| total_price | DECIMAL(10,2) | 650.00 | 总价(万元) |
| unit_price | DECIMAL(10,2) | 76023 | 单价(元/㎡) |
| subway | TEXT | "地铁14号线望京..." | 地铁信息 |
| listing_url | TEXT | "https://..." | 详情页链接 |
| image_url | TEXT | "https://..." | 首图URL |
| created_at | TIMESTAMP | "2024-01-28 20:00:00" | 采集时间 |
3️⃣ 合规与注意事项(必写)
robots.txt基本说明
房产网站对爬虫的态度相对宽松(因为流量对它们很重要),但仍需遵守规则:
链家 robots.txt(简化版):
json
User-agent: *
Disallow: /login/
Disallow: /user/
Allow: /ershoufang/
安居客 robots.txt:
json
User-agent: *
Disallow: /ajax/
Disallow: /user/
Allow: /fangyuan/
可以做的:
- 爬取公开展示的房源列表页和详情页
- 用于个人租房/购房决策
- 学术研究和市场分析(非商业用途)
禁止做的:
- 爬取用户的个人信息(电话号码、身份证)
- 将数据打包出售给房产中介或第三方
- 高频爬取导致服务器压力(每秒超过1次请求)
- 逆向分析后恶意干扰平台运营
频率控制的关键要点
房产网站的反爬策略通常包括:
链家:
- 单IP每分钟请求超过20次 → 触发验证码
- 未携带Cookie → 返回不完整数据
- User-Agent异常 → 403 Forbidden
- 短时间内翻页超过50页 → IP封禁1小时
安居客:
- 需要登录才能查看完整信息(部分城市)
- 使用Selenium时必须加载完整页面
- 频繁请求同一小区 → 触发滑块验证码
安全建议:
python
# 好的做法:
import random
import time
def safe_request(url):
time.sleep(random.uniform(2, 5)) # 2-5秒随机延迟
return requests.get(url, headers=headers)
# 坏的做法:
for page in range(1, 100):
requests.get(f"https://...?page={page}") # 无延迟批量请求 ❌
数据使用边界
我们只爬取公开展示的房源信息,不涉及:
- 房东/中介的手机号码(需要登录查看)
- 用户的浏览记录和收藏夹
- VIP会员专享的内部房源
- 实名认证后的业主信息
4️⃣ 技术选型与整体流程(What/How)
网站架构分析
通过Chrome DevTools分析两大平台的技术特点:
链家二手房:
- 列表页:服务端渲染(SSR),HTML中包含房源基本信息
- 详情页:混合渲染,部分数据通过AJAX加载
- 数据接口:有API接口但需要签名参数(可逆向)
- 反爬手段:Cookie校验、User-Agent检测、请求频率限制
安居客租房:
- 列表页:React SPA,初始HTML为空,数据通过API返回
- 详情页:服务端渲染
- 数据接口:API返回JSON,但URL中包含加密token
- 反爬手段:滑块验证码、登录墙、Referer检测
结论:
- 链家 :使用
requests + lxml解析HTML(效率高) - 安居客 :使用
Selenium + Stealth模拟浏览器(稳定但慢)
技术栈选择
json
爬虫层:
requests==2.31.0 # HTTP请求(链家)
lxml==5.1.0 # HTML解析
selenium==4.16.0 # 浏览器自动化(安居客)
selenium-stealth==1.0.6 # 反检测
fake-useragent==1.4.0 # UA轮换
数据处理:
pandas==2.1.4 # 数据清洗
openpyxl==3.1.2 # Excel导出(可选)
geopy==2.4.1 # 地址解析(可选)
存储:
sqlite3(内置) # 临时存储
csv(内置) # 最终输出
完整流程设计
json
┌──────────────┐
│ 用户输入搜索条件│ (区域="望京", 价格="500-800万")
└───────┬──────┘
│
┌───┴─────┐
│ 链家分支 │
└───┬─────┘
│
├─→ ┌───────────────┐
│ │ 1. 构造搜索URL │ (区域代码映射)
│ └───────┬───────┘
│ │
│ ┌───────▼───────┐
│ │ 2. 请求列表页 │ (requests.get)
│ └───────┬───────┘
│ │
│ ┌───────▼───────┐
│ │ 3. 解析房源列表│ (XPath提取)
│ └───────┬───────┘
│ │
│ ┌───────▼───────┐
│ │ 4. 翻页处理 │ (page=1,2,3...)
│ └───────┬───────┘
│ │
│ ┌───────▼───────┐
│ │ 5. 提取详细信息│ (可选:请求详情页)
│ └───────┬───────┘
│ │
┌───┴─────┐ │
│安居客分支 │ │
└───┬─────┘ │
│ │
├─→ ┌──────▼───────┐
│ │ 1. 启动Selenium│
│ └──────┬───────┘
│ │
│ ┌──────▼───────┐
│ │ 2. 输入搜索条件│
│ └──────┬───────┘
│ │
│ ┌──────▼───────┐
│ │ 3. 等待加载 │ (WebDriverWait)
│ └──────┬───────┘
│ │
│ ┌──────▼───────┐
│ │ 4. 提取房源元素│ (find_elements)
│ └──────┬───────┘
│ │
│ ┌──────▼───────┐
│ │ 5. 滚动翻页 │ (execute_script)
│ └──────┬───────┘
│ │
└──────────┴────────┐
│
┌──────▼───────┐
│ 数据清洗 │
└──────┬───────┘
│
┌──────▼───────┐
│ 字段标准化 │ (户型/价格/面积)
└──────┬───────┘
│
┌──────▼───────┐
│ 去重 │ (基于listing_id)
└──────┬───────┘
│
┌──────▼───────┐
│ 导出CSV │
└──────────────┘
5️⃣ 环境准备与依赖安装(可复现)
Python版本要求
json
Python 3.9+ # 本教程在Python 3.11.5测试通过
完整依赖安装
json
# 创建虚拟环境
python -m venv housing_scraper_env
source housing_scraper_env/bin/activate # Windows: housing_scraper_env\Scripts\activate
# 核心依赖
pip install requests==2.31.0
pip install lxml==5.1.0
pip install selenium==4.16.0
pip install selenium-stealth==1.0.6
pip install fake-useragent==1.4.0
pip install webdriver-manager==4.0.1 # 自动管理ChromeDriver
# 数据处理
pip install pandas==2.1.4
pip install openpyxl==3.1.2 # Excel支持
pip install geopy==2.4.1 # 地理位置解析(可选)
# 可视化(可选)
pip install matplotlib==3.8.2
pip install seaborn==0.13.0
项目目录结构
json
housing_scraper/
│
├── config.py # 配置参数
├── lianjia_scraper.py # 链家爬虫
├── anjuke_scraper.py # 安居客爬虫
├── data_cleaner.py # 数据清洗
├── storage.py # CSV存储
├── main.py # 主入口
├── requirements.txt # 依赖清单
│
├── data/
│ ├── lianjia_beijing_20240128.csv
│ ├── anjuke_beijing_20240128.csv
│ └── merged_housing_data.csv
│
├── logs/
│ └── scraper.log # 运行日志
│
└── screenshots/ # Selenium截图(调试用)
└── error_*.png
创建目录:
json
mkdir -p housing_scraper/{data,logs,screenshots}
cd housing_scraper
6️⃣ 核心实现:配置与工具层
配置文件(config.py)
python
"""
房产爬虫配置文件
包含URL模板、区域映射、字段映射等
"""
import os
from fake_useragent import UserAgent
# ========== 基础路径配置 ==========
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, 'data')
LOG_DIR = os.path.join(BASE_DIR, 'logs')
SCREENSHOT_DIR = os.path.join(BASE_DIR, 'screenshots')
# 确保目录存在
for directory in [DATA_DIR, LOG_DIR, SCREENSHOT_DIR]:
os.makedirs(directory, exist_ok=True)
# ========== 爬虫通用配置 ==========
TIMEOUT = 15 # 请求超时(秒)
RETRY_TIMES = 3 # 失败重试次数
DELAY_RANGE = (2, 5) # 请求延迟范围(秒)
MAX_PAGES = 20 # 最大翻页数
# User-Agent池
UA = UserAgent()
USER_AGENTS = [
'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/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
]
# ========== 链家配置 ==========
LIANJIA_BASE_URL = 'https://{city}.lianjia.com/ershoufang/{area}/'
# 城市代码映射
LIANJIA_CITY_MAP = {
'北京': 'bj',
'上海': 'sh',
'广州': 'gz',
'深圳': 'sz',
'成都': 'cd',
'杭州': 'hz'
}
# 北京区域代码映射(示例)
LIANJIA_BEIJING_AREAS = {
'朝阳': 'chaoyang',
'海淀': 'haidian',
'西城': 'xicheng',
'东城': 'dongcheng',
'丰台': 'fengtai',
'石景山': 'shijingshan',
'通州': 'tongzhou',
'昌平': 'changping',
'大兴': 'daxing',
'亦庄开发区': 'yizhuangkaifaqu',
'顺义': 'shunyi',
'房山': 'fangshan',
'门头沟': 'mentougou',
'平谷': 'pinggu',
'怀柔': 'huairou',
'密云': 'miyun',
'延庆': 'yanqing'
}
# 链家请求headers
LIANJIA_HEADERS = {
'User-Agent': UA.random,
'Referer': 'https://bj.lianjia.com/',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Cache-Control': 'max-age=0'
}
# ========== 安居客配置 ==========
ANJUKE_BASE_URL = 'https://{city}.anjuke.com/fangyuan/'
# 城市代码映射
ANJUKE_CITY_MAP = {
'北京': 'beijing',
'上海': 'shanghai',
'广州': 'guangzhou',
'深圳': 'shenzhen',
'成都': 'chengdu',
'杭州': 'hangzhou'
}
# 安居客请求headers
ANJUKE_HEADERS = {
'User-Agent': UA.random,
'Referer': 'https://beijing.anjuke.com/',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'zh-CN,zh;q=0.9'
}
# ========== Selenium配置 ==========
CHROME_OPTIONS = [
'--headless', # 无头模式(可注释掉以调试)
'--disable-gpu',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled',
'--window-size=1920,1080',
f'--user-agent={UA.random}'
]
# ========== 数据清洗配置 ==========
# 户型标准化映射
LAYOUT_NORMALIZE_MAP = {
'1室': '1室',
'一居': '1室',
'2室': '2室',
'两居': '2室',
'3室': '3室',
'三居': '3室',
'4室': '4室',
'四居': '4室',
'5室': '5室',
'五居': '5室'
}
# ========== 日志配置 ==========
LOGGING_CONFIG = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s - [%(levelname)s] - %(name)s - %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S'
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'standard'
},
'file': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOG_DIR, 'scraper.log'),
'maxBytes': 10485760, # 10MB
'backupCount': 5,
'formatter': 'standard',
'encoding': 'utf-8'
},
},
'loggers': {
'': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': False
}
}
}
7️⃣ 核心实现:链家爬虫(Lianjia Scraper)
链家爬虫完整实现(lianjia_scraper.py)
python
"""
链家二手房爬虫
技术要点:
1. HTML解析(lxml + XPath)
2. 分页处理
3. 数据字段提取
4. 异常处理与重试
"""
import requests
import time
import random
import logging
import re
from lxml import etree
from typing import List, Dict, Optional
from config import *
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger(__name__)
class LianjiaScaper:
"""
链家二手房爬虫类
核心功能:
1. 按区域搜索房源
2. 多页数据采集
3. 房源信息解析
"""
def __init__(self, city: str = '北京'):
"""
初始化爬虫
Args:
city: 城市名称(如"北京")
"""
self.city = city
self.city_code = LIANJIA_CITY_MAP.get(city, 'bj')
self.session = requests.Session()
self._setup_session()
logger.info(f"链家爬虫初始化完成: 城市={city}, 代码={self.city_code}")
def _setup_session(self):
"""配置Session的默认参数"""
self.session.headers.update(LIANJIA_HEADERS)
# 添加Cookie(提升成功率)
# 注意:这里的Cookie需要从浏览器手动获取
self.session.cookies.set('lianjia_uuid', 'your_cookie_here')
def _get_random_ua(self) -> str:
"""获取随机User-Agent"""
return random.choice(USER_AGENTS)
def _random_delay(self):
"""执行随机延迟"""
delay = random.uniform(*DELAY_RANGE)
logger.debug(f"延迟 {delay:.2f} 秒")
time.sleep(delay)
def scrape_area(self, area: str, max_pages: int = MAX_PAGES) -> List[Dict]:
"""
爬取指定区域的房源
Args:
area: 区域名称(如"朝阳"、"海淀")
max_pages: 最大翻页数
Returns:
房源列表
实现逻辑:
1. 构造URL
2. 循环请求每一页
3. 解析房源列表
4. 合并结果
"""
area_code = LIANJIA_BEIJING_AREAS.get(area)
if not area_code:
logger.error(f"未知区域: {area}")
return []
logger.info(f"=" * 60)
logger.info(f"开始爬取: {area}区 (最多{max_pages}页)")
logger.info(f"=" * 60)
all_listings = []
for page in range(1, max_pages + 1):
logger.info(f"\n>>> 正在爬取第 {page}/{max_pages} 页")
# 构造URL
# 链家分页规则:pg1, pg2, pg3...
if page == 1:
url = LIANJIA_BASE_URL.format(city=self.city_code, area=area_code)
else:
url = LIANJIA_BASE_URL.format(city=self.city_code, area=area_code) + f'pg{page}/'
logger.debug(f"请求URL: {url}")
# 请求页面
html = self._fetch_page(url)
if not html:
logger.warning(f"第{page}页获取失败,跳过")
continue
# 解析房源列表
listings = self._parse_listings(html, area)
if not listings:
logger.warning(f"第{page}页未解析到房源,可能已到最后一页")
break
logger.info(f"✓ 第{page}页成功获取 {len(listings)} 条房源")
all_listings.extend(listings)
# 检查是否有下一页
if not self._has_next_page(html):
logger.info(f"已到最后一页,共{page}页")
break
# 延迟
self._random_delay()
logger.info(f"\n" + "=" * 60)
logger.info(f"爬取完成: {area}区共 {len(all_listings)} 条房源")
logger.info(f"=" * 60 + "\n")
return all_listings
def _fetch_page(self, url: str, retries: int = RETRY_TIMES) -> Optional[str]:
"""
请求页面(带重试)
Args:
url: 目标URL
retries: 重试次数
Returns:
HTML文本 或 None
"""
for attempt in range(1, retries + 1):
try:
# 更换User-Agent
self.session.headers['User-Agent'] = self._get_random_ua()
response = self.session.get(
url,
timeout=TIMEOUT,
allow_redirects=True
)
if response.status_code == 200:
# 检查是否被重定向到验证码页面
if 'captcha' in response.url or '验证' in response.text:
logger.error("触发验证码,请等待或手动处理")
return None
logger.debug(f"✓ 页面获取成功 ({len(response.text)} 字节)")
return response.text
elif response.status_code == 404:
logger.warning(f"404 页面不存在: {url}")
return None
elif response.status_code == 429:
wait_time = 2 ** attempt * 5
logger.warning(f"429 被限流,等待 {wait_time} 秒")
time.sleep(wait_time)
else:
logger.warning(f"状态码异常: {response.status_code}")
except requests.Timeout:
logger.error(f"请求超时 (尝试 {attempt}/{retries})")
time.sleep(5)
except Exception as e:
logger.error(f"请求异常: {e}")
logger.error(f"✗ {retries}次尝试后仍失败: {url}")
return None
def _parse_listings(self, html: str, area: str) -> List[Dict]:
"""
解析房源列表
Args:
html: 页面HTML
area: 区域名称
Returns:
房源列表
链家HTML结构(2024年1月版本):
<ul class="sellListContent">
<li class="clear">
<div class="title">
<a href="/ershoufang/101103953958.html">望京 南湖东园二区 精装两居...</a>
</div>
<div class="houseInfo">
南湖东园二区 | 2室1厅 | 85.5平米 | 南北 | 中楼层(共33层) | 2010年建
</div>
<div class="positionInfo">
<a href="/xiaoqu/1111027377067/">南湖东园二区</a>
<a href="/ershoufang/wangjing/">望京</a>
</div>
<div class="totalPrice">
<span>650</span>万
</div>
<div class="unitPrice">
<span>76023</span>元/平
</div>
</li>
</ul>
"""
tree = etree.HTML(html)
listings = []
# XPath定位房源列表
# 注意:链家的HTML结构可能会变化,需要定期更新XPath
house_items = tree.xpath('//ul[@class="sellListContent"]/li[@class="clear"]')
logger.debug(f"找到 {len(house_items)} 个房源元素")
for item in house_items:
try:
listing = self._parse_single_listing(item, area)
if listing:
listings.append(listing)
except Exception as e:
logger.error(f"解析单条房源失败: {e}")
continue
return listings
def _parse_single_listing(self, item, area: str) -> Optional[Dict]:
"""
解析单条房源信息
Args:
item: lxml Element对象
area: 区域名称
Returns:
房源信息字典
"""
try:
# === 1. 标题和链接 ===
title_elem = item.xpath('.//div[@class="title"]/a')
if not title_elem:
return None
title = title_elem[0].text.strip()
detail_url = 'https://bj.lianjia.com' + title_elem[0].get('href')
# 提取房源ID(从URL中)
# URL格式:/ershoufang/101103953958.html
listing_id_match = re.search(r'/ershoufang/(\d+)\.html', detail_url)
listing_id = listing_id_match.group(1) if listing_id_match else None
# === 2. 房屋信息 ===
# houseInfo格式:"南湖东园二区 | 2室1厅 | 85.5平米 | 南北 | 中楼层(共33层) | 2010年建"
house_info = item.xpath('.//div[@class="houseInfo"]/text()')
house_info_text = house_info[0].strip() if house_info else ""
# 分割字段
info_parts = [part.strip() for part in house_info_text.split('|')]
# 解析各个字段
community = info_parts[0] if len(info_parts) > 0 else ""
layout = info_parts[1] if len(info_parts) > 1 else ""
# 面积(需要提取数字)
area_text = info_parts[2] if len(info_parts) > 2 else ""
building_area_match = re.search(r'([\d.]+)', area_text)
building_area = float(building_area_match.group(1)) if building_area_match else None
# 朝向
orientation = info_parts[3] if len(info_parts) > 3 else ""
# 楼层信息
floor_info = info_parts[4] if len(info_parts) > 4 else ""
# === 3. 位置信息 ===
position_elem = item.xpath('.//div[@class="positionInfo"]/a')
# 第一个a标签是小区,第二个是商圈
# community_name = position_elem[0].text.strip() if len(position_elem) > 0 else community
business_area = position_elem[1].text.strip() if len(position_elem) > 1 else ""
# === 4. 价格信息 ===
# 总价
total_price_elem = item.xpath('.//div[@class="totalPrice"]/span/text()')
total_price = float(total_price_elem[0]) if total_price_elem else None
# 单价
unit_price_elem = item.xpath('.//div[@class="unitPrice"]/span/text()')
unit_price = float(unit_price_elem[0]) if unit_price_elem else None
# === 5. 首图 ===
image_elem = item.xpath('.//img[@class="lj-lazy"]/@data-original')
image_url = image_elem[0] if image_elem else ""
# === 组装数据 ===
listing_data = {
'platform': '链家',
'listing_id': listing_id,
'title': title,
'community': community,
'district': area,
'area': business_area,
'layout': layout,
'building_area': building_area,
'orientation': orientation,
'floor_info': floor_info,
'total_price': total_price,
'unit_price': unit_price,
'subway': '', # 列表页无地铁信息,需请求详情页
'listing_url': detail_url,
'image_url': image_url,
'created_at': time.strftime('%Y-%m-%d %H:%M:%S')
}
logger.debug(f"✓ 解析成功: {title[:30]}... - ¥{total_price}万")
return listing_data
except Exception as e:
logger.error(f"解析房源时出错: {e}", exc_info=True)
return None
def _has_next_page(self, html: str) -> bool:
"""
检查是否有下一页
Args:
html: 页面HTML
Returns:
是否有下一页
链家的分页结构:
<div class="page-box house-lst-page-box">
<a href="/ershoufang/chaoyang/pg2/">下一页</a>
</div>
"""
tree = etree.HTML(html)
# 方法1:检查"下一页"链接
next_page = tree.xpath('//div[@class="page-box house-lst-page-box"]//a[contains(text(), "下一页")]')
if next_page:
return True
# 方法2:检查分页数字(更可靠)
page_data = tree.xpath('//div[@class="page-box house-lst-page-box"]/@page-data')
if page_data:
import json
try:
data = json.loads(page_data[0])
current_page = data.get('curPage', 0)
total_pages = data.get('totalPage', 0)
return current_page < total_pages
except:
pass
return False
def enrich_with_detail(self, listing: Dict) -> Dict:
"""
请求详情页,补充更多信息
Args:
listing: 房源基本信息
Returns:
补充后的房源信息
详情页可获取:
- 地铁信息
- 小区均价
- 配套设施
- 详细描述
注意:详情页请求会增加爬取时间,谨慎使用
"""
detail_url = listing['listing_url']
logger.debug(f"请求详情页: {detail_url}")
html = self._fetch_page(detail_url)
if not html:
return listing
tree = etree.HTML(html)
try:
# 地铁信息
subway_elem = tree.xpath('//div[@class="aroundInfo"]//span[@class="label"]/following-sibling::text()')
if subway_elem:
listing['subway'] = subway_elem[0].strip()
# 可以继续提取其他信息...
except Exception as e:
logger.error(f"解析详情页失败: {e}")
return listing
8️⃣ 核心实现:安居客爬虫(Anjuke Scraper)
安居客爬虫完整实现(anjuke_scraper.py)
python
"""
安居客租房爬虫
技术要点:
1. Selenium浏览器自动化
2. 反检测(selenium-stealth)
3. 动态页面加载等待
4. 滚动翻页
"""
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options
from selenium_stealth import stealth
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.chrome.service import Service
import time
import random
import logging
import re
from typing import List, Dict, Optional
from config import *
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger(__name__)
class AnjukeScraper:
"""
安居客租房爬虫类
核心功能:
1. Selenium模拟浏览器
2. 处理动态加载
3. 滚动翻页
"""
def __init__(self, city: str = '北京', headless: bool = True):
"""
初始化爬虫
Args:
city: 城市名称
headless: 是否无头模式(False可用于调试)
"""
self.city = city
self.city_code = ANJUKE_CITY_MAP.get(city, 'beijing')
self.headless = headless
self.driver = None
self._init_driver()
logger.info(f"安居客爬虫初始化完成: 城市={city}, 无头模式={headless}")
def _init_driver(self):
"""
初始化Chrome WebDriver
配置要点:
1. 使用webdriver-manager自动下载驱动
2. 添加反检测参数
3. 配置stealth插_options = Options()
for option in CHROME_OPTIONS:
if option == '--headless' and not self.headless:
continue # 调试模式下跳过无头模式
chrome_options.add_argument(option)
# 禁用自动化标志
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
chrome_options.add_experimental_option('useAutomationExtension', False)
# 自动下载并配置ChromeDriver
service = Service(ChromeDriverManager().install())
self.driver = webdriver.Chrome(service=service, options=chrome_options)
# 应用stealth插件(隐藏自动化特征)
stealth(
self.driver,
languages=["zh-CN", "zh"],
vendor="Google Inc.",
platform="Win32",
webgl_vendor="Intel Inc.",
renderer="Intel Iris OpenGL Engine",
fix_hairline=True,
)
# 设置隐式等待
self.driver.implicitly_wait(10)
logger.info("✓ WebDriver初始化成功")
except Exception as e:
logger.error(f"WebDriver初始化失败: {e}")
raise
def scrape_area(self, area: str = "", max_pages: int = 10) -> List[Dict]:
"""
爬取租房信息
Args:
area: 区域筛选(可选)
max_pages: 最大翻页数
Returns:
房源列表
安居客租房URL示例:
https://beijing.anjuke.com/fangyuan/
https://beijing.anjuke.com/fangyuan/chaoyang/
"""
logger.info(f"=" * 60)
logger.info(f"开始爬取安居客: {area if area else '全部区域'} (最多{max_pages}页)")
logger.info(f"=" * 60)
# 构造URL
if area:
url = f"https://{self.city_code}.anjuke.com/fangyuan/{area}/"
else:
url = f"https://{self.city_code}.anjuke.com/fangyuan/"
all_listings = []
try:
# 访问首页
logger.info(f"访问: {url}")
self.driver.get(url)
# 等待页面加载
time.sleep(random.uniform(3, 5))
# 检查是否需要登录
if self._check_login_required():
logger.warning("需要登录,请手动登录后继续...")
input("登录完成后按Enter继续...")
# 循环翻页
for page in range(1, max_pages + 1):
logger.info(f"\n>>> 正在爬取第 {page}/{max_pages} 页")
# 等待房源列表加载
listings = self._wait_and_parse_listings()
if not listings:
logger.warning(f"第{page}页未获取到房源")
break
logger.info(f"✓ 第{page}页成功获取 {len(listings)} 条房源")
all_listings.extend(listings)
# 检查是否有下一页
if not self._has_next_page():
logger.info(f"已到最后一页")
break
# 点击下一页
if not self._click_next_page():
logger.warning("翻页失败")
break
# 随机延迟
time.sleep(random.uniform(2, 4))
except Exception as e:
logger.error(f"爬取过程出错: {e}", exc_info=True)
# 出错时截图
screenshot_path = os.path.join(SCREENSHOT_DIR, f'error_{int(time.time())}.png')
self.driver.save_screenshot(screenshot_path)
logger.info(f"错误截图已保存: {screenshot_path}")
logger.info(f"\n" + "=" * 60)
logger.info(f"爬取完成: 共 {len(all_listings)} 条房源")
logger.info(f"=" * 60 + "\n")
return all_listings
def _check_login_required(self) -> bool:
"""检查是否需要登录"""
try:
# 检查是否有登录弹窗
login_modal = self.driver.find_elements(By.CLASS_NAME, "login-modal")
return len(login_modal) > 0
except:
return False
def _wait_and_parse_listings(self) -> List[Dict]:
"""
等待并解析房源列表
安居客的房源列表结构(可能变化):
<div class="house-list">
<div class="list-item">
<div class="house-title">
<a href="...">朝阳 望京 南湖东园二区 2室1厅...</a>
</div>
<div class="house-details">
<span>2室1厅</span>
<span>85㎡</span>
<span>南北</span>
</div>
<div class="price">
<span class="price-num">6500</span>元/月
</div>
</div>
</div>
"""
try:
# 等待房源列表加载(最多等待15秒)
wait = WebDriverWait(self.driver, 15)
wait.until(
EC.presence_of_element_located((By.CLASS_NAME, "house-list"))
)
# 滚动页面以加载所有图片
self._scroll_to_bottom()
# 获取所有房源元素
house_items = self.driver.find_elements(By.CLASS_NAME, "list-item")
logger.debug(f"找到 {len(house_items)} 个房源元素")
listings = []
for item in house_items:
try:
listing = self._parse_single_listing(item)
if listing:
listings.append(listing)
except Exception as e:
logger.error(f"解析单条房源失败: {e}")
continue
return listings
except Exception as e:
logger.error(f"等待房源列表失败: {e}")
return []
def _parse_single_listing(self, item) -> Optional[Dict]:
"""
解析单条房源
Args:
item: WebElement对象
Returns:
房源信息字典
"""
try:
# === 1. 标题和链接 ===
title_elem = item.find_element(By.CLASS_NAME, "house-title").find_element(By.TAG_NAME, "a")
title = title_elem.text.strip()
detail_url = title_elem.get_attribute('href')
# 提取房源ID
listing_id_match = re.search(r'/(\d+)', detail_url)
listing_id = listing_id_match.group(1) if listing_id_match else None
# === 2. 房屋详情 ===
details_elem = item.find_elements(By.CLASS_NAME, "house-details")
layout = ""
building_area = None
orientation = ""
if details_elem:
details_text = details_elem[0].text
# 解析户型(如"2室1厅")
layout_match = re.search(r'(\d+室\d+厅)', details_text)
if layout_match:
layout = layout_match.group(1)
# 解析面积
area_match = re.search(r'([\d.]+)㎡', details_text)
if area_match:
building_area = float(area_match.group(1))
# 解析朝向
if '南北' in details_text:
orientation = '南北'
elif '东西' in details_text:
orientation = '东西'
elif '南' in details_text:
orientation = '南'
elif '北' in details_text:
orientation = '北'
# === 3. 价格 ===
price_elem = item.find_elements(By.CLASS_NAME, "price-num")
price = None
if price_elem:
price_text = price_elem[0].text.strip()
price_match = re.search(r'([\d.]+)', price_text)
if price_match:
price = float(price_match.group(1))
# === 4. 位置信息 ===
# 从标题中提取区域和小区
# 标题格式示例:"朝阳 望京 南湖东园二区 2室1厅..."
title_parts = title.split()
district = title_parts[0] if len(title_parts) > 0 else ""
area = title_parts[1] if len(title_parts) > 1 else ""
community = title_parts[2] if len(title_parts) > 2 else ""
# === 5. 图片 ===
image_elem = item.find_elements(By.TAG_NAME, "img")
image_url = ""
if image_elem:
image_url = image_elem[0].get_attribute('src') or image_elem[0].get_attribute('data-src') or ""
# === 组装数据 ===
listing_data = {
'platform': '安居客',
'listing_id': listing_id,
'title': title,
'community': community,
'district': district,
'area': area,
'layout': layout,
'building_area': building_area,
'orientation': orientation,
'floor_info': '',
'total_price': price, # 租房是月租金
'unit_price': None,
'subway': '',
'listing_url': detail_url,
'image_url': image_url,
'created_at': time.strftime('%Y-%m-%d %H:%M:%S')
}
logger.debug(f"✓ 解析成功: {title[:30]}... - ¥{price}/月")
return listing_data
except Exception as e:
logger.error(f"解析房源时出错: {e}", exc_info=True)
return None
def _scroll_to_bottom(self):
"""滚动到页面底部(加载懒加载内容)"""
try:
# 获取页面高度
last_height = self.driver.execute_script("return document.body.scrollHeight")
while True:
# 滚动到底部
self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
# 等待加载
time.sleep(1)
# 计算新的高度
new_height = self.driver.execute_script("return document.body.scrollHeight")
if new_height == last_height:
break
last_height = new_height
except Exception as e:
logger.error(f"滚动页面失败: {e}")
def _has_next_page(self) -> bool:
"""检查是否有下一页"""
try:
# 查找"下一页"按钮
next_button = self.driver.find_elements(By.LINK_TEXT, "下一页")
if not next_button:
# 尝试其他定位方式
next_button = self.driver.find_elements(By.CLASS_NAME, "next-page")
return len(next_button) > 0
except:
return False
def _click_next_page(self) -> bool:
"""点击下一页"""
try:
# 查找并点击"下一页"按钮
next_button = self.driver.find_element(By.LINK_TEXT, "下一页")
next_button.click()
# 等待页面加载
time.sleep(2)
logger.debug("✓ 翻页成功")
return True
except Exception as e:
logger.error(f"点击下一页失败: {e}")
return False
def close(self):
"""关闭浏览器"""
if self.driver:
self.driver.quit()
logger.info("浏览器已关闭")
9️⃣ 数据清洗与标准化(Data Cleaner)
数据清洗模块(data_cleaner.py)
python
"""
数据清洗模块
处理房产数据的常见问题:
1. 价格格式统一
2. 户型标准化
3. 面积异常值处理
4. 缺失值填充
"""
import pandas as pd
import re
import logging
from typing import List, Dict
from config import LAYOUT_NORMALIZE_MAP
logger = logging.getLogger(__name__)
class DataCleaner:
"""
房产数据清洗器
核心功能:
1. 字段标准化
2. 异常值检测
3. 去重
"""
def __init__(self):
"""初始化清洗器"""
logger.info("数据清洗器初始化完成")
def clean_listings(self, listings: List[Dict]) -> pd.DataFrame:
"""
清洗房源列表
Args:
listings: 房源字典列表
Returns:
清洗后的DataFrame
"""
logger.info(f"开始清洗数据,原始记录数: {len(listings)}")
if not listings:
logger.warning("没有数据需要清洗")
return pd.DataFrame()
# 转换为DataFrame
df = pd.DataFrame(listings)
logger.info(f"原始列: {list(df.columns)}")
# === 1. 去重 ===
df = self._remove_duplicates(df)
# === 2. 清洗价格 ===
df = self._clean_price(df)
# === 3. 标准化户型 ===
df = self._normalize_layout(df)
# === 4. 清洗面积 ===
df = self._clean_area(df)
# === 5. 处理缺失值 ===
# === 6. 添加衍生字段 ===
df = self._add_derived_fields(df)
logger.info(f"清洗完成,最终记录数: {len(df)}")
return df
def _remove_duplicates(self, df: pd.DataFrame) -> pd.DataFrame:
"""
去重
去重策略:
1. 基于listing_id去重(优先)
2. 基于标题+价格去重(备用)
"""
original_count = len(df)
# 方法1:基于listing_id去重
if 'listing_id' in df.columns:
df = df.drop_duplicates(subset=['listing_id'], keep='first')
logger.info(f"基于listing_id去重: {original_count} → {len(df)}")
# 方法2:基于标题+价格去重
if 'title' in df.columns and 'total_price' in df.columns:
before = len(df)
df = df.drop_duplicates(subset=['title', 'total_price'], keep='first')
if before != len(df):
logger.info(f"基于标题+价格去重: {before} → {len(df)}")
return df
def _clean_price(self, df: pd.DataFrame) -> pd.DataFrame:
"""
清洗价格数据
问题:
1. 空值处理
2. 异常值过滤(如价格为0或过高)
3. 单位统一
"""
if 'total_price' not in df.columns:
return df
# 处理空值
df['total_price'] = pd.to_numeric(df['total_price'], errors='coerce')
# 过滤异常值
# 二手房合理价格范围:50万-5000万
# 租房合理价格范围:500-50000元/月
before = len(df)
if df['platform'].iloc[0] == '链家': # 二手房
df = df[df['total_price'].between(50, 5000, inclusive='both')]
else: # 租房
df = df[df['total_price'].between(500, 50000, inclusive='both')]
if before != len(df):
logger.info(f"过滤价格异常值: {before} → {len(df)}")
return df
def _normalize_layout(self, df: pd.DataFrame) -> pd.DataFrame:
"""
标准化户型
问题:
1. "两居"→"2室"
2. "2室1厅1卫"→提取核心信息
"""
if 'layout' not in df.columns:
return df
def normalize_single_layout(layout: str) -> str:
"""标准化单个户型"""
if pd.isna(layout) or not layout:
return ''
layout = str(layout).strip()
# 提取室数
for key, value in LAYOUT_NORMALIZE_MAP.items():
if key in layout:
return value
# 提取数字(如"2室1厅"→"2室")
match = re.search(r'(\d+)室', layout)
if match:
return f"{match.group(1)}室"
return layout
df['layout'] = df['layout'].apply(normalize_single_layout)
logger.debug(f"户型标准化完成,唯一值: {df['layout'].unique()}")
return df
def _clean_area(self, df: pd.DataFrame) -> pd.DataFrame:
"""
清洗面积数据
问题:
1. 空值处理
2. 异常值过滤(如<10㎡或>500㎡)
"""
if 'building_area' not in df.columns:
return df
# 转换为数值
df['building_area'] = pd.to_numeric(df['building_area'], errors='coerce')
# 过滤异常值
before = len(df)
df = df[df['building_area'].between(10, 500, inclusive='both') | df['building_area'].isna()]
if before != len(df):
logger.info(f"过滤面积异常值: {before} → {len(df)}")
return df
def _handle_missing_values(self, df: pd.DataFrame) -> pd.DataFrame:
"""
处理缺失值
策略:
1. 关键字段(价格、标题):删除记录
2. 非关键字段:填充默认值
"""
# 删除关键字段缺失的记录
critical_fields = ['title', 'total_price']
before = len(df)
df = df.dropna(subset=critical_fields)
if before != len(df):
logger.info(f"删除关键字段缺失记录: {before} → {len(df)}")
# 填充非关键字段
fill_values = {
'community': '未知小区',
'district': '未知',
'area': '未知',
'layout': '未知',
'orientation': '未知',
'floor_info': '未知',
'subway': ''
}
df = df.fillna(fill_values)
return df
def _add_derived_fields(self, df: pd.DataFrame) -> pd.DataFrame:
"""
添加衍生字段
新增字段:
1. 单价(如果缺失)
2. 房屋类型(根据户型判断)
3. 是否地铁房
"""
# === 1. 计算单价 ===
if 'unit_price' not in df.columns or df['unit_price'].isna().all():
if 'total_price' in df.columns and 'building_area' in df.columns:
# 二手房:万元/㎡ → 元/㎡
if df['platform'].iloc[0] == '链家':
df['unit_price'] = (df['total_price'] * 10000 / df['building_area']).round(2)
# === 2. 房屋类型 ===
def classify_house_type(layout: str) -> str:
"""根据户型分类"""
if pd.isna(layout):
return '未知'
if '1室' in layout or '一居' in layout:
return '小户型'
elif '2室' in layout or '两居' in layout:
return '中户型'
elif '3室' in layout or '三居' in layout:
return '大户型'
elif '4室' in layout or '四居' in layout or '5室' in layout:
return '豪宅'
else:
return '其他'
df['house_type'] = df['layout'].apply(classify_house_type)
# === 3. 是否地铁房 ===
if 'subway' in df.columns:
df['is_subway'] = df['subway'].apply(
lambda x: '是' if (isinstance(x, str) and len(x) > 0) else '否'
)
logger.debug("衍生字段添加完成")
return df
def export_statistics(self, df: pd.DataFrame) -> Dict:
"""
导出统计信息
Returns:
统计数据字典
"""
stats = {
'total_count': len(df),
'avg_price': df['total_price'].mean() if 'total_price' in df.columns else None,
'median_price': df['total_price'].median() if 'total_price' in df.columns else None,
'avg_area': df['building_area'].mean() if 'building_area' in df.columns else None,
'layout_distribution': df['layout'].value_counts().to_dict() if 'layout' in df.columns else {},
'district_distribution': df['district'].value_counts().to_dict() if 'district' in df.columns else {}
}
logger.info(f"统计信息: 总数={stats['total_count']}, 平均价={stats['avg_price']:.2f}")
return stats
🔟 数据存储模块(Storage)
CSV存储实现(storage.py)
python
"""
数据存储模块
支持CSV、Excel、SQLite等多种格式
"""
import pandas as pd
import os
import logging
from datetime import datetime
from typing import List, Dict
from config import DATA_DIR
logger = logging.getLogger(__name__)
class DataStorage:
"""
数据存储管理器
功能:
1. CSV导出
2. Excel导出
3. 数据合并
"""
def __init__(self):
"""初始化存储管理器"""
self.data_dir = DATA_DIR
logger.info(f"数据存储目录: {self.data_dir}")
def save_to_csv(self, df: pd.DataFrame, platform: str, city: str) -> str:
"""
保存为CSV文件
Args:
df: DataFrame数据
platform: 平台名称(链家/安居客)
city: 城市名称
Returns:
CSV文件路径
"""
if df.empty:
logger.warning("DataFrame为空,跳过保存")
return None
# 生成文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{platform}_{city}_{timestamp}.csv"
filepath = os.path.join(self.data_dir, filename)
try:
# 保存CSV(使用utf-8-sig编码兼容Excel)
df.to_csv(filepath, index=False, encoding='utf-8-sig')
logger.info(f"✓ CSV文件已保存: {filepath} ({len(df)} 条记录)")
return filepath
except Exception as e:
logger.error(f"保存CSV失败: {e}")
return None
def save_to_excel(self, df: pd.DataFrame, platform: str, city: str) -> str:
"""
保存为Excel文件
Args:
df: DataFrame数据
platform: 平台名称
city: 城市名称
Returns:
Excel文件路径
"""
if df.empty:
logger.warning("DataFrame为空,跳过保存")
return None
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{platform}_{city}_{timestamp}.xlsx"
filepath = os.path.join(self.data_dir, filename)
try:
# 使用ExcelWriter可以进行更多自定义
with pd.ExcelWriter(filepath, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='房源数据', index=False)
# 可以添加更多sheet
# stats_df.to_excel(writer, sheet_name='统计信息')
logger.info(f"✓ Excel文件已保存: {filepath}")
return filepath
except Exception as e:
logger.error(f"保存Excel失败: {e}")
return None
def merge_data(self, lianjia_df: pd.DataFrame, anjuke_df: pd.DataFrame) -> pd.DataFrame:
"""
合并链家和安居客的数据
Args:
lianjia_df: 链家数据
anjuke_df: 安居客数据
Returns:
合并后的DataFrame
"""
logger.info("开始合并数据...")
# 确保两个DataFrame的列一致
all_columns = list(set(lianjia_df.columns) | set(anjuke_df.columns))
# 添加缺失列(填充NaN)
for col in all_columns:
if col not in lianjia_df.columns:
lianjia_df[col] = None
if col not in anjuke_df.columns:
anjuke_df[col] = None
# 合并数据
merged_df = pd.concat([lianjia_df, anjuke_df], ignore_index=True)
logger.info(f"✓ 数据合并完成: 链家{len(lianjia_df)}条 + 安居客{len(anjuke_df)}条 = 总计{len(merged_df)}条")
return merged_df
def append_to_history(self, df: pd.DataFrame, filename: str = 'history.csv'):
"""
追加到历史数据文件
Args:
df: 新数据
filename: 历史文件名
"""
filepath = os.path.join(self.data_dir, filename)
try:
# 如果文件存在,先读取
if os.path.exists(filepath):
history_df = pd.read_csv(filepath, encoding='utf-8-sig')
# 合并数据(去重)
combined_df = pd.concat([history_df, df], ignore_index=True)
combined_df = combined_df.drop_duplicates(subset=['listing_id'], keep='last')
logger.info(f"历史数据合并: 原{len(history_df)}条 + 新{len(df)}条 = 去重后{len(combined_df)}条")
else:
combined_df = df
logger.info(f"创建新历史文件: {filename}")
# 保存
combined_df.to_csv(filepath, index=False, encoding='utf-8-sig')
logger.info(f"✓ 历史数据已更新: {filepath}")
except Exception as e:
logger.error(f"追加历史数据失败: {e}")
1️⃣1️⃣ 主程序与运行示例
主程序入口(main.py)
python
"""
房产爬虫主程序
整合链家和安居客的爬虫
"""
import logging
import logging.config
import argparse
from config import LOGGING_CONFIG
from lianjia_scraper import LianjiaScaper
from anjuke_scraper import AnjukeScraper
from data_cleaner import DataCleaner
from storage import DataStorage
# 配置日志
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger(__name__)
def main():
"""
主函数
支持命令行参数:
python main.py --platform lianjia --city 北京 --area 朝阳 --pages 5
"""
# 解析命令行参数
据爬虫')
parser.add_argument('--platform', type=str, default='lianjia',
choices=['lianjia', 'anjuke', 'both'],
help='爬取平台(lianjia/anjuke/both)')
parser.add_argument('--city', type=str, default='北京',
help='城市名称')
parser.add_argument('--area', type=str, default='朝阳',
help='区域名称')
parser.add_argument('--pages', type=int, default=5,
help='最大翻页数')
parser.add_argument('--detail', action='store_true',
help='是否爬取详情页(慢)')
args = parser.parse_args()
logger.info("=" * 80)
logger.info("房产数据爬虫启动")
logger.info(f"平台: {args.platform}, 城市: {args.city}, 区域: {args.area}, 页数: {args.pages}")
logger.info("=" * 80)
# 初始化组件
cleaner = DataCleaner()
storage = DataStorage()
lianjia_df = None
anjuke_df = None
# === 爬取链家 ===
if args.platform in ['lianjia', 'both']:
logger.info("\n" + "=" * 80)
logger.info("开始爬取链家二手房")
logger.info("=" * 80)
try:
scraper = LianjiaScaper(city=args.city)
listings = scraper.scrape_area(area=args.area, max_pages=args.pages)
if listings:
# 清洗数据
lianjia_df = cleaner.clean_listings(listings)
# 保存CSV
csv_path = storage.save_to_csv(lianjia_df, '链家', args.city)
# 导出统计
stats = cleaner.export_statistics(lianjia_df)
logger.info(f"链家统计: {stats}")
else:
logger.warning("链家未获取到数据")
except Exception as e:
logger.error(f"链家爬取失败: {e}", exc_info=True)
# === 爬取安居客 ===
if args.platform in ['anjuke', 'both']:
logger.info("\n" + "=" * 80)
logger.info("开始爬取安居客租房")
logger.info("=" * 80)
scraper = None
try:
scraper = AnjukeScraper(city=args.city, headless=True)
listings = scraper.scrape_area(area=args.area, max_pages=args.pages)
if listings:
# 清洗数据
anjuke_df = cleaner.clean_listings(listings)
# 保存CSV
csv_path = storage.save_to_csv(anjuke_df, '安居客', args.city)
# 导出统计
stats = cleaner.export_statistics(anjuke_df)
logger.info(f"安居客统计: {stats}")
else:
logger.warning("安居客未获取到数据")
except Exception as e:
logger.error(f"安居客爬取失败: {e}", exc_info=True)
finally:
if scraper:
scraper.close()
# === 合并数据(如果同时爬了两个平台)===
if args.platform == 'both' and lianjia_df is not None and anjuke_df is not None:
logger.info("\n" + "=" * 80)
logger.info("合并链家和安居客数据")
logger.info("=" * 80)
merged_df = storage.merge_data(lianjia_df, anjuke_df)
merged_path = storage.save_to_csv(merged_df, '合并', args.city)
logger.info(f"✓ 合并数据已保存: {merged_path}")
logger.info("\n" + "=" * 80)
logger.info("爬取任务完成!")
logger.info("=" * 80)
if __name__ == "__main__":
main()
运行命令示例
bash
# 1. 爬取链家朝阳区二手房(前5页)
python main.py --platform lianjia --city 北京 --area 朝阳 --pages 5
# 2. 爬取安居客海淀区租房(前10页)
python main.py --platform anjuke --city 北京 --area 海淀 --pages 10
# 3. 同时爬取两个平台并合并
python main.py --platform both --city 北京 --area 望京 --pages 3
# 4. 批量爬取多个区域(Shell脚本)
for area in 朝阳 海淀 东城 西城; do
python main.py --platform lianjia --area $area --pages 5
sleep 10
done
运行日志示例
json
2024-01-28 20:00:00 - [INFO] - __main__ - ================================================================================
2024-01-28 20:00:00 - [INFO] - __main__ - 房产数据爬虫启动
2024-01-28 20:00:00 - [INFO] - __main__ - 平台: lianjia, 城市: 北京, 区域: 朝阳, 页数: 5
2024-01-28 20:00:00 - [INFO] - __main__ - ================================================================================
2024-01-28 20:00:00 - [INFO] - lianjia_scraper - 链家爬虫初始化完成: 城市=北京, 代码=bj
2024-01-28 20:00:01 - [INFO] - lianjia_scraper - ============================================================
2024-01-28 20:00:01 - [INFO] - lianjia_scraper - 开始爬取: 朝阳区 (最多5页)
2024-01-28 20:00:01 - [INFO] - lianjia_scraper - ============================================================
2024-01-28 20:00:01 - [INFO] - lianjia_scraper - >>> 正在爬取第 1/5 页
2024-01-28 20:00:04 - [DEBUG] - lianjia_scraper - ✓ 页面获取成功 (125436 字节)
2024-01-28 20:00:04 - [DEBUG] - lianjia_scraper - 找到 30 个房源元素
2024-01-28 20:00:05 - [INFO] - lianjia_scraper - ✓ 第1页成功获取 30 条房源
2024-01-28 20:00:08 - [INFO] - lianjia_scraper - >>> 正在爬取第 2/5 页
...
2024-01-28 20:01:30
2024-01-28 20:01:30 - [INFO] - lianjia_scraper - 爬取完成: 朝阳区共 150 条房源
2024-01-28 20:01:30 - [INFO] - lianjia_scraper - ============================================================
2024-01-28 20:01:30 - [INFO] - data_cleaner - 开始清洗数据,原始记录数: 150
2024-01-28 20:01:30 - [INFO] - data_cleaner - 基于listing_id去重: 150 → 148
2024-01-28 20:01:31 - [INFO] - data_cleaner - 清洗完成,最终记录数: 148
2024-01-28 20:01:31 - [INFO] - storage - ✓ CSV文件已保存: data/链家_北京_20240128_200131.csv (148 条记录)
2024-01-28 20:01:31 - [INFO] - __main__ - 链家统计: {'total_count': 148, 'avg_price': 682.5, ...}
2024-01-28 20:01:31 - [INFO] - __main__ - ================================================================================
2024-01-28 20:01:31 - [INFO] - __main__ - 爬取任务完成!
2024-01-28 20:01:31 - [INFO] - __main__ - ================================================================================
CSV输出示例
csv
platform,listing_id,title,community,district,area,layout,building_area,orientation,floor_info,total_price,unit_price,subway,listing_url,image_url,created_at
链家,101103953958,望京 南湖东园二区 精装两居室 南北通透,南湖东园二区,朝阳,望京,2室1厅,85.5,南北,中楼层(共33层),650.0,76023.0,地铁14号线望京站,https://bj.lianjia.com/ershoufang/101103953958.html,https://image.ljcdn.com/...,2024-01-28 20:00:05
链家,101104567890,三元桥 凤凰城 精装三居 采光好,凤凰城,朝阳,三元桥,3室2厅,120.0,南,高楼层(共25层),880.0,73333.0,,https://bj.lianjia.com/ershoufang/101104567890.html,https
1️⃣2️⃣ 常见问题与优化建议
Q1: 链家返回空数据?
原因:触发了反爬机制
解决方案:
python
# 1. 手动获取Cookie
# 打开Chrome,访问链家,登录后按F12
# Network → 随便点击一个房源 → Headers → Request Headers → Cookie
# 复制Cookie字符串
# 2. 添加到代码
self.session.cookies.set('lianjia_uuid', 'your_cookie_value_here')
self.session.cookies.set('lianjia_ssid', 'another_cookie_value')
# 3. 增加延迟
DELAY_RANGE = (5, 10) # 从(2,5)改为(5,10)
Q2: 安居客滑块验证码?
现象:页面跳转到验证页面
解决方案:
python
# 方案1:手动处理(简单但不优雅)
def handle_captcha_manually():
"""检测到验证码时暂停,等待手动处理"""
if 'captcha' in driver.current_url:
logger.warning("检测到验证码,请手动完成后按Enter继续...")
input()
# 方案2:使用打码平台(付费)
from selenium_captcha_solver import CaptchaSolver
solver = CaptchaSolver(api_key='your_api_key')
solver.solve_slider_captcha(driver)
# 方案3:降低频率 + 更真实的行为模拟
time.sleep(random.uniform(5, 10))
driver.execute_script("window.scrollTo(0, 300);") # 模拟滚动
Q3: DataFrame列对不齐?
原因:链家和安居客的字段不完全一致
解决:
python
# 在merge前统一列
def align_columns(df1, df2):
"""对齐两个DataFrame的列"""
all_columns = list(set(df1.columns) | set(df2.columns))
for col in all_columns:
if col not in df1.columns:
df1[col] = None
if col not in df2.columns:
df2[col] = None
return df1[all_columns], df2[all_columns]
lianjia_df, anjuke_df = align_columns(lianjia_df, anjuke_df)
Q4: 爬取速度太慢?
优化方案:
python
# 1. 并发爬取(谨慎使用)
from concurrent.futures import ThreadPoolExecutor
def scrape_multiple_areas(areas, max_workers=2):
"""并发爬取多个区域"""
with ThreadPoolExecutor(max_workers=max_workers) as executor:
results = executor.map(lambda area: scraper.scrape_area(area), areas)
return list(results)
# 2. 只爬列表页,不请求详情页
# 链家列表页已包含90%的信息,详情页可选
# 3. 使用更快的解析库
from selectolax.parser import HTMLParser # 比lxml更快
进阶优化方向
1. 数据可视化:
python
import matplotlib.pyplot as plt
import seaborn as sns
def plot_price_distribution(df):
"""绘制价格分布图"""
plt.figure(figsize=(12, 6))
# 子图1:价格直方图
plt.subplot(1, 2, 1)
df['total_price'].hist(bins=30, edgecolor='black')
plt.xlabel('总价(万元)')
plt.ylabel('数量')
plt.title('价格分布')
# 子图2:各区域平均价
plt.subplot(1, 2, 2)
df.groupby('district')['total_price'].mean().plot(kind='bar')
plt.xlabel('区域')
plt.ylabel('平均价(万元)')
plt.title('各区域平均价')
plt.tight_layout()
plt.savefig('price_analysis.png', dpi=150)
2. 定时任务:
python
from apscheduler.schedulers.blocking import BlockingScheduler
def scheduled_scraping():
"""定时爬取(每天早上8点)"""
scheduler = BlockingScheduler()
scheduler.add_job(
func=main,
trigger='cron',
hour=8,
minute=0
)
scheduler.start()
3. 数据库存储:
python
import sqlite3
def save_to_sqlite(df, db_path='housing.db'):
"""保存到SQLite"""
conn = sqlite3.connect(db_path)
df.to_sql('listings', conn, if_exists='append', index=False)
conn.close()
总结与延伸
本教程的核心价值
通过这个完整的房产爬虫项目,我们实现了:
✅ 双平台数据采集 :链家(requests)+ 安居客(Selenium)
✅ 完整的数据处理流程 :爬取 → 清洗 → 存储 → 分析
✅ 工程化代码结构 :模块化、可扩展、易维护
✅ 实用性强:可直接用于找房、市场分析、投资决策
核心模板
| 技术要点 | 技术要点 | 收获 |
|---|---|---|
| 链家爬虫 | XPath解析、分页处理 | 掌握HTML解析技巧 |
| 安居客爬虫 | Selenium、反检测 | 学会处理动态页面 |
| 数据清洗 | Pandas、正则表达式 | 理解数据标准化 |
| 存储模块 | CSV、去重、合并 | 掌握数据持久化 |
- 投资分析工具:研究区域房价趋势
- 中介数据平台:汇总多平台房源信息
- 学术研究:房地产市场量化分析
延伸学习资源
爬虫进阶:
数据分析:
- Pandas官方教程
- 《利用Python进行数据分析》(Wes McKinney)
项目扩展:
- 添加贝壳找房、58同城等平台
- 实现价格预测模型(机器学习)
- 开发Web可视化界面(Flask + ECharts)
最后的话:
房产爬虫是爬虫领域的经典项目,它涉及的技术点几乎覆盖了爬虫开发的所有核心问题:反爬对抗、动态页面、数据清洗、存储管理。通过这个项目,我不仅掌握了爬虫技术,更学会了如何用数据驱动决策------在北京找房这件事上,我用这套系统节省了数周的看房时间,最终找到了性价比最高的房源。
代码是死的,数据是活的。希望这篇教程不仅教会你写爬虫,更能启发你用技术解决生活中的实际问题。如果本文对你有帮助,欢迎点赞关注!有任何问题随时交流,祝爬取愉快!
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)

免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!