文章目录
一、引言
在当今的数据分析和房地产研究领域,获取大量准确的房产数据具有重要意义。前几篇成功爬取了安居客、贝壳网二手小区的数据,这次来爬一下房天下的试试!(该教程大部分由Ai生成,省点时间精力~~)
二、准备工作
(一)安装必要的库
在开始编写代码之前,我们需要安装几个重要的 Python 库:
-
requests:用于发送 HTTP 请求,获取网页的 HTML 内容。
安装命令:pip install requests
-
beautifulsoup4:用于解析 HTML 和 XML 文档,方便我们从网页内容中提取所需的数据。
安装命令:pip install beautifulsoup4
-
pandas:用于数据处理和将数据保存为 Excel 文件。
安装命令:pip install pandas
-
logging:Python 内置的日志模块,用于记录程序运行过程中的信息和错误,无需额外安装。
(二)了解房天下网页结构
这里以佛山市顺德区为例,使用浏览器的开发者工具(通常按 F12
或右键选择 “检查”)查看页面的 HTML 结构。
我们可以F12的网络里,找到文档选项卡,里面有一个文件,通过预览可以看到网页上包含小区名称、参考均价、区域信息等内容,因此我们后续只需要请求这个文件的url即可得到小区的信息。
同时,通过点击每个小区可以进入详情页
我们尝试爬取了小区列表之后再逐一访问每个小区的详情页去爬取相关的信息~
三、代码实现
(一) 爬取流程
1. 创建会话 → 2. 获取总页数 → 3. 遍历列表页 →
4. 抓取详情页 → 5. 保存Excel
(二)配置参数
# 配置参数
CONFIG = {
# 模拟浏览器的请求头,让服务器认为请求是从浏览器发出的
'headers': {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
},
# 包含多个cookie信息,用于模拟用户的登录或会话状态
'cookies': "替换成自己的cookies",
# 请求超时时间,单位为秒
'timeout': 10,
# 请求重试次数
'retries': 3,
# 每次请求之间的随机延迟范围,单位为秒
'delay_range': (1, 3),
# 最大工作线程数,这里暂时未使用
'max_workers': 2,
}
目的: 这些配置参数用于模拟浏览器行为,避免被网站识别为爬虫并封禁,同时设置请求的超时时间、重试次数和请求间隔时间等。
用法:
- headers: 模拟浏览器的请求头,User - Agent用于告诉网站我们使用的浏览器类型,Accept - Language表示我们希望接收的语言。
- cookies: 包含一些登录或网站识别的信息,可根据自己登录后的情况进行修改。
- timeout: 设置请求的超时时间为 10 秒,如果在 10 秒内没有得到响应,请求将失败。
- retries: 请求失败后的重试次数,最多重试 3 次。
- delay_range: 每次请求之间的随机延迟时间范围为 1 到 3 秒,避免请求过于频繁。
- max_workers: 多线程时的最大线程数,这里暂时未使用多线程。
(三)日志配置
# 日志配置
logging.basicConfig(
# 日志级别为INFO,只记录INFO及以上级别的日志
level=logging.INFO,
# 日志的格式,包含时间、日志级别和日志信息
format='%(asctime)s - %(levelname)s - %(message)s',
# 日志输出到控制台
handlers=[logging.StreamHandler()]
)
# 获取一个名为__name__的日志记录器
logger = logging.getLogger(__name__)
目的: 使用logging模块记录程序运行过程中的信息和错误,方便我们调试和监控程序的运行状态。
用法:
- level=logging.INFO: 设置日志的记录级别为 INFO,表示只记录 INFO 及以上级别的日志(如 INFO、WARNING、ERROR 等)。
- format: 定义日志的格式,包括时间、日志级别和具体信息。
- handlers=[logging.StreamHandler()]: 将日志输出到控制台。
(四)请求重试装饰器
def retry(exceptions=Exception):
"""请求重试装饰器"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 循环重试指定次数
for _ in range(CONFIG['retries']):
try:
# 尝试执行被装饰的函数
return func(*args, **kwargs)
except exceptions as e:
# 若出现异常,记录警告日志并等待一段时间后重试
logger.warning(f"Retrying {func.__name__} due to error: {str(e)}")
time.sleep(random.uniform(*CONFIG['delay_range']))
# 若重试次数用完仍失败,抛出异常
raise Exception(f"Failed after {CONFIG['retries']} retries")
return wrapper
return decorator
目的: 在请求失败时进行重试,避免因网络波动等原因导致程序中断。
用法:
- exceptions=Exception: 指定需要重试的异常类型,默认为所有异常。
- decorator: 返回一个装饰器函数,用于装饰需要重试的函数。
- wrapper: 在函数执行过程中,如果出现指定的异常,会进行重试,每次重试之间会随机延迟一段时间,重试次数达到CONFIG[‘retries’]后仍失败则抛出异常。
(五)封装请求会话管理
class FangSession:
"""封装请求会话管理"""
def __init__(self):
# 创建一个requests会话对象
self.session = requests.Session()
# 更新会话的请求头
self.session.headers.update(CONFIG['headers'])
# 更新会话的cookie信息
self.session.cookies.update({c.split('=')[0]: c.split('=')[1] for c in CONFIG['cookies'].split('; ')})
@retry((requests.exceptions.RequestException,))
def get(self, url: str) -> requests.Response:
"""带重试和随机延迟的GET请求"""
# 随机延迟一段时间,避免频繁请求被服务器封禁
time.sleep(random.uniform(*CONFIG['delay_range']))
# 发送GET请求
response = self.session.get(url, timeout=CONFIG['timeout'])
# 检查响应状态码,若状态码不是200,抛出异常
response.raise_for_status()
return response
目的: 封装请求会话,方便管理请求头、cookie 和请求重试等操作。
用法:
- init: 初始化一个requests.Session对象,并设置请求头和 cookie。
- get: 发送 GET 请求,带有重试和随机延迟功能,timeout参数设置请求超时时间
- response.raise_for_status(): 用于检查请求的状态码,如果状态码不是 200(成功),会抛出异常。
(六)列表页解析器
class ListPageParser:
"""列表页解析器"""
@staticmethod
def parse_total_pages(html: str) -> Tuple[int, int]:
"""解析总页数和每页数量"""
# 使用BeautifulSoup解析HTML内容
soup = BeautifulSoup(html, 'lxml')
# 总数量解析
total = 0
# 查找包含总数量信息的元素
total_element = soup.find('p', class_='findplotNumwrap')
if total_element:
# 查找包含具体数字的元素
num_tag = total_element.find('b', class_='findplotNum')
if num_tag:
try:
# 提取数字并转换为整数
total = int(num_tag.get_text(strip=True).replace(',', ''))
except ValueError:
# 若转换失败,记录警告日志
logger.warning("Total count format error")
# 每页数量解析
# 查找列表项元素
items = soup.select('div.list.rel.mousediv')
# 计算每页的数量
per_page = len(items)
if total == 0 or per_page == 0:
# 若总数量或每页数量为0,抛出异常
raise ValueError("Invalid page structure")
# 计算总页数
total_pages = (total + per_page - 1) // per_page
return total_pages, per_page
@staticmethod
def parse_list_items(html: str) -> List[Dict]:
"""解析列表页小区信息"""
# 使用BeautifulSoup解析HTML内容
soup = BeautifulSoup(html, 'lxml')
# 查找列表项元素
items = soup.select('div.list.rel.mousediv')
results = []
for item in items:
try:
# 查找小区信息块
info_block = item.select_one('dl.plotListwrap')
# 查找价格信息块
price_block = item.select_one('div.listRiconwrap')
data = {
# 提取小区名称
'小区名称': ParserHelper.get_text(info_block, 'a.plotTit'),
# 解析参考均价
'参考均价': ParserHelper.parse_price(price_block),
# 解析区域信息
'区域信息': ParserHelper.parse_district(info_block),
# 解析详细地址
'详细地址': ParserHelper.parse_address(info_block),
# 解析在售数量
'在售数量': ParserHelper.parse_quantity(info_block, 'chushou'),
# 解析在租数量
'在租数量': ParserHelper.parse_quantity(info_block, 'chuzu'),
# 解析价格趋势
'价格趋势': ParserHelper.parse_trend(price_block),
# 提取物业类型
'物业类型': ParserHelper.get_text(info_block, 'span.plotFangType'),
# 解析楼盘ID
'楼盘ID': ParserHelper.parse_newcode(item),
# 构建详情链接
'详情链接': ParserHelper.build_url(item.select_one('a.plotTit')['href']),
# 构建图片链接
'图片链接': ParserHelper.build_image_url(item.select_one('img')['src'])
}
results.append(data)
except Exception as e:
# 若解析失败,记录警告日志
logger.warning(f"Failed to parse list item: {str(e)}")
return results
- parse_total_pages 函数
目的: 从列表页的 HTML 内容中解析出总页数和每页显示的小区数量。
用法:- 使用BeautifulSoup解析 HTML 内容。
- 找到包含总数量的元素,提取总数量并转换为整数。
- 统计当前页显示的小区数量作为每页数量。
- 根据总数量和每页数量计算总页数。
- parse_list_items 函数
目的: 从列表页的 HTML 内容中解析出每个小区的信息,并将其整理成字典形式。
用法:- 找到每个小区对应的 HTML 元素。
- 使用ParserHelper类的方法提取小区的各项信息,如名称、价格、区域等。
- 将提取的信息存储在字典中,并添加到结果列表中。
(七)详情页解析器
class DetailPageParser:
"""详情页解析器"""
@staticmethod
def parse(html: str) -> Dict:
"""解析详情页信息"""
# 使用BeautifulSoup解析HTML内容
soup = BeautifulSoup(html, 'lxml')
detail_data = {
'楼栋总数': 0,
'房屋总数': 0,
'建筑类型': '',
'物业公司': '',
'开发商': '',
'建筑年代': '',
'二手房源': 0,
'最近成交': 0
}
try:
# 查找详情信息列表项
lis = soup.select('div.village_info ul.clearfix li')
field_map = {
# 定义字段映射,包含字段名称和处理函数
'楼栋总数': ('楼栋总数', lambda x: ParserHelper.safe_int(x.replace('栋', ''))),
'房屋总数': ('房屋总数', lambda x: ParserHelper.safe_int(x.replace('户', ''))),
'建筑类型': ('建筑类型', str),
'物业公司': ('物业公司', str.strip),
'开发商': ('开发商', str.strip),
'建筑年代': ('建筑年代', DetailPageParser.clean_year),
'二手房源': ('二手房源', lambda x: ParserHelper.safe_int(x.replace('套', ''))),
'最近成交': ('最近成交', lambda x: ParserHelper.safe_int(x.replace('套', '')))
}
for li in lis:
# 查找列表项中的标签
if not (span := li.find('span')) or not (p := li.find('p')):
continue
# 提取标签文本
key = span.get_text(strip=True)
value = p.get_text(strip=True)
if key in field_map:
# 若字段在映射中,获取对应的字段名称和处理函数
field, processor = field_map[key]
try:
# 处理数据并更新到详情数据中
detail_data[field] = processor(value)
except Exception as e:
# 若处理失败,记录警告日志
logger.warning(f"Failed to process {key}: {value} - {str(e)}")
except Exception as e:
# 若解析详情页失败,记录错误日志
logger.error(f"Detail page parsing failed: {str(e)}")
return detail_data
@staticmethod
def clean_year(value: str) -> str:
"""清洗建筑年代"""
# 去除“年建成”字样并去除前后空格
cleaned = value.replace('年建成', '').strip()
if cleaned.isdigit() and 1900 < int(cleaned) < 2100:
# 若清洗后的值是有效的年份,返回该年份
return cleaned
return ''
- parse 函数
目的: 从详情页的 HTML 内容中解析出小区的详细信息,如楼栋总数、房屋总数等。
用法:- 初始化一个包含详细信息的字典。
- 找到包含详细信息的 HTML 元素列表。
- 使用field_map映射每个信息字段的处理函数。
- 遍历元素列表,提取信息并进行处理,存储到字典中。
- clean_year 函数
目的: 清洗建筑年代的信息,只保留符合格式的年份。
用法:- 去除 “年建成” 字样并去除前后空格。
- 检查处理后的字符串是否为有效的年份,如果是则返回,否则返回空字符串。
(八)解析辅助工具类
class ParserHelper:
"""解析辅助工具类"""
@staticmethod
def safe_int(value: str) -> int:
"""安全转换为整数"""
try:
# 去除前后空格并转换为整数,若为空则返回0
return int(value.strip()) if value.strip() else 0
except ValueError:
# 若转换失败,返回0
return 0
@staticmethod
def get_text(parent, selector: str) -> str:
"""安全获取文本"""
if not parent:
# 若父元素为空,返回空字符串
return ''
# 查找指定选择器的元素
target = parent.select_one(selector)
# 若元素存在,返回其文本内容,否则返回空字符串
return target.get_text(strip=True) if target else ''
@staticmethod
def parse_price(price_block):
"""解析价格"""
if not price_block:
# 若价格块为空,返回0
return 0
# 查找包含价格的元素
price_span = price_block.select_one('p.priceAverage > span:first-child')
if price_span:
try:
# 提取价格并转换为整数
return int(price_span.text.replace(' ', '').replace(',', ''))
except ValueError:
pass
return 0
@staticmethod
def parse_district(info_block) -> str:
"""解析区域信息"""
# 查找区域信息链接
links = info_block.select('p > a[href^="javascript"]') if info_block else []
# 拼接区域信息
return '-'.join(link.text.strip() for link in links if link.text.strip())
@staticmethod
def parse_address(info_block) -> str:
"""解析详细地址"""
if not info_block:
# 若信息块为空,返回空字符串
return ''
# 查找详细地址所在的标签
p_tag = info_block.select_one('dd > p:nth-of-type(2)')
# 若标签存在且有内容,返回最后一个子元素的文本内容,否则返回空字符串
return p_tag.contents[-1].strip() if p_tag and p_tag.contents else ''
@staticmethod
def parse_quantity(info_block, link_type: str) -> int:
"""解析在售/在租数量"""
# 查找包含在售/在租数量的链接
link = info_block.select_one(f'a[href*="{link_type}"]') if info_block else None
# 若链接存在且文本内容是数字,返回该数字,否则返回0
return int(link.text.strip()) if link and link.text.strip().isdigit() else 0
@staticmethod
def parse_trend(price_block) -> str:
"""解析价格趋势"""
if not price_block:
# 若价格块为空,返回'--'
return '--'
# 查找包含价格趋势的元素
trend_span = price_block.select_one('span.number')
if trend_span:
# 根据元素的类名判断价格趋势是上升还是下降
arrow = '↑' if 'red' in trend_span.get('class', []) else '↓'
# 提取价格趋势的数值
value = ''.join(filter(lambda x: x.isdigit() or x in ('.','%'), trend_span.text))
# 若有数值,返回带箭头的趋势,否则返回'--'
return f"{arrow}{value}" if value else '--'
return '--'
@staticmethod
def parse_newcode(item) -> str:
"""解析楼盘ID"""
# 获取包含楼盘ID的JSON数据
data_json = item.get('data-bgcomare', '{}').replace("'", '"')
# 解析JSON数据并返回楼盘ID,若不存在则返回空字符串
return json.loads(data_json).get('newcode', '')
@staticmethod
def build_url(path: str) -> str:
"""构建完整URL"""
# 若路径以'/'开头,拼接域名,否则返回原路径
return f'https://fs.esf.fang.com{path}' if path.startswith('/') else path
@staticmethod
def build_image_url(src: str) -> str:
"""构建完整图片URL"""
# 若图片链接以'http'开头,返回原链接,否则拼接'https:'
return src if src.startswith('http') else f'https:{src}
- safe_int 函数
目的: 安全地将字符串转换为整数,如果字符串为空或无法转换为整数,则返回 0。
用法: 去除字符串前后空格,尝试转换为整数,若失败则返回 0。 - get_text 函数
目的: 从指定的 HTML 元素中获取文本内容。
用法: 如果父元素存在,使用 CSS 选择器找到目标元素并返回其文本内容,若目标元素不存在则返回空字符串。 - parse_price 函数
目的: 从价格相关的 HTML 元素中解析出价格信息。
用法: 找到包含价格的元素,去除空格和逗号后尝试转换为整数,若失败则返回 0。 - parse_district 函数
目的: 从小区信息块中解析出区域信息。
用法: 找到包含区域信息的链接元素,提取文本并使用 - 连接。 - parse_address 函数
目的: 从小区信息块中解析出详细地址。
用法: 若小区信息块存在,使用 CSS 选择器定位到包含地址的元素,提取该元素最后一个子元素的文本内容;若信息块不存在或未找到相应元素,则返回空字符串。
parse_quantity 函数
目的: 从小区信息块中解析出在售或在租的数量。
用法: 根据传入的链接类型,在小区信息块中查找对应的链接元素。若链接元素存在且其文本内容为有效的数字,则将该数字转换为整数并返回;否则返回 0。 - parse_trend 函数
目的: 从价格相关的 HTML 元素中解析出价格趋势信息。
用法: 若价格相关的 HTML 元素存在,找到包含价格趋势的span元素。根据该元素的类名判断价格是上涨(类名包含red)还是下跌,分别用↑和↓表示。提取元素文本中的数字和小数点、百分号部分,与涨跌箭头拼接成价格趋势字符串返回;若未找到相应元素或提取不到有效数字,则返回–。 - parse_newcode 函数
目的: 从 HTML 元素的属性中解析出楼盘 ID。
用法: 获取元素的data-bgcomare属性值,将单引号替换为双引号后转换为 JSON 格式,尝试从中提取newcode字段的值并返回;若属性不存在或解析失败,则返回空字符串。 - build_url 函数
目的: 构建完整的 URL。
用法: 若传入的路径以/开头,则在其前面添加基础 URL https://fs.esf.fang.com;否则直接返回该路径。 - build_image_url 函数
目的: 构建完整的图片 URL。
用法: 若图片链接以http开头,则直接返回该链接;否则在其前面添加https:前缀。
(九)保存数据到 Excel
def save_to_excel(data: List[Dict], filename: str = '房天下小区数据.xlsx') -> None:
"""保存数据到Excel"""
columns = [
'楼盘ID', '小区名称', '参考均价', '区域信息', '详细地址',
'在售数量', '在租数量', '建筑年代', '楼栋总数',
'房屋总数', '建筑类型', '物业公司', '开发商', '二手房源',
'最近成交', '价格趋势', '物业类型', '详情链接', '图片链接'
]
# 创建DataFrame并指定列顺序
df = pd.DataFrame(data)[columns]
# 将DataFrame保存为Excel文件
df.to_excel(filename, index=False, engine='openpyxl')
# 记录信息日志
logger.info(f'数据已保存至 {filename}')
目的: 将爬取到的数据保存为 Excel 文件。
用法:
- columns: 定义 Excel 文件中的列名。
- pd.DataFrame(data)[columns]:将数据转换为pandas的DataFrame格式,并只保留指定的列。
- df.to_excel(filename, index=False, engine=‘openpyxl’):将DataFrame保存为 Excel 文件,index=False表示不保存行索引。
(十)主程序
def main():
"""主程序"""
# 创建请求会话对象
session = FangSession()
all_data = []
try:
# 获取第一页确定分页信息
logger.info("正在获取分页信息...")
# 发送请求获取第一页内容
########################## 注意修改url#############################################
url = 'https://fs.esf.fang.com/housing/617__0_3_0_0_1_0_0_0/'
########################## 注意修改url#############################################
first_page = session.get(url)
# 解析总页数和每页数量
total_pages, per_page = ListPageParser.parse_total_pages(first_page.text)
logger.info(f"总页数: {total_pages}, 每页数量: {per_page}")
# 为了测试,只处理前2页
total_pages = 2
# 采集列表页数据
for page in range(1, total_pages + 1):
logger.info(f"正在处理列表页 {page}/{total_pages}...")
try:
# 构建当前页的URL
########################## 注意修改url#############################################
url = f'https://fs.esf.fang.com/housing/617__0_3_0_0_{page}_0_0_0/'
########################## 注意修改url#############################################
# 发送请求获取当前页内容
response = session.get(url)
# 解析当前页的小区信息
page_data = ListPageParser.parse_list_items(response.text)
# 将当前页的小区信息添加到总数据中
all_data.extend(page_data)
except Exception as e:
# 若处理列表页失败,记录错误日志
logger.error(f"列表页 {page} 处理失败: {str(e)}")
continue
# 采集详情页数据
logger.info("开始采集详情页数据...")
for idx, item in enumerate(all_data, 1):
logger.info(f"正在处理详情页 ({idx}/{len(all_data)}):{item.get('小区名称', '')}")
try:
# 发送请求获取详情页内容
response = session.get(item['详情链接'])
# 解析详情页信息
detail_info = DetailPageParser.parse(response.text)
# 将详情页信息更新到小区信息中
item.update(detail_info)
except Exception as e:
# 若处理详情页失败,记录错误日志
logger.error(f"详情页处理失败:{item.get('小区名称', '')} - {str(e)}")
# 保存最终数据
save_to_excel(all_data)
logger.info("数据采集完成!")
except Exception as e:
# 若程序运行异常,记录错误日志
logger.error(f"程序运行异常: {str(e)}")
finally:
# 关闭请求会话
session.session.close()
if __name__ == "__main__":
main()
目的: 协调各个模块的功能,完成数据的爬取、解析和保存。
用法:
- 初始化FangSession对象。
- 获取第一页的 HTML 内容,解析出总页数和每页数量。
- 遍历所有列表页,采集小区信息。
- 遍历采集到的小区信息,访问详情页并解析详细信息。
- 将最终数据保存为 Excel 文件。
- 关闭会话。
完整代码
运行结果
注意事项(运行不了看这里!!!)
需要修改的地方有三处:
- 配置参数里的cookies替换成自己的!直接复制F12里的那个即可
- 主函数main里面需要改两处url,看下图: