㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐☆☆☆(基础级)
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: 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_urlcrawled_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=24 开始,配合延迟和重试。很多时候瓶颈并不在速度,而在页面结构不稳定、字段清洗和后续校验。

如果要做更规范的并发采集,可以考虑 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爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?

评论区留言告诉我你的需求,我会优先安排实现(更新)哒~


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


✅ 免责声明

本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。

使用或者参考本项目即表示您已阅读并同意以下条款:

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 “谁使用,谁负责” 。如不同意,请立即停止使用并删除本项目。!!!

更多推荐