简介:本资源是一套面向房地产数据分析初学者与行业研究者的Python链家二手房及租房数据爬虫实战源码,解决房产市场信息采集效率低、结构化难度大等实际问题,适用于市场调研、投资分析、教学实践等场景。压缩包共20个文件,含8个核心Python脚本(如core.py、woaiwojialib.py、scrawl.py等,覆盖反爬处理、数据建模、配置管理与页面抓取)、2个CSV数据文件(houseinfo.csv、community.csv)、2个PNG可视化截图、2个文本文件(含社区ID列表与依赖说明)、1个Jupyter笔记本(lianjia.ipynb)用于交互式分析演示,以及Git配置、开源许可与Markdown文档等工程规范文件,整体大小为10.85MB。已有704人学习下载。读者可直接运行爬虫获取链家真实房源与小区数据,复现完整采集—清洗—存储—分析流程;代码模块职责清晰、注释充分,附带可执行的环境配置与分步说明,大幅降低Python网络爬虫入门门槛。
1. 项目缘起与核心价值
最近在帮一个做房产数据分析的朋友处理点事情,他需要持续跟踪几个重点城市的二手房和租房市场动态,比如价格走势、房源分布、户型变化这些。一开始他想着手动去链家这类平台抄数据,但试了两天就放弃了——信息太分散,更新又频繁,纯人力根本搞不定。他跑来问我,有没有办法能自动化地把这些数据“搬”下来,整理成结构化的表格。我一听,这不就是典型的网络爬虫应用场景嘛,而且用Python来做再合适不过了。于是,就有了这个“基于Python的链家二手房租房在线数据爬虫”的设计与实现。
这个项目的核心目标很明确:自动化、高效率、结构化地采集链家网上的二手房销售与租房信息。它要解决的痛点,正是许多数据分析师、市场研究员甚至个人投资者都会遇到的:如何从海量、非结构化的网页信息中,快速提取出有用的、规整的数据。无论是想分析某个小区的历史成交价,还是想监控特定区域的租金波动,一个稳定可靠的爬虫都能省下大量重复劳动。这个项目适合有一定Python基础,想深入实战Web爬虫,或者对房产数据感兴趣的朋友。即使你刚学完Python语法,跟着这个流程走一遍,也能对请求发送、页面解析、数据存储有一个非常扎实的理解。
2. 项目整体设计与思路拆解
2.1 需求分析与目标定义
在做任何技术实现之前,我们必须先想清楚:到底要爬什么?链家网页面信息繁杂,我们需要明确数据边界。对于二手房,核心字段通常包括:房源标题、总价、单价、小区名称、所在区域(行政区、商圈)、房屋户型(几室几厅)、建筑面积、朝向、楼层、装修情况、建筑年代、挂牌时间、房源特色标签、带看次数等。对于租房,除了价格(月租金)、面积、户型、区域外,还需要关注租赁方式(整租/合租)、付款方式(押一付三等)、房源配置(是否配备家电)等。
确定了字段,接下来要定义爬取范围。是爬取单个城市,还是多个城市?是爬取全部区域,还是聚焦某个商圈?是爬取当前所有在售/在租房源,还是需要历史数据?这些问题的答案直接决定了爬虫的复杂度和运行策略。对于初期项目,我建议采取“由点到面”的策略:先实现对一个城市某个行政区的所有二手房列表页进行遍历和详情页抓取,确保核心流程跑通,然后再扩展至租房、多区域乃至多城市。
2.2 技术选型与工具栈
为什么用Python?因为它在数据抓取和处理领域的生态实在是太成熟了。围绕这个项目,我们主要会用到以下几个库:
请求库:
requests与httpxrequests是同步请求的绝对主力,简单易用,文档丰富,对于大多数反爬不严的页面足够了。但如果遇到需要更高并发或应对一些特殊异步接口的场景,httpx(支持HTTP/2和异步)是一个很好的备选或进阶选择。本项目以requests为基础进行讲解。解析库:
lxml与parsel页面解析是爬虫的核心。BeautifulSoup固然友好,但lxml在解析速度和内存占用上优势明显,特别适合处理大量的HTML文档。parsel库(Scrapy框架的解析组件)提供了与lxml类似的XPath和CSS选择器接口,并且集成了一些便捷方法。这里我们选择lxml,因为它更底层、更纯粹,有助于理解解析原理。数据存储:
pandas与sqlite3/pymysql爬下来的数据需要持久化。对于初步的探索和分析,将数据存入DataFrame再导出为CSV或Excel文件是最快的方式,pandas完美胜任。如果数据量很大,或者需要长期积累并复杂查询,那么存入数据库是必须的。轻量级项目可以用Python内置的sqlite3,数据量大了则可以考虑pymysql连接MySQL。本项目会演示CSV和SQLite两种方式。其他辅助工具
fake-useragent:随机生成User-Agent请求头,降低被简单封禁的风险。time/random:用于在请求间插入随机延时,模拟人类操作,遵守网站的robots.txt规则(尽管链家对爬虫相对宽容,但保持礼貌是长期稳定运行的前提)。logging:记录爬虫运行日志,方便出错时回溯和监控运行状态。
注意:在开始编写爬虫前,务必花时间人工浏览目标网站(链家),使用浏览器的开发者工具(F12)分析网页结构、网络请求(XHR/Fetch),理解其数据加载方式(是直接渲染在HTML中,还是通过Ajax接口动态加载)。这是爬虫成功的第一步,也是最重要的一步。
2.3 核心流程设计
整个爬虫的运作流程可以抽象为以下几个步骤,它们构成了一个清晰的管道(Pipeline):
- 种子URL生成:根据目标城市、区域、页码等参数,构造出列表页的URL队列。
- 列表页抓取与解析:遍历URL队列,下载列表页HTML,从中解析出当前页所有房源的详情页链接(href)。
- 详情页抓取与解析:逐个访问详情页链接,下载详情页HTML,从中提取我们事先定义好的各个字段信息。
- 数据清洗与存储:对提取的原始数据进行清洗(处理缺失值、格式化数字、统一单位等),然后存入CSV文件或数据库。
- 循环与控制:管理翻页逻辑,在请求间加入合理延时,处理可能出现的网络异常或页面结构变化。
3. 核心细节解析与实操要点
3.1 网页结构分析与数据定位
链家的页面结构相对规整。以北京二手房列表页为例,其URL模式通常为:https://bj.lianjia.com/ershoufang/pg{页码}/。每个房源在列表页上以一个li标签下的div模块呈现。我们需要从中提取的关键信息是详情页的链接,它通常在一个a标签的href属性里。
在详情页,所有信息都分布在不同的HTML标签中。例如,总价可能在某个span标签里,且带有特定的class,如total。小区名称、区域信息通常在标题或特定的信息栏中。这里就需要我们仔细查看HTML源码,找到稳定、唯一的定位路径。
实操要点:
- 使用XPath还是CSS选择器?两者均可,XPath功能更强大(可以按文本内容、属性值进行复杂定位),CSS选择器写法更简洁。我个人的习惯是:对于简单的、基于
class或id的定位,用CSS选择器;对于需要层级遍历或条件判断的复杂定位,用XPath。本项目会混合使用。 - 如何应对结构变化?网站前端可能会改版。因此,编写的XPath或CSS选择器不应过于脆弱(比如依赖非常长的、精确的层级路径)。尽量寻找具有唯一标识性的
class或>from fake_useragent import UserAgent ua = UserAgent() headers = { 'User-Agent': ua.random, 'Accept-Language': 'zh-CN,zh;q=0.9', # 接受中文 'Referer': 'https://bj.lianjia.com/' # 设置来源页,模拟从首页跳转 } 请求频率控制:这是最重要的道德和技术要点。在
for循环中,每完成一次请求(无论是列表页还是详情页),都使用time.sleep()暂停一段时间。暂停时间可以是一个固定值(如2秒),也可以是在一个区间内随机取值(如random.uniform(1, 3)),后者更接近人类行为。Cookie与Session:对于一些需要登录后才能查看的信息(如历史成交价),可能需要处理Cookie。
requests.Session()对象可以自动管理Cookie,在多次请求间保持会话状态。对于本项目公开可见的信息,通常不需要。IP代理:如果你需要极高频率地抓取,可能会触发IP限制。这时就需要使用代理IP池。但对于个人学习和中小规模数据采集,控制好请求间隔通常就足够了。引入代理会大大增加项目的复杂度和成本(需要寻找可靠的代理服务)。
- 去除空白字符:使用字符串的
.strip()方法。 - 提取数字:价格字段往往像“500万”、“8500元/月”。需要使用正则表达式(
re模块)提取其中的数字部分,并根据单位(万/元)进行换算。例如,“500万”应转换为5000000(以元为单位存储)。 - 拆分复合字段:例如,“朝阳 | 望京 | 融科橄榄城”这样的区域信息,需要拆分成“行政区”、“商圈”、“小区”三个独立字段。
- 处理缺失值:对于解析失败的字段,要赋予一个统一的占位符,如
None、NaN或空字符串,并在存储时保持一致。 - 统一格式:将面积统一为“平方米”,价格统一为“元”,日期统一为“YYYY-MM-DD”格式。
重要心得:把爬虫想象成一个有礼貌的访客。你的代码访问速度越快,对服务器造成的压力就越大,也就越容易被识别和封禁。设置合理的延迟,不仅是技术策略,也是对网站资源的尊重。我通常会把延迟设置得比感觉上的“最低限度”再长一些,求稳不求快。
3.3 数据清洗与规整
从网页上直接抓下来的数据是“脏”的,包含各种HTML实体、多余的空格、换行符、中文单位等,无法直接用于分析。
常见清洗任务:
清洗逻辑最好封装成独立的函数,这样主爬虫流程清晰,也便于后续维护和修改清洗规则。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
首先,确保你的Python环境(建议3.7以上)已经就绪。然后,通过pip安装所需的库。建议使用虚拟环境(如venv)来管理项目依赖。
# 创建并激活虚拟环境 (可选,但推荐) python -m venv lianjia_spider_env # Windows: lianjia_spider_env\Scripts\activate # Linux/Mac: source lianjia_spider_env/bin/activate # 安装核心库 pip install requests lxml pandas fake-useragent # 如果需要异步或HTTP/2,可以安装httpx # pip install httpx4.2 核心代码模块拆解
我们将爬虫拆分成几个功能模块,使结构更清晰。
模块一:请求模块 (fetcher.py)负责发送HTTP请求,包含错误重试和头部信息管理。
import requests import time import random from fake_useragent import UserAgent import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s: %(message)s') class PageFetcher: def __init__(self, delay_range=(1, 3)): self.ua = UserAgent() self.session = requests.Session() self.delay_range = delay_range def get_random_headers(self): return { 'User-Agent': self.ua.random, 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', } def fetch_page(self, url, max_retries=3): """获取页面HTML,支持重试""" for attempt in range(max_retries): try: headers = self.get_random_headers() # 为列表页和详情页设置不同的Referer,模拟更真实的行为 if 'ershoufang' in url or 'zufang' in url: headers['Referer'] = 'https://bj.lianjia.com/' resp = self.session.get(url, headers=headers, timeout=10) resp.raise_for_status() # 检查HTTP状态码是否为200 # 随机延迟,模拟人工操作 time.sleep(random.uniform(*self.delay_range)) return resp.text except requests.exceptions.RequestException as e: logging.warning(f"Attempt {attempt + 1} failed for {url}: {e}") if attempt < max_retries - 1: wait_time = 2 ** attempt # 指数退避 logging.info(f"Waiting {wait_time} seconds before retry...") time.sleep(wait_time) else: logging.error(f"Failed to fetch {url} after {max_retries} attempts.") return None模块二:解析模块 (parser.py)负责从HTML中提取目标数据,包含列表页解析和详情页解析。
from lxml import etree import re class LianjiaParser: @staticmethod def parse_list_page(html, base_url='https://bj.lianjia.com'): """解析列表页,获取详情页链接列表""" if not html: return [] detail_links = [] try: tree = etree.HTML(html) # 使用XPath定位房源条目和链接 # 注意:此XPath需要根据链家实际页面结构调整,这里是一个示例 items = tree.xpath('//ul[@class="sellListContent"]/li[@class="clear"]') for item in items: link_element = item.xpath('.//a[@class="img"]/@href') if link_element: detail_links.append(link_element[0]) else: # 另一种可能的定位方式 link_element = item.xpath('.//div[@class="title"]/a/@href') if link_element: detail_links.append(base_url + link_element[0] if not link_element[0].startswith('http') else link_element[0]) except Exception as e: logging.error(f"Error parsing list page: {e}") return detail_links @staticmethod def parse_detail_page(html, page_type='ershoufang'): """解析详情页,提取房源信息""" if not html: return {} data = {} try: tree = etree.HTML(html) data['title'] = LianjiaParser._safe_extract(tree, '//h1[@class="main"]/text()') data['total_price'] = LianjiaParser._extract_price(tree, '//span[@class="total"]/text()') data['unit_price'] = LianjiaParser._extract_unit_price(tree, '//span[@class="unitPriceValue"]/text()') # 区域信息可能在多个位置,这里尝试一个常见的路径 area_info = LianjiaParser._safe_extract(tree, '//div[@class="areaName"]/span[@class="info"]/a/text()') if area_info: # 假设格式为“朝阳 望京” parts = area_info.split() data['district'] = parts[0] if len(parts) > 0 else '' data['bizcircle'] = parts[1] if len(parts) > 1 else '' # 基础信息(户型、面积、朝向等) base_info_keys = tree.xpath('//div[@class="base"]/div[@class="content"]/ul/li') for li in base_info_keys: text = li.xpath('./text()') if text: key_value = text[0].split(':') if len(key_value) == 2: key, value = key_value data[key.strip()] = value.strip() # 交易信息(挂牌时间等) transaction_info_keys = tree.xpath('//div[@class="transaction"]/div[@class="content"]/ul/li') for li in transaction_info_keys: text = li.xpath('./span/text()') if text and len(text) >= 2: data[text[0].replace(':', '')] = text[1].strip() # 如果是租房,解析逻辑类似,但字段和XPath不同,此处省略租房详情解析... except Exception as e: logging.error(f"Error parsing detail page: {e}") return data @staticmethod def _safe_extract(tree, xpath_expr, default=''): """安全提取单个文本,防止空列表异常""" result = tree.xpath(xpath_expr) return result[0].strip() if result else default @staticmethod def _extract_price(price_str): """从‘500万’这样的字符串中提取数字并转换为整数(单位:元)""" if not price_str: return None match = re.search(r'([\d\.]+)', price_str) if match: num = float(match.group(1)) if '万' in price_str: num *= 10000 return int(num) return None @staticmethod def _extract_unit_price(price_str): """从‘单价85000元/平米’中提取数字""" if not price_str: return None match = re.search(r'([\d\.]+)', price_str) return int(float(match.group(1))) if match else None模块三:存储模块 (storage.py)负责将解析后的数据保存到文件或数据库。
import pandas as pd import sqlite3 import os from datetime import datetime class DataStorage: def __init__(self, output_dir='./data'): self.output_dir = output_dir os.makedirs(output_dir, exist_ok=True) def save_to_csv(self, data_list, filename_prefix='lianjia_ershoufang'): """保存数据列表到CSV文件""" if not data_list: logging.warning("No data to save.") return df = pd.DataFrame(data_list) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = f"{filename_prefix}_{timestamp}.csv" filepath = os.path.join(self.output_dir, filename) df.to_csv(filepath, index=False, encoding='utf-8-sig') # utf-8-sig支持Excel直接打开 logging.info(f"Data saved to {filepath}, total {len(data_list)} records.") def save_to_sqlite(self, data_list, table_name='ershoufang', db_name='lianjia.db'): """保存数据列表到SQLite数据库""" if not data_list: return conn = sqlite3.connect(os.path.join(self.output_dir, db_name)) df = pd.DataFrame(data_list) # 如果表不存在,会根据DataFrame自动创建;如果存在,则追加(需要处理重复) df.to_sql(table_name, conn, if_exists='append', index=False) conn.close() logging.info(f"Data appended to table '{table_name}' in {db_name}.")模块四:主控模块 (main.py)串联整个流程,负责URL调度、任务协调。
import logging from fetcher import PageFetcher from parser import LianjiaParser from storage import DataStorage def main(): logging.info("链家爬虫启动...") fetcher = PageFetcher(delay_range=(2, 5)) # 设置稍长的延迟,更安全 parser = LianjiaParser() storage = DataStorage() base_url = "https://bj.lianjia.com/ershoufang/" max_pages = 3 # 测试时只爬3页,实际可按需调整或设置为遍历到最后一页 all_data = [] for page in range(1, max_pages + 1): list_url = f"{base_url}pg{page}/" logging.info(f"正在抓取列表页: {list_url}") list_html = fetcher.fetch_page(list_url) if not list_html: logging.error(f"列表页 {list_url} 抓取失败,跳过。") continue detail_urls = parser.parse_list_page(list_html) logging.info(f"第{page}页找到 {len(detail_urls)} 个房源链接。") for idx, detail_url in enumerate(detail_urls): logging.info(f" ({idx+1}/{len(detail_urls)}) 抓取详情页: {detail_url}") detail_html = fetcher.fetch_page(detail_url) if detail_html: house_data = parser.parse_detail_page(detail_html) if house_data: house_data['source_url'] = detail_url # 记录来源 all_data.append(house_data) # 每处理完一个详情页,可以稍作休息(fetcher中已有全局延迟,这里可选) # 所有页面抓取完毕,保存数据 if all_data: storage.save_to_csv(all_data) storage.save_to_sqlite(all_data) logging.info(f"爬取结束,共获取 {len(all_data)} 条有效数据。") else: logging.warning("未获取到任何数据。") if __name__ == '__main__': main()4.3 运行与结果验证
运行python main.py,你会在项目根目录下看到一个data文件夹,里面会生成一个带有时间戳的CSV文件(如lianjia_ershoufang_20231027_143022.csv)和一个SQLite数据库文件(lianjia.db)。
用Excel或文本编辑器打开CSV文件,或用DB Browser for SQLite打开数据库文件,检查数据是否完整、规整。重点关注:
- 字段是否齐全?
- 价格、面积等数字字段是否已正确清洗(去除单位,转为数值)?
- 是否存在大量空值或解析错误?
- 数据量是否符合预期?
第一次运行时,建议将max_pages设为1或2,快速验证整个流程是否通畅。确认无误后,再逐步扩大爬取范围。
5. 常见问题与排查技巧实录
爬虫开发中,几乎一定会遇到各种问题。下面是我在实战中踩过的一些坑和对应的解决方法。
5.1 请求被拒绝或返回异常页面
- 现象:
requests.get()返回的状态码不是200,比如403、404,或者返回的HTML内容很短,包含“访问过于频繁”、“验证”等字样。 - 排查与解决:
- 检查请求头:首先确认
User-Agent是否已设置为常见的浏览器值。使用print(headers)或在开发者工具中对比你的请求头与浏览器正常访问的请求头差异,补全Accept、Accept-Language、Referer等关键字段。 - 降低请求频率:立即大幅增加请求间隔时间(比如调到5-10秒),并加入随机性。可能是触发了网站的速率限制。
- 模拟完整会话:尝试使用
requests.Session(),并在首次访问前,先GET一下网站首页,让Session获取初始Cookie。 - 验证网络环境:有些公共网络或公司网络可能会拦截异常流量。尝试更换网络环境(如手机热点)测试。
- 检查请求头:首先确认
5.2 解析不到数据或数据为空
- 现象:程序没有报错,但
parse_list_page或parse_detail_page返回的列表为空,或者提取的字段都是空字符串。 - 排查与解决:
- 确认页面已正确加载:将
fetcher.fetch_page返回的HTML内容的前1000个字符打印出来,或者保存到本地文件,用浏览器打开,看看是否是预期的页面。可能请求被重定向到了登录页或反爬页面。 - 检查XPath/CSS选择器:这是最常见的原因。网站改版了!你需要重新分析页面结构。使用浏览器的开发者工具,在“元素”(Elements)面板,找到你想要的数据对应的HTML标签,右键“Copy” -> “Copy XPath”或“Copy selector”。但注意,浏览器生成的XPath可能很长且脆弱,你需要手动将其简化为更稳定、更短的版本。技巧:优先使用
class、id或具有唯一性的style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
- 确认页面已正确加载:将