上次说到:基本流程已经通了。那么这次就开始写代码。当然,由于借鉴了funboost 和feapder的架构,就叫funspider吧。
一、基础数据类
均为伪代码
python
# utils/request.py
class Request:
"""
爬虫请求对象,类似 Scrapy 的 Request
"""
def __init__(self, url, callback, downloader=None, meta=None):
self.url = url
self.callback_name = callback.__name__
self.downloader_name = downloader.__name__ if downloader else None
self.meta = meta or {}
def to_dict(self):
return {
'url': self.url,
'callback_name': self.callback_name,
'downloader_name': self.downloader_name,
'meta': self.meta
}
# utils/response.py
class Response:
"""
统一的响应对象,兼容 requests/httpx/playwright
"""
def __init__(self, url, text, encoding='utf-8'):
self.url = url
self.text = text
self.encoding = encoding
self.meta = {}
@classmethod
def from_text(cls, url, text, encoding='utf-8'):
return cls(url, text, encoding)
# utils/item.py
class Item:
"""
数据对象,类似 Scrapy 的 Item
"""
def __init__(self, **kwargs):
self._data = kwargs
@classmethod
def update(cls, **kwargs):
return cls(**kwargs)
@property
def to_dict(self):
return self._data
核心爬虫基类
python
# core/spider.py
from funboost import BoostersManager, BoosterParams, BrokerEnum
import requests
from parsel import Selector
import copy
from funboost.utils.class_utils import ClsHelper
from argparse import ArgumentParser
from typing import Generator
import sys
sys.path.append('../')
from utils.item import Item
from utils.request import Request
from utils.response import Response
class CoreSpider:
"""
核心爬虫基类
功能:
1. 自动管理两个队列:crawl_queue(爬取)和 save_queue(保存)
2. 支持自定义下载器(requests/httpx/playwright/自定义)
3. 自动处理 Request 和 Item 的流转
4. 提供命令行工具(--publish / --consume)
"""
def __init__(self, site_name):
# Funboost 需要的初始化参数(用于类的序列化传递)
self.obj_init_params = ClsHelper.get_obj_init_params_for_funboost(copy.copy(locals()))
self.site_name = site_name
self.crawl_queue_name = f"fun_spider_{self.site_name}_crawl"
self.save_queue_name = f"fun_spider_{self.site_name}_save"
def download(self, url: str, meta: dict) -> Response:
"""
默认下载器:使用 requests
可以在子类中重写此方法,实现自定义下载逻辑:
- 添加代理
- 设置 Cookie
- 处理验证码
"""
print(f" [默认下载器] GET {url}")
response = requests.get(url, timeout=15)
response.raise_for_status()
return Response.from_text(text=response.text, url=url, encoding=response.encoding)
def _internal_crawl_executor(self, request: Request):
"""
[内部核心函数] Funboost 消费的函数
工作流程:
1. 接收一个 Request 对象
2. 动态选择下载器(自定义或默认)
3. 调用用户定义的回调函数(如 parse_list, parse_detail)
4. 处理回调返回的 Request(继续爬)或 Item(保存)
"""
request_dict = request.to_dict()
url = request_dict['url']
callback_name = request_dict['callback_name']
downloader_name = request_dict['downloader_name']
meta = request_dict['meta']
# 获取回调函数
callback_method = getattr(self, callback_name)
print(f"[爬取] {url} (回调: {callback_name})")
try:
# 动态选择下载器
if downloader_name:
downloader_method = getattr(self, downloader_name)
else:
downloader_method = self.download
response = downloader_method(url=url, meta=meta)
response.meta = meta
except requests.RequestException as e:
print(f"[下载失败] {url} - {e}")
return
except Exception as e:
print(f"[下载器错误] 在 '{downloader_name or 'default'}' 中: {e}")
return
# 调用回调函数(如 parse_list, parse_detail)
results = callback_method(response)
if not results:
return
# 处理返回值(Request 或 Item)
for result in results:
if isinstance(result, Request):
# 发布到爬取队列(递归爬取)
self._queue_push(
queue_name=self.crawl_queue_name,
consuming_function=self._internal_crawl_executor,
push_kwargs=(result,)
)
elif isinstance(result, Item):
# 发布到保存队列
self._queue_push(
queue_name=self.save_queue_name,
consuming_function=self.save_item,
push_kwargs=(result,)
)
def _queue_push(self, queue_name, consuming_function, push_kwargs):
"""
统一的任务发布接口
作用:动态创建 booster 并发布任务
"""
booster = BoostersManager.build_booster(BoosterParams(
queue_name=queue_name,
consuming_function=consuming_function,
broker_kind=BrokerEnum.REDIS
))
print(f"[发布任务] 队列: {queue_name}")
booster.push(*push_kwargs)
def start_requests(self) -> Generator[Request, None, None]:
"""
[必须实现] 子类必须重写此方法,定义初始 URL
示例:
def start_requests(self):
yield Request(url='https://example.com', callback=self.parse)
"""
raise NotImplementedError(f"请在 '{self.__class__.__name__}' 中实现 'start_requests'")
def save_item(self, item: Item):
"""
[可选重写] 默认保存逻辑
子类可以重写此方法,实现自定义保存逻辑:
- 保存到 MySQL
- 保存到 MongoDB
- 保存到 CSV/JSON 文件
"""
print(f"[入库] {item.to_dict}")
def _publish(self):
"""
发布初始任务
从 start_requests() 中获取初始 URL,发布到 crawl_queue
"""
print("[发布任务] 正在发布初始URL...")
for request in self.start_requests():
booster = BoostersManager.build_booster(BoosterParams(
queue_name=self.crawl_queue_name,
consuming_function=self._internal_crawl_executor,
broker_kind=BrokerEnum.REDIS
))
booster.push(request)
print("[完成] 初始URL发布完成")
def start_consume_crawl(self):
"""
启动消费者
同时启动两个消费者:
1. crawl_queue:爬取网页
2. save_queue:保存数据
"""
print(f"[启动] 开始消费爬取队列: '{self.crawl_queue_name}'")
BoostersManager.build_booster(BoosterParams(
queue_name=self.crawl_queue_name,
consuming_function=self._internal_crawl_executor,
broker_kind=BrokerEnum.REDIS,
max_workers=5 # 5个并发消费者
)).consume()
print(f"[启动] 开始消费保存队列: '{self.save_queue_name}'")
BoostersManager.build_booster(BoosterParams(
queue_name=self.save_queue_name,
consuming_function=self.save_item,
broker_kind=BrokerEnum.REDIS,
max_workers=2
)).consume()
def start(self):
"""
命令行入口
用法:
- python spider.py --publish # 发布初始任务
- python spider.py --consume # 启动消费者
"""
parser = ArgumentParser(description=f"{self.site_name} 爬虫")
parser.add_argument(
'--publish',
action="store_true",
help=f"发布 '{self.site_name}' 的初始任务"
)
parser.add_argument(
'--consume',
action="store_true",
help=f"启动消费者"
)
args = parser.parse_args()
if args.publish:
self._publish()
elif args.consume:
self.start_consume_crawl()
else:
parser.print_help()
运行流程:
bash
# 终端1: 发布初始任务
$ python baidu_spider.py --publish
[发布任务] 正在发布初始URL...
[发布任务] 队列: fun_spider_baidu_crawl
[完成] 初始URL发布完成
# 终端2: 启动消费者
$ python baidu_spider.py --consume
[启动] 开始消费爬取队列: 'fun_spider_baidu_crawl'
[爬取] https://www.baidu.com (回调: parse_final_page)
[自定义下载器] GET https://www.baidu.com
- [解析] 正在解析: https://www.baidu.com
[发布任务] 队列: fun_spider_baidu_save
[保存到MySQL] 标题: 百度一下,你就知道, URL: https://www.baidu.com
二、核心功能讲解
2.1 最重要的 就是自动创建队列
根据解析名创建队列。
python
def _publish(self):
"""
发布初始任务
从 start_requests() 中获取初始 URL,发布到 crawl_queue
"""
print("[发布任务] 正在发布初始URL...")
for request in self.start_requests():
booster = BoostersManager.build_booster(BoosterParams(
queue_name=self.crawl_queue_name,
consuming_function=self._internal_crawl_executor,
broker_kind=BrokerEnum.REDIS
))
booster.push(request)
print("[完成] 初始URL发布完成")
## 根据
2.2 动态下载器选择
python
# 根据 Request 对象的 downloader_name 动态选择
if downloader_name:
downloader_method = getattr(self, downloader_name)
else:
downloader_method = self.download # 默认 requests
实际应用场景:
- 普通静态页面 →
requests - 需要 JS 渲染 →
playwright - 需要 HTTP/2 →
httpx - 需要代理池 → 自定义
download_with_proxy
示例:使用 Playwright 下载器
python
def download_with_playwright(self, url: str, meta: dict) -> Response:
"""
使用 Playwright 处理 JavaScript 渲染
"""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url)
page.wait_for_selector('.content') # 等待内容加载
html = page.content()
browser.close()
return Response.from_text(url=url, text=html)
def start_requests(self):
# 指定使用 Playwright 下载器
yield Request(
url='https://spa-website.com',
callback=self.parse,
downloader=self.download_with_playwright
)
2.3 Request 和 Item 的自动流转
python
# 在解析函数中 yield Request → 自动发到 crawl_queue
# 在解析函数中 yield Item → 自动发到 save_queue
for result in callback_method(response):
if isinstance(result, Request):
# 递归爬取
self._queue_push(self.crawl_queue_name, ...)
# 再次发布。
elif isinstance(result, Item):
# 保存数据
# self._queue_push(self.save_queue_name, ...)
# 也可以直接入库
如果能看到这里,说明这个流程基本已经了解了。其实就是一个同步的,先publish任务扔进队列,然后启动消费,采集+ 解析+入库 。当然,任意一个部分出错+重试,都有funboost 兜底来重试,这个框架简直是绝了。爬虫有相当多的参数功能就可以直接用funboost。
三、存在的问题:类传递 Bug
3.1 问题现象
在上述代码中,有一个严重的 Bug:
python
print("[发布任务] 正在发布初始URL...")
for request in self.start_requests():
booster = BoostersManager.build_booster(BoosterParams(
queue_name=self.crawl_queue_name,
consuming_function=self._internal_crawl_executor,
broker_kind=BrokerEnum.REDIS
))
booster.push(request)
就是这里动态创建队列的时候,官方文档给了demo ,动态生成队列 ,以及使用类方法。但是动态生成队列 +使用类方法 就有点麻烦了。
这个我们回头再解决~
