㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐☆☆☆(基础级)
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。
全文目录:
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战,主理专栏👉 《Python爬虫实战》:从采集策略到反爬对抗,从数据清洗到分布式调度,持续输出可复用的方法论与可落地案例。内容主打一个“能跑、能用、能扩展”,让数据价值真正做到——抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间:如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
0️⃣ 前言(Preface)
这篇文章要做的事情很明确:我们会用 Python 抓取一个“家居展会参展品牌目录”类页面,采集品牌名、展馆、品类、展位号、链接等字段,最终把数据导出为 CSV,并同步写入 SQLite 数据库。
读完这篇文章,你大概率能拿走三样东西:
第一,一套适合会展目录页的采集思路。很多展会官网、行业展会、招商页面、品牌名录页,结构都很像:列表页展示品牌卡片,详情页补充品牌介绍、品类、展位、官网链接等信息。掌握这个套路,后面可以复用到家具展、建材展、家电展、消费品展、工业展等场景。
第二,一个能跑起来的小项目。文章不会只停在“requests 请求一下、BeautifulSoup 解析一下”的示例级别,而是会拆成 Fetcher、Parser、Storage、Runner 几个层次,并给出完整目录、配置文件、核心代码和本地 HTML 测试页面。哪怕没有真实展会站点,也可以先用本地示例页面跑通流程。
第三,一些实际采集时会遇到的坑。比如 403、429、动态渲染空壳、选择器失效、字段缺失、重复数据、乱码、请求超时、重试退避等问题。这些问题在教程里经常被轻描淡写,但真实项目里基本绕不开。
我个人比较喜欢把爬虫项目当作“数据工程的入口”来看,而不是单纯把网页内容抓下来。抓下来只是第一步,后面还有清洗、校验、去重、存储、增量更新、可追溯、可复跑。尤其是会展目录这种数据,看起来字段不多,但后续很适合做成品牌库、招商库、展位地图、行业画像,甚至做成一个小型检索系统。
1️⃣ 摘要(Abstract)
本文基于 Python、requests、BeautifulSoup、SQLite 和 CSV,构建一个面向“家居展会参展品牌目录”的采集项目,目标字段包括品牌名、展馆、品类、展位号、链接,并通过“采集 → 解析 → 清洗 → 存储”的流程完成结构化入库。
读完本文你将获得:
- 一个会展目录页采集项目的完整设计方法。
- 一套可运行的 Python 示例代码,支持本地 fixtures 测试和真实站点扩展。
- 面向目录页采集的合规说明、容错设计、去重策略和常见排错方案。
本文的定位不是“教你暴力抓站”,也不是讨论任何灰色玩法,而是站在技术分享和数据整理的角度,演示如何用温和、克制、可维护的方式做公开页面信息聚合。
2️⃣ 背景与需求(Why)
2.1 为什么要采集家居展会品牌目录
家居行业的展会通常信息密度很高。一个大型家居展可能包含多个展馆、多个品类、几百到几千个品牌参展。官网上通常会提供参展商目录,里面有品牌名称、展馆、展位号、品类、品牌介绍、官网链接等信息。
这些数据如果只停留在网页上,使用起来并不方便。比如:
- 想筛选“软体家具”类品牌,需要一页页点。
- 想找某个展馆内的所有品牌,需要手工复制。
- 想统计不同品类的品牌数量,需要自己整理表格。
- 想做后续招商、拜访、客户分层,需要先把数据结构化。
- 想把历年参展品牌做对比,需要可沉淀、可检索的数据。
所以,爬取这类公开目录页的核心价值不是“抓取”本身,而是把散落在网页里的信息转成可复用的数据资产。
比较常见的使用场景有:
-
数据分析
例如统计不同品类的品牌数量、不同展馆的品牌分布、头部品牌是否连续参展、某个品类是否在今年明显扩容等。
-
信息聚合
把官网目录、展会新闻、品牌官网、公开联系方式等信息汇总到一个内部表格或数据库里,方便检索和二次整理。
-
自动化建库
对于行业研究、市场拓展、展会运营、渠道开发来说,参展商目录是很好的原始数据源。把采集流程做成脚本之后,后续只要配置入口 URL 和选择器,就能快速生成基础品牌库。
-
辅助业务决策
比如销售团队可以按展馆安排拜访路线,市场团队可以按品类分析竞争格局,运营团队可以核验参展品牌信息是否完整。
2.2 目标站点类型
本文不绑定某一个具体展会官网,因为不同展会页面结构不一样,直接写死某个站点反而不利于复用。我们假设目标站点是一个典型的家居展会目录页,结构大致如下:
- 列表页展示多个品牌卡片。
- 每个卡片包含品牌名、展馆、品类、展位号、详情链接。
- 详情页可能补充品牌介绍、官网链接、品牌 logo、地区等信息。
- 列表页可能存在分页。
- 页面可能是静态 HTML,也可能是前端渲染或接口返回 JSON。
本文主线先做 静态 HTML 目录页采集。这是最适合入门和建库的起点,因为它稳定、清晰、调试成本低。后面会补充动态页面和接口页面的处理思路。
2.3 目标字段
本次采集字段如下:
| 字段 | 含义 | 示例 |
|---|---|---|
| brand_name | 品牌名 | 森禾家居 |
| hall | 展馆 | 3.1H |
| category | 品类 | 客厅家具 |
| booth_no | 展位号 | 3.1H-A128 |
| detail_url | 详情页链接 | https://example.com/exhibitors/senhe |
| source_url | 来源列表页 | https://example.com/exhibitors?page=1 |
| crawl_time | 采集时间 | 2026-06-09 10:30:00 |
用户要求的核心字段是:品牌名、展馆、品类、展位号、链接。为了工程可追溯,我建议额外保留两个字段:
source_url:数据来自哪个列表页。crawl_time:什么时候采集到的。
这两个字段在后续排查和增量更新时很有用。比如某条数据有争议,可以回到原始来源页面检查;如果以后多次采集,可以知道数据更新时间。
3️⃣ 合规与注意事项
做采集之前,必须先把边界讲清楚。技术能做到,不代表就应该没有节制地做。尤其是会展官网这类页面,很多是公开信息展示,但网站承载能力、使用条款、robots.txt、访问频率都需要考虑。
3.1 robots.txt 基本说明
robots.txt 是网站放在根目录下的一份爬虫访问声明文件,常见地址类似:
https://example.com/robots.txt
它通常会说明哪些路径允许或不允许自动化程序访问。比如:
User-agent: *
Disallow: /admin/
Disallow: /api/private/
Allow: /exhibitors/
这表示普通爬虫不应访问 /admin/ 和 /api/private/,但可以访问 /exhibitors/。
在 Python 里,可以用标准库 urllib.robotparser 做基础检查。这个模块不是万能的,但适合在普通项目里做第一层合规判断。
示例代码:
from urllib.robotparser import RobotFileParser
def can_fetch(url: str, user_agent: str = "*") -> bool:
robots_url = "https://example.com/robots.txt"
parser = RobotFileParser()
parser.set_url(robots_url)
parser.read()
return parser.can_fetch(user_agent, url)
如果站点明确禁止某个目录被自动化访问,就不要抓。项目可以在启动时检查 robots.txt,并在日志里记录检查结果。
3.2 控制访问频率
目录页采集最忌讳“上来就开几十个线程狂打”。这既不专业,也容易触发风控,更重要的是会给目标站点造成不必要压力。
比较稳妥的做法是:
- 单线程或低并发起步。
- 每次请求之间加入随机延迟。
- 设置合理 timeout。
- 遇到 429、503 等状态码时退避等待。
- 不重复抓同一个 URL。
- 调试阶段只抓少量页面。
- 正式运行前确认采集范围。
比如:
import random
import time
delay = random.uniform(1.0, 3.0)
time.sleep(delay)
很多时候,慢一点并不会影响整体效率。对于目录页来说,几百个品牌页面,控制在几分钟或十几分钟完成已经足够。为了快而快,反而容易让脚本变得不可用。
3.3 不采集敏感信息
本文只讨论公开展示的品牌目录信息,不涉及敏感个人信息,不采集用户账号、不绕过登录、不破解验证码、不访问后台接口、不规避付费限制。
建议遵守这些原则:
- 只采集公开页面展示的信息。
- 不采集身份证号、手机号、私人邮箱等敏感个人信息。
- 不绕过登录、付费墙、验证码、访问权限。
- 不使用攻击式并发。
- 不对目标站点造成异常访问压力。
- 不把采集数据用于违规用途。
- 如果站点有明确使用条款,应优先遵守站点规则。
技术分享的价值在于提高效率,而不是突破边界。这个态度我觉得很重要,也希望读者在实际项目里一直保留。
4️⃣ 技术选型与整体流程(What/How)
4.1 静态、动态和 API 的区别
会展目录页常见有三种形态:
4.1.1 静态 HTML 页面
浏览器打开页面后,品牌数据已经存在于 HTML 里。右键查看网页源代码,可以看到品牌名、展馆、展位号等文本。
这种情况最适合用:
- requests
- BeautifulSoup
- lxml
- parsel
优点是简单、稳定、速度快。本文主线就是这种。
4.1.2 动态渲染页面
浏览器打开页面时,初始 HTML 只有一个空壳,比如:
<div id="app"></div>
<script src="/static/js/app.js"></script>
品牌数据是前端 JavaScript 后续请求接口后渲染出来的。
这种情况有两个方向:
- 优先分析 Network,找到真实 JSON 接口。
- 如果接口复杂或签名麻烦,再考虑 Playwright 渲染页面。
4.1.3 API 返回 JSON
很多展会目录页背后都有接口,例如:
https://example.com/api/exhibitors?page=1&page_size=20
返回内容类似:
{
"items": [
{
"brandName": "森禾家居",
"hall": "3.1H",
"category": "客厅家具",
"boothNo": "3.1H-A128",
"detailUrl": "/exhibitors/senhe"
}
]
}
这种情况应该优先抓 API,而不是解析 HTML。因为 JSON 更结构化,也更适合入库。
4.2 本文属于哪一种
本文主线属于 静态目录页采集。
选择这个方向的原因有三个:
第一,家居展会目录页面经常存在可直接解析的 HTML 结构,尤其是 PC 端官网、招商页、展商名录页。
第二,静态采集代码更容易复现。requests + BeautifulSoup 不依赖浏览器环境,部署简单,适合作为工程起步版本。
第三,即使后面目标站点变成动态渲染,静态采集的工程结构仍然能复用。Fetcher 可以换成 PlaywrightFetcher,Parser 可以换成 JSONParser,但 Storage、去重、日志、配置这些都不用推倒重来。
4.3 整体流程
本文项目的流程是:
读取配置
↓
检查 robots.txt
↓
生成列表页 URL
↓
请求列表页 HTML
↓
解析品牌卡片与详情链接
↓
请求详情页 HTML
↓
解析品牌字段
↓
清洗字段
↓
去重
↓
写入 SQLite
↓
导出 CSV
也可以简化成四个关键词:
采集 → 解析 → 清洗 → 存储
展开后是:
Fetcher
负责 HTTP 请求、headers、timeout、session、cookie、重试、退避、robots 检查。
Parser
负责从列表页提取详情链接,从详情页提取品牌名、展馆、品类、展位号、链接。
Cleaner
负责去空格、统一字段格式、修复相对链接、处理缺失值。
Storage
负责写入 SQLite、导出 CSV、根据 detail_url 去重。
Runner
负责串起整个流程,控制分页、日志和运行参数。
4.4 为什么选 requests + BeautifulSoup + SQLite
requests
requests 的优点是直观。它封装了 HTTP 请求常用能力,支持 headers、cookies、session、timeout 等,写起来比标准库更舒服。
BeautifulSoup
BeautifulSoup 对 HTML 容错能力不错。真实页面经常有不闭合标签、嵌套混乱、无意义空格,BeautifulSoup 更适合做第一层解析。
lxml
lxml 速度快,支持 XPath。本文里 BeautifulSoup 使用 lxml 作为解析器,兼顾易用性和性能。如果后续页面结构复杂,也可以直接切换到 lxml XPath。
SQLite
SQLite 是最适合起步的数据库。不需要安装服务端,不需要配置账号密码,一个 .db 文件就能保存数据。对于几千到几十万条展商目录数据,SQLite 完全够用。
CSV
CSV 是最通用的交换格式。运营、市场、销售、研究同事拿到 CSV,可以直接用 Excel、WPS、Numbers 或数据分析工具打开。
5️⃣ 环境准备与依赖安装(可复现)
5.1 Python 版本
建议使用:
Python 3.10+
如果你使用 Python 3.11 或 3.12,也可以正常运行。本文代码没有使用非常新的语法,尽量保证兼容。
查看 Python 版本:
python --version
或:
python3 --version
5.2 创建虚拟环境
Linux / macOS:
python3 -m venv .venv
source .venv/bin/activate
Windows PowerShell:
python -m venv .venv
.venv\Scripts\Activate.ps1
5.3 安装依赖
pip install requests beautifulsoup4 lxml
如果想后续扩展动态渲染,可以额外安装:
pip install playwright
playwright install
但本文主线不依赖 Playwright。
5.4 推荐项目结构
推荐目录如下:
home_expo_spider/
├── README.md
├── requirements.txt
├── config.py
├── models.py
├── fetcher.py
├── parser.py
├── storage.py
├── utils.py
├── main.py
├── data/
│ ├── home_expo.db
│ └── exhibitors.csv
├── fixtures/
│ ├── list_page_1.html
│ ├── list_page_2.html
│ ├── detail_senhe.html
│ ├── detail_mujia.html
│ ├── detail_yiran.html
│ └── detail_qingmu.html
└── logs/
└── spider.log
说明:
config.py:项目配置。models.py:数据模型。fetcher.py:请求层。parser.py:解析层。storage.py:存储层。utils.py:通用工具函数。main.py:入口文件。fixtures/:本地测试 HTML。data/:输出数据库和 CSV。logs/:日志目录。
5.5 requirements.txt
requests>=2.31.0
beautifulsoup4>=4.12.0
lxml>=5.0.0
版本不必卡得太死。企业内部项目通常会固定版本,个人学习和小型采集项目可以先保持较新的稳定版本。
6️⃣ 核心实现:请求层(Fetcher)
请求层是爬虫里最容易被低估的一层。很多初学代码会写成这样:
import requests
html = requests.get(url).text
这行代码当然能跑,但工程上不够稳。真实项目至少要考虑:
- headers
- User-Agent
- referer
- timeout
- session
- cookie
- 状态码判断
- 重试
- 退避
- robots.txt
- 编码
- 日志
本文会写一个 Fetcher 类,把这些能力集中起来。
6.1 config.py
先定义配置:
# config.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
LOG_DIR = BASE_DIR / "logs"
FIXTURE_DIR = BASE_DIR / "fixtures"
DATA_DIR.mkdir(exist_ok=True)
LOG_DIR.mkdir(exist_ok=True)
DB_PATH = DATA_DIR / "home_expo.db"
CSV_PATH = DATA_DIR / "exhibitors.csv"
LOG_PATH = LOG_DIR / "spider.log"
# 示例域名。真实项目中替换成目标展会官网。
BASE_URL = "https://example.com"
# 真实采集时可改成真实列表页模板,比如:
# LIST_URL_TEMPLATE = "https://example.com/exhibitors?page={page}"
LIST_URL_TEMPLATE = "fixture://list_page_{page}.html"
START_PAGE = 1
END_PAGE = 2
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0 Safari/537.36"
)
DEFAULT_HEADERS = {
"User-Agent": USER_AGENT,
"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.7",
"Connection": "keep-alive",
}
REQUEST_TIMEOUT = (5, 15)
MAX_RETRIES = 3
BACKOFF_FACTOR = 1.5
MIN_DELAY = 1.0
MAX_DELAY = 2.5
CHECK_ROBOTS = False
这里我把 CHECK_ROBOTS 默认设成 False,原因是本文默认跑本地 fixtures,不访问真实站点。真实采集时建议改成 True。
fixture://list_page_1.html 是本文设计的本地测试协议,不是真的网络协议。Fetcher 会识别这种路径,从 fixtures/ 目录读取 HTML。这样读者复制代码后,不需要任何外部站点,也能验证解析、入库、导出流程。
6.2 utils.py
# utils.py
import logging
import random
import re
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.parse import urljoin
def setup_logger(log_path: Path) -> logging.Logger:
logger = logging.getLogger("home_expo_spider")
logger.setLevel(logging.INFO)
if logger.handlers:
return logger
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setFormatter(formatter)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.addHandler(stream_handler)
return logger
def normalize_text(value: Optional[str]) -> str:
if not value:
return ""
value = value.replace("\xa0", " ")
value = re.sub(r"\s+", " ", value)
return value.strip()
def now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def polite_sleep(min_delay: float, max_delay: float) -> None:
delay = random.uniform(min_delay, max_delay)
time.sleep(delay)
def make_absolute_url(base_url: str, maybe_url: str) -> str:
maybe_url = normalize_text(maybe_url)
if not maybe_url:
return ""
return urljoin(base_url, maybe_url)
normalize_text 是非常常用的小工具。网页里经常有换行、多个空格、 ,如果不统一处理,后续去重和展示会很难看。
6.3 fetcher.py
# fetcher.py
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from urllib.robotparser import RobotFileParser
import requests
import config
from utils import polite_sleep
class FetchError(Exception):
pass
class RobotsDeniedError(FetchError):
pass
class Fetcher:
def __init__(
self,
headers: Optional[dict] = None,
timeout: tuple = config.REQUEST_TIMEOUT,
max_retries: int = config.MAX_RETRIES,
backoff_factor: float = config.BACKOFF_FACTOR,
logger=None,
) -> None:
self.session = requests.Session()
self.headers = headers or config.DEFAULT_HEADERS
self.timeout = timeout
self.max_retries = max_retries
self.backoff_factor = backoff_factor
self.logger = logger
self._robots_cache: dict[str, RobotFileParser] = {}
def get_text(self, url: str, referer: Optional[str] = None) -> str:
if url.startswith("fixture://"):
return self._read_fixture(url)
if config.CHECK_ROBOTS and not self.can_fetch(url):
raise RobotsDeniedError(f"robots.txt denied: {url}")
headers = dict(self.headers)
if referer:
headers["Referer"] = referer
last_error: Optional[Exception] = None
for attempt in range(1, self.max_retries + 1):
try:
if self.logger:
self.logger.info("GET %s | attempt=%s", url, attempt)
response = self.session.get(
url,
headers=headers,
timeout=self.timeout,
)
if response.status_code in (403, 404):
raise FetchError(f"HTTP {response.status_code}: {url}")
if response.status_code in (429, 500, 502, 503, 504):
raise FetchError(f"temporary HTTP {response.status_code}: {url}")
response.raise_for_status()
if not response.encoding or response.encoding.lower() == "iso-8859-1":
response.encoding = response.apparent_encoding
return response.text
except (requests.RequestException, FetchError) as exc:
last_error = exc
wait_seconds = self.backoff_factor ** attempt
if self.logger:
self.logger.warning(
"request failed | url=%s | attempt=%s | error=%s | wait=%.2fs",
url,
attempt,
exc,
wait_seconds,
)
if attempt < self.max_retries:
time.sleep(wait_seconds)
raise FetchError(f"failed after retries: {url} | last_error={last_error}")
def can_fetch(self, url: str, user_agent: str = config.USER_AGENT) -> bool:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return True
root = f"{parsed.scheme}://{parsed.netloc}"
robots_url = f"{root}/robots.txt"
if robots_url not in self._robots_cache:
parser = RobotFileParser()
parser.set_url(robots_url)
try:
parser.read()
except Exception as exc:
if self.logger:
self.logger.warning("robots.txt read failed: %s | %s", robots_url, exc)
return True
self._robots_cache[robots_url] = parser
parser = self._robots_cache[robots_url]
return parser.can_fetch(user_agent, url)
def _read_fixture(self, fixture_url: str) -> str:
filename = fixture_url.replace("fixture://", "")
path = config.FIXTURE_DIR / filename
if self.logger:
self.logger.info("READ FIXTURE %s", path)
if not path.exists():
raise FetchError(f"fixture not found: {path}")
return path.read_text(encoding="utf-8")
def close(self) -> None:
self.session.close()
6.4 headers 说明
headers 不是越复杂越好。本文只保留常见字段:
DEFAULT_HEADERS = {
"User-Agent": "...",
"Accept": "...",
"Accept-Language": "...",
"Connection": "keep-alive",
}
其中最重要的是 User-Agent。它用于说明客户端身份。不要伪装成离谱的客户端,也不要频繁变换 UA 去做规避。对于普通公开页面采集,保持一个常见浏览器 UA 即可。
Referer 可以在请求详情页时补上来源列表页:
html = fetcher.get_text(detail_url, referer=list_url)
这更接近正常浏览路径。有些站点会检查来源页,虽然不建议刻意绕过限制,但合理补充 Referer 是正常请求行为。
6.5 timeout 说明
timeout=(5, 15) 的意思是:
- 连接超时 5 秒。
- 读取超时 15 秒。
不设置 timeout 的后果是脚本可能长时间卡住。实际项目里我几乎一定会显式设置 timeout,这属于非常基础但很重要的习惯。
6.6 session/cookie 说明
requests.Session() 可以复用连接,也能保存 cookie。对于公开目录页,通常不需要手工设置 cookie。但使用 session 有两个好处:
- 多次请求同一站点时更高效。
- 如果站点返回一些普通 cookie,后续请求可以自动携带。
本文没有写固定 cookie,因为固定 cookie 很容易过期,也不适合教学文章直接展示。真实项目如果需要登录态,应该先判断是否符合站点规则和业务授权,不建议把个人 cookie 写进代码。
6.7 失败处理:重试与退避
请求失败常见原因包括:
- 网络波动。
- 目标站点临时 503。
- 请求超时。
- 连接被重置。
- 限流返回 429。
本文重试策略是:
for attempt in range(1, self.max_retries + 1):
try:
...
except:
wait_seconds = self.backoff_factor ** attempt
time.sleep(wait_seconds)
例如 BACKOFF_FACTOR=1.5 时,大致等待:
第 1 次失败后:1.5 秒
第 2 次失败后:2.25 秒
第 3 次失败后:不再重试
真实项目里还可以加入随机抖动:
wait_seconds = self.backoff_factor ** attempt + random.uniform(0, 1)
这样可以避免多个任务同时重试。
7️⃣ 核心实现:解析层(Parser)
解析层负责把 HTML 变成结构化数据。会展目录页最常见的解析逻辑是:
- 列表页找品牌卡片。
- 从卡片里提取详情链接。
- 详情页提取品牌名、展馆、品类、展位号、官网链接等。
- 如果详情页字段缺失,则回退使用列表页字段。
7.1 models.py
先定义数据模型:
# models.py
from dataclasses import dataclass, asdict
@dataclass
class Exhibitor:
brand_name: str
hall: str
category: str
booth_no: str
detail_url: str
source_url: str
crawl_time: str
def to_dict(self) -> dict:
return asdict(self)
使用 dataclass 有几个好处:
- 字段清晰。
- 转 dict 方便。
- 后续写 CSV、SQLite 都简单。
- 比随手传 dict 更容易维护。
7.2 parser.py
# parser.py
from bs4 import BeautifulSoup
import config
from models import Exhibitor
from utils import make_absolute_url, normalize_text, now_str
class ExpoParser:
def __init__(self, base_url: str = config.BASE_URL) -> None:
self.base_url = base_url
def parse_list_page(self, html: str, source_url: str) -> list[dict]:
soup = BeautifulSoup(html, "lxml")
cards = soup.select(".exhibitor-card")
items: list[dict] = []
for card in cards:
name = self._select_text(card, ".brand-name")
hall = self._select_text(card, ".hall")
category = self._select_text(card, ".category")
booth_no = self._select_text(card, ".booth-no")
link_tag = card.select_one("a.detail-link")
detail_href = link_tag.get("href", "") if link_tag else ""
detail_url = make_absolute_url(self.base_url, detail_href)
if not detail_url and link_tag:
detail_url = normalize_text(link_tag.get("data-url", ""))
item = {
"brand_name": name,
"hall": hall,
"category": category,
"booth_no": booth_no,
"detail_url": detail_url,
"source_url": source_url,
}
if item["brand_name"] or item["detail_url"]:
items.append(item)
return items
def parse_detail_page(self, html: str, fallback: dict) -> Exhibitor:
soup = BeautifulSoup(html, "lxml")
brand_name = (
self._select_text(soup, "h1.brand-title")
or self._meta_content(soup, "brand:name")
or fallback.get("brand_name", "")
)
hall = (
self._detail_value(soup, "展馆")
or self._select_text(soup, ".detail-hall")
or fallback.get("hall", "")
)
category = (
self._detail_value(soup, "品类")
or self._select_text(soup, ".detail-category")
or fallback.get("category", "")
)
booth_no = (
self._detail_value(soup, "展位号")
or self._select_text(soup, ".detail-booth")
or fallback.get("booth_no", "")
)
detail_url = fallback.get("detail_url", "")
source_url = fallback.get("source_url", "")
return Exhibitor(
brand_name=normalize_text(brand_name),
hall=normalize_text(hall),
category=normalize_text(category),
booth_no=normalize_text(booth_no),
detail_url=normalize_text(detail_url),
source_url=normalize_text(source_url),
crawl_time=now_str(),
)
def _select_text(self, node, selector: str) -> str:
tag = node.select_one(selector)
if not tag:
return ""
return normalize_text(tag.get_text(" ", strip=True))
def _meta_content(self, soup: BeautifulSoup, name: str) -> str:
tag = soup.select_one(f'meta[name="{name}"]')
if not tag:
return ""
return normalize_text(tag.get("content", ""))
def _detail_value(self, soup: BeautifulSoup, label: str) -> str:
rows = soup.select(".detail-row")
for row in rows:
label_text = self._select_text(row, ".label")
value_text = self._select_text(row, ".value")
label_text = label_text.replace(":", "").replace(":", "").strip()
if label_text == label:
return value_text
return ""
7.3 解析方式说明
本文使用 CSS selector:
cards = soup.select(".exhibitor-card")
原因是会展目录页通常是卡片式结构,CSS selector 足够直观。相比 XPath,CSS selector 对前端同学也更友好。
如果页面结构更复杂,可以改成 XPath。比如用 lxml:
from lxml import html
tree = html.fromstring(page_html)
names = tree.xpath('//div[contains(@class, "exhibitor-card")]//h3/text()')
我个人习惯是:
- 简单页面用 BeautifulSoup + CSS selector。
- 节点关系复杂、层级深、需要轴定位时用 XPath。
- API 返回 JSON 时不用 HTML 解析,直接
response.json()。
7.4 列表页如何拿详情链接
列表页常见结构:
<div class="exhibitor-card">
<h3 class="brand-name">森禾家居</h3>
<p class="hall">3.1H</p>
<p class="category">客厅家具</p>
<p class="booth-no">3.1H-A128</p>
<a class="detail-link" href="/exhibitors/senhe">查看详情</a>
</div>
解析代码:
link_tag = card.select_one("a.detail-link")
detail_href = link_tag.get("href", "") if link_tag else ""
detail_url = make_absolute_url(self.base_url, detail_href)
注意:很多页面详情链接是相对路径,比如:
/exhibitors/senhe
必须转成绝对 URL:
from urllib.parse import urljoin
detail_url = urljoin("https://example.com", "/exhibitors/senhe")
得到:
https://example.com/exhibitors/senhe
否则后续请求会失败。
7.5 详情页如何抽字段
详情页结构可能是:
<h1 class="brand-title">森禾家居</h1>
<div class="detail-row">
<span class="label">展馆:</span>
<span class="value">3.1H</span>
</div>
<div class="detail-row">
<span class="label">品类:</span>
<span class="value">客厅家具</span>
</div>
<div class="detail-row">
<span class="label">展位号:</span>
<span class="value">3.1H-A128</span>
</div>
解析方法:
def _detail_value(self, soup: BeautifulSoup, label: str) -> str:
rows = soup.select(".detail-row")
for row in rows:
label_text = self._select_text(row, ".label")
value_text = self._select_text(row, ".value")
label_text = label_text.replace(":", "").replace(":", "").strip()
if label_text == label:
return value_text
return ""
这种写法比直接写死 .hall、.category 更灵活。因为详情页常见的是“标签 + 值”结构,标签文本可能比 class 更稳定。
7.6 缺失字段怎么办
真实页面非常容易缺字段。比如:
- 列表页有展位号,详情页没有。
- 详情页有品牌名,列表页品牌名被截断。
- 某个品牌没有品类。
- 某条数据详情链接失效。
- 页面改版导致某个选择器失效。
本文采用的策略是:
brand_name = detail_brand_name or fallback_brand_name
hall = detail_hall or fallback_hall
category = detail_category or fallback_category
booth_no = detail_booth_no or fallback_booth_no
也就是详情页优先,列表页兜底。
如果字段仍然缺失,就保留空字符串,不让程序崩掉。因为目录采集不是金融交易系统,不应该因为某个品牌缺一个展位号就终止整个任务。
后续可以加数据质量检查,比如:
if not exhibitor.brand_name:
logger.warning("missing brand_name: %s", exhibitor.detail_url)
if not exhibitor.booth_no:
logger.warning("missing booth_no: %s", exhibitor.detail_url)
8️⃣ 数据存储与导出(Storage)
本项目同时支持 SQLite 和 CSV。
SQLite 负责结构化存储,适合增量更新、去重和查询。CSV 负责导出,适合交付和人工查看。
8.1 字段映射表
| Python 字段 | 数据库字段 | 类型 | 示例值 | 说明 |
|---|---|---|---|---|
| brand_name | brand_name | TEXT | 森禾家居 | 品牌名 |
| hall | hall | TEXT | 3.1H | 展馆 |
| category | category | TEXT | 客厅家具 | 品类 |
| booth_no | booth_no | TEXT | 3.1H-A128 | 展位号 |
| detail_url | detail_url | TEXT UNIQUE | https://example.com/exhibitors/senhe | 详情链接 |
| source_url | source_url | TEXT | fixture://list_page_1.html | 来源列表页 |
| crawl_time | crawl_time | TEXT | 2026-06-09 10:30:00 | 采集时间 |
8.2 去重策略
本文使用 detail_url 唯一去重。
原因很简单:在会展目录页里,品牌名可能重复。例如一个集团旗下多个品牌、同名品牌、品牌中文名和英文名混排,都可能造成名称重复。展位号也可能因为联合展位出现重复。详情页链接相对更稳定。
SQLite 表结构中设置:
detail_url TEXT UNIQUE
插入时使用:
ON CONFLICT(detail_url) DO UPDATE SET ...
这样同一个详情链接再次采集时,会更新已有记录,而不是插入重复行。
如果某些站点没有详情页链接,可以退而求其次使用内容 hash:
import hashlib
raw_key = f"{brand_name}|{hall}|{category}|{booth_no}"
content_hash = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
不过只要有详情页,我优先建议用 URL。
8.3 storage.py
# storage.py
import csv
import sqlite3
from pathlib import Path
from typing import Iterable
from models import Exhibitor
class Storage:
def __init__(self, db_path: Path, csv_path: Path) -> None:
self.db_path = db_path
self.csv_path = csv_path
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
self.init_db()
def init_db(self) -> None:
sql = """
CREATE TABLE IF NOT EXISTS exhibitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
brand_name TEXT NOT NULL DEFAULT '',
hall TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
booth_no TEXT NOT NULL DEFAULT '',
detail_url TEXT NOT NULL UNIQUE,
source_url TEXT NOT NULL DEFAULT '',
crawl_time TEXT NOT NULL DEFAULT ''
);
"""
self.conn.execute(sql)
self.conn.commit()
def upsert_exhibitor(self, item: Exhibitor) -> None:
sql = """
INSERT INTO exhibitors (
brand_name,
hall,
category,
booth_no,
detail_url,
source_url,
crawl_time
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(detail_url) DO UPDATE SET
brand_name = excluded.brand_name,
hall = excluded.hall,
category = excluded.category,
booth_no = excluded.booth_no,
source_url = excluded.source_url,
crawl_time = excluded.crawl_time;
"""
self.conn.execute(
sql,
(
item.brand_name,
item.hall,
item.category,
item.booth_no,
item.detail_url,
item.source_url,
item.crawl_time,
),
)
self.conn.commit()
def upsert_many(self, items: Iterable[Exhibitor]) -> int:
count = 0
for item in items:
if not item.detail_url:
continue
self.upsert_exhibitor(item)
count += 1
return count
def fetch_all(self) -> list[dict]:
sql = """
SELECT
brand_name,
hall,
category,
booth_no,
detail_url,
source_url,
crawl_time
FROM exhibitors
ORDER BY hall ASC, booth_no ASC, brand_name ASC;
"""
rows = self.conn.execute(sql).fetchall()
return [dict(row) for row in rows]
def export_csv(self) -> None:
rows = self.fetch_all()
fieldnames = [
"brand_name",
"hall",
"category",
"booth_no",
"detail_url",
"source_url",
"crawl_time",
]
with self.csv_path.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def count(self) -> int:
sql = "SELECT COUNT(*) AS total FROM exhibitors;"
row = self.conn.execute(sql).fetchone()
return int(row["total"])
def close(self) -> None:
self.conn.close()
8.4 为什么 CSV 使用 utf-8-sig
很多中文 Windows 环境下,用 Excel 直接打开 UTF-8 CSV 可能显示乱码。utf-8-sig 会在文件开头写入 BOM,Excel 通常能正确识别编码。
代码:
with self.csv_path.open("w", newline="", encoding="utf-8-sig") as f:
...
如果后续主要给程序读取,普通 utf-8 也可以。如果主要给业务同事用 Excel 打开,我更倾向 utf-8-sig。
9️⃣ 运行方式与结果展示
9.1 main.py
# main.py
import config
from fetcher import FetchError, Fetcher
from parser import ExpoParser
from storage import Storage
from utils import polite_sleep, setup_logger
def build_list_url(page: int) -> str:
return config.LIST_URL_TEMPLATE.format(page=page)
def run() -> None:
logger = setup_logger(config.LOG_PATH)
fetcher = Fetcher(logger=logger)
parser = ExpoParser(base_url=config.BASE_URL)
storage = Storage(db_path=config.DB_PATH, csv_path=config.CSV_PATH)
total_parsed = 0
total_saved = 0
try:
for page in range(config.START_PAGE, config.END_PAGE + 1):
list_url = build_list_url(page)
logger.info("start list page: %s", list_url)
try:
list_html = fetcher.get_text(list_url)
except FetchError as exc:
logger.error("list page failed: %s | %s", list_url, exc)
continue
list_items = parser.parse_list_page(list_html, source_url=list_url)
logger.info("list page parsed: %s | items=%s", list_url, len(list_items))
for item in list_items:
detail_url = item.get("detail_url", "")
if not detail_url:
logger.warning("missing detail_url: %s", item)
continue
try:
detail_html = fetcher.get_text(detail_url, referer=list_url)
exhibitor = parser.parse_detail_page(detail_html, fallback=item)
storage.upsert_exhibitor(exhibitor)
total_parsed += 1
total_saved += 1
logger.info(
"saved exhibitor | brand=%s | booth=%s | url=%s",
exhibitor.brand_name,
exhibitor.booth_no,
exhibitor.detail_url,
)
except FetchError as exc:
logger.error("detail page failed: %s | %s", detail_url, exc)
continue
except Exception as exc:
logger.exception("parse/save failed: %s | %s", detail_url, exc)
continue
polite_sleep(config.MIN_DELAY, config.MAX_DELAY)
storage.export_csv()
logger.info("finished")
logger.info("total_parsed=%s", total_parsed)
logger.info("total_saved=%s", total_saved)
logger.info("db_count=%s", storage.count())
logger.info("csv_path=%s", config.CSV_PATH)
finally:
fetcher.close()
storage.close()
if __name__ == "__main__":
run()
9.2 本地 fixtures 示例页面
为了让项目真正可运行,我们准备几份本地 HTML。
fixtures/list_page_1.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>家居展会参展品牌目录 - 第 1 页</title>
</head>
<body>
<main class="expo-directory">
<h1>家居展会参展品牌目录</h1>
<div class="exhibitor-card">
<h3 class="brand-name">森禾家居</h3>
<p>展馆:<span class="hall">3.1H</span></p>
<p>品类:<span class="category">客厅家具</span></p>
<p>展位号:<span class="booth-no">3.1H-A128</span></p>
<a class="detail-link" href="fixture://detail_senhe.html">查看详情</a>
</div>
<div class="exhibitor-card">
<h3 class="brand-name">木家造作</h3>
<p>展馆:<span class="hall">4.2H</span></p>
<p>品类:<span class="category">实木家具</span></p>
<p>展位号:<span class="booth-no">4.2H-B066</span></p>
<a class="detail-link" href="fixture://detail_mujia.html">查看详情</a>
</div>
</main>
</body>
</html>
fixtures/list_page_2.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>家居展会参展品牌目录 - 第 2 页</title>
</head>
<body>
<main class="expo-directory">
<h1>家居展会参展品牌目录</h1>
<div class="exhibitor-card">
<h3 class="brand-name">一然软装</h3>
<p>展馆:<span class="hall">5.1H</span></p>
<p>品类:<span class="category">窗帘布艺</span></p>
<p>展位号:<span class="booth-no">5.1H-C219</span></p>
<a class="detail-link" href="fixture://detail_yiran.html">查看详情</a>
</div>
<div class="exhibitor-card">
<h3 class="brand-name">青木睡眠</h3>
<p>展馆:<span class="hall">6.1H</span></p>
<p>品类:<span class="category">床垫睡眠</span></p>
<p>展位号:<span class="booth-no">6.1H-D018</span></p>
<a class="detail-link" href="fixture://detail_qingmu.html">查看详情</a>
</div>
</main>
</body>
</html>
fixtures/detail_senhe.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>森禾家居 - 参展商详情</title>
<meta name="brand:name" content="森禾家居">
</head>
<body>
<article class="brand-detail">
<h1 class="brand-title">森禾家居</h1>
<div class="detail-row">
<span class="label">展馆:</span>
<span class="value">3.1H</span>
</div>
<div class="detail-row">
<span class="label">品类:</span>
<span class="value">客厅家具</span>
</div>
<div class="detail-row">
<span class="label">展位号:</span>
<span class="value">3.1H-A128</span>
</div>
<section class="intro">
<p>森禾家居专注现代客厅空间产品,覆盖沙发、茶几、电视柜等组合方案。</p>
</section>
</article>
</body>
</html>
fixtures/detail_mujia.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>木家造作 - 参展商详情</title>
</head>
<body>
<article class="brand-detail">
<h1 class="brand-title">木家造作</h1>
<div class="detail-row">
<span class="label">展馆:</span>
<span class="value">4.2H</span>
</div>
<div class="detail-row">
<span class="label">品类:</span>
<span class="value">实木家具</span>
</div>
<div class="detail-row">
<span class="label">展位号:</span>
<span class="value">4.2H-B066</span>
</div>
<section class="intro">
<p>木家造作主打实木餐厅、卧室和书房家具,产品风格偏自然、克制。</p>
</section>
</article>
</body>
</html>
fixtures/detail_yiran.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>一然软装 - 参展商详情</title>
</head>
<body>
<article class="brand-detail">
<h1 class="brand-title">一然软装</h1>
<div class="detail-row">
<span class="label">展馆:</span>
<span class="value">5.1H</span>
</div>
<div class="detail-row">
<span class="label">品类:</span>
<span class="value">窗帘布艺</span>
</div>
<div class="detail-row">
<span class="label">展位号:</span>
<span class="value">5.1H-C219</span>
</div>
<section class="intro">
<p>一然软装提供窗帘、墙布、抱枕、空间软装搭配等产品与服务。</p>
</section>
</article>
</body>
</html>
fixtures/detail_qingmu.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>青木睡眠 - 参展商详情</title>
</head>
<body>
<article class="brand-detail">
<h1 class="brand-title">青木睡眠</h1>
<div class="detail-row">
<span class="label">展馆:</span>
<span class="value">6.1H</span>
</div>
<div class="detail-row">
<span class="label">品类:</span>
<span class="value">床垫睡眠</span>
</div>
<div class="detail-row">
<span class="label">展位号:</span>
<span class="value">6.1H-D018</span>
</div>
<section class="intro">
<p>青木睡眠关注床垫、床架和睡眠空间解决方案,适合卧室家具渠道关注。</p>
</section>
</article>
</body>
</html>
9.3 启动方式
在项目根目录执行:
python main.py
如果你的环境默认 Python 2 或多个 Python 版本并存,可以执行:
python3 main.py
运行后会生成:
data/home_expo.db
data/exhibitors.csv
logs/spider.log
9.4 输出在哪里
SQLite 数据库:
data/home_expo.db
CSV 文件:
data/exhibitors.csv
日志文件:
logs/spider.log
9.5 示例日志
2026-06-09 10:30:01 | INFO | home_expo_spider | start list page: fixture://list_page_1.html
2026-06-09 10:30:01 | INFO | home_expo_spider | READ FIXTURE fixtures/list_page_1.html
2026-06-09 10:30:01 | INFO | home_expo_spider | list page parsed: fixture://list_page_1.html | items=2
2026-06-09 10:30:01 | INFO | home_expo_spider | READ FIXTURE fixtures/detail_senhe.html
2026-06-09 10:30:01 | INFO | home_expo_spider | saved exhibitor | brand=森禾家居 | booth=3.1H-A128 | url=fixture://detail_senhe.html
9.6 示例结果
导出的 CSV 大致如下:
| brand_name | hall | category | booth_no | detail_url | source_url | crawl_time |
|---|---|---|---|---|---|---|
| 森禾家居 | 3.1H | 客厅家具 | 3.1H-A128 | fixture://detail_senhe.html | fixture://list_page_1.html | 2026-06-09 10:30:01 |
| 木家造作 | 4.2H | 实木家具 | 4.2H-B066 | fixture://detail_mujia.html | fixture://list_page_1.html | 2026-06-09 10:30:03 |
| 一然软装 | 5.1H | 窗帘布艺 | 5.1H-C219 | fixture://detail_yiran.html | fixture://list_page_2.html | 2026-06-09 10:30:05 |
| 青木睡眠 | 6.1H | 床垫睡眠 | 6.1H-D018 | fixture://detail_qingmu.html | fixture://list_page_2.html | 2026-06-09 10:30:07 |
如果要查看 SQLite:
sqlite3 data/home_expo.db
进入后执行:
SELECT brand_name, hall, category, booth_no, detail_url
FROM exhibitors
ORDER BY hall, booth_no;
也可以统计各品类数量:
SELECT category, COUNT(*) AS total
FROM exhibitors
GROUP BY category
ORDER BY total DESC;
统计各展馆数量:
SELECT hall, COUNT(*) AS total
FROM exhibitors
GROUP BY hall
ORDER BY hall ASC;
🔟 常见问题与排错
爬虫项目写起来不难,难的是处理各种“不按教程来”的情况。下面这些问题是目录页采集里很常见的。
10.1 403 怎么办
403 表示服务器拒绝访问。可能原因包括:
- 缺少 User-Agent。
- Referer 不符合预期。
- 请求频率太高。
- 访问路径不允许。
- 站点做了基础风控。
- 需要登录或权限。
建议按顺序检查:
第一,确认页面是否公开可访问。用浏览器无登录状态打开目标 URL,看能不能正常访问。
第二,检查 robots.txt 和站点使用规则。如果明确不允许自动化访问,就不要继续。
第三,补充合理 headers:
headers = {
"User-Agent": "...",
"Accept-Language": "zh-CN,zh;q=0.9",
}
第四,降低请求频率。把延迟调大:
MIN_DELAY = 3.0
MAX_DELAY = 8.0
第五,不要用攻击式并发。对于目录页,很多时候单线程就够了。
如果这些处理后仍然 403,说明站点可能不希望被自动化访问。这个时候不建议继续硬绕。
10.2 429 怎么办
429 通常表示请求太频繁。处理方式:
- 增加请求间隔。
- 降低并发。
- 加入退避重试。
- 记录失败 URL,稍后重跑。
- 不在短时间内重复抓同一页面。
示例退避:
import time
for attempt in range(1, 4):
try:
response = session.get(url, timeout=(5, 15))
if response.status_code == 429:
wait = 2 ** attempt
time.sleep(wait)
continue
break
except Exception:
time.sleep(2 ** attempt)
需要注意,429 不是让你换一堆代理继续冲。对于普通业务采集,应该优先频控和减量。
10.3 HTML 抓到空壳怎么办
如果拿到的 HTML 里只有:
<div id="app"></div>
<script src="/static/js/app.js"></script>
说明页面可能是前端动态渲染。处理思路:
第一,打开浏览器开发者工具。
第二,进入 Network 面板。
第三,刷新页面。
第四,筛选 Fetch/XHR 请求。
第五,查看有没有返回品牌列表的 JSON 接口。
如果能找到接口,优先抓接口。比如:
import requests
url = "https://example.com/api/exhibitors"
params = {
"page": 1,
"page_size": 20,
}
response = requests.get(url, params=params, timeout=(5, 15))
data = response.json()
for item in data["items"]:
print(item["brandName"], item["boothNo"])
如果接口难以定位,或页面必须执行 JS 才能拿到数据,可以考虑 Playwright。
简单示例:
from playwright.sync_api import sync_playwright
def render_page(url: str) -> str:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until="networkidle")
html = page.content()
browser.close()
return html
不过 Playwright 成本更高:
- 依赖浏览器环境。
- 速度比 requests 慢。
- 部署更麻烦。
- 更容易给目标站点造成压力。
所以我的建议一直是:能抓 API 就抓 API,能 requests 就别上浏览器。
10.4 解析报错怎么办
解析报错常见有几类。
10.4.1 选择器失效
比如原来是:
soup.select(".exhibitor-card")
后来页面改成:
<div class="brand-card"></div>
就会抓不到数据。
解决方法:
- 保存一份原始 HTML 方便排查。
- 在日志里记录解析数量。
- 如果列表页解析数量为 0,立刻报警或停止。
- 选择器写得不要过度依赖层级。
比如不要写:
soup.select("body > div:nth-child(2) > main > div > div:nth-child(3)")
这种选择器非常脆弱。
更建议写:
soup.select(".exhibitor-card")
或:
soup.select("[data-role='exhibitor-card']")
10.4.2 字段节点不存在
错误写法:
name = card.select_one(".brand-name").text.strip()
如果 .brand-name 不存在,会报:
AttributeError: 'NoneType' object has no attribute 'text'
更稳的写法:
def select_text(node, selector):
tag = node.select_one(selector)
if not tag:
return ""
return tag.get_text(" ", strip=True)
10.4.3 页面结构不统一
有的品牌详情页结构是:
<div class="detail-row">
<span class="label">展馆:</span>
<span class="value">3.1H</span>
</div>
有的可能是:
<li><b>展馆</b><em>3.1H</em></li>
这时可以写多套候选规则:
hall = (
parse_by_detail_row(soup, "展馆")
or select_text(soup, ".detail-hall")
or select_text(soup, "[data-field='hall']")
or fallback.get("hall", "")
)
采集项目不怕规则多,就怕规则散。最好统一封装在 Parser 里。
10.5 编码和乱码如何处理
乱码通常出现在这些情况:
- 网站不是 UTF-8。
- requests 自动判断错编码。
- CSV 用 Excel 打开时识别错。
- 页面混合编码。
本文 Fetcher 里做了:
if not response.encoding or response.encoding.lower() == "iso-8859-1":
response.encoding = response.apparent_encoding
CSV 导出使用:
encoding="utf-8-sig"
如果还是乱码,可以临时打印:
print(response.encoding)
print(response.apparent_encoding)
print(response.text[:500])
也可以强制指定:
response.encoding = "gb18030"
不过强制编码要谨慎,最好先确认页面实际编码。
10.6 CSV 打开后字段错位怎么办
可能原因:
- 字段里包含英文逗号。
- 字段里包含换行。
- 没有用 csv 模块正确写入。
- 手工拼接字符串。
错误写法:
line = f"{brand_name},{hall},{category},{booth_no},{detail_url}\n"
如果品牌名里有逗号,CSV 就错位。
正确写法:
import csv
with open("data.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
不要手写 CSV 拼接。这个坑很常见,也很没必要。
10.7 数据重复怎么办
先确认去重键。本文用 detail_url 唯一。
如果真实站点详情 URL 里带无意义参数,比如:
https://example.com/exhibitors/1001?from=list
https://example.com/exhibitors/1001?utm_source=expo
需要 URL 规范化。可以去掉追踪参数:
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
def normalize_url(url: str) -> str:
parts = urlsplit(url)
query_pairs = []
for k, v in parse_qsl(parts.query):
if k.lower().startswith("utm_"):
continue
if k.lower() in {"from", "spm"}:
continue
query_pairs.append((k, v))
query = urlencode(query_pairs)
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, ""))
10.8 日志里显示保存成功,但 CSV 没数据
检查几点:
- 是否调用了
storage.export_csv()。 - CSV 是否写到了你以为的目录。
- 数据是否写进 SQLite。
- 当前运行路径是否正确。
- 是否有权限写文件。
可以加一行:
print(config.CSV_PATH.resolve())
看实际路径。
10.9 真实网站分页规则不一样怎么办
本文配置是:
LIST_URL_TEMPLATE = "fixture://list_page_{page}.html"
START_PAGE = 1
END_PAGE = 2
真实站点可能是:
https://example.com/exhibitors?page=1
改成:
LIST_URL_TEMPLATE = "https://example.com/exhibitors?page={page}"
也可能是:
https://example.com/exhibitors/p/1
改成:
LIST_URL_TEMPLATE = "https://example.com/exhibitors/p/{page}"
如果是 POST 接口分页,则 Fetcher 要加 post_json 或 post_form 方法。这属于另一类实现,后面进阶部分会提到。
1️⃣1️⃣ 进阶优化
基础版本能跑之后,才谈优化。不要一开始就把 Scrapy、Playwright、代理池、分布式全塞进去。小项目过度设计,最后经常变成“看起来很专业,实际不稳定”。
11.1 并发优化
如果目标站点允许、数据量较大、并且已经做好频控,可以考虑并发。
11.1.1 线程池版本
requests 是同步阻塞库,适合用线程池做轻量并发。
示例:
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_and_parse_detail(item):
detail_url = item["detail_url"]
detail_html = fetcher.get_text(detail_url, referer=item["source_url"])
return parser.parse_detail_page(detail_html, fallback=item)
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(fetch_and_parse_detail, item) for item in list_items]
for future in as_completed(futures):
try:
exhibitor = future.result()
storage.upsert_exhibitor(exhibitor)
except Exception as exc:
logger.exception("worker failed: %s", exc)
注意并发数不要过大。对目录页来说,max_workers=2 或 3 已经够用。
11.1.2 asyncio 版本
如果想用异步,可以考虑 httpx 或 aiohttp。但异步代码复杂度更高,初版没必要上来就写。
11.2 Scrapy 化
当项目变大,比如:
- 多个展会站点。
- 每个站点都有多套规则。
- 需要队列调度。
- 需要自动去重。
- 需要中间件。
- 需要 pipeline。
- 需要失败重试和状态管理。
这时可以考虑 Scrapy。
Scrapy 的项目结构通常是:
expo_scrapy/
├── scrapy.cfg
└── expo_scrapy/
├── items.py
├── pipelines.py
├── settings.py
└── spiders/
└── home_expo.py
简单 spider 示例:
import scrapy
class HomeExpoSpider(scrapy.Spider):
name = "home_expo"
allowed_domains = ["example.com"]
start_urls = ["https://example.com/exhibitors?page=1"]
def parse(self, response):
for card in response.css(".exhibitor-card"):
detail_url = card.css("a.detail-link::attr(href)").get()
item = {
"brand_name": card.css(".brand-name::text").get(default="").strip(),
"hall": card.css(".hall::text").get(default="").strip(),
"category": card.css(".category::text").get(default="").strip(),
"booth_no": card.css(".booth-no::text").get(default="").strip(),
"source_url": response.url,
}
if detail_url:
yield response.follow(
detail_url,
callback=self.parse_detail,
cb_kwargs={"fallback": item},
)
next_url = response.css("a.next::attr(href)").get()
if next_url:
yield response.follow(next_url, callback=self.parse)
def parse_detail(self, response, fallback):
yield {
"brand_name": response.css("h1.brand-title::text").get(default=fallback["brand_name"]).strip(),
"hall": self.detail_value(response, "展馆") or fallback["hall"],
"category": self.detail_value(response, "品类") or fallback["category"],
"booth_no": self.detail_value(response, "展位号") or fallback["booth_no"],
"detail_url": response.url,
"source_url": fallback["source_url"],
}
def detail_value(self, response, label):
for row in response.css(".detail-row"):
label_text = row.css(".label::text").get(default="").replace(":", "").replace(":", "").strip()
value_text = row.css(".value::text").get(default="").strip()
if label_text == label:
return value_text
return ""
Scrapy 的好处是工程能力强,但学习成本也更高。本文这个 requests 版本适合作为最小可行版本,Scrapy 适合作为后续演进。
11.3 断点续跑
采集中断是常态,不是异常。断网、目标站点临时错误、程序 bug、机器重启,都可能发生。
断点续跑可以这样做:
- 已入库 URL 不再请求。
- 失败 URL 记录到失败表。
- 每次启动先加载已抓集合。
- 运行结束导出失败清单。
SQLite 增加一张表:
CREATE TABLE IF NOT EXISTS failed_urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
source_url TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
crawl_time TEXT NOT NULL DEFAULT ''
);
Python 方法:
def save_failed_url(self, url: str, source_url: str, error: str, crawl_time: str) -> None:
sql = """
INSERT INTO failed_urls (url, source_url, error, crawl_time)
VALUES (?, ?, ?, ?);
"""
self.conn.execute(sql, (url, source_url, error, crawl_time))
self.conn.commit()
判断是否已抓:
def exists_detail_url(self, detail_url: str) -> bool:
sql = "SELECT 1 FROM exhibitors WHERE detail_url = ? LIMIT 1;"
row = self.conn.execute(sql, (detail_url,)).fetchone()
return row is not None
主流程里:
if storage.exists_detail_url(detail_url):
logger.info("skip existing url: %s", detail_url)
continue
11.4 日志与监控
一个稍微认真一点的采集项目,至少要知道:
- 请求了多少列表页。
- 请求了多少详情页。
- 成功多少。
- 失败多少。
- 解析出多少条。
- 入库多少条。
- 空字段比例是多少。
- 失败 URL 是哪些。
- 运行耗时多少。
可以定义一个简单统计器:
from dataclasses import dataclass
from time import time
@dataclass
class SpiderStats:
list_pages: int = 0
detail_pages: int = 0
parsed_items: int = 0
saved_items: int = 0
failed_requests: int = 0
missing_brand_name: int = 0
missing_booth_no: int = 0
started_at: float = 0.0
def start(self):
self.started_at = time()
def elapsed(self) -> float:
return time() - self.started_at
运行结束打印:
logger.info("stats | list_pages=%s", stats.list_pages)
logger.info("stats | detail_pages=%s", stats.detail_pages)
logger.info("stats | parsed_items=%s", stats.parsed_items)
logger.info("stats | saved_items=%s", stats.saved_items)
logger.info("stats | failed_requests=%s", stats.failed_requests)
logger.info("stats | elapsed=%.2fs", stats.elapsed())
日志不是给机器看的,是给未来排查问题的自己看的。这个习惯越早养成越好。
11.5 定时任务
如果展会目录会不断更新,可以定时运行。
Linux cron 示例:
crontab -e
每天凌晨 2 点执行:
0 2 * * * cd /path/to/home_expo_spider && /path/to/home_expo_spider/.venv/bin/python main.py >> logs/cron.log 2>&1
如果任务复杂,可以考虑 Airflow、Prefect 等调度工具。但对单站目录采集来说,cron 已经足够。
11.6 数据清洗增强
后续可以加一些更细的清洗规则。
11.6.1 展馆字段标准化
def normalize_hall(hall: str) -> str:
hall = normalize_text(hall).upper()
hall = hall.replace("馆", "")
hall = hall.replace(" ", "")
return hall
把:
3.1h
3.1 H
3.1H馆
统一成:
3.1H
11.6.2 展位号标准化
def normalize_booth_no(booth_no: str) -> str:
booth_no = normalize_text(booth_no).upper()
booth_no = booth_no.replace("展位号", "")
booth_no = booth_no.replace(":", "")
booth_no = booth_no.replace(":", "")
booth_no = booth_no.replace(" ", "")
return booth_no
11.6.3 品类拆分
有些品牌品类是:
客厅家具 / 实木家具 / 定制家具
可以拆成列表:
import re
def split_categories(category: str) -> list[str]:
category = normalize_text(category)
if not category:
return []
parts = re.split(r"[/、,,;;|]+", category)
return [p.strip() for p in parts if p.strip()]
如果要做多品类关系,数据库可以增加一张表:
CREATE TABLE exhibitor_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
detail_url TEXT NOT NULL,
category TEXT NOT NULL
);
11.7 从 CSV 走向 MySQL
SQLite 适合单机小项目。多人协作或线上服务,可以换 MySQL。
表结构示例:
CREATE TABLE exhibitors (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
brand_name VARCHAR(255) NOT NULL DEFAULT '',
hall VARCHAR(100) NOT NULL DEFAULT '',
category VARCHAR(255) NOT NULL DEFAULT '',
booth_no VARCHAR(100) NOT NULL DEFAULT '',
detail_url VARCHAR(1000) NOT NULL,
source_url VARCHAR(1000) NOT NULL DEFAULT '',
crawl_time DATETIME NOT NULL,
UNIQUE KEY uk_detail_url (detail_url(255)),
KEY idx_hall (hall),
KEY idx_category (category),
KEY idx_booth_no (booth_no)
) DEFAULT CHARSET=utf8mb4;
Python 可以使用:
pip install pymysql
插入逻辑:
import pymysql
conn = pymysql.connect(
host="localhost",
user="root",
password="password",
database="expo",
charset="utf8mb4",
)
sql = """
INSERT INTO exhibitors
(brand_name, hall, category, booth_no, detail_url, source_url, crawl_time)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
brand_name = VALUES(brand_name),
hall = VALUES(hall),
category = VALUES(category),
booth_no = VALUES(booth_no),
source_url = VALUES(source_url),
crawl_time = VALUES(crawl_time);
"""
但初学阶段不要急着上 MySQL。先让 SQLite 跑通,后面迁移并不难。
11.8 支持 JSON API 页面
如果目标展会目录背后是 JSON 接口,可以新增一个 API Parser。
示例响应:
{
"data": {
"list": [
{
"name": "森禾家居",
"hallName": "3.1H",
"categoryName": "客厅家具",
"boothNo": "3.1H-A128",
"detailUrl": "/exhibitors/senhe"
}
]
}
}
代码:
import requests
from models import Exhibitor
from utils import make_absolute_url, normalize_text, now_str
def fetch_api_page(page: int) -> list[Exhibitor]:
url = "https://example.com/api/exhibitors"
params = {
"page": page,
"pageSize": 20,
}
response = requests.get(url, params=params, timeout=(5, 15))
response.raise_for_status()
data = response.json()
rows = data.get("data", {}).get("list", [])
result = []
for row in rows:
detail_url = make_absolute_url("https://example.com", row.get("detailUrl", ""))
item = Exhibitor(
brand_name=normalize_text(row.get("name", "")),
hall=normalize_text(row.get("hallName", "")),
category=normalize_text(row.get("categoryName", "")),
booth_no=normalize_text(row.get("boothNo", "")),
detail_url=detail_url,
source_url=response.url,
crawl_time=now_str(),
)
result.append(item)
return result
如果能拿到稳定 API,解析层会比 HTML 简洁很多。
11.9 支持增量更新
展会目录可能每天新增品牌。全量抓取当然简单,但数据量大时可以做增量:
- 按更新时间抓。
- 按页码抓到已知 URL 后停止。
- 每次只抓前 N 页。
- 比较历史快照。
如果目录按最新排序,可以这样:
seen_existing = 0
for item in list_items:
if storage.exists_detail_url(item["detail_url"]):
seen_existing += 1
else:
seen_existing = 0
if seen_existing >= 20:
logger.info("many existing items found, stop incremental crawl")
break
这个策略不一定适合所有站点,但对“最新参展商排前面”的目录页很好用。
11.10 保存原始 HTML
正式项目中,我建议保存部分原始 HTML,尤其是失败页面和解析异常页面。
from pathlib import Path
import hashlib
RAW_DIR = Path("data/raw_html")
RAW_DIR.mkdir(parents=True, exist_ok=True)
def save_raw_html(url: str, html: str) -> Path:
name = hashlib.md5(url.encode("utf-8")).hexdigest() + ".html"
path = RAW_DIR / name
path.write_text(html, encoding="utf-8")
return path
好处是:
- 选择器失效时可以复盘。
- 页面异常时有证据。
- 不需要反复请求目标站点。
- 方便写单元测试。
11.11 单元测试解析器
Parser 最适合写单元测试。因为它不需要网络,只需要 HTML 字符串。
示例:
from parser import ExpoParser
def test_parse_list_page():
html = """
<div class="exhibitor-card">
<h3 class="brand-name">森禾家居</h3>
<span class="hall">3.1H</span>
<span class="category">客厅家具</span>
<span class="booth-no">3.1H-A128</span>
<a class="detail-link" href="/exhibitors/senhe">详情</a>
</div>
"""
parser = ExpoParser(base_url="https://example.com")
items = parser.parse_list_page(html, "https://example.com/exhibitors?page=1")
assert len(items) == 1
assert items[0]["brand_name"] == "森禾家居"
assert items[0]["detail_url"] == "https://example.com/exhibitors/senhe"
安装 pytest:
pip install pytest
运行:
pytest
采集项目只要 Parser 稳,整体就稳了一半。
1️⃣2️⃣ 总结与延伸阅读
这篇文章围绕“家居展会参展品牌目录”做了一个完整的小型采集项目。我们采集的字段包括:
- 品牌名
- 展馆
- 品类
- 展位号
- 链接
- 来源列表页
- 采集时间
技术上,我们采用:
- requests 负责请求。
- BeautifulSoup 负责解析。
- SQLite 负责结构化存储。
- CSV 负责导出交付。
- 本地 fixtures 负责可复现测试。
- 日志负责运行追踪。
- detail_url 负责去重。
流程上,我们把项目拆成:
采集 → 解析 → 清洗 → 存储
工程上,我们考虑了:
- headers
- timeout
- session
- robots.txt
- 重试退避
- 缺失字段容错
- URL 去重
- CSV 编码
- SQLite upsert
- 运行日志
- 常见错误处理
这已经不是一段临时脚本,而是一个可以继续扩展的小项目骨架。
后续可以继续做几件事:
第一,把真实展会目录接进来。替换 LIST_URL_TEMPLATE,根据真实 HTML 修改 parser.py 的选择器。
第二,支持 API 采集。如果目标页面背后有 JSON 接口,优先写 API Fetcher 和 JSON Parser。
第三,上 Scrapy。当站点数量增加、任务变复杂时,用 Scrapy 接管调度、队列、去重和 pipeline。
第四,上 Playwright。只在页面必须动态渲染、且没有更合适接口时使用浏览器自动化。
第五,做数据产品化。比如把 SQLite 换成 MySQL 或 PostgreSQL,加一个搜索页面,按展馆、品类、品牌名筛选。
第六,做长期更新。配合 cron 或 Airflow,每天定时采集,沉淀品牌参展历史。
最后说一点个人感受:目录页采集看起来朴素,但很适合作为数据工程入门题。它没有太复杂的算法,却会逼着你认真处理请求、解析、清洗、存储、容错和可复跑。等这些基础都打稳,再去做更复杂的站点,心里会踏实很多。
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以“入门 → 进阶 → 工程化 → 项目落地”的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~
✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 “谁使用,谁负责” 。如不同意,请立即停止使用并删除本项目。!!!

133

被折叠的 条评论
为什么被折叠?



