Python爬虫实战:房价/租金指数时间序列爬虫实战 - 从多页采集到趋势分析的完整方案(附CSV导出 + SQLite持久化存储)!

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

㊗️爬虫难度指数:⭐⭐

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

全文目录:

🌟 开篇语

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

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

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

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

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

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

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

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

1️⃣ 摘要(Abstract)

用 Python + requests + pandas 爬取房价指数平台(以中国房价行情网为例)的历史月度数据,采集月份、房价指数、租金指数、同比环比等核心字段,支持多城市、多年份批量爬取,最终输出时间序列 CSV 文件,可直接用于房地产市场趋势分析、预测建模和数据可视化。

读完本文你能获得

  • 一套完整的房价指数时间序列数据采集方案(可扩展到其他指数数据)
  • 应对分页数据和时间维度爬取的实战经验
  • 从数据采集到时间序列分析的完整工程化流程
  • 可直接用于房地产研究的历史数据集(覆盖2010-2026年)
  • 同比环比计算、移动平均、趋势预测等实用分析方法
  • 完整的数据可视化案例(K线图、热力图、相关性分析)

2️⃣ 背景与需求(Why)

为什么要爬房价指数数据?

两年前在做房地产行业研究时,我深刻体会到高质量历史数据的稀缺性。虽然统计局会公布70个大中城市的房价指数,但数据颗粒度粗、更新滞后,而且缺少租金、二手房等细分数据。

作为数据分析师,我需要的是:覆盖更多城市、更长时间跨度、更细维度的历史数据。这些数据的价值体现在:

  • 市场研究:分析不同城市房价的涨跌周期和波动规律
  • 投资决策:识别价值洼地和泡沫风险
  • 政策评估:评估限购、限贷等调控政策的效果
  • 预测建模:基于ARIMA、LSTM等时间序列模型预测未来走势
  • 学术研究:房地产市场与宏观经济的关联性研究
  • 租售比分析:评估租金回报率和投资性价比

目标站点选择

本文选择中国房价行情网https://www.creprice.cn)作为示例,原因如下:

  1. 数据完整:覆盖全国300+城市,历史数据追溯到2010年
  2. 维度丰富:包含新房、二手房、租金三大板块
  3. 更新及时:每月5-10日更新上月数据
  4. 结构清晰:按月份分页展示,适合批量采集
  5. 合法合规:公开展示的统计数据,非个人隐私

注意:本文仅作技术交流,采集的数据仅用于个人学习和非商业研究,请勿用于商业用途或二次贩卖。

目标字段清单

字段名 类型 示例值 说明
city VARCHAR(50) 北京 城市名称
date DATE 2026-01 统计月份
new_house_price FLOAT 65432 新房均价(元/㎡)
new_house_mom FLOAT 1.2 新房环比(%)
new_house_yoy FLOAT 5.8 新房同比(%)
second_hand_price FLOAT 58234 二手房均价(元/㎡)
second_hand_mom FLOAT -0.5 二手房环比(%)
second_hand_yoy FLOAT -2.3 二手房同比(%)
rent_price FLOAT 89.5 租金均价(元/㎡/月)
rent_mom FLOAT 0.8 租金环比(%)
rent_yoy FLOAT 3.2 租金同比(%)
rent_sale_ratio FLOAT 1.64 租售比(%)
data_source VARCHAR(50) 中国房价行情网 数据来源
crawl_time DATETIME 2026-01-28 16:00:00 采集时间

3️⃣ 合规与注意事项(必写)

法律与道德边界

房价指数数据属于公开统计信息,但我们仍需严格遵守以下原则:

  1. 遵守 robots.txt

    • 检查目标站点的爬虫协议
    • 尊重 Disallow 规则
    • 不要爬取明确禁止的路径
  2. 频率控制(极其重要)

    • 每次请求间隔 3-8 秒(房价数据更新不频繁,无需快速爬取)
    • 单IP每日请求量不超过 2000 次
    • 避免在业务高峰期(工作日上午9-11点)大量爬取
    • 建议在凌晨1-5点进行批量爬取
  3. 数据使用规范

    • 仅采集公开展示的统计数据
    • 不采集用户评论、联系方式等信息
    • 采集的数据仅用于个人学习和非商业研究
    • 不篡改数据或用于误导性宣传
  4. 技术限制

    • 不使用分布式爬虫进行高并发攻击
    • 不篡改网站数据或影响正常服务
    • 遇到验证码或限流时主动停止

数据使用建议

可以做的

  • 个人学习和研究
  • 学术论文的数据支撑
  • 非商业的市场分析报告
  • 开源数据集(注明来源)

不可以做的

  • 二次贩卖数据
  • 用于商业竞品分析而不注明来源
  • 篡改数据误导投资者
  • 大规模高频爬取影响网站正常服务

风险提示

  • IP封禁风险:频繁请求可能导致IP被封(通常24小时)
  • 数据准确性:第三方平台数据可能存在误差,建议交叉验证
  • 法律风险:违规使用数据可能面临法律诉讼
  • 接口变动:网站可能随时调整页面结构或接口参数

4️⃣ 技术选型与整体流程(What/How)

站点架构分析

中国房价行情网采用传统的服务端渲染(SSR)架构,数据直接嵌入在HTML中。我们有两种技术方案:

方案 优点 缺点 适用场景
方案A:解析HTML 实现简单,无需逆向 性能一般,易受页面改版影响 小规模采集(<1000页)
方案B:抓取API 性能高,数据完整 需要逆向分析,可能有加密 大规模采集(>1000页)

本文选择方案A,因为:

  • 房价数据页面结构稳定(多年未大改)
  • 数据量适中(每个城市约200个月份)
  • 无复杂的JavaScript渲染
  • 适合教学和快速实现

技术栈选型

json 复制代码
┌─────────────────┬──────────────────────────────┐
│ 技术栈          │ 选择理由                      │
├─────────────────┼──────────────────────────────┤
│ requests        │ 发送HTTP请求,Session复用     │
│ lxml            │ XPath解析HTML,性能优于BS4    │
│ pandas          │ 数据处理、时间序列分析        │
│ numpy           │ 数值计算(同比环比)          │
│ sqlite3         │ 本地数据库存储(可选)        │
│ matplotlib      │ 数据可视化                    │
│ seaborn         │ 高级可视化(热力图等)        │
│ statsmodels     │ 时间序列分析(ARIMA)         │
└─────────────────┴──────────────────────────────┘

整体数据流

json 复制代码
┌──────────────────┐
│ 1. 配置城市列表   │  → ['北京', '上海', '深圳', ...]
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 2. 遍历城市      │  → for city in cities:
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 3. 请求列表页    │  → 获取该城市的月份列表
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 4. 解析月度数据  │  → XPath提取表格数据
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 5. 计算同比环比  │  → 基于历史数据计算
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 6. 数据清洗校验  │  → 去重、异常值处理
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 7. 存储到CSV     │  → 时间序列格式输出
└────────┬─────────┘
│
▼
┌──────────────────┐
│ 8. 可视化分析    │  → K线图、趋势图等
└──────────────────┘

5️⃣ 环境准备与依赖安装(可复现)

Python 版本要求

bash 复制代码
Python >= 3.8

为什么选择Python 3.8+?

  • 支持海象运算符 :=(简化代码逻辑)
  • 字典保持插入顺序(便于时间序列排序)
  • 性能优化(相比3.7提升10-15%)
  • 更好的类型提示支持(便于IDE提示)

依赖安装

bash 复制代码
# 核心依赖
pip install requests lxml pandas numpy openpyxl

# 可视化依赖
pip install matplotlib seaborn plotly

# 时间序列分析
pip install statsmodels scikit-learn

# 工具库
pip install fake-useragent retrying python-dotenv tqdm

各依赖详细说明

python 复制代码
"""
依赖包功能说明:

1. requests (2.28+)
   - 发送HTTP请求
   - Session复用提升性能
   - 自动处理Cookie和重定向

2. lxml (4.9+)
   - XPath解析HTML
   - 性能是BeautifulSoup的5-10倍
   - 支持复杂的选择器

3. pandas (1.5+)
   - DataFrame数据结构
   - 时间序列处理(resample、rolling等)
   - 数据透视和聚合
   - CSV/Excel读写

4. numpy (1.23+)
   - 数值计算(同比环比)
   - 数组操作
   - 统计函数

5. matplotlib (3.5+)
   - 基础可视化
   - K线图、折线图
   - 中文字体配置

6. seaborn (0.12+)
   - 高级可视化
   - 热力图、小提琴图
   - 美观的默认样式

7. statsmodels (0.14+)
   - ARIMA时间序列建模
   - 季节性分解
   - 自相关分析

8. plotly (5.14+)
   - 交互式图表
   - 支持导出HTML
   - 动态时间序列可视化
"""

项目结构

json 复制代码
housing_index_scraper/
├── config.py                  # 配置管理(城市列表、API地址)
├── fetcher.py                 # 请求层(发送HTTP请求)
├── parser.py                  # 解析层(提取HTML数据)
├── calculator.py              # 计算层(同比环比)
├── storage.py                 # 存储层(CSV写入)
├── analyzer.py                # 分析层(时间序列分析)
├── visualizer.py              # 可视化层(图表生成)
├── validator.py               # 校验层(数据验证)
├── main.py                    # 主程序入口
├── utils.py                   # 工具函数(日志、重试)
├── .env                       # 环境变量
├── requirements.txt           # 依赖清单
├── data/
│   ├── raw/                   # 原始HTML(调试用)
│   ├── processed/             # 处理后的CSV
│   │   ├── beijing.csv        # 北京历史数据
│   │   ├── shanghai.csv       # 上海历史数据
│   │   └── all_cities.csv     # 合并数据
│   └── charts/                # 生成的图表
│       ├── beijing_trend.png
│       └── cities_heatmap.png
└── logs/
    └── scraper.log            # 运行日志

初始化脚本

bash 复制代码
#!/bin/bash
# setup.sh - 项目初始化脚本

echo "🚀 开始初始化房价指数爬虫项目..."

# 1. 创建目录结构
mkdir -p housing_index_scraper/{data/{raw,processed,charts},logs}
cd housing_index_scraper

# 2. 创建虚拟环境
python3 -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

# 3. 安装依赖
cat > requirements.txt << EOF
requests>=2.28.0
lxml>=4.9.0
pandas>=1.5.0
numpy>=1.23.0
openpyxl>=3.0.0
matplotlib>=3.5.0
seaborn>=0.12.0
plotly>=5.14.0
statsmodels>=0.14.0
scikit-learn>=1.2.0
fake-useragent>=1.4.0
retrying>=1.3.3
python-dotenv>=1.0.0
tqdm>=4.65.0
EOF

pip install -r requirements.txt

# 4. 创建配置文件
cat > .env << EOF
# 爬取配置
MAX_WORKERS=3
REQUEST_DELAY_MIN=3
REQUEST_DELAY_MAX=8

# 数据配置
START_YEAR=2010
END_YEAR=2026
EOF

# 5. 配置matplotlib中文字体
python3 << PYTHON_EOF
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False
print("✅ matplotlib中文字体配置完成")
PYTHON_EOF

echo "✅ 初始化完成!可以运行 python main.py 开始爬取"

6️⃣ 核心实现:请求层(Fetcher)

config.py - 配置中心

python 复制代码
"""
配置管理模块
所有配置项集中管理,便于维护和环境切换
"""

import os
from dotenv import load_dotenv
from datetime import datetime

# 加载环境变量
load_dotenv()

class Config:
    """
    配置管理类
    
    设计原则:
    1. 所有硬编码的值都放在这里
    2. 支持环境变量覆盖(开发/生产环境隔离)
    3. 提供默认值(确保程序能正常运行)
    4. 分类管理(API、爬取、存储、分析等)
    """
    
    # ==================== 站点配置 ====================
    # 基础URL
    BASE_URL = "https://www.creprice.cn"
    
    # 城市列表页URL模板
    # 示例:https://www.creprice.cn/市/二手房价格
    CITY_URL_TEMPLATE = "{base_url}/{city}/二手房价格"
    
    # 月度数据URL模板(如果有分页)
    # 示例:https://www.creprice.cn/市/二手房价格/2023年
    MONTHLY_URL_TEMPLATE = "{base_url}/{city}/二手房价格/{year}年"
    
    # ==================== 城市列表配置 ====================
    # 重点城市列表(一线+新一线)
    # 可根据需求扩展到全国300+城市
    CITIES = [
        '北京', '上海', '广州', '深圳',  # 一线
        '成都', '杭州', '重庆', '武汉', '西安',  # 新一线
        '苏州', '南京', '天津', '长沙', '郑州',
        '东莞', '青岛', '沈阳', '宁波', '昆明',
    ]
    
    # 城市名称到URL路径的映射
    # 有些城市的URL路径与中文名不同
    CITY_URL_MAP = {
        '北京': 'beijing',
        '上海': 'shanghai',
        '广州': 'guangzhou',
        '深圳': 'shenzhen',
        # ... 更多城市映射
    }
    
    # ==================== 请求配置 ====================
    # User-Agent池(轮换使用)
    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',
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
    ]
    
    # 请求超时设置(秒)
    TIMEOUT = 30  # 房价页面较大,适当增加超时时间
    
    # 重试配置
    MAX_RETRY_TIMES = 5       # 最大重试次数(房价数据重要,多重试几次)
    RETRY_DELAY = 5           # 初始重试间隔(秒)
    BACKOFF_FACTOR = 2        # 指数退避因子
    
    # 请求频率控制(极其重要!)
    REQUEST_DELAY_MIN = int(os.getenv('REQUEST_DELAY_MIN', 3))
    REQUEST_DELAY_MAX = int(os.getenv('REQUEST_DELAY_MAX', 8))
    
    # 并发控制
    MAX_WORKERS = int(os.getenv('MAX_WORKERS', 3))  # 最多3个线程并发
    
    # 代理配置(可选)
    USE_PROXY = os.getenv('USE_PROXY', 'False').lower() == 'true'
    PROXY_HTTP = os.getenv('PROXY_HTTP', '')
    PROXY_HTTPS = os.getenv('PROXY_HTTPS', '')
    
    # ==================== 时间范围配置 ====================
    # 起始年份(房价行情网数据从2010年开始)
    START_YEAR = int(os.getenv('START_YEAR', 2010))
    
    # 结束年份(默认当前年份)
    END_YEAR = int(os.getenv('END_YEAR', datetime.now().year))
    
    # 起始月份(如果只爬部分月份)
    START_MONTH = 1
    END_MONTH = 12
    
    # ==================== 存储配置 ====================
    # CSV输出路径
    DATA_DIR = 'data/processed'
    RAW_DATA_DIR = 'data/raw'
    CHART_DIR = 'data/charts'
    
    # CSV文件命名规则
    # 单城市:{city}_{start_year}_{end_year}.csv
    # 合并:all_cities_{start_year}_{end_year}.csv
    
    # ==================== 解析配置 ====================
    # XPath选择器(根据实际网站结构调整)
    XPATH_SELECTORS = {
        # 月份列表
        'month_list': '//table[@class="data-table"]//tr',
        
        # 表格中的各字段
        'date': './td[1]/text()',
        'new_house_price': './td[2]/text()',
        'new_house_mom': './td[3]/text()',
        'new_house_yoy': './td[4]/text()',
        'second_hand_price': './td[5]/text()',
        'second_hand_mom': './td[6]/text()',
        'second_hand_yoy': './td[7]/text()',
        'rent_price': './td[8]/text()',
        'rent_mom': './td[9]/text()',
        'rent_yoy': './td[10]/text()',
    }
    
    # ==================== 数据校验配置 ====================
    # 价格有效范围(元/㎡)
    VALID_PRICE_RANGE = {
        'new_house': (1000, 200000),
        'second_hand': (1000, 200000),
        'rent': (10, 500),
    }
    
    # 同比环比有效范围(%)
    VALID_CHANGE_RANGE = (-50, 100)
    
    # 必需字段列表
    REQUIRED_FIELDS = ['city', 'date', 'new_house_price']
    
    # ==================== 分析配置 ====================
    # 移动平均窗口(月)
    MA_WINDOWS = [3, 6, 12]
    
    # ARIMA模型参数
    ARIMA_ORDER = (1, 1, 1)  # (p, d, q)
    
    # 预测步长(月)
    FORECAST_STEPS = 6
    
    # ==================== 日志配置 ====================
    LOG_FILE = 'logs/scraper.log'
    LOG_LEVEL = 'INFO'
    LOG_FORMAT = '%(asctime)s | %(levelname)-8s | %(filename)s:%(lineno)d | %(message)s'
    LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
    
    # ==================== 可视化配置 ====================
    # 图表样式
    PLOT_STYLE = 'seaborn-v0_8-darkgrid'
    FIGURE_SIZE = (14, 8)
    DPI = 100
    
    # 中文字体(解决matplotlib中文显示问题)
    CHINESE_FONT = 'SimHei'  # Windows
    # CHINESE_FONT = 'Arial Unicode MS'  # Mac
    # CHINESE_FONT = 'WenQuanYi Micro Hei'  # Linux
    
    # ==================== 其他配置 ====================
    # 是否保存原始HTML(用于调试)
    SAVE_RAW_HTML = True
    
    # 是否生成详细日志
    VERBOSE = True
    
    # 是否显示进度条
    SHOW_PROGRESS = True

配置设计亮点

  1. 环境变量支持 :敏感配置(代理、延迟)可通过.env文件覆盖
  2. 分类清晰:按功能模块分类,便于查找和修改
  3. 合理默认值 :即使不配置.env也能正常运行
  4. 可扩展性:新增配置项只需在这里添加

utils.py - 工具函数库

python 复制代码
"""
工具函数模块
提供日志、重试、数据处理等通用功能
"""

import time
import random
import logging
import json
from pathlib import Path
from functools import wraps
from datetime import datetime, timedelta
from config import Config

# ==================== 日志配置 ====================
def setup_logger(name='housing_scraper'):
    """
    配置日志系统
    
    特点:
    1. 双输出:文件+控制台
    2. 分级显示:文件DEBUG,控制台INFO
    3. 格式化输出:时间、级别、文件、行号
    4. 自动创建日志目录
    
    Args:
        name: logger名称
        
    Returns:
        Logger对象
    """
    # 确保日志目录存在
    Path(Config.LOG_FILE).parent.mkdir(parents=True, exist_ok=True)
    
    # 创建logger
    logger = logging.getLogger(name)
    logger.setLevel(logging.DEBUG)
    
    # 避免重复添加handler
    if logger.handlers:
        return logger
    
    # 文件handler
    file_handler = logging.FileHandler(
        Config.LOG_FILE,
        encoding='utf-8',
        mode='a'
    )
    file_handler.setLevel(logging.DEBUG)
    
    # 控制台handler
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO if not Config.VERBOSE else logging.DEBUG)
    
    # 日志格式
    formatter = logging.Formatter(
        Config.LOG_FORMAT,
        datefmt=Config.LOG_DATE_FORMAT
    )
    
    file_handler.setFormatter(formatter)
    console_handler.setFormatter(formatter)
    
    logger.addHandler(file_handler)
    logger.addHandler(console_handler)
    
    return logger

# 初始化全局logger
logger = setup_logger()

# ==================== 请求控制函数 ====================
def random_delay():
    """
    随机延迟,模拟人类浏览行为
    
    设计要点:
    1. 延迟时间服从均匀分布
    2. 房价数据不需要快速爬取,延迟可以长一些
    3. 记录实际延迟时间
    
    Returns:
        float: 实际延迟时间
    """
    delay = random.uniform(Config.REQUEST_DELAY_MIN, Config.REQUEST_DELAY_MAX)
    logger.debug(f"⏱️  延迟 {delay:.2f} 秒")
    time.sleep(delay)
    return delay

def retry_on_failure(max_attempts=None, delay=None, backoff=None,
                    exceptions=(Exception,)):
    """
    重试装饰器
    
    设计要点:
    1. 支持指数退避(每次失败延迟翻倍)
    2. 可指定需要重试的异常类型
    3. 记录每次重试的详细日志
    4. 房价数据重要,默认重试5次
    
    Args:
        max_attempts: 最大尝试次数
        delay: 初始重试延迟(秒)
        backoff: 退避因子
        exceptions: 需要重试的异常类型
        
    Returns:
        装饰器函数
    """
    if max_attempts is None:
        max_attempts = Config.MAX_RETRY_TIMES
    if delay is None:
        delay = Config.RETRY_DELAY
    if backoff is None:
        backoff = Config.BACKOFF_FACTOR
    
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            current_delay = delay
            
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == max_attempts:
                        logger.error(
                            f"💥 {func.__name__} 失败(已重试{max_attempts}次): {e}"
                        )
                        raise
                    
                    logger.warning(
                        f"⚠️  {func.__name__} 第{attempt}次尝试失败: {e},"
                        f"{current_delay}秒后重试..."
                    )
                    time.sleep(current_delay)
                    current_delay *= backoff
            
        return wrapper
    return decorator

# ==================== 时间处理函数 ====================
def generate_month_range(start_year, end_year, start_month=1, end_month=12):
    """
    生成月份范围
    
    设计要点:
    1. 生成连续的月份列表
    2. 支持跨年
    3. 返回标准格式(YYYY-MM)
    
    Args:
        start_year: 起始年份
        end_year: 结束年份
        start_month: 起始月份
        end_month: 结束月份
        
    Returns:
        List[str]: 月份列表
        
    示例:
        generate_month_range(2023, 2024, 10, 3)
        → ['2023-10', '2023-11', '2023-12', '2024-01', '2024-02', '2024-03']
    """
    months = []
    
    current = datetime(start_year, start_month, 1)
    end = datetime(end_year, end_month, 1)
    
    while current <= end:
        months.append(current.strftime('%Y-%m'))
        # 加一个月
        if current.month == 12:
            current = datetime(current.year + 1, 1, 1)
        else:
            current = datetime(current.year, current.month + 1, 1)
    
    return months

def parse_month_string(month_str):
    """
    解析月份字符串
    
    支持的格式:
    - "2023年1月" → datetime(2023, 1, 1)
    - "2023-01" → datetime(2023, 1, 1)
    - "2023/1" → datetime(2023, 1, 1)
    
    Args:
        month_str: 月份字符串
        
    Returns:
        datetime对象
    """
    import re
    
    # 提取年和月
    match = re.search(r'(\d{4})[\-年/](\d{1,2})', month_str)
    if not match:
        logger.warning(f"⚠️  无法解析月份: {month_str}")
        return None
    
    year = int(match.group(1))
    month = int(match.group(2))
    
    try:
        return datetime(year, month, 1)
    except ValueError:
        logger.warning(f"⚠️  无效的月份: {year}-{month}")
        return None

# ==================== 数据处理函数 ====================
def clean_number(text):
    """
    清洗数字文本
    
    处理以下问题:
    1. 移除非数字字符(如 "12,345元" → 12345)
    2. 处理百分号(如 "5.8%" → 5.8)
    3. 处理负号(如 "-2.3%" → -2.3)
    4. 处理空值
    
    Args:
        text: 原始文本
        
    Returns:
        float: 清洗后的数字,失败返回None
    """
    if not text or text.strip() in ['--', '-', 'N/A', '']:
        return None
    
    import re
    
    # 移除逗号、单位等
    text = text.replace(',', '').replace('元', '').replace('㎡', '')
    text = text.replace('%', '').strip()
    
    # 提取数字(包括负号和小数点)
    match = re.search(r'[-]?\d+\.?\d*', text)
    if not match:
        return None
    
    try:
        return float(match.group())
    except ValueError:
        return None

def calculate_mom(current, previous):
    """
    计算环比增长率
    
    公式:MoM = (current - previous) / previous * 100
    
    Args:
        current: 当前值
        previous: 上期值
        
    Returns:
        float: 环比增长率(%),保留2位小数
    """
    if not current or not previous or previous == 0:
        return None
    
    mom = (current - previous) / previous * 100
    return round(mom, 2)

def calculate_yoy(current, year_ago):
    """
    计算同比增长率
    
    公式:YoY = (current - year_ago) / year_ago * 100
    
    Args:
        current: 当前值
        year_ago: 去年同期值
        
    Returns:
        float: 同比增长率(%),保留2位小数
    """
    if not current or not year_ago or year_ago == 0:
        return None
    
    yoy = (current - year_ago) / year_ago * 100
    return round(yoy, 2)

# ==================== 文件操作函数 ====================
def save_html(html, filepath):
    """
    保存HTML到文件(用于调试)
    
    Args:
        html: HTML字符串
        filepath: 文件路径
    """
    if not Config.SAVE_RAW_HTML:
        return
    
    Path(filepath).parent.mkdir(parents=True, exist_ok=True)
    with open(filepath, 'w', encoding='utf-8') as f:
        f.write(html)
    logger.debug(f"💾 已保存HTML到: {filepath}")

def ensure_dir(path):
    """
    确保目录存在
    
    Args:
        path: 目录路径或文件路径
    """
    if '.' in Path(path).name:  # 是文件路径
        Path(path).parent.mkdir(parents=True, exist_ok=True)
    else:  # 是目录路径
        Path(path).mkdir(parents=True, exist_ok=True)

# ==================== 进度条函数 ====================
def create_progress_bar(total, desc='Processing'):
    """
    创建进度条
    
    Args:
        total: 总数
        desc: 描述文本
        
    Returns:
        tqdm对象或None
    """
    if not Config.SHOW_PROGRESS:
        return None
    
    try:
        from tqdm import tqdm
        return tqdm(total=total, desc=desc, unit='item')
    except ImportError:
        logger.warning("⚠️  未安装tqdm,无法显示进度条")
        return None

工具函数设计原则

  1. 纯函数:无副作用,易于测试
  2. 容错性强:异常情况返回None而不是抛出异常
  3. 文档完整:每个函数都有详细的docstring和示例
  4. 可配置:通过Config控制行为

6️⃣ 核心实现:请求层(Fetcher)(续)

fetcher.py - HTTP请求封装

python 复制代码
"""
HTTP请求模块
负责发送请求并获取HTML响应
"""

import requests
import random
from lxml import etree
from config import Config
from utils import logger, random_delay, retry_on_failure, save_html

class HousingFetcher:
    """
    房价数据采集器
    
    设计要点:
    1. Session复用提升性能
    2. 自动重试机制
    3. 随机UA轮换
    4. 支持代理
    5. 保存原始HTML(调试用)
    """
    
    def __init__(self, use_proxy=False):
        """
        初始化采集器
        
        Args:
            use_proxy: 是否使用代理
        """
        # 创建Session(复用TCP连接)
        self.session = requests.Session()
        
        # 设置默认请求头
        self._set_default_headers()
        
        # 配置代理
        if use_proxy and Config.PROXY_HTTP:
            self.session.proxies = {
                'http': Config.PROXY_HTTP,
                'https': Config.PROXY_HTTPS,
            }
            logger.info(f"✅ 已配置代理: {Config.PROXY_HTTP}")
        
        # 统计信息
        self.request_count = 0
        self.success_count = 0
        self.fail_count = 0
        
        logger.info("✅ HousingFetcher 初始化完成")
    
    def _set_default_headers(self):
        """设置默认请求头"""
        self.session.headers.update({
            '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',
        })
    
    def _get_random_ua(self):
        """获取随机UA"""
        return random.choice(Config.USER_AGENTS)
    
    @retry_on_failure(exceptions=(requests.RequestException,))
    def fetch_city_data(self, city, year=None):
        """
        获取指定城市的房价数据
        
        Args:
            city: 城市名称
            year: 年份(可选,如果有按年份分页)
            
        Returns:
            lxml.etree._Element: 解析后的HTML树,失败返回None
            
        核心流程:
        1. 构造URL
        2. 设置动态UA
        3. 发送GET请求
        4. 验证响应
        5. 解析HTML
        6. 保存原始数据
        """
        self.request_count += 1
        
        # ========== 构造URL ==========
        url = self._build_url(city, year)
        
        # ========== 设置动态UA ==========
        headers = {'User-Agent': self._get_random_ua()}
        
        # ========== 发送请求 ==========
        try:
            logger.info(f"🔍 [{self.request_count}] 请求 {city} {year if year else '全部'}年数据...")
            
            response = self.session.get(
                url,
                headers=headers,
                timeout=Config.TIMEOUT
            )
            
            # ========== 验证响应 ==========
            if response.status_code == 200:
                # 检查是否被重定向到错误页
                if '404' in response.text or 'not found' in response.text.lower():
                    logger.warning(f"⚠️  页面不存在: {url}")
                    self.fail_count += 1
                    return None
                
                # ========== 解析HTML ==========
                html_tree = etree.HTML(response.text)
                
                # ========== 保存原始数据 ==========
                if Config.SAVE_RAW_HTML:
                    filename = f"{city}_{year if year else 'all'}.html"
                    save_html(response.text, f"{Config.RAW_DATA_DIR}/{filename}")
                
                # ========== 统计与延迟 ==========
                self.success_count += 1
                logger.info(f"✅ [{self.request_count}] 请求成功")
                random_delay()
                
                return html_tree
            
            elif response.status_code == 403:
                logger.warning(
                    f"⚠️  403 Forbidden - 可能触发反爬\n"
                    f"  建议:\n"
                    f"    1. 增加延迟 (当前{Config.REQUEST_DELAY_MIN}-{Config.REQUEST_DELAY_MAX}秒)\n"
                    f"    2. 更换IP或使用代理\n"
                    f"    3. 检查User-Agent是否被识别"
                )
                self.fail_count += 1
                return None
            
            elif response.status_code == 404:
                logger.warning(f"⚠️  404 Not Found: {url}")
                self.fail_count += 1
                return None
            
            elif response.status_code == 429:
                logger.warning(
                    f"⚠️  429 Too Many Requests\n"
                    f"  建议:等待30分钟后再试,或使用代理"
                )
                self.fail_count += 1
                raise requests.RequestException("触发频率限制")
            
            else:
                logger.error(f"❌ HTTP {response.status_code}")
                self.fail_count += 1
                return None
        
        except requests.Timeout:
            logger.error(f"⏱️  请求超时({Config.TIMEOUT}秒)")
            self.fail_count += 1
            raise
        
        except requests.RequestException as e:
            logger.error(f"❌ 请求异常: {e}")
            self.fail_count += 1
            raise
        
        except Exception as e:
            logger.error(f"💥 未知错误: {e}")
            self.fail_count += 1
            return None
    
    def _build_url(self, city, year=None):
        """
        构造URL
        
        Args:
            city: 城市名称
            year: 年份
            
        Returns:
            str: 完整URL
        """
        # 如果有城市URL映射,使用映射后的名称
        city_path = Config.CITY_URL_MAP.get(city, city)
        
        if year:
            return Config.MONTHLY_URL_TEMPLATE.format(
                base_url=Config.BASE_URL,
                city=city_path,
                year=year
            )
        else:
            return Config.CITY_URL_TEMPLATE.format(
                base_url=Config.BASE_URL,
                city=city_path
            )
    
    def get_statistics(self):
        """获取请求统计"""
        success_rate = (
            self.success_count / self.request_count * 100
            if self.request_count > 0 else 0
        )
        
        return {
            'total': self.request_count,
            'success': self.success_count,
            'fail': self.fail_count,
            'success_rate': round(success_rate, 2),
        }
    
    def close(self):
        """关闭Session"""
        self.session.close()
        
        stats = self.get_statistics()
        logger.info(
            f"\n📊 请求统计:\n"
            f"  总请求: {stats['total']}\n"
            f"  成功: {stats['success']}\n"
            f"  失败: {stats['fail']}\n"
            f"  成功率: {stats['success_rate']}%"
        )
        
        logger.info("🔒 Fetcher已关闭")

7️⃣ 核心实现:解析层(Parser)

parser.py - HTML解析与数据提取

python 复制代码
"""
HTML解析模块
负责从HTML中提取结构化数据
"""

from lxml import etree
from config import Config
from utils import logger, clean_number, parse_month_string

class HousingParser:
    """
    房价数据解析器
    
    设计要点:
    1. 使用XPath高效提取数据
    2. 容错处理(字段缺失、格式异常)
    3. 数据清洗和类型转换
    4. 支持多种HTML结构
    """
    
    @staticmethod
    def parse_monthly_data(html_tree, city):
        """
        解析月度数据列表
        
        Args:
            html_tree: lxml解析后的HTML树
            city: 城市名称
            
        Returns:
            List[dict]: 月度数据列表
            
        核心逻辑:
        1. 使用XPath定位表格行
        2. 逐行提取各字段
        3. 数据清洗和类型转换
        4. 组装结构化数据
        """
        if html_tree is None:
            return []
        
        try:
            # ========== 定位表格行 ==========
            rows = html_tree.xpath(Config.XPATH_SELECTORS['month_list'])
            
            if not rows:
                logger.warning(f"⚠️  未找到数据表格")
                return []
            
            # ========== 逐行解析 ==========
            monthly_data = []
            for row in rows:
                data = HousingParser._parse_single_row(row, city)
                if data:
                    monthly_data.append(data)
            
            logger.info(f"✅ 解析到 {len(monthly_data)} 条月度数据")
            return monthly_data
        
        except Exception as e:
            logger.error(f"❌ 解析失败: {e}")
            return []
    
    @staticmethod
    def _parse_single_row(row, city):
        """
        解析单行数据
        
        Args:
            row: lxml行元素
            city: 城市名称
            
        Returns:
            dict: 结构化数据
            
        字段映射:
        - 月份 → date
        - 新房均价 → new_house_price
        - 新房环比 → new_house_mom
        - 新房同比 → new_house_yoy
        - 二手房均价 → second_hand_price
        - 二手房环比 → second_hand_mom
        - 二手房同比 → second_hand_yoy
        - 租金均价 → rent_price
        - 租金环比 → rent_mom
        - 租金同比 → rent_yoy
        """
        try:
            # ========== 提取字段 ==========
            selectors = Config.XPATH_SELECTORS
            
            # 月份(必需字段)
            date_text = row.xpath(selectors['date'])
            if not date_text:
                return None
            
            date = parse_month_string(date_text[0])
            if not date:
                return None
            
            # ========== 提取价格数据 ==========
            # 新房
            new_house_price = clean_number(
                row.xpath(selectors['new_house_price'])[0]
                if row.xpath(selectors['new_house_price']) else None
            )
            
            new_house_mom = clean_number(
                row.xpath(selectors['new_house_mom'])[0]
                if row.xpath(selectors['new_house_mom']) else None
            )
            
            new_house_yoy = clean_number(
                row.xpath(selectors['new_house_yoy'])[0]
                if row.xpath(selectors['new_house_yoy']) else None
            )
            
            # 二手房
            second_hand_price = clean_number(
                row.xpath(selectors['second_hand_price'])[0]
                if row.xpath(selectors['second_hand_price']) else None
            )
            
            second_hand_mom = clean_number(
                row.xpath(selectors['second_hand_mom'])[0]
                if row.xpath(selectors['second_hand_mom']) else None
            )
            
            second_hand_yoy = clean_number(
                row.xpath(selectors['second_hand_yoy'])[0]
                if row.xpath(selectors['second_hand_yoy']) else None
            )
            
            # 租金
            rent_price = clean_number(
                row.xpath(selectors['rent_price'])[0]
                if row.xpath(selectors['rent_price']) else None
            )
            
            rent_mom = clean_number(
                row.xpath(selectors['rent_mom'])[0]
                if row.xpath(selectors['rent_mom']) else None
            )
            
            rent_yoy = clean_number(
                row.xpath(selectors['rent_yoy'])[0]
                if row.xpath(selectors['rent_yoy']) else None
            )
            
            # ========== 组装数据 ==========
            return {
                'city': city,
                'date': date.strftime('%Y-%m'),
                'new_house_price': new_house_price,
                'new_house_mom': new_house_mom,
                'new_house_yoy': new_house_yoy,
                'second_hand_price': second_hand_price,
                'second_hand_mom': second_hand_mom,
                'second_hand_yoy': second_hand_yoy,
                'rent_price': rent_price,
                'rent_mom': rent_mom,
                'rent_yoy': rent_yoy,
                'rent_sale_ratio': None,  # 后续计算
            }
        
        except Exception as e:
            logger.error(f"❌ 解析单行失败: {e}")
            logger.debug(f"问题行: {etree.tostring(row, encoding='unicode')}")
            return None

8️⃣ 数据计算与校验

calculator.py - 同比环比计算

python 复制代码
"""
数据计算模块
负责计算同比环比、租售比等指标
"""

import pandas as pd
from utils import logger, calculate_mom, calculate_yoy

class HousingCalculator:
    """
    房价数据计算器
    
    功能:
    1. 验算同比环比(与爬取的数据对比)
    2. 计算租售比
    3. 计算移动平均
    4. 填充缺失值
    """
    
    @staticmethod
    def calculate_all_metrics(df):
        """
        计算所有指标
        
        Args:
            df: pandas DataFrame
            
        Returns:
            df: 计算后的DataFrame
        """
        # 确保按日期排序
        df = df.sort_values('date').reset_index(drop=True)
        
        # 计算环比
        df = HousingCalculator._calculate_mom_verified(df)
        
        # 计算同比
        df = HousingCalculator._calculate_yoy_verified(df)
        
        # 计算租售比
        df = HousingCalculator._calculate_rent_sale_ratio(df)
        
        # 计算移动平均
        df = HousingCalculator._calculate_moving_average(df)
        
        logger.info("✅ 所有指标计算完成")
        return df
    
    @staticmethod
    def _calculate_mom_verified(df):
        """
        计算并验证环比
        
        设计要点:
        1. 基于价格计算环比
        2. 与爬取的环比数据对比
        3. 如果差异>2%,记录警告
        """
        for price_col in ['new_house_price', 'second_hand_price', 'rent_price']:
            mom_col = price_col.replace('_price', '_mom')
            mom_calc_col = mom_col + '_calc'
            
            # 计算环比
            df[mom_calc_col] = df[price_col].pct_change() * 100
            
            # 对比验 in df.columns:
                diff = abs(df[mom_col] - df[mom_calc_col])
                large_diff = diff > 2  # 差异>2%视为异常
                
                if large_diff.any():
                    logger.warning(
                        f"⚠️  {price_col}的环比数据存在{large_diff.sum()}处异常"
                    )
        
        return df
    
    @staticmethod
    def _calculate_yoy_verified(df):
        """
        计算并验证同比
        
        设计要点:
        1. 基于12个月前的数据计算同比
        2. 与爬取的同比数据对比
        """
        for price_col in ['new_house_price', 'second_hand_price', 'rent_price']:
            yoy_col = price_col.replace('_price', '_yoy')
            yoy_calc_col = yoy_col + '_calc'
            
            # 计算同比(相对于12个月前)
            df[yoy_calc_col] = df[price_col].pct_change(periods=12) * 100
            
            # 对比验证
            if yoy_col in df.columns:
                diff = abs(df[yoy_col] - df[yoy_calc_col])
                large_diff = diff > 2
                
                if large_diff.any():
                    logger.warning(
                        f"⚠️  {price_col}的同比数据存在{large_diff.sum()}处异常"
                    )
        
        return df
    
    @staticmethod
    def _calculate_rent_sale_ratio(df):
        """
        计算租售比
        
        公式:租售比 = 月租金 / (房价 / 12) * 100%
        或简化为:租售比 = 月租金 * 12 / 房价 * 100%
        
        注:租售比越高,租金回报越好
        """
        # 使用二手房价格计算(更贴近实际)
        df['rent_sale_ratio'] = (
            df['rent_price'] * 12 / df['second_hand_price'] * 100
        ).round(2)
        
        return df
    
    @staticmethod
    def _calculate_moving_average(df):
        """
        计算移动平均
        
        窗口:3个月、6个月、12个月
        """
        for window in Config.MA_WINDOWS:
            for col in ['new_house_price', 'second_hand_price', 'rent_price']:
                ma_col = f"{col}_ma{window}"
                df[ma_col] = df[col].rolling(window=window).mean().round(2)
        
        return df

validator.py - 数据校验

python 复制代码
"""
数据校验模块
负责验证数据的有效性和完整性
"""

from config import Config
from utils import logger

class HousingValidator:
    """
    房价数据校验器
    
    校验规则:
    1. 必需字段完整性
    2. 价格范围合理性
    3. 同比环比范围合理性
    4. 时间连续性
    """
    
    @staticmethod
    def validate_record(record):
        """
        校验单条记录
        
        Args:
            record: dict,单条数据
            
        Returns:
            tuple: (is_valid, error_msg)
        """
        # ========== 必需字段检查 ==========
        for field in Config.REQUIRED_FIELDS:
            if field not in record or record[field] is None:
                return False, f"缺少必需字段: {field}"
        
        # ========== 价格范围检查 ==========
        # 新房价格
        if record.get('new_house_price'):
            min_p, max_p = Config.VALID_PRICE_RANGE['new_house']
            if not (min_p <= record['new_house_price'] <= max_p):
                return False, f"新房价格异常: {record['new_house_price']}"
        
        # 二手房价格
        if record.get('second_p, max_p = Config.VALID_PRICE_RANGE['second_hand']
            if not (min_p <= record['second_hand_price'] <= max_p):
                return False, f"二手房价格异常: {record['second_hand_price']}"
        
        # 租金
        if record.get('rent_price'):
            min_p, max_p = Config.VALID_PRICE_RANGE['rent']
            if not (min_p <= record['rent_price'] <= max_p):
                return False, f"租金异常: {record['rent_price']}"
        
        # ========== 同比环比范围检查 ==========
        min_change, max_change = Config.VALID_CHANGE_RANGE
        
        for field in ['new_house_mom', 'new_house_yoy', 
                     'second_hand_mom', 'second_hand_yoy',
                     'rent_mom', 'rent_yoy']:
            value = record.get(field)
            if value is not None:
                if not (min_change <= value <= max_change):
                    return False, f"{field}超出合理范围: {value}"
        
        return True, ""
    
    @staticmethod
    def filter_valid_records(records):
        """过滤有效记录"""
        valid_records = []
        
        for record in records:
            is_valid, error = HousingValidator.validate_record(record)
            if is_valid:
                valid_records.append(record)
            else:
                logger.warning(
                    f"⚠️  数据无效 [{record.get('city')} {record.get('date')}]: {error}"
                )
        
        logger.info(f"✅ 校验完成: {len(valid_records)}/{len(records)} 条有效")
        return valid_records
    
    @staticmethod
    def check_continuity(df):
        """
        检查时间连续性
        
        Args:
            df: pandas DataFrame,按date排序
            
        Returns:
            List[str]: 缺失的月份列表
        """
        import pandas as pd
        
        # 生成完整的月份序列
        start = pd.to_datetime(df['date'].min())
        end = pd.to_datetime(df['date'].max())
        full_range = pd.date_range(start, end, freq='MS')
        
        # 找出缺失的月份
        existing = pd.to_datetime(df['date'])
        missing = full_range.difference(existing)
        
        if len(missing) > 0:
            missing_months = [m.strftime('%Y-%m') for m in missing]
            logger.warning(f"⚠️  数据不连续,缺失{len(missing)}个月: {missing_months[:5]}...")
            return missing_months
        
        logger.info("✅ 数据连续性检查通过")
        return []

9️⃣ 数据存储与导出

storage.py - CSV存储

python 复制代码
"""
数据存储模块
负责将数据保存为CSV等格式
"""

import pandas as pd
from pathlib import Path
from config import Config
from utils import logger, ensure_dir

class HousingStorage:
    """
    房价数据存储器
    
    功能:
    1. 保存为CSV(时间序列格式)
    2. 支持增量追加
    3. 数据去重
    4. 支持Excel导出
    """
    
    @staticmethod
    def save_to_csv(data, city, output_path=None):
        """
        保存为CSV
        
        Args:
            data: List[dict]或DataFrame
            city: 城市名称
            output_path: 输出路径(可选)
            
        Returns:
            str: 输出文件路径
        """
        # 转换为DataFrame
        if isinstance(data, list):
            df = pd.DataFrame(data)
        else:
            df = data
        
        if df.empty:
            logger.warning("⚠️  数据为空,跳过保存")
            return None
        
        # 确保date列为datetime类型
        df['date'] = pd.to_datetime(df['date'])
        
        # 按日期排序
        df = df.sort_values('date')
        
        # 去重(保留最新的)
        df = df.drop_duplicates(subset=['city', 'date'], keep='last')
        
        # 确定输出路径
        if output_path is None:
            filename = f"{city}_{Config.START_YEAR}_{Config.END_YEAR}.csv"
            output_path = f"{Config.DATA_DIR}/{filename}"
        
        ensure_dir(output_path)
        
        # 保存
        df.to_csv(output_path, index=False, encoding='utf-8-sig')
        
        logger.info(f"📊 已保存 {len(df)} 条数据到: {output_path}")
        return output_path
    
    @staticmethod
    def append_to_csv(new_data, existing_path):
        """
        追加数据到已有CSV
        
        Args:
            new_data: 新数据
            existing_path: 已有CSV路径
            
        Returns:
            str: 输出路径
        """
        # 读取已有数据
        if Path(existing_path).exists():
            existing_df = pd.read_csv(existing_path, parse_dates=['date'])
        else:
            existing_df = pd.DataFrame()
        
        # 转换新数据
        if isinstance(new_data, list):
            new_df = pd.DataFrame(new_data)
        else:
            new_df = new_data
        
        # 合并
        combined = pd.concat([existing_df, new_df], ignore_index=True)
        
        # 去重并排序
        combined['date'] = pd.to_datetime(combined['date'])
        combined = combined.drop_duplicates(subset=['city', 'date'], keep='last')
        combined = combined.sort_values('date')
        
        # 保存
        combined.to_csv(existing_path, index=False, encoding='utf-8-sig')
        
        logger.info(f"✅ 追加 {len(new_df)} 条,总计 {len(combined)} 条")
        return existing_path
    
    @staticmethod
    def merge_all_cities(city_files, output_path=None):
        """
        合并所有城市的数据
        
        Args:
            city_files: List[str],城市CSV文件路径列表
            output_path: 输出路径
            
        Returns:
            str: 合并后的文件路径
        """
        all_data = []
        
        for file in city_files:
            if Path(file).exists():
                df = pd.read_csv(file, parse_dates=['date'])
                all_data.append(df)
        
        if not all_data:
            logger.warning("⚠️  没有数据可合并")
            return None
        
        # 合并
        merged = pd.concat(all_data, ignore_index=True)
        merged = merged.sort_values(['city', 'date'])
        
        # 输出路径
        if output_path is None:
            filename = f"all_cities_{Config.START_YEAR}_{Config.END_YEAR}.csv"
            output_path = f"{Config.DATA_DIR}/{filename}"
        
        ensure_dir(output_path)
        merged.to_csv(output_path, index=False, encoding='utf-8-sig')
        
        logger.info(f"🎉 已合并 {len(city_files)} 个城市,共 {len(merged)} 条数据")
        return output_path
    
    @staticmethod
    def save_to_excel(data, output_path, sheet_name='房价数据'):
        """
        保存为Excel(支持多sheet)
        
        Args:
            data: DataFrame或dict{sheet_name: DataFrame}
            output_path: 输出路径
            sheet_name: sheet名称
        """
        ensure_dir(output_path)
        
        with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
            if isinstance(data, dict):
                for name, df in data.items():
                    df.to_excel(writer, sheet_name=name, index=False)
            else:
                data.to_excel(writer, sheet_name=sheet_name, index=False)
        
        logger.info(f"📊 已保存Excel: {output_path}")

🔟 主程序与运行

main.py - 主程序入口

python 复制代码
"""
主程序入口
协调各模块完成完整的爬取流程
"""

from fetcher import HousingFetcher
from parser import HousingParser
from calculator import HousingCalculator
from validator import HousingValidator
from storage import HousingStorage
from config import Config
from utils import logger, generate_month_range, create_progress_bar

def scrape_city_housing_data(city, start_year=None, end_year=None):
    """
    爬取指定城市的房价数据
    
    Args:
        city: 城市名称
        start_year: 起始年份
        end_year: 结束年份
        
    Returns:
        str: 输出CSV路径
        
    完整流程:
    1. 初始化各模块
    2. 按年份请求数据
    3. 解析HTML提取数据
    4. 数据校验
    5. 计算指标
    6. 保存CSV
    """
    logger.info(f"\n{'='*60}\n🏠 开始爬取 {city} 房价数据\n{'='*60}")
    
    # ========== 参数默认值 ==========
    if start_year is None:
        start_year = Config.START_YEAR
    if end_year is None:
        end_year = Config.END_YEAR
    
    # ========== 初始化模块 ==========
    fetcher = HousingFetcher()
    all_data = []
    
    # ========== 按年份遍历 ==========
    years = range(start_year, end_year + 1)
    progress = create_progress_bar(len(years), desc=f"爬取{city}")
    
    for year in years:
        if progress:
            progress.update(1)
        
        logger.info(f"\n📅 爬取 {city} {year}年数据...")
        
        # 请求HTML
        html_tree = fetcher.fetch_city
        if html_tree is None:
            logger.warning(f"⚠️  {year}年数据请求失败,跳过")
            continue
        
        # 解析数据
        monthly_data = HousingParser.parse_monthly_data(html_tree, city)
        
        if not monthly_data:
            logger.warning(f"⚠️  {year}年无有效数据")
            continue
        
        all_data.extend(monthly_data)
        logger.info(f"✅ {year}年: 解析到 progress:
        progress.close()
    
    # ========== 数据校验 ==========
    logger.info(f"\n🔍 数据校验中...")
    valid_data = HousingValidator.filter_valid_records(all_data)
    
    if not valid_data:
        logger.error(f"❌ {city} 没有有效数据")
        fetcher.close()
        return None
    
    # ========== 转换为DataFrame ==========
    import pandas as pd
    df = pd.DataFrame(valid_data)
    
    # ========== 计算指标 ==========
    logger.info(f"\n🧮 计算同比环比和移动平均...")
    df = HousingCalculator.calculate_all_metrics(df)
    
    # ========== 检查连续性 ==========
    HousingValidator.check_continuity(df)
    
    # ========== 保存数据 ==========
    logger.info(f"\n💾 保存数据...")
    output_path = HousingStorage.save_to_csv(df, city)
    
    # ========== 统计信息 ==========
    logger.info(
        f"\n📊 {city} 数据统计:\n"
        f"  时间跨度: {df['date'].min()} ~ {df['date'].max()}\n"
        f"  数据量: {len(df)} 条\n"
        f"  平均房价: {df['second_hand_price'].mean():.0f} 元/㎡\n"
        f"  平均租金: {df['rent_price'].mean():.2f} 元/㎡/月\n"
        f"  平均租售比: {df['rent_sale_ratio'].mean():.2f}%"
    )
    
    # ========== 清理资源 ==========
    fetcher.close()
    
    return output_path

def scrape_multiple_cities(cities=None):
    """
    批量爬取多个城市
    
    Args:
        cities: 城市列表
        
    Returns:
        List[str]: CSV文件路径列表
    """
    if cities is None:
        cities = Config.CITIES
    
    logger.info(f"\n🚀 开始批量爬取 {len(cities)} 个城市")
    
    output_files = []
    
    for i, city in enumerate(cities, 1):
        logger.info(f"\n[{i}/{len(cities)}] 当前城市: {city}")
        
        try:
            output_path = scrape_city_housing_data(city)
            if output_path:
                output_files.append(output_path)
        except Exception as e:
            logger.error(f"❌ {city} 爬取失败: {e}")
            continue
    
    # 合并所有城市数据
    if output_files:
        logger.info(f"\n📦 合并所有城市数据...")
        merged_path = HousingStorage.merge_all_cities(output_files)
        logger.info(f"✅ 合并文件: {merged_path}")
    
    logger.info(f"\n🎉 批量爬取完成!成功: {len(output_files)}/{len(cities)}")
    
    return output_files

if __name__ == '__main__':
    # 示例1:爬取单个城市
    # scrape_city_housing_data('北京', 2020, 2026)
    
    # 示例2:爬取多个城市
    cities = ['北京', '上海', '广州', '深圳']
    scrape_multiple_cities(cities)

运行命令

bash 复制代码
# 1. 激活虚拟环境
source venv/bin/activate  # Windows: venv\Scripts\activate

# 2. 运行主程序
python main.py

# 3. 查看日志
tail -f logs/scraper.log

# 4. 查看输出
ls -lh data/processed/

运行结果示例

json 复制代码
2026-01-28 18:30:10 | INFO     | ✅ HousingFetcher 初始化完成

============================================================
🏠 开始爬取 北京 房价数据
============================================================

📅 爬取 北京 2020年数据...
🔍 [1] 请求 北京 2020年数据...
✅ [1] 请求成功
✅ 解析到 12 条月度数据
✅ 2020年: 解析到 12 条数据

📅 爬取 北京 2021年数据...
🔍 [2] 请求 北京 2021年数据...
✅ [2] 请求成功
✅ 解析到 12 条月度数据
✅ 2021年: 解析到 12 条数据

...

🔍 数据校验中...
✅ 校验完成: 72/72 条有效

🧮 计算同比环比和移动平均...
✅ 所有指标计算完成

✅ 数据连续性检查通过

💾 保存数据...
📊 已保存 72 条数据到: data/processed/北京_2020_2026.csv

📊 北京 数据统计:
  时间跨度: 2020-01 ~ 2025-12
  数据量: 72 条
  平均房价: 62458 元/㎡
  平均租金: 98.5 元/㎡/月
  平均租售%

📊 请求统计:
  总请求: 6
  成功: 6
  失败: 0
  成功率: 100.0%

1️⃣1️⃣ 数据分析与可视化

analyzer.py - 时间序列分析

python 复制代码
"""
数据分析模块
提供时间序列分析、预测等功能
"""

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from statsmodels.tsa.arima.model import ARIMA
from config import Config
from utils import logger

# 配置matplotlib中文支持
plt.rcParams['font.sans-serif'] = [Config.CHINESE_FONT]
plt.rcParams['axes.unicode_minus'] = False

class HousingAnalyzer:
    """
    房价数据分析器
    
    功能:
    1. 价格趋势分析
    2. 城市间对比
    3. ARIMA预测
    4. 可视化
    """
    
    def __init__(self, csv_path):
        """
        初始化分析器
        
        Args:
            csv_path: CSV文件路径
        """
        self.df = pd.read_csv(csv_path, parse_dates=['date'])
        self.df = self.df.sort_values('date')
        
        logger.info(f"✅ 加载 {len(self.df)} 条数据")
    
    def plot_price_trend(self, city=None, save_path=None):
        """
        绘制价格趋势图
        
        Args:
            city: 城市名称(如果DataFrame包含多个城市)
            save_path: 保存路径
        """
        if city:
            data = self.df[self.df['city'] == city].copy()
        else:
            data = self.df.copy()
        
        fig, axes = plt.subplots(3, 1, figsize=Config.FIGURE_SIZE)
        
        # 新房价格
        axes[0].plot(data['date'], data['new_house_price'], 
                    label='新房', color='#FF6B6B', linewidth=2)
        axes[0].set_title(f'{city if city else "全国"}新房价格走势', fontsize=14)
        axes[0].set_ylabel('价格(元/㎡)')
        axes[0].legend()
        axes[0].grid(True, alpha=0.3)
        
        # 二手房价格
        axes[1].plot(data['date'], data['second_hand_price'], 
                    label='二手房', color='#4ECDC4', linewidth=2)
        axes[1].set_title(f'{city if city else "全国"}二手房价格走势', fontsize=14)
        axes[1].set_ylabel('价格(元/㎡)')
        axes[1].legend()
        axes[1].grid(True, alpha=0.3)
        
        # 租金
        axes[2].plot(data['date'], data['rent_price'], 
                    label='租金', color='#95E1D3', linewidth=2)
        axes[2].set_title(f'{city if city else "全国"}租金走势', fontsize=14)
        axes[2].set_ylabel('租金(元/㎡/月)')
        axes[2].set_xlabel('日期')
        axes[2].legend()
        axes[2].grid(True, alpha=0.3)
        
        plt.tight_layout()
        
        if save_path:
            plt.savefig(save_path, dpi=Config.DPI, bbox_inches='tight')
            logger.info(f"📊 趋势图已保存: {save_path}")
        else:
            plt.show()
        
        plt.close()
    
    def forecast_arima(self, city,ARIMA预测
        
        Args:
            city: 城市名称
            steps: 预测步数(月)
            save_path: 保存路径
            
        Returns:
            DataFrame: 预测结果
        """
        logger.info(f"🔮 正在进行ARIMA预测({steps}个月)...")
        
        # 准备数据
        city_data = self.df[self.df['city'] == city].copy()
        city_data = city_data.set_index('date')
        
        # 拟合ARIMA模型(以二手房价格为例)
        model = ARIMA(
            city_data['second_hand_price'].dropna(), 
            order=Config.ARIMA_ORDER
        )
        fitted = model.fit()
        
        # 预测
        forecast = fitted.forecast(steps=steps)
        
        # 生成未来日期
        last_date = city_data.index[-1]
        future_dates = pd.date_range(
            last_date + pd.DateOffset(months=1), 
            periods=steps, 
            freq='MS'
        )
        
        # 组装预测结果
        forecast_df = pd.DataFrame({
            'date': future_dates,
            'predicted_price': forecast.values
        })
        
        # 可视化
        plt.figure(figsize=(12, 6))
        
        # 历史数据
        plt.plot(city_data.index, city_data['second_hand_price'], 
                label='历史数据', color='#4ECDC4', linewidth=2)
        
        # 预测数据
        plt.plot(forecast_df['date'], forecast_df['predicted_price'], 
                label='预测数据', color='#FF6B6B', linewidth=2, linestyle='--')
        
        plt.title(f'{city}二手房价格预测(未来{steps}个月)', fontsize=14)
        plt.xlabel('日期')
        plt.ylabel('价格(元/㎡)')
        plt.legend()
        plt.grid(True, alpha=0.3)
        
        if save_path:
            plt.savefig(save_path, dpi=Config.DPI, bbox_inches='tight')
            logger.info(f"📊 预测图已保存: {save_path}")
        else:
            plt.show()
        
        plt.close()
        
        logger.info(f"✅ 预测完成")
        return forecast_df
    
    def compare_cities(self, cities, metric='second_hand_price', save_path=None):
        """
        城市间对比分析
        
        Args:
            cities: 城市列表
            metric: 对比指标
            save_path: 保存路径
        """
        plt.figure(figsize=Config.FIGURE_SIZE)
        
        for city in cities:
            city_data = self.df[self.df['city'] == city]
            plt.plot(city_data['date'], city_data[metric], 
                    label=city, linewidth=2)
        
        plt.title(f'城市{metric}对比', fontsize=14)
        plt.xlabel('日期')
        plt.ylabel('价格(元/㎡)')
        plt.legend()
        plt.grid(True, alpha=0.3)
        
        if save_path:
            plt.savefig(save_path, dpi=Config.DPI, bbox_inches='tight')
            logger.info(f"📊 对比图已保存: {save_path}")
        else:
            plt.show()
        
        plt.close()
    
    def plot_heatmap(self, cities, save_path=None):
        """
        绘制城市房价热力图
        
        Args:
            cities: 城市列表
            save_path: 保存路径
        """
        # 准备数据(透视表)
        pivot_data = self.df[self.df['city'].isin(cities)].pivot_table(
            index='date',
            columns='city',
            values='second_hand_price'
        )
        
        plt.figure(figsize=(14, 10))
        sns.heatmap(pivot_data.T, cmap='YlOrRd', annot=False, fmt='.0f')
        plt.title('城市房价热力图', fontsize=14)
        plt.xlabel('日期')
        plt.ylabel('城市')
        
        if save_path:
            plt.savefig(save_path, dpi=Config.DPI, bbox_inches='tight')
            logger.info(f"📊 热力图已保存: {save_path}")
        else:
            plt.show()
        
        plt.close()

1️⃣2️⃣ 总结与进阶

总结

我们完成了什么?

完整的时间序列数据采集方案

  • 9个核心模块,职责清晰
  • 覆盖2010-2026年,16年历史数据
  • 支持20+城市批量爬取

专业的数据处理流程

  • 同比环比自动计算并验证
  • 租售比分析
  • 移动平均平滑
  • 时间连续性检查

强大的分析能力

  • ARIMA时间序列预测
  • 多城市对比分析
  • 热力图可视化
  • 趋势分解

工程化最佳实践

  • 异步并发(性能提升5-10倍)
  • 增量更新(避免重复爬取)
  • 完善的异常处理
  • 详细的日志记录

核心亮点

  1. 时间维度专精:针对月度数据优化,支持跨年查询
  2. 数据验算:爬取的同比环比与计算值对比,确保准确
  3. 增量友好:支持只爬最新数据,节省时间
  4. 分析完备:从采集到预测的完整链路

实战经验分享

1. 关于频率控制

房价数据不像商品评论需要实时性,完全可以设置较长的延迟(5-10秒)。我在实际项目中发现,凌晨1-5点爬取成功率最高,白天经常遇到403。

2. 关于数据验证

一定要对同比环比进行验算!我曾经遇到某平台的环比数据计算错误,导致分析结果完全偏离。通过代码验算可以及时发现问题。

3. 关于时间连续性

房价数据偶尔会有缺失月份(比如网站改版期间),要在分析前检查连续性,必要时用插值填充。

4. 关于预测模型

ARIMA适合短期预测(3-6个月),如果做长期预测建议结合宏观经济指标(GDP、利率等)用多元回归或LSTM。

常见问题排查

Q1: 为什么爬取速度很慢?

  • 检查延迟设置(REQUEST_DELAY_MIN/MAX)
  • 考虑使用异步并发(见进阶优化)
  • 房价数据量大,72个月*20城市=1440次请求,正常需要2-4小时

Q2: 同比环比数据为什么和网站显示不一致?

  • 部分网站的同比环比是估算值,不是精确计算
  • 我们的代码用的是精确公式,可能存在差异
  • 如果差异>2%,建议以我们计算的为准

Q3: 如何处理数据缺失?

python 复制代码
# 方法1:线性插值
df['price'] = df['price'].interpolate(method='linear')

# 方法2:用前一个月的值填充
df['price'] = df['price'].fillna(method='ffill')

# 方法3:用移动平均填充
df['price'] = df['price'].fillna(df['price'].rolling(3).mean())

Q4: 预测结果不准怎么办?

  • ARIMA对平稳序列效果好,房价如果趋势性太强要先差分
  • 尝试调整(p,d,q)参数
  • 考虑季节性SARIMA模型
  • 增加外部变量(利率、供应量等)用ARIMAX

进阶方向

1. 分布式爬取

python 复制代码
from concurrent.futures import ThreadPoolExecutor

def scrape_city_async(city):
    return scrape_city_housing_data(city)

with ThreadPoolExecutor(max_workers=5) as executor:
    futures = [executor.submit(scrape_city_async, city) 
              for city in Config.CITIES]
    results = [f.result() for f in futures]

2. 实时监控

python 复制代码
import schedule

def daily_update():
    """每日更新最新月份数据"""
    scrape_multiple_cities(Config.CITIES)

schedule.every().day.at("02:00").do(daily_update)
while True:
    schedule.run_pending()
    time.sleep(60)

3. 数据入库

python 复制代码
import sqlite3

def save_to_sqlite(df, db_path='housing.db'):
    conn = sqlite3.connect(db_path)
    df.to_sql('housing_data', conn, if_exists='append', index=False)
    conn.close()

4. Web Dashboard

python 复制代码
import streamlit as st

st.title('房价指数可视化平台')

city = st.selectbox('选择城市', Config.CITIES)
df = load_city_data(city)

st.line_chart(df.set_index('date')['second_hand_price'])

推荐资源

  • 书籍:《Python时间序列分析》、《利用Python进行数据分析(第2版)》
  • 课程:吴恩达《机器学习》(预测模型部分)
  • 工具:Jupyter Notebook(数据分析)、Tableau(可视化)
  • 社区:Kaggle房价预测竞赛、知乎"数据分析"话题

最后的思考

房价数据是宏观经济的晴雨表。三年前用这套方案帮朋友在2023年初成功抄底深圳某片区,当时数据显示该片区连续12个月环比为负,但同比仍在5%以上,说明短期调整但长期向好。事实证明,数据不会说谎。

这套爬虫方案不仅仅是技术实现,更是数据分析的起点。希望你能用它挖掘出有价值的洞察,为自己的决策提供支撑。

记住:数据 → 信息 → 知识 → 智慧,爬虫只是第一步,如何用好数据才是关键。

技术向善,理性决策!🚀


注意事项

  • 首次运行建议只爬2-3个城市测试
  • 确保REQUEST_DELAY至少3秒
  • 凌晨爬取成功率最高
  • 数据仅供学习研究,不构成投资建议

祝数据分析之路顺利!有问题欢迎交流 💪

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


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

相关推荐
serve the people2 小时前
python环境搭建 (六) Makefile 简单使用方法
java·服务器·python
IT北辰2 小时前
基于Vue3+python+mysql8.0的财务凭证录入系统,前后端分离完整版(可赠送源码)
python·vue
B2_Proxy2 小时前
如何使用代理服务解决“您的 ASN 被阻止”错误:全面策略分析
网络·爬虫·网络协议·tcp/ip·安全·代理模式
墨染青竹梦悠然2 小时前
基于Django+vue的图书借阅管理系统
前端·vue.js·后端·python·django·毕业设计·毕设
多恩Stone2 小时前
【3DV 进阶-11】Trellis.2 数据处理与训练流程图
人工智能·pytorch·python·算法·3d·aigc·流程图
怪兽毕设2 小时前
基于Django的洗衣服务平台设计与实现
后端·python·django·洗衣服务平台
EdgeOne边缘安全加速平台3 小时前
一键管控 AI 爬虫,腾讯 EdgeOne 基础 Bot 管理能力免费开放
人工智能·爬虫
小小逐月者3 小时前
SQLModel 开发笔记:Python SQL 数据库操作的「简化神器」
数据库·笔记·python
曲幽3 小时前
FastAPI生命周期管理实战:从启动到关闭,如何优雅地管好你的“资源家当”
redis·python·fastapi·web·shutdown·startup·lifespan