Python爬虫实战:房产数据采集实战 - 链家二手房&安居客租房多页爬虫完整方案(附CSV导出 + SQLite持久化存储)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!

㊗️爬虫难度指数:⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈

💕订阅后更新会优先推送,按目录学习更高效💯~

1️⃣ 摘要(Abstract)

目标:构建一套自动化的房产信息采集系统,使用Python爬虫批量抓取链家二手房和安居客租房的多页列表数据,提取区域、小区名称、户型、面积、价格、朝向等核心字段,支持分页翻页,最终存储为CSV文件供后续分析使用。

你将获得

  • 掌握房产类网站的两大主流反爬策略:链家的数据接口加密 vs 安居客的动态渲染
  • 学会处理复杂的分页逻辑、地址解析、价格清洗等房产数据特有的工程问题
  • 理解房产数据的多维度提取:不仅是价格,还包括地理位置、配套设施、交通信息
  • 获得一套可直接运行的爬虫代码,轻松扩展到贝壳、58同城等其他房产平台

2️⃣ 背景与需求(Why)

为什么要爬取房产数据?

作为一个在北京工作多年的程序员,我深刻体会到找房的痛苦。每次打开链家或安居客,面对成百上千条房源信息,我需要:

  1. 手动对比价格:同一个小区的不同房源,价格差异可能达到几十万
  2. 记录历史信息:今天看中的房子,明天可能就下架了,没有历史记录无法追溯
  3. 跨平台比价:链家的房源和安居客不完全重合,需要多个平台切换
  4. 数据分析困难:想知道某个区域的平均单价、户型分布,只能靠眼睛估算

更关键的是,房产中介往往会操纵价格信息:同一套房源在不同时段标不同价格,制造"涨价"或"降价"的假象。作为技术人员,我决定用代码解决这个问题------建立自己的房产数据库

通过持续爬取数据,我不仅找到了性价比最高的房源,还分析出了以下规律:

  • 每月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、去重、合并 掌握数据持久化
  1. 投资分析工具:研究区域房价趋势
  2. 中介数据平台:汇总多平台房源信息
  3. 学术研究:房地产市场量化分析

延伸学习资源

爬虫进阶

数据分析

项目扩展

  • 添加贝壳找房、58同城等平台
  • 实现价格预测模型(机器学习)
  • 开发Web可视化界面(Flask + ECharts)

最后的话

房产爬虫是爬虫领域的经典项目,它涉及的技术点几乎覆盖了爬虫开发的所有核心问题:反爬对抗、动态页面、数据清洗、存储管理。通过这个项目,我不仅掌握了爬虫技术,更学会了如何用数据驱动决策------在北京找房这件事上,我用这套系统节省了数周的看房时间,最终找到了性价比最高的房源。

代码是死的,数据是活的。希望这篇教程不仅教会你写爬虫,更能启发你用技术解决生活中的实际问题。如果本文对你有帮助,欢迎点赞关注!有任何问题随时交流,祝爬取愉快!

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


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

相关推荐
不懒不懒2 小时前
【机器学习:下采样 VS 过采样——逻辑回归在信用卡欺诈检测中的实践】
python·numpy·scikit-learn·matplotlib·pip·futurewarning
Leinwin2 小时前
Moltbot 部署至 Azure Web App 完整指南:从本地到云端的安全高效跃迁
后端·python·flask
叫我辉哥e12 小时前
新手进阶Python:办公看板集成AI智能助手+语音交互+自动化问答
python
真智AI2 小时前
用 FAISS 搭个轻量 RAG 问答(Python)
开发语言·python·faiss
2401_857683542 小时前
使用Kivy开发跨平台的移动应用
jvm·数据库·python
咩咩不吃草2 小时前
【HTML】核心标签与【Python爬虫库】实战指南
css·爬虫·python·html
serve the people2 小时前
python环境搭建 (七) pytest、pytest-asyncio、pytest-cov 试生态的核心组合
开发语言·python·pytest
java1234_小锋2 小时前
分享一套不错的基于Python的Django宠物信息管理系统
开发语言·python·宠物
2401_841495642 小时前
【Web开发】基于Flask搭建简单的应用网站
后端·python·flask·视图函数·应用实例·路由装饰器·调试模式