㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [📋 摘要(Abstract)](#📋 摘要(Abstract))
- [🎯 背景与需求(Why)](#🎯 背景与需求(Why))
- [⚖️ 合规与注意事项(必读)](#⚖️ 合规与注意事项(必读))
-
- [1. robots.txt 基本说明](#1. robots.txt 基本说明)
- [2. 频率控制策略](#2. 频率控制策略)
- [3. 数据使用边界](#3. 数据使用边界)
- [🛠️ 技术选型与整体流程(What/How)](#🛠️ 技术选型与整体流程(What/How))
- [🔧 环境准备与依赖安装(可复现)](#🔧 环境准备与依赖安装(可复现))
- [🌐 核心实现:请求层(Fetcher)](#🌐 核心实现:请求层(Fetcher))
- [🔍 核心实现:日期解析模块(Date Parser)](#🔍 核心实现:日期解析模块(Date Parser))
- [🔍 核心实现:解析层(Parser)](#🔍 核心实现:解析层(Parser))
- [🌍 核心实现:地理信息标准化(Geo Normalizer)](#🌍 核心实现:地理信息标准化(Geo Normalizer))
- [💾 核心实现:存储层(Storage)](#💾 核心实现:存储层(Storage))
- [🚀 主流程编排(Main)](#🚀 主流程编排(Main))
- [📊 运行结果展示](#📊 运行结果展示)
-
- [CSV 输出示例](#CSV 输出示例)
- 数据统计输出
- [🎓 总结](#🎓 总结)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
💕订阅后更新会优先推送,按目录学习更高效💯~
📋 摘要(Abstract)
本文将带你构建一个全国展会信息聚合爬虫 ,通过 Python + Selenium + requests 混合技术栈,批量采集各大会展平台的展会名称、举办时间、展馆地点、主办方、行业分类等结构化数据,最终产出可用于市场分析、商机挖掘、行业研究的完整展会数据库。
读完本文你将收获:
- 掌握多页面分页遍历、日期解析、地理位置标准化的实战技巧
- 学会应对动态加载、图片验证码、Cookie 反爬的工程化解决方案
- 获得一套覆盖全国主要城市、可持续更新的展会日历数据集
🎯 背景与需求(Why)
为什么要爬取展会日历数据?
在B2B商业领域,展会是企业获取商机、拓展市场的重要渠道。然而,展会信息高度分散:
- 信息分散:不同城市的展会信息分布在各自的会展官网、行业协会网站
- 格式混乱:时间格式不统一("2026年3月15-17日" vs "3/15/2026-3/17/2026")
- 更新频繁:每月都有新展会发布、老展会取消或延期
- 缺乏聚合:企业需要逐个网站查询,效率极低
商业价值:
- 参展决策:企业可根据行业、时间、地点筛选目标展会
- 市场洞察:分析各行业展会数量、规模趋势(如新能源展会同比增长300%)
- 竞品监控:追踪竞争对手的参展动态
- 供应链协同:上下游企业可基于展会数据规划采购/销售计划
目标站点与字段清单
目标站点:
- 中国国际展览中心官网(示例)
- 各省市会展办公室网站
- 垂直行业展会平台(如慧聪网、中国会展门户)
技术特点:
- 分页方式多样:点击翻页、滚动加载、URL参数分页三种混合
- 日期格式复杂:需解析"2026年3月15-17日"、"3月中旬"、"2026.03.15-03.17"等多种格式
- 地理信息非结构化:"上海新国际博览中心N1-N5馆" 需提取城市、展馆名
核心采集字段:
| 字段名 | 类型 | 说明 | 示例值 |
|---|---|---|---|
exhibition_id |
str | 展会唯一标识 | "EXH202603001" |
exhibition_name |
str | 展会全称 | "第25届中国国际医疗器械展览会" |
short_name |
str | 展会简称 | "CMEF 2026" |
industry |
str | 所属行业 | "医疗健康" |
start_date |
date | 开始日期 | "2026-03-15" |
end_date |
date | 结束日期 | "2026-03-17" |
duration_days |
int | 展期天数 | 3 |
city |
str | 举办城市 | "上海" |
venue |
str | 展馆名称 | "上海新国际博览中心" |
venue_halls |
str | 展馆范围 | "N1-N5馆" |
organizer |
str | 主办方 | "中国医疗器械行业协会" |
co_organizer |
list | 联合主办方 | ["国药励展", "上海市商务委"] |
scale |
str | 展会规模 | "20万平米" |
exhibitor_count |
int | 参展商数量 | 4500 |
visitor_estimate |
int | 预计观众 | 180000 |
status |
str | 展会状态 | "招展中/已结束/延期" |
official_website |
str | 官网链接 | "https://www.cmef.com.cn" |
contact_phone |
str | 联系电话 | "021-12345678" |
crawl_time |
datetime | 采集时间 | "2026-01-30 15:30:00" |
⚖️ 合规与注意事项(必读)
1. robots.txt 基本说明
会展网站通常对爬虫持开放态度(毕竟希望展会信息被更多人看到),但也有基本规则:
json
# 典型 robots.txt
User-agent: *
Disallow: /admin/ # 禁止后台
Disallow: /api/internal/ # 禁止内部API
Allow: /exhibition/list/ # 允许展会列表
Crawl-delay: 1 # 建议间隔 1 秒
我们的策略:
- ✅ 只爬取公开展会列表和详情页
- ✅ 遵守 1-2 秒请求间隔
- ❌ 不爬取需要付费或登录才能看到的VIP展商名录
- ❌ 不爬取用户注册信息、交易数据
2. 频率控制策略
技术层面:
- 请求间隔:1-2 秒随机延时(模拟人工浏览)
- 单站点限制:每小时不超过 1000 次请求
- 时段分配:避开高峰期(工作日 9-18 点),选择夜间爬取
道德层面:
- 展会信息是公开宣传数据,不涉及商业机密
- 采集结果用于行业研究/个人学习,不用于恶意竞争
- 如发现网站明确禁止爬虫,立即停止并尊重对方意愿
3. 数据使用边界
| 使用场景 | 是否允许 | 说明 |
|---|---|---|
| 个人参展决策 | ✅ | 完全合规 |
| 企业内部市场分析 | ✅ | 正常使用 |
| 开发展会聚合APP | ✅ | 需标注数据来源 |
| 行业研究报告 | ✅ | 引用需注明出处 |
| 转售数据给第三方 | ⚠️ | 需评估版权风险 |
| 恶意干扰竞品展会 | ❌ | 违法违规 |
🛠️ 技术选型与整体流程(What/How)
采集方式分析
会展网站存在三种典型分页模式:
| 分页类型 | 识别特征 | 技术方案 | 难度 |
|---|---|---|---|
| URL参数分页 | ?page=1, ?page=2 |
requests 直接请求 | ⭐ |
| POST分页 | 点击翻页触发 AJAX | 抓包分析接口 | ⭐⭐ |
| 滚动加载 | 下拉触发加载 | Selenium 模拟滚动 | ⭐⭐⭐ |
| 点击翻页 | JavaScript 控制 | Selenium 点击元素 | ⭐⭐⭐ |
本项目采用混合策略:
- 优先尝试 URL 参数分页(最快)
- 无效则尝试 POST 接口(次之)
- 最后用 Selenium(兜底方案)
整体流程图
json
┌────────────────────────────────────────────────┐
│ 1. 初始化(配置加载 + 驱动准备) │
└─────────────────┬──────────────────────────────┘
│
┌─────────────────▼──────────────────────────────┐
│ 2. 城市列表遍历 │
│ - 读取目标城市清单 │
│ - 构建各城市展会列表URL │
└─────────────────┬──────────────────────────────┘
│
┌─────────────────▼──────────────────────────────┐
│ 3. 分页策略判断 │
│ - 检测分页类型(URL/POST/滚动/点击) │
│ - 选择对应爬取方法 │
└─────────────────┬──────────────────────────────┘
│
┌─────────────────▼──────────────────────────────┐
│ 4. 展会列表页采集 │
│ - 提取展会基本信息 │
│ - 提取详情页链接 │
│ - 判断是否有下一页 │
└─────────────────┬──────────────────────────────┘
│
┌─────────────────▼──────────────────────────────┐
│ 5. 展会详情页采集 │
│ - 提取完整信息(主办方、规模等) │
│ - 解析复杂日期格式 │
│ - 提取联系方式 │
└─────────────────┬──────────────────────────────┘
│
┌─────────────────▼──────────────────────────────┐
│ 6. 数据清洗与标准化 │
│ - 日期格式统一 │
│ - 城市名称标准化("上海市" → "上海") │
│ - 展馆名称去重("国家会展中心" vs "虹桥国展")│
└─────────────────┬──────────────────────────────┘
│
┌─────────────────▼──────────────────────────────┐
│ 7. 数据去重与存储 │
│ - 基于展会名+日期去重 │
│ - 存入 SQLite + 导出 CSV/JSON/Excel │
└────────────────────────────────────────────────┘
为什么选择混合技术栈?
python
# 技术选型决策树
if url_pattern_exists:
use_requests() # 最快,CPU占用低
elif ajax_api_found:
use_requests_post() # 次快,需抓包分析
else:
use_selenium() # 兜底方案,资源占用高
优势:
- 性能优化:80% 的网站用 requests 解决,速度提升 10 倍
- 兼容性强:Selenium 覆盖剩余 20% 的复杂场景
- 资源节约:只在必要时启动浏览器,降低内存占用
🔧 环境准备与依赖安装(可复现)
Python 版本要求
- 推荐版本:Python 3.9+(支持新语法特性)
- 最低版本:Python 3.7
安装依赖
bash
pip install requests==2.31.0 \
beautifulsoup4==4.12.3 \
lxml==5.1.0 \
selenium==4.15.0 \
pandas==2.1.3 \
python-dateutil==2.8.2 \
fake-useragent==1.4.0 \
webdriver-manager==4.0.1 \
openpyxl==3.1.2 \
geopy==2.4.1 \
--break-system-packages
依赖说明:
| 包名 | 用途 | 核心功能 |
|---|---|---|
requests |
HTTP请求 | 静态页面采集 |
beautifulsoup4 |
HTML解析 | 提取结构化数据 |
selenium |
浏览器自动化 | 动态页面采集 |
pandas |
数据处理 | 清洗、去重、导出 |
python-dateutil |
日期解析 | 智能解析各种日期格式 |
fake-useragent |
UA生成 | 避免被识别为爬虫 |
geopy |
地理编码 | 城市名称标准化 |
项目目录结构
json
exhibition_crawler/
├── config.py # 配置文件(URL模板、选择器字典)
├── fetcher.py # 请求层(requests + Selenium 混合)
├── parser.py # 解析层(列表页 + 详情页)
├── date_parser.py # 日期解析模块(重点)
├── geo_normalizer.py # 地理信息标准化
├── storage.py # 存储层(SQLite + 多格式导出)
├── utils.py # 工具函数(日志、延时、去重)
├── main.py # 主入口
├── requirements.txt # 依赖清单
├── city_list.json # 目标城市清单
├── output/ # 输出目录
│ ├── exhibitions.csv
│ ├── exhibitions.json
│ ├── exhibitions.db
│ └── exhibitions.xlsx
├── logs/ # 日志目录
│ ├── crawler.log
│ └── error.log
└── cache/ # 缓存目录(已爬页面)
└── page_*.html
🌐 核心实现:请求层(Fetcher)
请求层负责与目标网站交互,需要智能判断使用 requests 还是 Selenium。
完整代码实现
python
# fetcher.py
import requests
import time
import logging
import hashlib
from pathlib import Path
from typing import Optional, Dict, Any
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
from fake_useragent import UserAgent
class ExhibitionFetcher:
"""展会数据请求类(requests + Selenium 混合)"""
def __init__(self,
use_cache: bool = True,
cache_dir: str = "cache",
request_delay: tuple = (1, 2)):
"""
初始化请求器
Args:
use_cache: 是否启用缓存(避免重复请求)
cache_dir: 缓存目录
request_delay: 请求延时范围(秒)
"""
self.logger = logging.getLogger(__name__)
self.use_cache = use_cache
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
self.request_delay = request_delay
# 初始化 requests session
self.session = requests.Session()
self.ua = UserAgent()
self._setup_session()
# Selenium 驱动(延迟初始化)
self.driver: Optional[webdriver.Chrome] = None
self.driver_initialized = False
def _setup_session(self):
"""配置 requests session"""
self.session.headers.update({
'User-Agent': self.ua.chrome,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;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',
})
# 配置重试策略
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
def fetch_url(self, url: str, method: str = 'GET', **kwargs) -> Optional[str]:
"""
使用 requests 获取页面(优先方案)
Args:
url: 目标URL
method: 请求方法(GET/POST)
**kwargs: 额外参数(data, params, headers等)
Returns:
页面HTML,失败返回 None
"""
try:
# 检查缓存
if self.use_cache and method == 'GET':
cached = self._get_cache(url)
if cached:
self.logger.info(f"使用缓存: {url}")
return cached
# 随机延时
time.sleep(self._random_delay())
# 发起请求
if method.upper() == 'GET':
response = self.session.get(url, timeout=15, **kwargs)
else:
response = self.session.post(url, timeout=15, **kwargs)
response.raise_for_status()
# 检测编码
if response.encoding == 'ISO-8859-1':
response.encoding = response.apparent_encoding
html = response.text
# 保存缓存
if self.use_cache and method == 'GET':
self._save_cache(url, html)
self.logger.info(f"请求成功: {url}")
return html
except requests.exceptions.RequestException as e:
self.logger.error(f"请求失败 [{url}]: {e}")
return None
def fetch_with_selenium(self, url: str, wait_selector: Optional[str] = None) -> Optional[str]:
"""
使用 Selenium 获取页面(兜底方案)
Args:
url: 目标URL
wait_selector: 等待加载的CSS选择器
Returns:
页面HTML
"""
try:
# 延迟初始化驱动
if not self.driver_initialized:
self._init_selenium()
self.logger.info(f"使用 Selenium 访问: {url}")
self.driver.get(url)
# 等待元素加载
if wait_selector:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, wait_selector))
)
else:
time.sleep(3) # 默认等待 3 秒
html = self.driver.page_source
# 随机延时
time.sleep(self._random_delay())
return html
except Exception as e:
self.logger.error(f"Selenium 请求失败: {e}")
return None
def _init_selenium(self):
"""初始化 Selenium 驱动"""
try:
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument(f'user-agent={self.ua.chrome}')
# 反检测配置
chrome_options.add_argument('--disable-blink-features=AutomationControlled')
chrome_options.add_experimental_option('excludeSwitches', ['enable-automation'])
chrome_options.add_experimental_option('useAutomationExtension', False)
service = Service(ChromeDriverManager().install())
self.driver = webdriver.Chrome(service=service, options=chrome_options)
# 隐藏 webdriver 标志
self.driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
'source': '''
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
'''
})
self.driver_initialized = True
self.logger.info("Selenium 驱动初始化成功")
except Exception as e:
self.logger.error(f"Selenium 初始化失败: {e}")
raise
def detect_pagination_type(self, html: str) -> str:
"""
检测分页类型
Args:
html: 页面HTML
Returns:
'url' / 'post' / 'scroll' / 'click' / 'none'
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
# 检测 URL 参数分页
next_links = soup.select('a[href*="page="], a[href*="p="], a.next-page')
if next_links:
self.logger.info("检测到 URL 参数分页")
return 'url'
# 检测 POST 分页(按钮带 onclick)
next_buttons = soup.select('button[onclick*="page"], a[onclick*="loadMore"]')
if next_buttons:
self.logger.info("检测到 POST/AJAX 分页")
return 'post'
# 检测滚动加载
if 'data-scroll-load' in html or 'infinite-scroll' in html:
self.logger.info("检测到滚动加载")
return 'scroll'
# 检测点击翻页
if soup.select('.pagination, .page-num, .next'):
self.logger.info("检测到点击翻页")
return 'click'
self.logger.info("未检测到分页")
return 'none'
def _get_cache(self, url: str) -> Optional[str]:
"""获取缓存"""
cache_key = hashlib.md5(url.encode()).hexdigest()
cache_file = self.cache_dir / f"page_{cache_key}.html"
if cache_file.exists():
try:
return cache_file.read_text(encoding='utf-8')
except:
return None
return None
def _save_cache(self, url: str, html: str):
"""保存缓存"""
cache_key = hashlib.md5(url.encode()).hexdigest()
cache_file = self.cache_dir / f"page_{cache_key}.html"
try:
cache_file.write_text(html, encoding='utf-8')
except Exception as e:
self.logger.warning(f"缓存保存失败: {e}")
def _random_delay(self) -> float:
"""生成随机延时"""
import random
return random.uniform(self.request_delay[0], self.request_delay[1])
def close(self):
"""关闭资源"""
self.session.close()
if self.driver:
self.driver.quit()
self.logger.info("Selenium 驱动已关闭")
代码关键点解析
1. 智能缓存机制
python
# 使用 MD5 生成缓存文件名(避免URL中特殊字符问题)
cache_key = hashlib.md5(url.encode()).hexdigest()
cache_file = self.cache_dir / f"page_{cache_key}.html"
优势:
- 避免重复请求(同一URL只爬一次)
- 加速调试(修改解析逻辑时无需重新爬取)
- 节省带宽(大型网站友好)
注意事项:
- 缓存会占用磁盘空间,定期清理旧缓存
- 对于实时性要求高的数据(如展会状态),可设置缓存过期时间
2. 编码自动检测
python
if response.encoding == 'ISO-8859-1':
response.encoding = response.apparent_encoding
为什么需要?
- 某些老旧网站声明编码为
ISO-8859-1(拉丁文),但实际是GBK(中文) apparent_encoding会根据内容智能猜测编码
常见编码问题:
python
# 问题:乱码显示 "ä¸å½å±ä¼"
response.text # 默认用错误编码解析
# 解决:手动指定编码
response.encoding = 'gbk'
html = response.text
3. 分页类型自动检测
python
def detect_pagination_type(self, html: str) -> str:
# 检测 URL 参数分页
next_links = soup.select('a[href*="page="], a[href*="p="]')
if next_links:
return 'url'
识别逻辑:
| 分页特征 | CSS选择器 | 返回类型 |
|---|---|---|
href="?page=2" |
a[href*="page="] |
url |
onclick="loadPage(2)" |
button[onclick*="page"] |
post |
data-scroll-load="true" |
[data-scroll-load] |
scroll |
<div class="pagination"> |
.pagination |
click |
🔍 核心实现:日期解析模块(Date Parser)
日期解析是本项目的技术难点,需要处理至少 10 种不同格式。
完整代码实现
python
# date_parser.py
import re
import logging
from datetime import datetime, timedelta
from dateutil.parser import parse as dateutil_parse
from typing import Optional, Tuple
class ExhibitionDateParser:
"""展会日期智能解析类"""
def __init__(self):
self.logger = logging.getLogger(__name__)
# 月份映射(中文 → 数字)
self.month_map = {
'一月': 1, '二月': 2, '三月': 3, '四月': 4,
'五月': 5, '六月': 6, '七月': 7, '八月': 8,
'九月': 9, '十月': 10, '十一月': 11, '十二月': 12,
'1月': 1, '2月': 2, '3月': 3, '4月': 4,
'5月': 5, '6月': 6, '7月': 7, '8月': 8,
'9月': 9, '10月': 10, '11月': 11, '12月': 12
}
def parse_date_range(self, date_str: str) -> Optional[Tuple[datetime, datetime]]:
"""
解析日期范围
Args:
date_str: 日期字符串
Returns:
(start_date, end_date) 或 None
"""
date_str = date_str.strip()
# 尝试多种解析方法
parsers = [
self._parse_standard_format, # 2026-03-15 至 2026-03-17
self._parse_chinese_format, # 2026年3月15-17日
self._parse_slash_format, # 3/15/2026-3/17/2026
self._parse_dot_format, # 2026.03.15-03.17
self._parse_month_range, # 3月中旬
self._parse_single_date, # 2026年3月15日(单日展会)
]
for parser in parsers:
try:
result = parser(date_str)
if result:
start_date, end_date = result
self.logger.info(f"解析成功: {date_str} → {start_date} 至 {end_date}")
return (start_date, end_date)
except Exception as e:
continue
self.logger.warning(f"日期解析失败: {date_str}")
return None
def _parse_standard_format(self, date_str: str) -> Optional[Tuple[datetime, datetime]]:
"""
解析标准格式:2026-03-15 至 2026-03-17
"""
pattern = r'(\d{4}-\d{1,2}-\d{1,2})\s*[至到~-]\s*(\d{4}-\d{1,2}-\d{1,2})'
match = re.search(pattern, date_str)
if match:
start_str, end_str = match.groups()
start_date = datetime.strptime(start_str, '%Y-%m-%d')
end_date = datetime.strptime(end_str, '%Y-%m-%d')
return (start_date, end_date)
return None
def _parse_chinese_format(self, date_str: str) -> Optional[Tuple[datetime, datetime]]:
"""
解析中文格式:2026年3月15-17日
"""
# 匹配:2026年3月15-17日
pattern = r'(\d{4})年(\d{1,2})月(\d{1,2})[日号]?[---至到](\d{1,2})[日号]?'pattern, date_str)
if match:
year, month, start_day, end_day = match.groups()
start_date = datetime(int(year), int(month), int(start_day))
end_date = datetime(int(year), int(month), int(end_day))
return (start_date, end_date)
# 匹配:2026年3月15日-17日
pattern2 = r'(\d{4})年(\d{1,2})月(\d{1,2})[日号][---至到](\d{1,2})[日号]'
match2 = re.search(pattern2, date_str)
if match2:
year, month, start_day, end_day = match2.groups()
start_date = datetime(int(year), int(month), int(start_day))
end_date = datetime(int(year), int(month), int(end_day))
return (start_date, end_date)
# 匹配:2026年3月15日至4月2日(跨月)
pattern3 = r'(\d{4})年(\d{1,2})月(\d{1,2})[日号]?[---至到](\d{1,2})月(\d{1,2})[日号]?'
match3 = re.search(pattern3, date_str)
if match3:
year, start_month, start_day, end_month, end_day = match3.groups()
start_date = datetime(int(year), int(start_month), int(start_day))
end_date = datetime(int(year), int(end_month), int(end_day))
return (start_date, end_date)
return None
def _parse_slash_format(self, date_str: str) -> Optional[Tuple[datetime, datetime]]:
"""
解析斜杠格式:3/15/2026-3/17/2026
"""
pattern = r'(\d{1,2}/\d{1,2}/\d{4})\s*[---至到]\s*(\d{1,2}/\d{1,2}/\d{4})'
match = re.search(pattern, date_str)
if match:
start_str, end_str = match.groups()
start_date = datetime.strptime(start_str, '%m/%d/%Y')
end_date = datetime.strptime(end_str, '%m/%d/%Y')
return (start_date, end_date)
return None
def _parse_dot_format(self, date_str: str) -> Optional[Tuple[datetime, datetime]]:
"""
解析点号格式:2026.03.15-03.17
"""
# 完整日期
pattern = r'(\d{4})\.(\d{1,2})\.(\d{1,2})\s*[---至到]\s*(\d{4})\.(\d{1,2})\.(\d{1,2})'
match = re.search(pattern, date_str)
if match:
y1, m1, d1, y2, m2, d2 = match.groups()
start_date = datetime(int(y1), int(m1), int(d1))
end_date = datetime(int(y2), int(m2), int(d2))
return (start_date, end_date)
# 简写格式:2026.03.15-17
pattern2 = r'(\d{4})\.(\d{1,2})\.(\d{1,2})\s*[---至到]\s*(\d{1,2})'
match2 = re.search(pattern2, date_str)
if match2:
year, month, start_day, end_day = match2.groups()
start_date = datetime(int(year), int(month), int(start_day))
end_date = datetime(int(year), int(month), int(end_day))
return (start_date, end_date)
return None
def _parse_month_range(self, date_str: str) -> Optional[Tuple[datetime, datetime]]:
"""
解析模糊日期:3月中旬、2026年第二季度
"""
# 匹配:3月中旬
pattern = r'(\d{4})?年?(\d{1,2})月(上旬|中旬|下旬)'
match = re.search(pattern, date_str)
if match:
year_str, month_str, period = match.groups()
year = int(year_str) if year_str else datetime.now().year
month = int(month_str)
# 上旬:1-10日,中旬:11-20日,下旬:21-30日
if period == '上旬':
start_day, end_day = 1, 10
elif period == '中旬':
start_day, end_day = 11, 20
else: # 下旬
start_day, end_day = 21, 28 # 保守估计
start_date = datetime(year, month, start_day)
end_date = datetime(year, month, end_day)
return (start_date, end_date)
# 匹配:2026年第二季度
pattern2 = r'(\d{4})年第([一二三四1234])季度'
match2 = re.search(pattern2, date_str)
if match2:
year_str, quarter_str = match2.groups()
year = int(year_str)
quarter_map = {'一': 1, '二': 2, '三': 3, '四': 4,
'1': 1, '2': 2, '3': 3, '4': 4}
quarter = quarter_map.get(quarter_str, 1)
# 计算季度起止日期
start_month = (quarter - 1) * 3 + 1
end_month = quarter * 3
start_date = datetime(year, start_month, 1)
# 获取季度最后一天
if end_month == 12:
end_date = datetime(year, 12, 31)
else:
end_date = datetime(year, end_month + 1, 1) - timedelta(days=1)
return (start_date, end_date)
return None
def _parse_single_date(self, date_str: str) -> Optional[Tuple[datetime, datetime]]:
"""
解析单日展会:2026年3月15日
"""
# 中文格式
pattern = r'(\d{4})年(\d{1,2})月(\d{1,2})[日号]'
match = re.search(pattern, date_str)
if match:
year, month, day = match.groups()
date = datetime(int(year), int(month), int(day))
return (date, date) # 开始和结束日期相同
# 使用 dateutil 尝试解析
try:
date = dateutil_parse(date_str, fuzzy=True)
return (date, date)
except:
pass
return None
def calculate_duration(self, start_date: datetime, end_date: datetime) -> int:
"""
计算展期天数
Returns:
天数(包含首尾两天)
"""
return (end_date - start_date).days + 1
代码关键点解析
1. 正则表达式设计
python
# 匹配:2026年3月15-17日
pattern = r'(\d{4})年(\d{1,2})月(\d{1,2})[日号]?[---至到](\d{1,2})[日号]?'
关键技巧:
\d{1,2}:匹配 1-2 位数字(兼容 "3月" 和 "03月")[日号]?:可选匹配(有些格式没有"日"字)[---至到]:兼容多种连接符(半角连字符、全角连字符、中文)
2. 容错解析策略
python
parsers = [
self._parse_standard_format,
self._parse_chinese_format,
# ...
]
for parser in parsers:
try:
result = parser(date_str)
if result:
return result
except Exception:
continue # 失败则尝试下一种方法
优势:
- 任何一种方法成功即返回,避免全部尝试
- 异常不会中断整个流程
- 可灵活添加新的解析方法
3. 模糊日期处理
python
# "3月中旬" → 3月11日至3月20日
if period == '中旬':
start_day, end_day = 11, 20
适用场景:
- 展会刚发布,具体日期未确定
- 网站只显示大致时间
注意事项:
- 数据库应记录是否为模糊日期(
is_fuzzy_date字段) - 后续需人工确认精确日期
🔍 核心实现:解析层(Parser)
解析层负责从HTML中提取展会信息,包括列表页和详情页两部分。
完整代码实现
python
# parser.py
from bs4 import BeautifulSoup
import re
import logging
from typing import List, Dict, Optional
from date_parser import ExhibitionDateParser
class ExhibitionParser:
"""展会数据解析类"""
def __init__(self):
self.logger = logging.getLogger(__name__)
self.date_parser = ExhibitionDateParser()
def parse_list_page(self, html: str, city: str) -> List[Dict]:
"""
解析列表页,提取展会基本信息
Args:
html: 页面HTML
city: 当前城市
Returns:
展会列表
"""
exhibitions = []
try:
soup = BeautifulSoup(html, 'lxml')
# 定位展会列表容器(需根据实际网站调整)
items = soup.select('.exhibition-item, .expo-list-item, .event-card')
if not items:
# 尝试备用选择器
items = soup.select('div[class*="exhibition"], li[class*="expo"]')
self.logger.info(f"找到 {len(items)} 个展会条目")
for item in items:
exhibition = self._parse_list_item(item, city)
if exhibition:
exhibitions.append(exhibition)
return exhibitions
except Exception as e:
self.logger.error(f"列表页解析失败: {e}", exc_info=True)
return []
def _parse_list_item(self, item, city: str) -> Optional[Dict]:
"""
解析单个展会条目
Args:
item: BeautifulSoup 元素
city: 城市名称
Returns:
展会数据字典
"""
try:
# 提取展会名称
title_elem = item.select_one('.title, .exhibition-name, h3, h4')
exhibition_name = title_elem.get_text(strip=True) if title_elem else None
if not exhibition_name:
return None
# 提取日期
date_elem = item.select_one('.date, .time, .exhibition-date, [class*="date"]')
date_str = date_elem.get_text(strip=True) if date_elem else ""
# 解析日期范围
date_range = self.date_parser.parse_date_range(date_str)
if date_range:
start_date, end_date = date_range
duration_days = self.date_parser.calculate_duration(start_date, end_date)
else:
start_date = end_date = None
duration_days = 0
# 提取地点
venue_elem = item.select_one('.venue, .location, .address, [class*="venue"]')
venue_str = venue_elem.get_text(strip=True) if venue_elem else ""
venue, venue_halls = self._extract_venue_info(venue_str)
# 提取详情页链接
detail_link_elem = item.select_one('a[href]')
detail_url = detail_link_elem['href'] if detail_link_elem else None
# 如果是相对路径,补全为绝对路径
if detail_url and not detail_url.startswith('http'):
# 需要从配置中获取 base_url
base_url = self._get_base_url(item)
detail_url = base_url.rstrip('/') + '/' + detail_url.lstrip('/')
# 提取行业(如果列表页有)
industry_elem = item.select_one('.industry, .category, .tag')
industry = industry_elem.get_text(strip=True) if industry_elem else "未分类"
# 构建数据
exhibition = {
'exhibition_name': exhibition_name,
'short_name': self._extract_short_name(exhibition_name),
'industry': industry,
'start_date': start_date,
'end_date': end_date,
'duration_days': duration_days,
'city': city,
'venue': venue,
'venue_halls': venue_halls,
'detail_url': detail_url,
'raw_date_str': date_str, # 保留原始日期字符串
}
self.logger.debug(f"解析展会: {exhibition_name}")
return exhibition
except Exception as e:
self.logger.error(f"展会条目解析失败: {e}")
return None
def parse_detail_page(self, html: str, base_data: Dict) -> Dict:
"""
解析详情页,补充完整信息
Args:
html: 详情页HTML
base_data: 列表页已获取的基本数据
Returns:
完整的展会数据
"""
try:
soup = BeautifulSoup(html, 'lxml')
# 提取主办方
organizer = self._extract_organizer(soup)
# 提取联合主办方
co_organizers = self._extract_co_organizers(soup)
# 提取展会规模
scale = self._extract_scale(soup)
# 提取参展商数量
exhibitor_count = self._extract_number(soup, ['参展商', '参展企业', '展商'])
# 提取预计观众
visitor_estimate = self._extract_number(soup, ['观众', '参观人数', '专业观众'])
# 提取展会状态
status = self._extract_status(soup)
# 提取官网链接
official_website = self._extract_official_website(soup)
# 提取联系电话
contact_phone = self._extract_contact_phone(soup)
# 补充基础数据
complete_data = base_data.copy()
complete_data.update({
'organizer': organizer,
'co_organizer': co_organizers,
'scale': scale,
'exhibitor_count': exhibitor_count,
'visitor_estimate': visitor_estimate,
'status': status,
'official_website': official_website,
'contact_phone': contact_phone,
})
return complete_data
except Exception as e:
self.logger.error(f"详情页解析失败: {e}")
return base_data # 失败则返回基础数据
def _extract_venue_info(self, venue_str: str) -> tuple:
"""
提取展馆信息
Args:
venue_str: 原始地点字符串
Returns:
(展馆名称, 展馆范围)
"""
# 提取展馆名称
venue_pattern = r'(.+?(?:会展中心|国际博览中心|展览馆|展览中心|博览中心))'
venue_match = re.search(venue_pattern, venue_str)
venue = venue_match.group(1) if venue_match else venue_str
# 提取展馆范围(如 "N1-N5馆")
halls_pattern = r'([A-Z]?\d+[---至到][A-Z]?\d+馆|[A-Z]?\d+馆)'
halls_match = re.search(halls_pattern, venue_str)
venue_halls = halls_match.group(0) if halls_match else ""
return venue.strip(), venue_halls.strip()
def _extract_short_name(self, full_name: str) -> str:
"""
提取展会简称(通常是英文缩写)
Args:
full_name: 完整展会名称
Returns:
简称
"""
# 匹配括号内的英文缩写
pattern = r'\(([A-Z]{2,10})\)|(([A-Z]{2,10}))'
match = re.search(pattern, full_name)
if match:
return match.group(1) or match.group(2)
# 如果没有缩写,返回前15个字符
return full_name[:15] + ('...' if len(full_name) > 15 else '')
def _extract_organizer(self, soup: BeautifulSoup) -> str:
"""提取主办方"""
keywords = ['主办', '主办单位', '主办方', 'organizer']
for keyword in keywords:
# 查找包含关键词的元素
elem = soup.find(text=re.compile(keyword))
if elem:
# 获取其父元素或下一个兄弟元素
parent = elem.parent
value_elem = parent.find_next('td') or parent.find_next('div') or parent.find_next('span')
if value_elem:
return value_elem.get_text(strip=True)
return "未知主办方"
def _extract_co_organizers(self, soup: BeautifulSoup) -> List[str]:
"""提取联合主办方"""
keywords = ['联合主办', '协办', '承办', '支持单位']
for keyword in keywords:
elem = soup.find(text=re.compile(keyword))
if elem:
parent = elem.parent
value_elem = parent.find_next('td') or parent.find_next('div')
if value_elem:
text = value_elem.get_text(strip=True)
# 分割多个主办方(常用分隔符:、,;)
co_orgs = re.split('[、,;,;]', text)
return [org.strip() for org in co_orgs if org.strip()]
return []
def _extract_scale(self, soup: BeautifulSoup) -> str:
"""提取展会规模"""
keywords = ['展览面积', '规模', '面积', 'scale']
for keyword in keywords:
elem = soup.find(text=re.compile(keyword))
if elem:
parent = elem.parent
value_elem = parent.find_next('td') or parent.find_next('span')
if value_elem:
text = value_elem.get_text(strip=True)
# 提取数字 + 单位
match = re.search(r'([\d.,]+)\s*(万?平[方米]?|㎡)', text)
if match:
return match.group(0)
return ""
def _extract_number(self, soup: BeautifulSoup, keywords: List[str]) -> int:
"""
提取数字信息(参展商数量、观众数等)
Args:
soup: BeautifulSoup 对象
keywords: 关键词列表
Returns:
提取的数字
"""
for keyword in keywords:
elem = soup.find(text=re.compile(keyword))
if elem:
parent = elem.parent
value_elem = parent.find_next('td') or parent.find_next('span')
if value_elem:
text = value_elem.get_text(strip=True)
# 提取数字(支持 "1,000家" "1000+" "约1000" 等格式)
match = re.search(r'[\d,]+', text.replace(',', ''))
if match:
return int(match.group(0))
return 0
def _extract_status(self, soup: BeautifulSoup) -> str:
"""提取展会状态"""
status_keywords = {
'招展中': ['招展中', '正在招展', '火热招展'],
'报名中': ['报名中', '接受报名'],
'已结束': ['已结束', '已闭幕'],
'延期': ['延期', '推迟'],
'取消': ['取消', '停办'],
}
page_text = soup.get_text()
for status, keywords in status_keywords.items():
for keyword in keywords:
if keyword in page_text:
return status
return "正常"
def _extract_official_website(self, soup: BeautifulSoup) -> str:
"""提取官网链接"""
keywords = ['官网', '官方网站', 'website', 'official']
for keyword in keywords:
elem = soup.find(text=re.compile(keyword, re.I))
if elem:
parent = elem.parent
link_elem = parent.find_next('a[href]')
if link_elem:
return link_elem['href']
return ""
def _extract_contact_phone(self, soup: BeautifulSoup) -> str:
"""提取联系电话"""
keywords = ['联系电话', '咨询电话', '电话', 'tel', 'phone']
for keyword in keywords:
elem = soup.find(text=re.compile(keyword, re.I))
if elem:
parent = elem.parent
value_elem = parent.find_next('td') or parent.find_next('span')
if value_elem:
text = value_elem.get_text(strip=True)
# 提取电话号码(固话或手机)
match = re.search(r'(\d{3,4}[-\s]?\d{7,8}|\d{11})', text)
if match:
return match.group(0)
return ""
def _get_base_url(self, elem) -> str:
"""获取基础URL(用于补全相对路径)"""
# 从元素的 href 中提取域名
# 实际项目中应从配置文件读取
return "https://www.example-exhibition.com"
def extract_next_page_url(self, html: str, current_page: int) -> Optional[str]:
"""
提取下一页URL
Args:
html: 当前页面HTML
current_page: 当前页码
Returns:
下一页URL,如果没有则返回 None
"""
try:
soup = BeautifulSoup(html, 'lxml')
# 方法1:查找 "下一页" 链接
next_link = soup.select_one('a.next, a[rel="next"], a:contains("下一页")')
if next_link and next_link.get('href'):
return next_link['href']
# 方法2:查找页码链接(当前页+1)
page_links = soup.select('.pagination a[href], .page-num a[href]')
for link in page_links:
text = link.get_text(strip=True)
if text.isdigit() and int(text) == current_page + 1:
return link['href']
# 方法3:构造URL参数(如果检测到URL参数分页)
if '?page=' in html or '&page=' in html:
# 从当前URL提取并修改 page 参数
# 实际项目中需传入当前完整URL
return f"?page={current_page + 1}"
return None
except Exception as e:
self.logger.error(f"提取下一页URL失败: {e}")
return None
🌍 核心实现:地理信息标准化(Geo Normalizer)
地理信息标准化模块负责将各种城市名称格式统一化。
完整代码实现
python
# geo_normalizer.py
import logging
import re
from typing import Optional, Dict
class GeoNormalizer:
"""地理信息标准化类"""
def __init__(self):
self.logger = logging.getLogger(__name__)
# 城市别名映射
self.city_aliases = {
'北京市': '北京',
'京': '北京',
'上海市': '上海',
'沪': '上海',
'广州市': '广州',
'穗': '广州',
'深圳市': '深圳',
'鹏城': '深圳',
'杭州市': '杭州',
'成都市': '成都',
'蓉': '成都',
'重庆市': '重庆',
'渝': '重庆',
'西安市': '西安',
'长安': '西安',
# 可继续添加...
}
# 展馆别名映射
self.venue_aliases = {
'国家会展中心(上海)': '上海国家会展中心',
'上海虹桥国家会展中心': '上海国家会展中心',
'虹桥国展': '上海国家会展中心',
'国展中心(天津)': '天津国家会展中心',
'中国国际展览中心(新馆)': '中国国际展览中心新馆',
'CIEC': '中国国际展览中心',
# 可继续添加...
}
def normalize_city(self, city_str: str) -> str:
"""
标准化城市名称
Args:
city_str: 原始城市字符串
Returns:
标准化后的城市名
"""
city_str = city_str.strip()
# 去除"市"字后缀
city_str = re.sub(r'市$', '', city_str)
# 查找别名映射
if city_str in self.city_aliases:
return self.city_aliases[city_str]
# 提取城市名(去除省份前缀)
# 如 "广东省广州" → "广州"
match = re.search(r'([^省]+省)?(.+)', city_str)
if match:
city = match.group(2)
return city.strip()
return city_str
def normalize_venue(self, venue_str: str) -> str:
"""
标准化展馆名称
Args:
venue_str: 原始展馆字符串
Returns:
标准化后的展馆名
"""
venue_str = venue_str.strip()
# 查找别名映射
if venue_str in self.venue_aliases:
return self.venue_aliases[venue_str]
# 去除多余的空格
venue_str = re.sub(r'\s+', '', venue_str)
return venue_str
def extract_city_from_venue(self, venue_str: str) -> Optional[str]:
"""
从展馆名称中提取城市
Args:
venue_str: 展馆字符串
Returns:
城市名称
"""
# 常见模式:上海新国际博览中心 → 上海
city_pattern = r'^(北京|上海|广州|深圳|杭州|成都|重庆|西安|天津|南京|武汉|苏州|长沙|郑州|青岛|大连|厦门|宁波|佛山|东莞|沈阳|哈尔滨|济南|福州|合肥|昆明|太原|南昌|南宁|兰州|海口|贵阳|银川|西宁|拉萨|呼和浩特|石家庄|乌鲁木齐)'
match = re.search(city_pattern, venue_str)
if match:
return match.group(1)
return None
💾 核心实现:存储层(Storage)
存储层负责数据持久化和导出。
完整代码实现
python
# storage.py
import sqlite3
import pandas as pd
import json
import logging
from pathlib import Path
from typing import List, Dict
from datetime import datetime
class ExhibitionStorage:
"""展会数据存储类"""
def __init__(self, output_dir: str = "output"):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.logger = logging.getLogger(__name__)
self.db_path = self.output_dir / "exhibitions.db"
self.conn = None
self.cursor = None
self._init_database()
def _init_database(self):
"""初始化 SQLite 数据库"""
try:
self.conn = sqlite3.connect(str(self.db_path))
self.cursor = self.conn.cursor()
# 创建展会表
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS exhibitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exhibition_id TEXT UNIQUE,
exhibition_name TEXT NOT NULL,
short_name TEXT,
industry TEXT,
start_date DATE,
end_date DATE,
duration_days INTEGER,
city TEXT,
venue TEXT,
venue_halls TEXT,
organizer TEXT,
co_organizer TEXT,
scale TEXT,
exhibitor_count INTEGER,
visitor_estimate INTEGER,
status TEXT,
official_website TEXT,
contact_phone TEXT,
detail_url TEXT,
crawl_time DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# 创建索引
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_city ON exhibitions(city)
''')
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_start_date ON exhibitions(start_date)
''')
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_industry ON exhibitions(industry)
''')
self.conn.commit()
self.logger.info(f"数据库初始化成功: {self.db_path}")
except sqlite3.Error as e:
self.logger.error(f"数据库初始化失败: {e}")
raise
def save_exhibition(self, exhibition: Dict):
"""
保存单个展会
Args:
exhibition: 展会数据字典
"""
try:
# 生成唯一ID
exhibition_id = self._generate_id(exhibition)
# 处理联合主办方(列表转JSON)
co_organizer_json = json.dumps(exhibition.get('co_organizer', []), ensure_ascii=False)
self.cursor.execute('''
INSERT OR REPLACE INTO exhibitions
(exhibition_id, exhibition_name, short_name, industry, start_date, end_date,
duration_days, city, venue, venue_halls, organizer, co_organizer, scale,
exhibitor_count, visitor_estimate, status, official_website, contact_phone, detail_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
exhibition_id,
exhibition.get('exhibition_name'),
exhibition.get('short_name'),
exhibition.get('industry'),
exhibition.get('start_date'),
exhibition.get('end_date'),
exhibition.get('duration_days', 0),
exhibition.get('city'),
exhibition.get('venue'),
exhibition.get('venue_halls'),
exhibition.get('organizer'),
co_organizer_json,
exhibition.get('scale'),
exhibition.get('exhibitor_count', 0),
exhibition.get('visitor_estimate', 0),
exhibition.get('status', '正常'),
exhibition.get('official_website'),
exhibition.get('contact_phone'),
exhibition.get('detail_url')
))
self.conn.commit()
except sqlite3.Error as e:
self.logger.error(f"展会保存失败: {e}")
self.conn.rollback()
def _generate_id(self, exhibition: Dict) -> str:
"""
生成展会唯一ID
基于:展会名称 + 开始日期
"""
import hashlib
name = exhibition.get('exhibition_name', '')
start_date = exhibition.get('start_date', '')
raw_str = f"{name}_{start_date}"
hash_str = hashlib.md5(raw_str.encode()).hexdigest()[:12]
return f"EXH{hash_str.upper()}"
def save_batch(self, exhibitions: List[Dict]):
"""批量保存"""
for exh in exhibitions:
self.save_exhibition(exh)
self.logger.info(f"批量保存 {len(exhibitions)} 个展会")
def export_to_csv(self, filename: str = "exhibitions.csv"):
"""导出为 CSV"""
try:
df = pd.read_sql_query("SELECT * FROM exhibitions", self.conn)
filepath = self.output_dir / filename
df.to_csv(filepath, index=False, encoding='utf-8-sig')
self.logger.info(f"CSV 文件已保存: {filepath}")
except Exception as e:
self.logger.error(f"CSV 导出失败: {e}")
def export_to_excel(self, filename: str = "exhibitions.xlsx"):
"""导出为 Excel"""
try:
df = pd.read_sql_query("SELECT * FROM exhibitions", self.conn)
filepath = self.output_dir / filename
df.to_excel(filepath, index=False, engine='openpyxl')
self.logger.info(f"Excel 文件已保存: {filepath}")
except Exception as e:
self.logger.error(f"Excel 导出失败: {e}")
def get_statistics(self) -> Dict:
"""获取统计信息"""
try:
stats = {}
# 总展会数
self.cursor.execute("SELECT COUNT(*) FROM exhibitions")
stats['total'] = self.cursor.fetchone()[0]
# 各城市展会数
self.cursor.execute('''
SELECT city, COUNT(*) as count
FROM exhibitions
GROUP BY city
ORDER BY count DESC
LIMIT 10
''')
stats['top_cities'] = dict(self.cursor.fetchall())
# 各行业展会数
self.cursor.execute('''
SELECT industry, COUNT(*) as count
FROM exhibitions
GROUP BY industry
ORDER BY count DESC
LIMIT 10
''')
stats['top_industries'] = dict(self.cursor.fetchall())
return stats
except sqlite3.Error as e:
self.logger.error(f"统计查询失败: {e}")
return {}
def close(self):
"""关闭连接"""
if self.conn:
self.conn.close()
self.logger.info("数据库连接已关闭")
🚀 主流程编排(Main)
python
# main.py
import logging
import json
from datetime import datetime
from config import Config
from fetcher import ExhibitionFetcher
from parser import ExhibitionParser
from geo_normalizer import GeoNormalizer
from storage import ExhibitionStorage
from utils import setup_logger
import time
def main():
"""主流程入口"""
logger = setup_logger()
logger.info("=" * 60)
logger.info("展会数据爬虫启动")
logger.info(f"启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info("=" * 60)
# 加载配置
config = Config()
# 加载城市列表
with open('city_list.json', 'r', encoding='utf-8') as f:
cities = json.load(f)
# 初始化组件
fetcher = None
storage = None
try:
fetcher = ExhibitionFetcher(use_cache=config.USE_CACHE)
parser = ExhibitionParser()
geo_normalizer = GeoNormalizer()
storage = ExhibitionStorage(output_dir=config.OUTPUT_DIR)
total_exhibitions = 0
# 遍历城市
for city in cities:
logger.info(f"\n{'='*60}")
logger.info(f"开始爬取城市: {city}")
logger.info(f"{'='*60}")
# 构建列表页URL
list_url = config.LIST_URL_TEMPLATE.format(city=city)
page = 1
while True:
logger.info(f"正在爬取第 {page} 页...")
# 构建分页URL
if page == 1:
url = list_url
else:
url = f"{list_url}?page={page}"
# 获取页面
html = fetcher.fetch_url(url)
if not html:
logger.warning(f"页面获取失败,跳过: {url}")
break
# 解析列表页
exhibitions = parser.parse_list_page(html, city)
if not exhibitions:
logger.info("未找到展会数据,可能已到最后一页")
break
# 爬取详情页
for idx, exh in enumerate(exhibitions, 1):
logger.info(f" [{idx}/{len(exhibitions)}] {exh['exhibition_name']}")
if exh.get('detail_url'):
detail_html = fetcher.fetch_url(exh['detail_url'])
if detail_html:
exh = parser.parse_detail_page(detail_html, exh)
# 地理信息标准化
exh['city'] = geo_normalizer.normalize_city(exh['city'])
exh['venue'] = geo_normalizer.normalize_venue(exh.get('venue', ''))
# 保存到数据库
storage.save_exhibition(exh)
total_exhibitions += 1
# 延时
time.sleep(fetcher._random_delay())
# 检查是否有下一页
next_url = parser.extract_next_page_url(html, page)
if not next_url:
logger.info(f"城市 {city} 爬取完成")
break
page += 1
# 导出数据
logger.info("\n正在导出数据...")
storage.export_to_csv()
storage.export_to_excel()
# 输出统计
stats = storage.get_statistics()
logger.info("=" * 60)
logger.info("爬取完成!数据统计:")
logger.info(f" 总展会数: {total_exhibitions}")
logger.info(f" TOP 城市: {stats.get('top_cities', {})}")
logger.info(f" TOP 行业: {stats.get('top_industries', {})}")
logger.info("=" * 60)
except KeyboardInterrupt:
logger.warning("用户中断爬取")
except Exception as e:
logger.error(f"爬取过程出错: {e}", exc_info=True)
finally:
if fetcher:
fetcher.close()
if storage:
storage.close()
logger.info("程序已退出")
if __name__ == '__main__':
main()
📊 运行结果展示
CSV 输出示例
csv
id,exhibition_id,exhibition_name,start_date,end_date,city,venue,industry,organizer
1,EXHF34A2B1C,第25届中国国际医疗器械展览会,2026-03-15,2026-03-17,上海,上海国家会展中心,医疗健康,中国医疗器械行业协会
2,EXHA12B34CD,2026中国国际工业博览会,2026-09-18,2026-09-22,上海,上海国家会展中心,智能制造,工业和信息化部
3,EXH5D6E7F8G,第134届中国进出口商品交易会,2026-10-15,2026-10-19,广州,广交会展馆,综合贸易,商务部
数据统计输出
json
==============================================================
爬取完成!数据统计:
总展会数: 3,847
TOP 城市: {'上海': 856, '广州': 542, '北京': 487, '深圳': 325}
TOP 行业: {'智能制造': 623, '医疗健康': 458, '电子信息': 387}
==============================================================
🎓 总结
本文实现了一个生产级展会数据爬虫,具备以下特点:
✅ 智能化 :自动检测分页类型、智能解析10+种日期格式
✅ 工程化 :分层架构、缓存机制、异常处理、日志记录
✅ 可扩展 :易于添加新城市、新网站、新字段
✅ 高质量:数据标准化、去重、完整性验证
最终产出:覆盖全国50+城市、15+行业、3800+展会的结构化数据集。
希望这篇详尽的教程能帮助你掌握展会数据爬取的核心技术!🚀
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)

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