自建 Firecrawl 进行通用型网页抓取
偷懒了很久,把抓取 Bot 的这个功能补上了。
这几年来,我一直在维护一个方便朋友 Telegram 群聊的社交媒体抓取 Bot FastFetchBot。这个 Bot 见证了我自学编程找工作的历程。其功能很简单:自动读取群聊中每一条消息中的 URL,如果 URL 匹配了特定社交媒体转发链接的正则表达式,使用各种方法对其进行爬取并发送至群聊中,搭配以 telegra.ph 作为备份,提升群友阅读体验的同时亦可防止该内容原文丢失。
这个项目运行了多年,我一直没有去做一个更有趣的功能:通用型网页抓取。除了那些设置起了高墙的主流信息流通渠道——社交媒体之外,占据互联网世界另外半壁江山的“古法通讯”的传统网页,包括各类机构的官方网站,大型公共新闻媒体网站,以及个人博客,同样值得留档。
我正在对这坨屎山进行清理重构。一旦完成,会另写文章讲解开发该项目时的一些心得。
通用型网页抓取原理
常规的网站难以被抓取的一大原因是其网页格式的不确定性。各个社交媒体的帖子格式完全固定。哪怕没有访问 API 到机会,只要能通过浏览器访问,就有机会将其结构化爬取。而随便扔出来一条古法时代的屎山网页,其对 Web 语义规范的遵循程度各异。此时我们很难直接通过特定的 HTML 标签将我们所需的正文爬取。
我在 Telegram 上曾长期利用过另一古法爬取通用型网页的公共 bot InstantViewBot。该 bot 的爬取发挥时好时坏,正是吃了各个网站格式各异的亏。
好在大语言模型的出现解决了这一痛点。大语言模型可以根据其经验准确地判断出繁杂的 HTML 屎山里何为标题何为作者名何为正文内容何为评论区,并生成结构化的输出。我们只需要攻克爬取部分即可,解析交由大语言模型来做。
Firecrawl
Firecrawl 是一个用于通用型网页批量爬取的开源项目,同时其官方也提供量贩爬取服务。
该项目系统化地集成了从 Playwright 虚拟浏览器爬取网页,到使用大语言模型解析网页内容,再到保存内容至数据库的流程。整个系统还搭配了 Redis 和 RabbitMQ 等消息队列组件,健壮性尚可。对使用者来说,只要访问 API 就可等待解析结果送上桌来。
我们只要自己部署,就不用给官方送钱了。
自建流程参考
Firecrawl 的官方文档对如何自建写得含混不清,强烈怀疑是为了骗你买官方服务。
建议在自己的 VPS 上直接 git clone 下载 Firecrawl repo,然后本地编译 image,否则 Postgre 部分还需要额外自建,略显麻烦。而且实际上数据库以外的其他几个组件使用线上 image 亦可。
我的服务器用的是 Traefik 做的反向代理,所以部署的难点在于如何对 Traefik 进行设置。
自建公网服务的一大隐患是被攻击者滥用。Firecrawl 官方不支持自建用户使用内置的鉴权系统,但是我们可以使用 Traefik 的中间件实现相同的效果。以下是 Traefik 的 traefik.yml 所需添加的中间件部分:
1
2
3
4
5
experimental:
plugins:
api-key-middleware:
moduleName: "github.com/dtomlinson91/traefik-api-key-middleware"
version: "v0.1.2"
Docker compose 文件如下,记得把 API 所需的 CPU 调低一点(我最多只能到3),免得把小鸡烧了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
name: firecrawl
x-common-service: &common-service
# NOTE: If you don't want to build the service locally,
# comment out the build: statement and uncomment the image: statement
# image: ghcr.io/firecrawl/firecrawl
build: apps/api
ulimits:
nofile:
soft: 65535
hard: 65535
networks:
- backend
extra_hosts:
- "host.docker.internal:host-gateway"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
compress: "true"
x-common-env: &common-env
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
REDIS_RATE_LIMIT_URL: ${REDIS_URL:-redis://redis:6379}
PLAYWRIGHT_MICROSERVICE_URL: ${PLAYWRIGHT_MICROSERVICE_URL:-http://playwright-service:3000/scrape}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-postgres}"
POSTGRES_DB: ${POSTGRES_DB:-postgres}
POSTGRES_HOST: ${POSTGRES_HOST:-nuq-postgres}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
USE_DB_AUTHENTICATION: ${USE_DB_AUTHENTICATION:-false}
NUM_WORKERS_PER_QUEUE: ${NUM_WORKERS_PER_QUEUE:-8}
CRAWL_CONCURRENT_REQUESTS: ${CRAWL_CONCURRENT_REQUESTS:-10}
MAX_CONCURRENT_JOBS: ${MAX_CONCURRENT_JOBS:-5}
BROWSER_POOL_SIZE: ${BROWSER_POOL_SIZE:-5}
OPENAI_API_KEY: ${OPENAI_API_KEY}
OPENAI_BASE_URL: ${OPENAI_BASE_URL}
MODEL_NAME: ${MODEL_NAME}
MODEL_EMBEDDING_NAME: ${MODEL_EMBEDDING_NAME}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL}
SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL}
BULL_AUTH_KEY: ${BULL_AUTH_KEY}
TEST_API_KEY: ${TEST_API_KEY}
SUPABASE_ANON_TOKEN: ${SUPABASE_ANON_TOKEN}
SUPABASE_URL: ${SUPABASE_URL}
SUPABASE_SERVICE_TOKEN: ${SUPABASE_SERVICE_TOKEN}
SELF_HOSTED_WEBHOOK_URL: ${SELF_HOSTED_WEBHOOK_URL}
LOGGING_LEVEL: ${LOGGING_LEVEL}
PROXY_SERVER: ${PROXY_SERVER}
PROXY_USERNAME: ${PROXY_USERNAME}
PROXY_PASSWORD: ${PROXY_PASSWORD}
SEARXNG_ENDPOINT: ${SEARXNG_ENDPOINT}
SEARXNG_ENGINES: ${SEARXNG_ENGINES}
SEARXNG_CATEGORIES: ${SEARXNG_CATEGORIES}
services:
playwright-service:
# NOTE: If you don't want to build the service locally,
# comment out the build: statement and uncomment the image: statement
# image: ghcr.io/firecrawl/playwright-service:latest
build: apps/playwright-service-ts
environment:
PORT: 3000
PROXY_SERVER: ${PROXY_SERVER}
PROXY_USERNAME: ${PROXY_USERNAME}
PROXY_PASSWORD: ${PROXY_PASSWORD}
BLOCK_MEDIA: ${BLOCK_MEDIA}
# Configure maximum concurrent pages for Playwright browser instances
MAX_CONCURRENT_PAGES: ${CRAWL_CONCURRENT_REQUESTS:-10}
networks:
- backend
# Resource limits for Docker Compose (not Swarm)
cpus: 2.0
mem_limit: 4G
memswap_limit: 4G
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
compress: "true"
tmpfs:
- /tmp/.cache:noexec,nosuid,size=1g
firecrawlapi:
<<: *common-service
environment:
<<: *common-env
HOST: "0.0.0.0"
PORT: ${INTERNAL_PORT:-3002}
EXTRACT_WORKER_PORT: ${EXTRACT_WORKER_PORT:-3004}
WORKER_PORT: ${WORKER_PORT:-3005}
NUQ_RABBITMQ_URL: amqp://rabbitmq:5672
ENV: local
depends_on:
redis:
condition: service_started
playwright-service:
condition: service_started
rabbitmq:
condition: service_healthy
# ports:
# - "${PORT:-3002}:${INTERNAL_PORT:-3002}"
command: node dist/src/harness.js --start-docker
# Resource limits for Docker Compose (not Swarm)
# Increase if you have more CPU cores/RAM available
cpus: 3.0
mem_limit: 8G
memswap_limit: 8G
labels:
- traefik.enable=true
- traefik.http.routers.firecrawlapi.rule=Host(`example.com`)
- traefik.http.routers.firecrawlapi.entrypoints=websecure
- traefik.http.routers.firecrawlapi.tls.certresolver=myResolver
- traefik.http.services.firecrawlapi.loadbalancer.server.port=3002
- traefik.http.routers.firecrawlapi.middlewares=fc-auth
- traefik.http.middlewares.fc-auth.plugin.api-key-middleware.bearerHeader=true
- traefik.http.middlewares.fc-auth.plugin.api-key-middleware.bearerHeaderName=Authorization
- traefik.http.middlewares.fc-auth.plugin.api-key-middleware.keys=yourkeys
redis:
# NOTE: If you want to use Valkey (open source) instead of Redis (source available),
# uncomment the Valkey statement and comment out the Redis statement.
# Using Valkey with Firecrawl is untested and not guaranteed to work. Use with caution.
image: redis:alpine
# image: valkey/valkey:alpine
networks:
- backend
command: redis-server --bind 0.0.0.0
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
compress: "true"
rabbitmq:
image: rabbitmq:3-management
networks:
- backend
command: rabbitmq-server
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
interval: 5s
timeout: 5s
retries: 3
start_period: 5s
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
compress: "true"
nuq-postgres:
build: apps/nuq-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
networks:
- backend
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
compress: "true"
networks:
backend:
driver: bridge
环境变量方面,OPENAI_API_KEY 自然不可少。此外别忘记设置 BULL_AUTH_KEY ,避免被人看光光管理界面。其他采用默认即可。
代码整合
Firecrawl 官方提供了 Python SDK。下面是我手动调整过的一个 Gemini 3 Pro 给我 vibe 出来的单例模式客户端,供君参考:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
from __future__ import annotations
import threading
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from firecrawl import Firecrawl
from app.config import FIRECRAWL_API_URL, FIRECRAWL_API_KEY, FIRECRAWL_TIMEOUT_SECONDS
@dataclass(frozen=True)
class FirecrawlSettings:
api_url: str
api_key: str
timeout_seconds: int = 60 # 你也可以在反代侧控制超时
class FirecrawlClient:
"""
FirecrawlClient: 对 firecrawl python SDK 的封装 + 单例访问点。
- 提供 scrape / crawl 等常用方法,方便其他模块调用
- 线程安全单例(适合 Web 服务 / worker 多线程场景)
"""
_instance: Optional["FirecrawlClient"] = None
_lock = threading.Lock()
def __init__(self, config: FirecrawlSettings):
self._settings: FirecrawlSettings = config
self._app: Firecrawl = self._create_app(config)
@staticmethod
def _create_app(config: FirecrawlSettings) -> Firecrawl:
try:
return Firecrawl(api_url=config.api_url, api_key=config.api_key)
except TypeError:
return Firecrawl(api_url=config.api_url, api_key=config.api_key)
@classmethod
def get_instance(cls) -> "FirecrawlClient":
"""
线程安全的单例获取。
- 首次调用可传 settings
- 之后重复调用可不传
"""
if cls._instance is not None:
return cls._instance
with cls._lock:
if cls._instance is not None:
return cls._instance
config = FirecrawlSettings(
api_url=FIRECRAWL_API_URL,
api_key=FIRECRAWL_API_KEY,
timeout_seconds=FIRECRAWL_TIMEOUT_SECONDS,
)
cls._instance = cls(config)
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""测试用:重置单例。"""
with cls._lock:
cls._instance = None
def scrape_url(
self,
url: str,
formats: Optional[List[str]] = None,
only_main_content: bool = True,
timeout_seconds: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
单页抓取(最常用)
"""
params: Dict[str, Any] = {
"formats": formats or ["markdown"],
"onlyMainContent": only_main_content,
}
if extra_params:
params.update(extra_params)
# if timeout_seconds is None:
# timeout_seconds = self._settings.timeout_seconds
try:
return self._app.scrape(url, formats=formats, only_main_content=only_main_content).model_dump(
exclude_none=True)
except Exception as e:
raise RuntimeError(f"Firecrawl scrape_url failed: url={url}") from e
在你的代码中记得把 Traefik 那个中间件的密钥输入进 api_key 部分去创建 Firecrawl 对象。Firecrawl 的 SDK 会把这个 key 添加到 HTTP Header 的 Authorization 里。
1
return Firecrawl(api_url=config.api_url, api_key=config.api_key)
调用该单例 fc = FirecrawlClient.get_instance() ,使用 scrape_url 即可进行爬取。
后 AI 时代写技术博客的碎碎念
虽然本文的绝大多数内容都是通过和 Gemini 3 Pro 聊天聊出来的,但我觉得进行一个记录还是有价值的。一是把最近做的有趣的事情分析给朋友;二是其实做一件事的大多数流程都很顺利,但被卡住的那部分痛点可能即使是问 AI 也要折腾很久,而这部分才是 AI 时代真正值得人类去做的工作。