在当今的数据分析和房地产研究领域,获取大量准确的房产数据具有重要意义。前几篇成功爬取了安居客、贝壳网二手小区的数据,这次来爬一下房天下的试试!(该教程大部分由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,
}
目的: 这些配置参数用于模拟浏览器行为,避免被网站识别为爬虫并封禁,同时设置请求的超时时间、重试次数和请求间隔时间等。
用法:
# 日志配置
logging.basicConfig(
# 日志级别为INFO,只记录INFO及以上级别的日志
level=logging.INFO,
# 日志的格式,包含时间、日志级别和日志信息
format='%(asctime)s - %(levelname)s - %(message)s',
# 日志输出到控制台
handlers=[logging.StreamHandler()]
)
# 获取一个名为__name__的日志记录器
logger = logging.getLogger(__name__)
目的: 使用logging模块记录程序运行过程中的信息和错误,方便我们调试和监控程序的运行状态。
用法:
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
目的: 在请求失败时进行重试,避免因网络波动等原因导致程序中断。
用法:
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 和请求重试等操作。
用法:
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
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 ''
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}
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 文件。
用法:
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()
目的: 协调各个模块的功能,完成数据的爬取、解析和保存。
用法:
房天下二手小区爬取完整代码
需要修改的地方有三处: