用 Python 抓取创客空间公开目录页:从资源目录到可落库数据集
㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐☆☆☆(基础级)
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。
全文目录:
- 附录:完整代码清单
-
- requirements.txt
- config.yaml
- makerspace_crawler/**init**.py
- makerspace_crawler/models.py
- makerspace_crawler/config.py
- makerspace_crawler/utils.py
- makerspace_crawler/fetcher.py
- makerspace_crawler/parser.py
- makerspace_crawler/storage.py
- run.py
- README.md
- 启动本地演示站点
- 运行爬虫
- 输出
- 说明
- demo_site/page-2.html
- demo_site/detail/maker-alpha.html
- demo_site/detail/maker-beta.html
- demo_site/detail/maker-gamma.html
- demo_site/detail/maker-delta.html
- demo_site/detail/maker-epsilon.html
- 迁移到真实公开目录页时怎么改
- 🌟 文末
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战,主理专栏👉 《Python爬虫实战》:从采集策略到反爬对抗,从数据清洗到分布式调度,持续输出可复用的方法论与可落地案例。内容主打一个“能跑、能用、能扩展”,让数据价值真正做到——抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间:如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
0️⃣ 前言(Preface)
这篇文章要做的事情很明确:用 Python 抓取一个“创客空间公开目录页”,采集空间名、地区、设备类别、开放方式和链接,最终把数据保存到 SQLite 和 CSV 中,方便后续检索、建库或做资源地图。
读完这篇文章,你至少可以获得三样东西:
第一,理解公开目录页这类资源型网页应该怎么拆解,包括列表页、详情页、分页、字段缺失和去重。
第二,拿到一套可以本地跑通的 Python 爬虫工程,里面包含请求层、解析层、存储层、配置文件和演示 HTML 页面。
第三,知道如何把这个小项目迁移到真实站点,同时避开常见的 403、429、动态渲染空壳、编码乱码和选择器失效问题。
我个人比较喜欢这类“目录型数据”的抓取练习。它不像抢购、评论、社交内容那样敏感,也不像复杂反爬场景那样容易把新手带偏。它更接近真实的数据整理工作:页面结构清楚,字段稳定,价值在于把分散的信息清洗成规整的数据表。
1️⃣ 摘要(Abstract)
本文基于 Python、requests、BeautifulSoup、SQLite 和 CSV,构建一个面向“创客空间公开目录页”的资源目录爬虫,目标字段包括空间名、地区、设备类别、开放方式和详情链接,最终输出结构化数据文件与本地数据库。
读完本文,你可以掌握:
一套适合公开资源目录页的爬虫分层写法:Fetcher 负责请求,Parser 负责解析,Storage 负责落库。
一套可复现的工程结构:配置文件控制入口地址和 CSS 选择器,代码无需写死某个网站。
一套面向建库的处理思路:字段清洗、缺失容错、URL 去重、日志记录、失败重试和导出 CSV。
这不是一篇追求“炫技”的爬虫文章。我的目标是把一个看似普通的目录页,处理成真正可以维护、可以扩展、可以反复运行的小型采集工程。
2️⃣ 背景与需求(Why)
2.1 为什么要爬创客空间公开目录页
很多城市、高校、协会或社区平台都会维护一些创客空间、创新实验室、开放工坊、公共技术平台的目录页。这些页面通常对外公开,内容包括空间名称、所在地区、可用设备、预约方式、开放对象、官方网站或详情页链接。
如果只看一两个页面,用浏览器打开当然没问题。但当目录数量变多时,手动复制就会变得很低效。
比较典型的需求有几个。
第一是信息聚合。比如你想做一个城市创客空间导航,把不同来源的空间信息统一整理到一个表里。人工整理不仅慢,而且容易出现字段不一致、链接漏填、名称重复等问题。
第二是数据分析。假设你收集了几百条创客空间数据,就可以分析哪些地区资源更集中,哪些空间提供 3D 打印、激光切割、木工、电子实验、CNC 加工等设备,哪些空间开放方式更友好。
第三是自动化更新。公开目录页偶尔会新增空间,也会调整链接或开放方式。如果用脚本定期跑一遍,就能减少人工维护成本。
这类任务的重点不在于“突破限制”,而在于“把公开信息变成可用数据”。我更愿意把它看成数据工程入门,而不是单纯爬虫练习。
2.2 目标站点形态
本文默认目标站点是一个普通的公开目录页,大致有两类页面。
第一类是列表页。列表页展示多个创客空间条目,每个条目包含名称、地区、可能还有简短标签,并提供详情页链接。
第二类是详情页。详情页包含更完整的信息,例如空间名称、所在地区、设备类别、开放方式、介绍内容等。
本文会附带一个本地演示站点,目录结构如下:
demo_site/
├── index.html
├── page-2.html
└── detail/
├── maker-alpha.html
├── maker-beta.html
├── maker-gamma.html
├── maker-delta.html
└── maker-epsilon.html
真实站点只要结构相近,改配置即可适配。
2.3 目标字段清单
本项目采集字段如下:
| 字段 | 英文字段名 | 说明 |
|---|---|---|
| 空间名 | space_name | 创客空间、开放工坊或实验室名称 |
| 地区 | region | 所在城市、区县、校区或园区 |
| 设备类别 | equipment_category | 3D 打印、激光切割、CNC、电子实验等 |
| 开放方式 | open_method | 预约开放、会员开放、工作日开放、活动开放等 |
| 链接 | url | 详情页完整链接 |
为了方便实际建库,代码里还会额外保存 source_url 和 crawled_at 两个辅助字段。它们不影响核心需求,但对排查数据来源和增量更新很有帮助。
3️⃣ 合规与注意事项
做爬虫之前,我习惯先把边界说清楚。技术本身是中性的,但采集行为需要克制。尤其是面向公开网页时,最稳妥的做法不是“能抓就抓”,而是“只抓应该抓的内容”。
3.1 robots.txt 基本说明
robots.txt 是网站放在根目录下的爬虫访问规则文件,例如:
https://example.com/robots.txt
它通常会告诉爬虫哪些路径允许访问,哪些路径不希望被自动抓取。虽然它不是强制访问控制机制,但在技术实践中应当尊重。
本文代码里会提供一个 respect_robots 配置项。开启后,爬虫在访问 URL 前会通过 urllib.robotparser 判断当前 User-Agent 是否允许访问该路径。
需要注意的是,robots.txt 不能替代法律判断,也不能代表网站所有细节规则。它只是一个很基础的爬虫礼仪入口。遇到站点服务条款、版权声明、接口说明时,也应该一并查看。
3.2 频率控制,不做攻击式并发
公开目录页通常没有必要高频访问。几十页、几百页的数据,用低速串行爬取就足够了。
本文默认每次请求后睡眠 1 秒左右,并支持随机抖动。这样可以减少对目标站点的压力,也能让自己的脚本更稳定。
不建议做以下行为:
短时间内发起大量并发请求。
对错误页面疯狂重试。
绕过站点明确限制。
无视 429 Too Many Requests 响应。
采集资源目录的核心是稳定,不是速度。很多时候,慢一点反而更省事。
3.3 不采集敏感信息
本文只面向公开目录页,字段也只包含空间名称、地区、设备类别、开放方式和公开链接。
不采集个人身份证件、手机号、邮箱、精确住址、登录后内容、付费内容或其他敏感信息。
如果详情页里出现联系人姓名、私人联系方式等信息,建议默认不要抓,除非它明确属于公开机构联系信息,并且采集目的合理、存储方式安全。
3.4 不绕过登录或付费限制
本文不讨论绕过登录、不讨论破解验证码、不讨论突破付费墙,也不讨论任何规避访问限制的方式。
如果一个页面需要登录才能访问,应该优先确认是否有正式 API、授权数据源或开放数据下载入口。
爬虫更适合处理公开、低频、结构化、非敏感的数据整理场景。把边界定清楚,后面的代码才写得踏实。
4️⃣ 技术选型与整体流程(What / How)
4.1 静态、动态还是 API
常见网页数据来源大致有三类。
第一类是静态 HTML。浏览器打开页面后,数据已经在 HTML 源码里。这类页面最适合用 requests + BeautifulSoup/lxml 处理。
第二类是动态渲染页面。HTML 源码里只有一个空壳,数据由 JavaScript 请求接口后再渲染。这类页面要么直接分析接口,要么使用 Playwright、Selenium 这类浏览器自动化工具。
第三类是公开 API。有些站点会提供 JSON 接口,列表页其实只是前端展示。能用 API 时,优先用 API,因为 JSON 通常比 HTML 更稳定、更干净。
本文选择的是第一类:静态公开目录页。
原因很简单。创客空间目录页通常更接近信息发布页面,页面结构相对稳定,不需要复杂登录状态,也不需要浏览器执行大量脚本。用 requests 抓 HTML,再用 BeautifulSoup 解析,足够清晰。
4.2 整体流程
整个爬虫流程可以写成一句话:
采集 → 解析 → 清洗 → 存储
展开后是:
读取配置
↓
生成列表页 URL
↓
请求列表页 HTML
↓
解析详情页链接
↓
请求详情页 HTML
↓
解析空间名、地区、设备类别、开放方式
↓
字段清洗与容错
↓
按照 URL 去重
↓
写入 SQLite
↓
导出 CSV
这种分层的好处是明显的。
如果请求失败,只改 Fetcher。
如果页面结构变了,只改 Parser 或配置选择器。
如果要从 CSV 换成 MySQL,只改 Storage。
如果要加并发、断点续跑、定时任务,也不会把主逻辑搅乱。
4.3 为什么选 requests、BeautifulSoup 和 SQLite
requests 的优点是简单、稳定、可控。对于静态页面,它比浏览器自动化工具轻很多。
BeautifulSoup 的优点是容错好,适合解析结构不那么标准的 HTML。真实网页经常有标签不闭合、class 混乱、文本换行等情况,BeautifulSoup 比手写正则稳得多。
lxml 可以作为 BeautifulSoup 的解析器,速度快,兼容性也不错。
SQLite 是本地建库的好选择。它不需要单独启动数据库服务,一个 .db 文件就能保存结构化数据。后续要迁移到 MySQL 或 PostgreSQL,也很容易。
CSV 则方便给别人看,也方便用 Excel、Pandas 或 BI 工具继续分析。
5️⃣ 环境准备与依赖安装
5.1 Python 版本
建议使用 Python 3.10 及以上版本。本文代码在 Python 3.10、3.11、3.12 中都可以正常运行。
检查版本:
python --version
或者:
python3 --version
5.2 创建虚拟环境
mkdir makerspace-directory-crawler
cd makerspace-directory-crawler
python -m venv .venv
Windows 激活:
.venv\Scripts\activate
macOS / Linux 激活:
source .venv/bin/activate
5.3 安装依赖
创建 requirements.txt:
requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.3.0
PyYAML==6.0.2
tqdm==4.66.5
安装:
pip install -r requirements.txt
这里没有上 Scrapy,也没有上 Playwright。不是它们不好,而是这个场景暂时不需要。工具越重,维护成本越高。先用轻量方案跑通,再根据实际页面复杂度升级,这是我个人更推荐的路线。
5.4 推荐项目结构
完整项目结构如下:
makerspace-directory-crawler/
├── config.yaml
├── requirements.txt
├── run.py
├── README.md
├── data/
│ ├── makerspaces.db
│ └── makerspaces.csv
├── demo_site/
│ ├── index.html
│ ├── page-2.html
│ └── detail/
│ ├── maker-alpha.html
│ ├── maker-beta.html
│ ├── maker-gamma.html
│ ├── maker-delta.html
│ └── maker-epsilon.html
└── makerspace_crawler/
├── __init__.py
├── config.py
├── fetcher.py
├── parser.py
├── storage.py
├── models.py
└── utils.py
如果你只想快速跑,也可以把代码合在一个文件里。但从可维护角度看,我建议从一开始就分层。小项目写清楚,比后期再拆要舒服很多。
6️⃣ 核心实现:请求层(Fetcher)
请求层只做一件事:把 URL 变成 HTML。
它不应该关心字段怎么解析,也不应该关心数据怎么入库。这样做可以避免代码越写越乱。
6.1 配置文件
先写 config.yaml:
site:
name: "Local Makerspace Demo"
base_url: "http://127.0.0.1:8000"
start_urls:
- "http://127.0.0.1:8000/index.html"
- "http://127.0.0.1:8000/page-2.html"
crawler:
user_agent: "MakerspaceDirectoryBot/1.0 (+https://example.org/bot-info)"
referer: "http://127.0.0.1:8000/"
timeout: 12
delay_seconds: 1.0
delay_jitter_seconds: 0.5
max_retries: 3
backoff_factor: 0.8
respect_robots: false
parser:
list:
item_selector: ".space-card"
link_selector: "a.detail-link"
link_attr: "href"
detail:
space_name_selector: "h1.space-name"
region_selector: ".meta-region"
equipment_category_selector: ".equipment-list li"
open_method_selector: ".open-method"
storage:
sqlite_path: "data/makerspaces.db"
csv_path: "data/makerspaces.csv"
这里把站点入口、请求参数、选择器和存储路径都放在配置里。后续换真实站点时,优先改配置,而不是到处改 Python 代码。
6.2 数据模型
创建 makerspace_crawler/models.py:
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass
class MakerSpaceItem:
space_name: str
region: str
equipment_category: str
open_method: str
url: str
source_url: str
crawled_at: str
@classmethod
def empty(cls, url: str, source_url: str) -> "MakerSpaceItem":
return cls(
space_name="",
region="",
equipment_category="",
open_method="",
url=url,
source_url=source_url,
crawled_at=datetime.now(timezone.utc).isoformat()
)
字段不多,但我还是建议用 dataclass。它比字典更清楚,也更容易被 IDE 补全。
6.3 配置读取
创建 makerspace_crawler/config.py:
from pathlib import Path
from typing import Any
import yaml
def load_config(path: str | Path) -> dict[str, Any]:
config_path = Path(path)
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with config_path.open("r", encoding="utf-8") as f:
config = yaml.safe_load(f)
if not isinstance(config, dict):
raise ValueError("Config file must be a YAML mapping.")
required_top_keys = ["site", "crawler", "parser", "storage"]
for key in required_top_keys:
if key not in config:
raise ValueError(f"Missing required config section: {key}")
return config
配置文件读完后先做最基础的检查。不要等代码跑到一半才发现少了字段。
6.4 工具函数
创建 makerspace_crawler/utils.py:
import hashlib
import logging
import random
import re
import time
from pathlib import Path
from urllib.parse import urljoin, urldefrag
def setup_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
def ensure_parent_dir(file_path: str) -> None:
path = Path(file_path)
path.parent.mkdir(parents=True, exist_ok=True)
def normalize_text(text: str | None) -> str:
if not text:
return ""
text = text.replace("\xa0", " ")
text = re.sub(r"\s+", " ", text)
return text.strip()
def normalize_url(base_url: str, href: str) -> str:
absolute_url = urljoin(base_url, href)
clean_url, _fragment = urldefrag(absolute_url)
return clean_url
def sleep_with_jitter(delay_seconds: float, jitter_seconds: float) -> None:
if delay_seconds <= 0 and jitter_seconds <= 0:
return
delay = delay_seconds + random.uniform(0, max(jitter_seconds, 0))
time.sleep(delay)
def content_hash(*parts: str) -> str:
raw = "||".join(parts)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
normalize_text 很常用。真实网页里换行、多个空格、不可见字符很多,不清洗的话,CSV 里会非常难看。
6.5 Fetcher 实现
创建 makerspace_crawler/fetcher.py:
import logging
import urllib.robotparser
from functools import lru_cache
from urllib.parse import urlparse, urljoin
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
class Fetcher:
def __init__(self, crawler_config: dict):
self.user_agent = crawler_config.get(
"user_agent",
"MakerspaceDirectoryBot/1.0"
)
self.referer = crawler_config.get("referer", "")
self.timeout = int(crawler_config.get("timeout", 10))
self.respect_robots = bool(crawler_config.get("respect_robots", True))
max_retries = int(crawler_config.get("max_retries", 3))
backoff_factor = float(crawler_config.get("backoff_factor", 0.8))
self.session = requests.Session()
self.session.headers.update({
"User-Agent": self.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.8",
"Connection": "keep-alive",
})
if self.referer:
self.session.headers.update({"Referer": self.referer})
retry = Retry(
total=max_retries,
connect=max_retries,
read=max_retries,
status=max_retries,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET"]),
backoff_factor=backoff_factor,
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
def fetch_text(self, url: str) -> str:
if self.respect_robots and not self.can_fetch(url):
raise PermissionError(f"Blocked by robots.txt: {url}")
logger.info("Fetching: %s", url)
response = self.session.get(url, timeout=self.timeout)
response.raise_for_status()
if not response.encoding or response.encoding.lower() == "iso-8859-1":
response.encoding = response.apparent_encoding
return response.text
@lru_cache(maxsize=64)
def _get_robot_parser(self, root_url: str) -> urllib.robotparser.RobotFileParser:
robots_url = urljoin(root_url, "/robots.txt")
parser = urllib.robotparser.RobotFileParser()
parser.set_url(robots_url)
try:
parser.read()
except Exception as exc:
logger.warning("Failed to read robots.txt: %s, error=%s", robots_url, exc)
return parser
def can_fetch(self, url: str) -> bool:
parsed = urlparse(url)
root_url = f"{parsed.scheme}://{parsed.netloc}"
parser = self._get_robot_parser(root_url)
return parser.can_fetch(self.user_agent, url)
这一层包含几个关键点。
headers 里设置了 User-Agent、Accept、Accept-Language 和 Referer。User-Agent 不要伪装成奇怪的浏览器,也不要写得太挑衅。公开目录采集更适合使用清晰的标识。
timeout 是必须写的。没有 timeout 的请求,在网络异常时可能卡很久。
Session 可以复用连接,也方便统一维护 headers 和 cookie。如果目标站点需要正常的匿名 cookie,Session 会自动保存。本文不处理登录 cookie。
失败处理使用了 Retry。对于 429、500、502、503、504 这类临时问题,可以重试并退避。对于 404、403 这类状态,不建议盲目重试。
编码处理也很重要。有些页面响应头没有正确声明编码,response.text 可能出现乱码。这里用 response.apparent_encoding 做一次兜底。
7️⃣ 核心实现:解析层(Parser)
解析层负责把 HTML 变成结构化字段。
本文选择 BeautifulSoup + CSS Selector。原因是配置起来直观,适合目录页。如果你更喜欢 XPath,也可以换成 lxml.html。
7.1 Parser 代码
创建 makerspace_crawler/parser.py:
import logging
from datetime import datetime, timezone
from typing import Iterable
from bs4 import BeautifulSoup
from .models import MakerSpaceItem
from .utils import normalize_text, normalize_url
logger = logging.getLogger(__name__)
class DirectoryParser:
def __init__(self, parser_config: dict):
self.list_config = parser_config["list"]
self.detail_config = parser_config["detail"]
def parse_detail_links(self, html: str, page_url: str) -> list[str]:
soup = BeautifulSoup(html, "lxml")
item_selector = self.list_config.get("item_selector", "")
link_selector = self.list_config.get("link_selector", "a")
link_attr = self.list_config.get("link_attr", "href")
links: list[str] = []
if item_selector:
containers = soup.select(item_selector)
else:
containers = [soup]
for container in containers:
link_node = container.select_one(link_selector)
if not link_node:
logger.debug("No link node found in one list item.")
continue
href = link_node.get(link_attr)
if not href:
logger.debug("Link node found but href is empty.")
continue
links.append(normalize_url(page_url, href))
unique_links = list(dict.fromkeys(links))
logger.info("Parsed %s detail links from %s", len(unique_links), page_url)
return unique_links
def parse_detail(self, html: str, detail_url: str, source_url: str) -> MakerSpaceItem:
soup = BeautifulSoup(html, "lxml")
space_name = self._select_one_text(
soup,
self.detail_config.get("space_name_selector", "")
)
region = self._select_one_text(
soup,
self.detail_config.get("region_selector", "")
)
equipment_category = self._select_many_text(
soup,
self.detail_config.get("equipment_category_selector", "")
)
open_method = self._select_one_text(
soup,
self.detail_config.get("open_method_selector", "")
)
item = MakerSpaceItem(
space_name=space_name,
region=region,
equipment_category=equipment_category,
open_method=open_method,
url=detail_url,
source_url=source_url,
crawled_at=datetime.now(timezone.utc).isoformat()
)
return item
@staticmethod
def _select_one_text(soup: BeautifulSoup, selector: str) -> str:
if not selector:
return ""
node = soup.select_one(selector)
if not node:
return ""
return normalize_text(node.get_text(" ", strip=True))
@staticmethod
def _select_many_text(soup: BeautifulSoup, selector: str) -> str:
if not selector:
return ""
nodes = soup.select(selector)
values = []
for node in nodes:
text = normalize_text(node.get_text(" ", strip=True))
if text:
values.append(text)
return "、".join(dict.fromkeys(values))
7.2 列表页如何拿详情链接
列表页通常长这样:
<article class="space-card">
<h2>海湾创客工坊</h2>
<p>地区:上海 · 浦东</p>
<a class="detail-link" href="/detail/maker-alpha.html">查看详情</a>
</article>
对应配置:
parser:
list:
item_selector: ".space-card"
link_selector: "a.detail-link"
link_attr: "href"
解析逻辑是:
先选中所有 .space-card。
在每个卡片里找 a.detail-link。
读取 href。
用 urljoin 转成绝对链接。
用 dict.fromkeys 去重,同时保留顺序。
这种写法比直接在全页面找所有 a 更稳,因为它限定了链接必须出现在空间卡片里。
7.3 详情页如何抽字段
详情页示例:
<h1 class="space-name">海湾创客工坊</h1>
<div class="meta-region">上海 · 浦东新区</div>
<ul class="equipment-list">
<li>3D 打印</li>
<li>激光切割</li>
<li>电子焊接</li>
</ul>
<div class="open-method">预约开放,工作日 10:00-18:00</div>
对应配置:
detail:
space_name_selector: "h1.space-name"
region_selector: ".meta-region"
equipment_category_selector: ".equipment-list li"
open_method_selector: ".open-method"
设备类别通常是一组标签,所以用 select 抓多个节点,再用顿号拼接。
如果真实站点的设备类别写在一个段落里,例如:
<p class="equipment">设备:3D 打印、激光切割、CNC</p>
可以把选择器改成:
equipment_category_selector: ".equipment"
代码仍然可以工作,只是抓到的是一个节点文本。
7.4 缺失字段怎么办
实际采集时,不要假设每个详情页都很完整。
有的页面没有开放方式。
有的页面设备类别为空。
有的页面名称在 <title> 里,而不是 <h1> 里。
有的页面甚至已经下线。
本文的处理策略是:字段缺失时填空字符串,不让整个任务中断。
这样做的好处是可以先保留数据,再后续人工补充或二次清洗。目录型数据最怕因为某一个页面异常导致整批任务失败。
当然,也可以增加校验规则。例如空间名为空时不入库,或者写入错误日志。这部分后面会讲。
8️⃣ 数据存储与导出(Storage)
本文选择 SQLite 作为主存储,CSV 作为导出格式。
SQLite 适合反复运行、去重、查询。
CSV 适合交付、查看和导入其他工具。
8.1 字段映射表
| 中文字段 | 数据库字段 | 类型 | 示例值 |
|---|---|---|---|
| 空间名 | space_name | TEXT | 海湾创客工坊 |
| 地区 | region | TEXT | 上海 · 浦东新区 |
| 设备类别 | equipment_category | TEXT | 3D 打印、激光切割、电子焊接 |
| 开放方式 | open_method | TEXT | 预约开放,工作日 10:00-18:00 |
| 链接 | url | TEXT UNIQUE | http://127.0.0.1:8000/detail/maker-alpha.html |
| 来源列表页 | source_url | TEXT | http://127.0.0.1:8000/index.html |
| 抓取时间 | crawled_at | TEXT | 2026-06-09T08:30:00+00:00 |
8.2 去重策略
本文使用 URL 唯一去重。
对于目录页来说,详情页 URL 通常就是一个空间的稳定标识。数据库里把 url 设置为 UNIQUE,再次抓到同一条时执行更新。
如果真实站点 URL 经常变化,可以考虑内容 hash。比如用空间名、地区、设备类别生成 hash:
hash_value = sha256(f"{space_name}|{region}|{equipment_category}".encode()).hexdigest()
但我一般会先用 URL。原因是简单、直观,排查问题也方便。
8.3 Storage 代码
创建 makerspace_crawler/storage.py:
import csv
import logging
import sqlite3
from pathlib import Path
from .models import MakerSpaceItem
from .utils import ensure_parent_dir
logger = logging.getLogger(__name__)
class SQLiteStorage:
def __init__(self, sqlite_path: str):
self.sqlite_path = sqlite_path
ensure_parent_dir(sqlite_path)
self.conn = sqlite3.connect(sqlite_path)
self.conn.row_factory = sqlite3.Row
self.init_table()
def init_table(self) -> None:
sql = """
CREATE TABLE IF NOT EXISTS makerspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
space_name TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
equipment_category TEXT NOT NULL DEFAULT '',
open_method TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL UNIQUE,
source_url TEXT NOT NULL DEFAULT '',
crawled_at TEXT NOT NULL DEFAULT ''
);
"""
self.conn.execute(sql)
self.conn.commit()
def upsert_item(self, item: MakerSpaceItem) -> None:
sql = """
INSERT INTO makerspaces (
space_name,
region,
equipment_category,
open_method,
url,
source_url,
crawled_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
space_name = excluded.space_name,
region = excluded.region,
equipment_category = excluded.equipment_category,
open_method = excluded.open_method,
source_url = excluded.source_url,
crawled_at = excluded.crawled_at;
"""
self.conn.execute(
sql,
(
item.space_name,
item.region,
item.equipment_category,
item.open_method,
item.url,
item.source_url,
item.crawled_at,
)
)
self.conn.commit()
def fetch_all(self) -> list[sqlite3.Row]:
sql = """
SELECT
space_name,
region,
equipment_category,
open_method,
url,
source_url,
crawled_at
FROM makerspaces
ORDER BY id ASC;
"""
cursor = self.conn.execute(sql)
return cursor.fetchall()
def export_csv(self, csv_path: str) -> None:
ensure_parent_dir(csv_path)
rows = self.fetch_all()
fieldnames = [
"space_name",
"region",
"equipment_category",
"open_method",
"url",
"source_url",
"crawled_at",
]
with Path(csv_path).open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow({key: row[key] for key in fieldnames})
logger.info("Exported %s rows to CSV: %s", len(rows), csv_path)
def close(self) -> None:
self.conn.close()
这里有两个小细节值得注意。
第一,CSV 使用 utf-8-sig。如果你经常用 Excel 打开中文 CSV,会知道普通 UTF-8 有时会被错误识别,加 BOM 可以减少乱码概率。
第二,入库使用 ON CONFLICT(url) DO UPDATE。重复运行脚本时,同一 URL 不会插入多条,而是更新旧记录。
9️⃣ 运行方式与结果展示
9.1 主入口代码
创建 run.py:
import argparse
import logging
from pathlib import Path
from tqdm import tqdm
from makerspace_crawler.config import load_config
from makerspace_crawler.fetcher import Fetcher
from makerspace_crawler.parser import DirectoryParser
from makerspace_crawler.storage import SQLiteStorage
from makerspace_crawler.utils import setup_logging, sleep_with_jitter
logger = logging.getLogger(__name__)
def crawl(config_path: str) -> None:
config = load_config(config_path)
site_config = config["site"]
crawler_config = config["crawler"]
parser_config = config["parser"]
storage_config = config["storage"]
start_urls = site_config.get("start_urls", [])
if not start_urls:
raise ValueError("No start_urls configured.")
fetcher = Fetcher(crawler_config)
parser = DirectoryParser(parser_config)
storage = SQLiteStorage(storage_config["sqlite_path"])
delay_seconds = float(crawler_config.get("delay_seconds", 1.0))
jitter_seconds = float(crawler_config.get("delay_jitter_seconds", 0.5))
visited_detail_urls: set[str] = set()
try:
for list_url in start_urls:
logger.info("Processing list page: %s", list_url)
try:
list_html = fetcher.fetch_text(list_url)
except Exception as exc:
logger.exception("Failed to fetch list page: %s, error=%s", list_url, exc)
continue
detail_urls = parser.parse_detail_links(list_html, list_url)
for detail_url in tqdm(detail_urls, desc=f"Details from {Path(list_url).name}"):
if detail_url in visited_detail_urls:
logger.info("Skip duplicated detail url in current run: %s", detail_url)
continue
visited_detail_urls.add(detail_url)
try:
sleep_with_jitter(delay_seconds, jitter_seconds)
detail_html = fetcher.fetch_text(detail_url)
item = parser.parse_detail(detail_html, detail_url, list_url)
if not item.space_name:
logger.warning("Skip item because space_name is empty: %s", detail_url)
continue
storage.upsert_item(item)
logger.info("Saved item: %s", item.space_name)
except Exception as exc:
logger.exception("Failed to process detail page: %s, error=%s", detail_url, exc)
continue
storage.export_csv(storage_config["csv_path"])
finally:
storage.close()
def main() -> None:
setup_logging()
arg_parser = argparse.ArgumentParser(
description="Crawl public makerspace directory pages."
)
arg_parser.add_argument(
"-c",
"--config",
default="config.yaml",
help="Path to config YAML file."
)
args = arg_parser.parse_args()
crawl(args.config)
if __name__ == "__main__":
main()
这个入口文件做了几件事。
读取配置。
遍历列表页。
解析详情链接。
逐个请求详情页。
解析字段。
空间名为空则跳过。
写入 SQLite。
最后导出 CSV。
9.2 __init__.py
创建 makerspace_crawler/__init__.py:
__version__ = "1.0.0"
9.3 本地演示站点
为了让代码真正可运行,下面提供一组本地 HTML 页面。
创建 demo_site/index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>创客空间公开目录 - 第 1 页</title>
</head>
<body>
<main>
<h1>创客空间公开目录</h1>
<article class="space-card">
<h2>海湾创客工坊</h2>
<p>地区:上海 · 浦东新区</p>
<a class="detail-link" href="/detail/maker-alpha.html">查看详情</a>
</article>
<article class="space-card">
<h2>北城硬件实验室</h2>
<p>地区:北京 · 海淀区</p>
<a class="detail-link" href="/detail/maker-beta.html">查看详情</a>
</article>
<article class="space-card">
<h2>湖畔开放制造空间</h2>
<p>地区:杭州 · 西湖区</p>
<a class="detail-link" href="/detail/maker-gamma.html">查看详情</a>
</article>
<nav>
<a href="/page-2.html">下一页</a>
</nav>
</main>
</body>
</html>
创建 demo_site/page-2.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>创客空间公开目录 - 第 2 页</title>
</head>
<body>
<main>
<h1>创客空间公开目录</h1>
<article class="space-card">
<h2>南山创意工坊</h2>
<p>地区:深圳 · 南山区</p>
<a class="detail-link" href="/detail/maker-delta.html">查看详情</a>
</article>
<article class="space-card">
<h2>山城社区 Maker Lab</h2>
<p>地区:重庆 · 渝中区</p>
<a class="detail-link" href="/detail/maker-epsilon.html">查看详情</a>
</article>
</main>
</body>
</html>
创建 demo_site/detail/maker-alpha.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>海湾创客工坊</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">海湾创客工坊</h1>
<div class="meta-region">上海 · 浦东新区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>3D 打印</li>
<li>激光切割</li>
<li>电子焊接</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">预约开放,工作日 10:00-18:00</div>
</section>
</article>
</body>
</html>
创建 demo_site/detail/maker-beta.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>北城硬件实验室</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">北城硬件实验室</h1>
<div class="meta-region">北京 · 海淀区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>单片机开发</li>
<li>示波器</li>
<li>PCB 小批量制作</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">会员开放,需提前登记项目用途</div>
</section>
</article>
</body>
</html>
创建 demo_site/detail/maker-gamma.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>湖畔开放制造空间</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">湖畔开放制造空间</h1>
<div class="meta-region">杭州 · 西湖区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>CNC 雕刻</li>
<li>木工工具</li>
<li>激光切割</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">活动日开放,部分设备需培训后使用</div>
</section>
</article>
</body>
</html>
创建 demo_site/detail/maker-delta.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>南山创意工坊</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">南山创意工坊</h1>
<div class="meta-region">深圳 · 南山区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>3D 打印</li>
<li>摄影棚</li>
<li>快速成型工具</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">团队预约开放,周末提供公开体验场次</div>
</section>
</article>
</body>
</html>
创建 demo_site/detail/maker-epsilon.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>山城社区 Maker Lab</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">山城社区 Maker Lab</h1>
<div class="meta-region">重庆 · 渝中区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>电子焊接</li>
<li>开源硬件套件</li>
<li>基础手工工具</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">社区开放,需现场登记</div>
</section>
</article>
</body>
</html>
9.4 启动本地演示站点
在项目根目录执行:
cd demo_site
python -m http.server 8000
保持这个终端不要关闭。
再打开另一个终端,回到项目根目录:
cd makerspace-directory-crawler
python run.py -c config.yaml
如果一切正常,会看到类似日志:
2026-06-09 16:12:01 | INFO | __main__ | Processing list page: http://127.0.0.1:8000/index.html
2026-06-09 16:12:01 | INFO | makerspace_crawler.fetcher | Fetching: http://127.0.0.1:8000/index.html
2026-06-09 16:12:01 | INFO | makerspace_crawler.parser | Parsed 3 detail links from http://127.0.0.1:8000/index.html
Details from index.html: 100%|██████████| 3/3 [00:04<00:00, 1.34s/it]
2026-06-09 16:12:06 | INFO | __main__ | Processing list page: http://127.0.0.1:8000/page-2.html
2026-06-09 16:12:06 | INFO | makerspace_crawler.storage | Exported 5 rows to CSV: data/makerspaces.csv
9.5 输出位置
SQLite 数据库:
data/makerspaces.db
CSV 文件:
data/makerspaces.csv
9.6 示例结果
CSV 中会出现类似数据:
| space_name | region | equipment_category | open_method | url |
|---|---|---|---|---|
| 海湾创客工坊 | 上海 · 浦东新区 | 3D 打印、激光切割、电子焊接 | 预约开放,工作日 10:00-18:00 | http://127.0.0.1:8000/detail/maker-alpha.html |
| 北城硬件实验室 | 北京 · 海淀区 | 单片机开发、示波器、PCB 小批量制作 | 会员开放,需提前登记项目用途 | http://127.0.0.1:8000/detail/maker-beta.html |
| 湖畔开放制造空间 | 杭州 · 西湖区 | CNC 雕刻、木工工具、激光切割 | 活动日开放,部分设备需培训后使用 | http://127.0.0.1:8000/detail/maker-gamma.html |
| 南山创意工坊 | 深圳 · 南山区 | 3D 打印、摄影棚、快速成型工具 | 团队预约开放,周末提供公开体验场次 | http://127.0.0.1:8000/detail/maker-delta.html |
| 山城社区 Maker Lab | 重庆 · 渝中区 | 电子焊接、开源硬件套件、基础手工工具 | 社区开放,需现场登记 | http://127.0.0.1:8000/detail/maker-epsilon.html |
9.7 用 SQLite 查看结果
可以使用命令行:
sqlite3 data/makerspaces.db
进入后执行:
.headers on
.mode column
SELECT space_name, region, equipment_category, open_method
FROM makerspaces;
也可以直接统计设备关键词:
SELECT region, COUNT(*) AS total
FROM makerspaces
GROUP BY region
ORDER BY total DESC;
虽然这只是一个小型采集项目,但数据一旦进了数据库,就已经具备了继续分析的基础。
🔟 常见问题与排错
10.1 遇到 403 怎么办
403 通常表示服务器拒绝访问。常见原因包括 User-Agent 缺失、Referer 不符合预期、访问路径不允许、IP 访问频率异常等。
建议按顺序排查:
先用浏览器打开同一个 URL,确认页面是否公开可访问。
检查 robots.txt 和站点说明,确认路径是否适合爬取。
设置合理的 User-Agent,不要使用默认的 python-requests。
降低访问频率,把 delay_seconds 调大。
不要对 403 做高频重试。
如果一个公开目录页偶发 403,可能是临时策略;如果持续 403,就应该停止采集,改用官方开放数据或人工确认授权。
本文配置中可以这样调整:
crawler:
user_agent: "MakerspaceDirectoryBot/1.0 (+https://example.org/bot-info)"
referer: "https://target-site.example/"
delay_seconds: 2.0
delay_jitter_seconds: 1.0
我不建议一遇到 403 就立刻上代理池。对于资源目录采集来说,第一反应应该是降频和确认规则,而不是对抗。
10.2 遇到 429 怎么办
429 表示 Too Many Requests,也就是请求太频繁。
处理方式:
增加请求间隔。
减少并发。
尊重响应头里的 Retry-After。
降低重试次数,避免越重试越糟。
当前代码的 Retry 已经把 429 放进了可重试状态码:
status_forcelist=(429, 500, 502, 503, 504)
但这不代表可以无脑重试。更合理的方式是加大 delay_seconds,例如:
crawler:
delay_seconds: 5.0
delay_jitter_seconds: 2.0
目录型数据一般不需要抢时间。慢慢跑,稳定拿到数据,比快速失败更有价值。
10.3 HTML 抓到空壳怎么办
有时用 requests 抓到的 HTML 只有:
<div id="app"></div>
<script src="/assets/app.js"></script>
这说明页面内容可能由 JavaScript 动态渲染。
这时有两个方向。
第一,打开浏览器开发者工具,查看 Network 面板,寻找返回 JSON 的接口。如果接口公开、无需登录、允许访问,优先抓接口。
第二,使用 Playwright 渲染页面后再解析 DOM。
如果改成 Playwright,思路大致是:
from playwright.sync_api import sync_playwright
def fetch_rendered_html(url: str, timeout_ms: int = 15000) -> str:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until="networkidle", timeout=timeout_ms)
html = page.content()
browser.close()
return html
安装:
pip install playwright
playwright install chromium
不过 Playwright 比 requests 重很多。能用静态 HTML 或公开 JSON 接口解决时,不要轻易上浏览器自动化。
10.4 解析报错怎么办
解析报错常见原因有:
页面结构变化。
选择器写错。
字段节点不存在。
详情页跳转到错误页。
HTML 内容与预期不一致。
排查时可以先把 HTML 保存到本地:
from pathlib import Path
Path("debug.html").write_text(detail_html, encoding="utf-8")
然后打开 debug.html 看实际结构。
也可以临时打印选择器结果:
soup = BeautifulSoup(detail_html, "lxml")
print(soup.select("h1.space-name"))
print(soup.select(".equipment-list li"))
我写爬虫时有一个习惯:不要一上来就写完整流程。先拿一个详情页,确认选择器能抓到字段,再扩展到列表页和批量抓取。这样能少走很多弯路。
10.5 编码或乱码如何处理
如果 CSV 打开乱码,优先检查两点。
第一,写文件时是否使用了 utf-8-sig:
open(csv_path, "w", encoding="utf-8-sig", newline="")
第二,requests 是否正确识别网页编码:
if not response.encoding or response.encoding.lower() == "iso-8859-1":
response.encoding = response.apparent_encoding
如果目标网站明确是 GBK,也可以手动设置:
response.encoding = "gbk"
但不要一开始就写死编码。多数现代页面是 UTF-8,只有在确认乱码时再处理。
10.6 数据重复怎么办
本文已经用 URL 唯一去重:
url TEXT NOT NULL UNIQUE
如果同一个空间出现在多个分页中,数据库只会保留一条。
如果不同 URL 指向同一个空间,可以增加逻辑去重。例如在入库前判断空间名和地区是否相同:
SELECT id FROM makerspaces
WHERE space_name = ? AND region = ?;
但这种去重更容易误伤。比如同一个城市里可能真的有两个名字相似的空间。实际项目中,我会先用 URL 去重,再导出后做人工抽查。
10.7 详情页字段为空怎么办
字段为空不一定是代码错了。可能是页面本来没有这个字段,也可能是选择器不匹配。
建议分三步排查:
确认 HTML 里是否存在该文本。
确认选择器是否能选到节点。
确认文本是否被脚本动态渲染。
如果只是少数字段缺失,可以保留空值。如果大量字段为空,说明解析规则需要调整。
10.8 分页不固定怎么办
本文为了清晰,直接在配置中写了两个列表页:
start_urls:
- "http://127.0.0.1:8000/index.html"
- "http://127.0.0.1:8000/page-2.html"
真实站点可能是:
/list?page=1
/list?page=2
/list?page=3
可以在代码里生成:
start_urls = [
f"https://example.com/list?page={page}"
for page in range(1, 11)
]
也可能有“下一页”按钮。这时可以在 Parser 里增加 parse_next_page 方法,通过选择器提取下一页链接。
示例:
def parse_next_page(html: str, page_url: str) -> str:
soup = BeautifulSoup(html, "lxml")
node = soup.select_one("a.next")
if not node:
return ""
href = node.get("href")
if not href:
return ""
return normalize_url(page_url, href)
分页策略没有固定答案。真实项目里,我一般先看站点规模。如果只有几十页,配置里写清楚也没问题。如果分页很多,再写自动翻页。
1️⃣1️⃣ 进阶优化
11.1 并发抓取
当前代码是串行的。对于小目录来说,串行足够。如果数据量上千,可以考虑线程池。
一个简单的线程池思路如下:
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_and_parse_detail(fetcher, parser, detail_url, source_url):
html = fetcher.fetch_text(detail_url)
return parser.parse_detail(html, detail_url, source_url)
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [
executor.submit(fetch_and_parse_detail, fetcher, parser, url, list_url)
for url in detail_urls
]
for future in as_completed(futures):
item = future.result()
storage.upsert_item(item)
但并发不是越大越好。公开目录页建议从 max_workers=2 或 4 开始,配合延迟和重试。很多时候瓶颈并不在速度,而在页面结构不稳定、字段清洗和后续校验。
如果要做更规范的并发采集,可以考虑 Scrapy。Scrapy 自带请求调度、去重、重试、限速、中间件和管道,适合更大的采集任务。
11.2 断点续跑
断点续跑的核心是:已经抓过的详情页,下次不重复抓。
本文的数据库已经有 URL 唯一约束,但当前运行时还是会请求详情页,然后更新记录。如果想减少请求,可以在抓之前查数据库:
def url_exists(conn, url: str) -> bool:
sql = "SELECT 1 FROM makerspaces WHERE url = ? LIMIT 1;"
row = conn.execute(sql, (url,)).fetchone()
return row is not None
然后:
if storage.exists(detail_url):
logger.info("Skip existing url: %s", detail_url)
continue
可以在 SQLiteStorage 中增加方法:
def exists(self, url: str) -> bool:
sql = "SELECT 1 FROM makerspaces WHERE url = ? LIMIT 1;"
row = self.conn.execute(sql, (url,)).fetchone()
return row is not None
不过要注意,如果目标网站内容会更新,完全跳过旧 URL 可能导致数据陈旧。更稳妥的是加一个 --incremental 参数,需要增量时跳过,需要刷新时全量更新。
11.3 日志与监控
现在的日志已经记录了请求、解析和保存动作。真实项目中可以继续增加几个指标:
列表页成功数。
详情页成功数。
详情页失败数。
字段缺失率。
重复 URL 数。
导出行数。
例如:
stats = {
"list_success": 0,
"list_failed": 0,
"detail_success": 0,
"detail_failed": 0,
"empty_name": 0,
}
最后打印:
logger.info("Crawl stats: %s", stats)
如果任务跑在服务器上,可以把日志输出到文件:
logging.basicConfig(
filename="crawler.log",
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
有了日志,问题就能追踪。没有日志,爬虫一失败,只能靠猜。
11.4 定时任务
如果需要定期更新,可以用 cron。
例如每天凌晨 2 点运行:
0 2 * * * cd /path/to/makerspace-directory-crawler && /path/to/.venv/bin/python run.py -c config.yaml >> crawler.log 2>&1
如果任务更多、更复杂,可以考虑 Airflow、Prefect 或 Dagster。对于单个目录页爬虫,cron 已经够用。
11.5 配置多个站点
如果要采集多个公开目录,可以给每个站点一个配置文件:
configs/
├── city-a.yaml
├── city-b.yaml
└── university-labs.yaml
运行:
python run.py -c configs/city-a.yaml
python run.py -c configs/city-b.yaml
python run.py -c configs/university-labs.yaml
也可以在数据库里增加 site_name 字段,区分来源。
字段设计可以变成:
site_name TEXT NOT NULL DEFAULT '',
space_name TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
equipment_category TEXT NOT NULL DEFAULT '',
open_method TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL UNIQUE
做资源聚合时,来源站点很重要。后面如果某条数据有问题,至少能知道它来自哪里。
11.6 字段标准化
目录页抓下来的设备类别可能写法不统一。
例如:
3D打印
3D 打印
三维打印
快速成型
它们可能都指向类似设备。为了后续统计,可以做标准化映射:
EQUIPMENT_MAPPING = {
"3D打印": "3D 打印",
"3D 打印": "3D 打印",
"三维打印": "3D 打印",
"快速成型": "快速成型",
"激光切割机": "激光切割",
"激光切割": "激光切割",
}
处理函数:
def normalize_equipment(raw: str) -> str:
parts = [part.strip() for part in raw.replace(",", "、").split("、")]
normalized = []
for part in parts:
if not part:
continue
normalized.append(EQUIPMENT_MAPPING.get(part, part))
return "、".join(dict.fromkeys(normalized))
字段标准化不要急着一步到位。先抓原始数据,再根据真实数据分布做映射,效果会更好。
11.7 从 SQLite 迁移到 MySQL
当数据规模变大,或者需要多人访问,可以迁移到 MySQL。
表结构大致如下:
CREATE TABLE makerspaces (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
space_name VARCHAR(255) NOT NULL DEFAULT '',
region VARCHAR(255) NOT NULL DEFAULT '',
equipment_category TEXT,
open_method TEXT,
url VARCHAR(1024) NOT NULL UNIQUE,
source_url VARCHAR(1024) NOT NULL DEFAULT '',
crawled_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
Python 可以使用 pymysql 或 SQLAlchemy。项目早期我不建议直接引入太多数据库抽象,除非你确定后面会支持多种数据库。
11.8 用 Pandas 做简单分析
抓完 CSV 后,可以用 Pandas 快速看一下数据。
安装:
pip install pandas
分析代码:
import pandas as pd
df = pd.read_csv("data/makerspaces.csv")
print(df.head())
print(df["region"].value_counts())
equipment_series = (
df["equipment_category"]
.dropna()
.str.split("、")
.explode()
.str.strip()
)
print(equipment_series.value_counts())
你会得到类似结果:
3D 打印 2
激光切割 2
电子焊接 2
CNC 雕刻 1
木工工具 1
采集不是终点。真正有价值的是后面的清洗、分析和产品化展示。
1️⃣2️⃣ 总结与延伸阅读
这篇文章完成了一个完整的创客空间公开目录页采集工程。
我们从需求开始,明确了目标字段:空间名、地区、设备类别、开放方式和链接。
然后讨论了合规边界,包括 robots.txt、频率控制、不采集敏感信息、不绕过登录或付费限制。
技术实现上,本文选择了适合静态目录页的轻量方案:requests 负责请求,BeautifulSoup 负责解析,SQLite 负责存储,CSV 负责导出。
代码层面,我们拆出了 Fetcher、Parser、Storage、Config 和 Utils。这个结构虽然比单文件脚本多几步,但长期看更耐用。页面结构变了,可以只改选择器。存储方式变了,可以只改 Storage。请求策略要调整,也不会影响解析逻辑。
最后,我们还补充了常见排错和进阶优化,包括 403、429、动态渲染、编码乱码、并发、断点续跑、日志监控、定时任务和字段标准化。
如果你准备继续深入,可以沿着三个方向走。
第一个方向是 Scrapy。它适合更大规模、更标准化的采集任务,内置调度器、去重、下载中间件和 Item Pipeline。
第二个方向是 Playwright。它适合处理动态渲染页面,尤其是前端框架渲染、接口隐藏较深、页面需要等待加载的情况。
第三个方向是数据产品化。比如把 SQLite 换成 PostgreSQL,把 CSV 导入到可视化工具,或者做一个简单的地图页面,把创客空间按地区展示出来。
我一直觉得,爬虫最有意思的地方不是“抓到了多少页面”,而是把散乱信息整理成可复用的数据。创客空间目录页就是一个很适合练手的题材:不复杂,但足够贴近真实;不花哨,但能练到请求、解析、清洗、存储和维护这些基本功。
下面给出完整项目代码清单,方便复制复现。
附录:完整代码清单
requirements.txt
requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.3.0
PyYAML==6.0.2
tqdm==4.66.5
config.yaml
site:
name: "Local Makerspace Demo"
base_url: "http://127.0.0.1:8000"
start_urls:
- "http://127.0.0.1:8000/index.html"
- "http://127.0.0.1:8000/page-2.html"
crawler:
user_agent: "MakerspaceDirectoryBot/1.0 (+https://example.org/bot-info)"
referer: "http://127.0.0.1:8000/"
timeout: 12
delay_seconds: 1.0
delay_jitter_seconds: 0.5
max_retries: 3
backoff_factor: 0.8
respect_robots: false
parser:
list:
item_selector: ".space-card"
link_selector: "a.detail-link"
link_attr: "href"
detail:
space_name_selector: "h1.space-name"
region_selector: ".meta-region"
equipment_category_selector: ".equipment-list li"
open_method_selector: ".open-method"
storage:
sqlite_path: "data/makerspaces.db"
csv_path: "data/makerspaces.csv"
makerspace_crawler/init.py
__version__ = "1.0.0"
makerspace_crawler/models.py
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass
class MakerSpaceItem:
space_name: str
region: str
equipment_category: str
open_method: str
url: str
source_url: str
crawled_at: str
@classmethod
def empty(cls, url: str, source_url: str) -> "MakerSpaceItem":
return cls(
space_name="",
region="",
equipment_category="",
open_method="",
url=url,
source_url=source_url,
crawled_at=datetime.now(timezone.utc).isoformat()
)
makerspace_crawler/config.py
from pathlib import Path
from typing import Any
import yaml
def load_config(path: str | Path) -> dict[str, Any]:
config_path = Path(path)
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with config_path.open("r", encoding="utf-8") as f:
config = yaml.safe_load(f)
if not isinstance(config, dict):
raise ValueError("Config file must be a YAML mapping.")
required_top_keys = ["site", "crawler", "parser", "storage"]
for key in required_top_keys:
if key not in config:
raise ValueError(f"Missing required config section: {key}")
return config
makerspace_crawler/utils.py
import hashlib
import logging
import random
import re
import time
from pathlib import Path
from urllib.parse import urljoin, urldefrag
def setup_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
def ensure_parent_dir(file_path: str) -> None:
path = Path(file_path)
path.parent.mkdir(parents=True, exist_ok=True)
def normalize_text(text: str | None) -> str:
if not text:
return ""
text = text.replace("\xa0", " ")
text = re.sub(r"\s+", " ", text)
return text.strip()
def normalize_url(base_url: str, href: str) -> str:
absolute_url = urljoin(base_url, href)
clean_url, _fragment = urldefrag(absolute_url)
return <= 0 and jitter_seconds <= 0:
return
delay = delay_seconds + random.uniform(0 clean_url
def sleep_with_jitter(delay_seconds: float, jitter_seconds: float) -> None:
if delay_seconds, max(jitter_seconds, 0))
time.sleep(delay)
def content_hash(*parts: str) -> str:
raw = "||".join(parts)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
makerspace_crawler/fetcher.py
import logging
import urllib.robotparser
from functools import lru_cache
from urllib.parse import urlparse, urljoin
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
class Fetcher:
def __init__(self, crawler_config: dict):
self.user_agent = crawler_config.get(
"user_agent",
"MakerspaceDirectoryBot/1.0"
)
self.referer = crawler_config.get("referer", "")
self.timeout = int(crawler_config.get("timeout", 10))
self.respect_robots = bool(crawler_config.get("respect_robots", True))
max_retries = int(crawler_config.get("max_retries", 3))
backoff_factor = float(crawler_config.get("backoff_factor", 0.8))
self.session = requests.Session()
self.session.headers.update({
"User-Agent": self.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.8",
"Connection": "keep-alive",
})
if self.referer:
self.session.headers.update({"Referer": self.referer})
retry = Retry(
total=max_retries,
connect=max_retries,
read=max_retries,
status=max_retries,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET"]),
backoff_factor=backoff_factor,
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
def fetch_text(self, url: str) -> str:
if self.respect_robots and not self.can_fetch(url):
raise PermissionError(f"Blocked by robots.txt: {url}")
logger.info("Fetching: %s", url)
response = self.session.get(url, timeout=self.timeout)
response.raise_for_status()
if not response.encoding or response.encoding.lower() == "iso-8859-1":
response.encoding = response.apparent_encoding
return response.text
@lru_cache(maxsize=64)
def _get_robot_parser(self, root_url: str) -> urllib.robotparser.RobotFileParser:
robots_url = urljoin(root_url, "/robots.txt")
parser = urllib.robotparser.RobotFileParser()
parser.set_url(robots_url)
try:
parser.read()
except Exception as exc:
logger.warning("Failed to read robots.txt: %s, error=%s", robots_url, exc)
return parser
def can_fetch(self, url: str) -> bool:
parsed = urlparse(url)
root_url = f"{parsed.scheme}://{parsed.netloc}"
parser = self._get_robot_parser(root_url)
return parser.can_fetch(self.user_agent, url)
makerspace_crawler/parser.py
import logging
from datetime import datetime, timezone
from bs4 import BeautifulSoup
from .models import MakerSpaceItem
from .utils import normalize_text, normalize_url
logger = logging.getLogger(__name__)
class DirectoryParser:
def __init__(self, parser_config: dict):
self.list_config = parser_config["list"]
self.detail_config = parser_config["detail"]
def parse_detail_links(self, html: str, page_url: str) -> list[str]:
soup = BeautifulSoup(html, "lxml")
item_selector = self.list_config.get("item_selector", "")
link_selector = self.list_config.get("link_selector", "a")
link_attr = self.list_config.get("link_attr", "href")
links: list[str] = []
if item_selector:
containers = soup.select(item_selector)
else:
containers = [soup]
for container in containers:
link_node = container.select_one(link_selector)
if not link_node:
logger.debug("No link node found in one list item.")
continue
href = link_node.get(link_attr)
if not href:
logger.debug("Link node found but href is empty.")
continue
links.append(normalize_url(page_url, href))
unique_links = list(dict.fromkeys(links))
logger.info("Parsed %s detail links from %s", len(unique_links), page_url)
return unique_links
def parse_detail(self, html: str, detail_url: str, source_url: str) -> MakerSpaceItem:
soup = BeautifulSoup(html, "lxml")
space_name = self._select_one_text(
soup,
self.detail_config.get("space_name_selector", "")
)
region = self._select_one_text(
soup,
self.detail_config.get("region_selector", "")
)
equipment_category = self._select_many_text(
soup,
self.detail_config.get("equipment_category_selector", "")
)
open_method = self._select_one_text(
soup,
self.detail_config.get("open_method_selector", "")
)
return MakerSpaceItem(
space_name=space_name,
region=region,
equipment_category=equipment_category,
open_method=open_method,
url=detail_url,
source_url=source_url,
crawled_at=datetime.now(timezone.utc).isoformat()
)
@staticmethod
def _select_one_text(soup: BeautifulSoup, selector: str) -> str:
if not selector:
return ""
node = soup.select_one(selector)
if not node:
return ""
return normalize_text(node.get_text(" ", strip=True))
@staticmethod
def _select_many_text(soup: BeautifulSoup, selector: str) -> str:
if not selector:
return ""
nodes = soup.select(selector)
values = []
for node in nodes:
text = normalize_text(node.get_text(" ", strip=True))
if text:
values.append(text)
return "、".join(dict.fromkeys(values))
makerspace_crawler/storage.py
import csv
import logging
import sqlite3
from pathlib import Path
from .models import MakerSpaceItem
from .utils import ensure_parent_dir
logger = logging.getLogger(__name__)
class SQLiteStorage:
def __init__(self, sqlite_path: str):
self.sqlite_path = sqlite_path
ensure_parent_dir(sqlite_path)
self.conn = sqlite3.connect(sqlite_path)
self.conn.row_factory = sqlite3.Row
self.init_table()
def init_table(self) -> None:
sql = """
CREATE TABLE IF NOT EXISTS makerspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
space_name TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
equipment_category TEXT NOT NULL DEFAULT '',
open_method TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL UNIQUE,
source_url TEXT NOT NULL DEFAULT '',
crawled_at TEXT NOT NULL DEFAULT ''
);
"""
self.conn.execute(sql)
self.conn.commit()
def upsert_item(self, item: MakerSpaceItem) -> None:
sql = """
INSERT INTO makerspaces (
space_name,
region,
equipment_category,
open_method,
url,
source_url,
crawled_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
space_name = excluded.space_name,
region = excluded.region,
equipment_category = excluded.equipment_category,
open_method = excluded.open_method,
source_url = excluded.source_url,
crawled_at = excluded.crawled_at;
"""
self.conn.execute(
sql,
(
item.space_name,
item.region,
item.equipment_category,
item.open_method,
item.url,
item.source_url,
item.crawled_at,
)
)
self.conn.commit()
def exists(self, url: str) -> bool:
sql = "SELECT 1 FROM makerspaces WHERE url = ? LIMIT 1;"
row = self.conn.execute(sql, (url,)).fetchone()
return row is not None
def fetch_all(self) -> list[sqlite3.Row]:
sql = """
SELECT
space_name,
region,
equipment_category,
open_method,
url,
source_url,
crawled_at
FROM makerspaces
ORDER BY id ASC;
"""
cursor = self.conn.execute(sql)
return cursor.fetchall()
def export_csv(self, csv_path: str) -> None:
ensure_parent_dir(csv_path)
rows = self.fetch_all()
fieldnames = [
"space_name",
"region",
"equipment_category",
"open_method",
"url",
"source_url",
"crawled_at",
]
with Path(csv_path).open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow({key: row[key] for key in fieldnames})
logger.info("Exported %s rows to CSV: %s", len(rows), csv_path)
def close(self) -> None:
self.conn.close()
run.py
import argparse
import logging
from pathlib import Path
from tqdm import tqdm
from makerspace_crawler.config import load_config
from makerspace_crawler.fetcher import Fetcher
from makerspace_crawler.parser import DirectoryParser
from makerspace_crawler.storage import SQLiteStorage
from makerspace_crawler.utils import setup_logging, sleep_with_jitter
logger = logging.getLogger(__name__)
def crawl(config_path: str) -> None:
config = load_config(config_path)
crawler_config = config["crawler"]
parser_config = config["parser"]
storage_config = config["storage"]
start_urls = config["site"].get("start_urls", [])
if not start_urls:
raise ValueError("No start_urls configured.")
fetcher = Fetcher(crawler_config)
parser = DirectoryParser(parser_config)
storage = SQLiteStorage(storage_config["sqlite_path"])
delay_seconds = float(crawler_config.get("delay_seconds", 1.0))
jitter_seconds = float(crawler_config.get("delay_jitter_seconds", 0.5))
visited_detail_urls: set[str] = set()
try:
for list_url in start_urls:
logger.info("Processing list page: %s", list_url)
try:
list_html = fetcher.fetch_text(list_url)
except Exception as exc:
logger.exception("Failed to fetch list page: %s, error=%s", list_url, exc)
continue
detail_urls = parser.parse_detail_links(list_html, list_url)
for detail_url in tqdm(detail_urls, desc=f"Details from {Path(list_url).name}"):
if detail_url in visited_detail_urls:
logger.info("Skip duplicated detail url in current run: %s", detail_url)
continue
visited_detail_urls.add(detail_url)
try:
sleep_with_jitter(delay_seconds, jitter_seconds)
detail_html = fetcher.fetch_text(detail_url)
item = parser.parse_detail(detail_html, detail_url, list_url)
if not item.space_name:
logger.warning("Skip item because space_name is empty: %s", detail_url)
continue
storage.upsert_item(item)
logger.info("Saved item: %s", item.space_name)
except Exception as exc:
logger.exception("Failed to process detail page: %s, error=%s", detail_url, exc)
continue
storage.export_csv(storage_config["csv_path"])
finally:
storage.close()
def main() -> None:
setup_logging()
arg_parser = argparse.ArgumentParser(
description="Crawl public makerspace directory pages."
)
arg_parser.add_argument(
"-c",
"--config",
default="config.yaml",
help="Path to config YAML file."
)
args = arg_parser.parse_args()
crawl(args.config)
if __name__ == "__main__":
main()
README.md
# Makerspace Directory Crawler
一个用于采集创客空间公开目录页的小型 Python 爬虫示例。
## 功能
- 抓取公开列表页
- 解析详情页链接
- 抽取空间名、地区、设备类别、开放方式、链接
- 写入 SQLite
- 导出 CSV
- 支持请求重试、退避、延迟、随机抖动
- 支持通过配置文件调整选择器
## 安装
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
Windows:
.venv\Scripts\activate
pip install -r requirements.txt
启动本地演示站点
cd demo_site
python -m http.server 8000
运行爬虫
另开终端:
python run.py -c config.yaml
输出
data/makerspaces.db
data/makerspaces.csv
说明
本项目仅用于公开资源目录页的数据整理示例。实际采集前请确认目标站点规则,控制访问频率,不采集敏感信息,不绕过登录、付费或其他访问限制。
## demo_site/index.html
```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>创客空间公开目录 - 第 1 页</title>
</head>
<body>
<main>
<h1>创客空间公开目录</h1>
<article class="space-card">
<h2>海湾创客工坊</h2>
<p>地区:上海 · 浦东新区</p>
<a class="detail-link" href="/detail/maker-alpha.html">查看详情</a>
</article>
<article class="space-card">
<h2>北城硬件实验室</h2>
<p>地区:北京 · 海淀区</p>
<a class="detail-link" href="/detail/maker-beta.html">查看详情</a>
</article>
<article class="space-card">
<h2>湖畔开放制造空间</h2>
<p>地区:杭州 · 西湖区</p>
<a class="detail-link" href="/detail/maker-gamma.html">查看详情</a>
</article>
<nav>
<a href="/page-2.html">下一页</a>
</nav>
</main>
</body>
</html>
demo_site/page-2.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>创客空间公开目录 - 第 2 页</title>
</head>
<body>
<main>
<h1>创客空间公开目录</h1>
<article class="space-card">
<h2>南山创意工坊</h2>
<p>地区:深圳 · 南山区</p>
<a class="detail-link" href="/detail/maker-delta.html">查看详情</a>
</article>
<article class="space-card">
<h2>山城社区 Maker Lab</h2>
<p>地区:重庆 · 渝中区</p>
<a class="detail-link" href="/detail/maker-epsilon.html">查看详情</a>
</article>
</main>
</body>
</html>
demo_site/detail/maker-alpha.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>海湾创客工坊</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">海湾创客工坊</h1>
<div class="meta-region">上海 · 浦东新区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>3D 打印</li>
<li>激光切割</li>
<li>电子焊接</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">预约开放,工作日 10:00-18:00</div>
</section>
</article>
</body>
</html>
demo_site/detail/maker-beta.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>北城硬件实验室</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">北城硬件实验室</h1>
<div class="meta-region">北京 · 海淀区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>单片机开发</li>
<li>示波器</li>
<li>PCB 小批量制作</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">会员开放,需提前登记项目用途</div>
</section>
</article>
</body>
</html>
demo_site/detail/maker-gamma.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>湖畔开放制造空间</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">湖畔开放制造空间</h1>
<div class="meta-region">杭州 · 西湖区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>CNC 雕刻</li>
<li>木工工具</li>
<li>激光切割</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">活动日开放,部分设备需培训后使用</div>
</section>
</article>
</body>
</html>
demo_site/detail/maker-delta.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>南山创意工坊</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">南山创意工坊</h1>
<div class="meta-region">深圳 · 南山区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>3D 打印</li>
<li>摄影棚</li>
<li>快速成型工具</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">团队预约开放,周末提供公开体验场次</div>
</section>
</article>
</body>
</html>
demo_site/detail/maker-epsilon.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>山城社区 Maker Lab</title>
</head>
<body>
<article class="space-detail">
<h1 class="space-name">山城社区 Maker Lab</h1>
<div class="meta-region">重庆 · 渝中区</div>
<section>
<h2>设备类别</h2>
<ul class="equipment-list">
<li>电子焊接</li>
<li>开源硬件套件</li>
<li>基础手工工具</li>
</ul>
</section>
<section>
<h2>开放方式</h2>
<div class="open-method">社区开放,需现场登记</div>
</section>
</article>
</body>
</html>
迁移到真实公开目录页时怎么改
真实站点迁移时,通常只需要改 config.yaml。
假设真实列表页是:
https://example.org/makerspaces
详情卡片结构是:
<div class="directory-item">
<a class="title" href="/makerspaces/1001">某某创客空间</a>
</div>
详情页结构是:
<h1 class="title">某某创客空间</h1>
<span class="area">广州 · 天河区</span>
<div class="devices">
<a>3D 打印</a>
<a>激光切割</a>
</div>
<p class="opening">预约开放</p>
配置可以改为:
site:
name: "Example Makerspace Directory"
base_url: "https://example.org"
start_urls:
- "https://example.org/makerspaces"
crawler:
user_agent: "MakerspaceDirectoryBot/1.0 (+https://example.org/bot-info)"
referer: "https://example.org/makerspaces"
timeout: 12
delay_seconds: 2.0
delay_jitter_seconds: 1.0
max_retries: 3
backoff_factor: 1.0
respect_robots: true
parser:
list:
item_selector: ".directory-item"
link_selector: "a.title"
link_attr: "href"
detail:
space_name_selector: "h1.title"
region_selector: ".area"
equipment_category_selector: ".devices a"
open_method_selector: ".opening"
storage:
sqlite_path: "data/makerspaces.db"
csv_path: "data/makerspaces.csv"
我建议第一次迁移时只抓 1 个列表页、3 个详情页,确认字段正确后再扩大范围。爬虫项目最怕一上来就全站跑,跑完才发现字段错位。小步验证,反而更快。
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以“入门 → 进阶 → 工程化 → 项目落地”的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~
✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 “谁使用,谁负责” 。如不同意,请立即停止使用并删除本项目。!!!
更多推荐
所有评论(0)