372 lines
13 KiB
Markdown
372 lines
13 KiB
Markdown
# 爬虫状态管理系统设计
|
||
|
||
## Context
|
||
|
||
当前爬虫系统存在两个核心缺陷:
|
||
|
||
1. **关键词消费不可恢复**:`get_available()` 通过 `last_requested_date=today` 标记关键词已用,一旦爬虫崩溃,该关键词当天不会再被分配,已爬取的页面数据白白浪费。
|
||
2. **分页状态无持久化**:所有 3 个爬虫的分页逻辑都在内存中(Boss MAX_PAGES=3, QCWY MAX_PAGES=50, Zhilian MAX_PAGES=15),网络异常或进程重启后无法从断点恢复。
|
||
|
||
用户需求:关键词用完后标记不再重复请求 + 记录分页进度实现断点续爬。
|
||
|
||
---
|
||
|
||
## 架构决策
|
||
|
||
1. **扩展现有 keyword 表**(而非新建表):crawl 状态与 keyword 是 1:1 日粒度关系,新建表增加 JOIN 开销且无收益
|
||
2. **状态机驱动**:`crawl_status` 字段控制关键词生命周期,替代简单的 date 比较
|
||
3. **服务端记录进度**:爬虫每完成一页向服务端汇报,而非本地记录(支持多机分布式爬取)
|
||
4. **过期检测**:`crawling` 状态超时自动降级为 `partial`,防止僵死
|
||
|
||
---
|
||
|
||
## 实施步骤
|
||
|
||
### Step 1: 扩展 Keyword 模型
|
||
|
||
**修改** `app/models/keyword.py`
|
||
|
||
在 `BaseKeyword` 中新增字段:
|
||
|
||
```python
|
||
class BaseKeyword(Model):
|
||
id = fields.IntField(pk=True)
|
||
city = fields.CharField(max_length=64)
|
||
job = fields.CharField(max_length=128)
|
||
last_requested_date = fields.DateField(null=True)
|
||
last_requested_at = fields.DatetimeField(null=True)
|
||
|
||
# --- 新增:爬取状态管理 ---
|
||
crawl_status = fields.CharField(max_length=16, default="idle")
|
||
# 状态值: idle / crawling / completed / failed / partial
|
||
last_completed_page = fields.IntField(default=0) # 最后完成的页码
|
||
total_pages = fields.IntField(default=0) # 发现的总页数(0=未知)
|
||
jobs_found = fields.IntField(default=0) # 累计发现的职位数
|
||
crawl_started_at = fields.DatetimeField(null=True) # 当次爬取开始时间
|
||
crawler_id = fields.CharField(max_length=64, default="") # 爬虫实例标识
|
||
error_message = fields.TextField(default="") # 最后错误信息
|
||
retry_count = fields.IntField(default=0) # 当天重试次数
|
||
|
||
created_at = fields.DatetimeField(auto_now_add=True)
|
||
updated_at = fields.DatetimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
abstract = True
|
||
```
|
||
|
||
状态机流转:
|
||
|
||
```
|
||
idle ──(get_available)──► crawling ──(all pages done)──► completed
|
||
│
|
||
├──(spider reports error)──► failed ──(retry<3)──► crawling
|
||
│
|
||
└──(timeout 30min)──► partial ──(get_available)──► crawling
|
||
|
||
次日 00:00:所有状态 → idle(通过 last_requested_date != today 自动重置)
|
||
```
|
||
|
||
### Step 2: 重写 get_available() 控制器
|
||
|
||
**修改** `app/controllers/keyword.py` 的 `get_available()` 方法
|
||
|
||
优先级调度逻辑(替代当前的简单 date 过滤):
|
||
|
||
```
|
||
优先级 1: crawl_status='partial' AND last_requested_date=today (断点续爬)
|
||
优先级 2: crawl_status='failed' AND retry_count<3 AND last_requested_date=today (失败重试)
|
||
优先级 3: (last_requested_date!=today OR last_requested_date IS NULL) (全新关键词)
|
||
```
|
||
|
||
返回值增加 `last_completed_page` 和 `crawl_status`,使爬虫知道从哪页开始:
|
||
|
||
```python
|
||
items = [{
|
||
"id": r.id,
|
||
"city": r.city,
|
||
"job": r.job,
|
||
"last_completed_page": r.last_completed_page, # 新增
|
||
"crawl_status": r.crawl_status, # 新增
|
||
}]
|
||
```
|
||
|
||
认领时原子更新:
|
||
```python
|
||
update_fields = {
|
||
"last_requested_date": today,
|
||
"last_requested_at": now,
|
||
"crawl_status": "crawling",
|
||
"crawl_started_at": now,
|
||
"crawler_id": crawler_id, # 从请求参数获取
|
||
}
|
||
# 如果是全新关键词(非续爬),重置分页状态
|
||
if is_fresh:
|
||
update_fields["last_completed_page"] = 0
|
||
update_fields["total_pages"] = 0
|
||
update_fields["jobs_found"] = 0
|
||
update_fields["error_message"] = ""
|
||
update_fields["retry_count"] = 0
|
||
```
|
||
|
||
### Step 3: 新增进度汇报 API
|
||
|
||
**修改** `app/api/v1/keyword/keyword.py` — 新增 2 个端点
|
||
|
||
#### 3.1 页面进度汇报
|
||
|
||
```
|
||
POST /api/v1/keyword/page-progress
|
||
Body: {
|
||
"source": "boss",
|
||
"keyword_id": 123,
|
||
"page": 2,
|
||
"total_pages": 10, // 可选,爬虫发现的总页数
|
||
"jobs_found": 15 // 本页发现的职位数
|
||
}
|
||
```
|
||
|
||
控制器逻辑:
|
||
```python
|
||
async def report_page_progress(self, source, keyword_id, page, total_pages=None, jobs_found=0):
|
||
model = self._ensure_model(source)
|
||
update_data = {"last_completed_page": page}
|
||
if total_pages is not None and total_pages > 0:
|
||
update_data["total_pages"] = total_pages
|
||
# jobs_found 累加
|
||
await model.filter(id=keyword_id).update(
|
||
last_completed_page=page,
|
||
jobs_found=F("jobs_found") + jobs_found,
|
||
**({"total_pages": total_pages} if total_pages else {})
|
||
)
|
||
```
|
||
|
||
#### 3.2 爬取完成/失败汇报
|
||
|
||
```
|
||
POST /api/v1/keyword/crawl-complete
|
||
Body: {
|
||
"source": "boss",
|
||
"keyword_id": 123,
|
||
"status": "completed" | "failed",
|
||
"error_message": "optional error detail"
|
||
}
|
||
```
|
||
|
||
控制器逻辑:
|
||
```python
|
||
async def report_crawl_complete(self, source, keyword_id, status, error_message=""):
|
||
model = self._ensure_model(source)
|
||
update_data = {"crawl_status": status, "error_message": error_message}
|
||
if status == "failed":
|
||
# 使用 F 表达式原子递增 retry_count
|
||
await model.filter(id=keyword_id).update(
|
||
crawl_status="failed",
|
||
error_message=error_message,
|
||
)
|
||
# retry_count 单独递增
|
||
obj = await model.filter(id=keyword_id).first()
|
||
if obj:
|
||
obj.retry_count += 1
|
||
await obj.save(update_fields=["retry_count"])
|
||
else:
|
||
await model.filter(id=keyword_id).update(**update_data)
|
||
```
|
||
|
||
### Step 4: 新增请求 Schema
|
||
|
||
**修改** `app/schemas/keyword.py`(或新建)
|
||
|
||
```python
|
||
class PageProgressRequest(BaseModel):
|
||
source: str
|
||
keyword_id: int
|
||
page: int
|
||
total_pages: Optional[int] = None
|
||
jobs_found: int = 0
|
||
|
||
class CrawlCompleteRequest(BaseModel):
|
||
source: str
|
||
keyword_id: int
|
||
status: Literal["completed", "failed"]
|
||
error_message: str = ""
|
||
```
|
||
|
||
### Step 5: 过期爬取检测(定时任务)
|
||
|
||
**修改** `app/core/scheduler.py` — 新增 `stale_crawl_cleanup_job`
|
||
|
||
```python
|
||
# 每 10 分钟检查一次
|
||
async def stale_crawl_cleanup():
|
||
"""将超过 30 分钟仍为 crawling 状态的关键词降级为 partial"""
|
||
threshold = datetime.now() - timedelta(minutes=30)
|
||
for model in [BossKeyword, QcwyKeyword, ZhilianKeyword]:
|
||
count = await model.filter(
|
||
crawl_status="crawling",
|
||
crawl_started_at__lt=threshold,
|
||
).update(crawl_status="partial")
|
||
if count:
|
||
logger.info(f"{model.__name__}: {count} 条僵死爬取任务已标记为 partial")
|
||
```
|
||
|
||
### Step 6: 修改爬虫 — Boss 直聘
|
||
|
||
**修改** `jobs_spider/boss/boos_api.py`
|
||
|
||
#### 6.1 增强 fetch_service_params()
|
||
|
||
```python
|
||
def fetch_service_params() -> Optional[Dict[str, Any]]:
|
||
try:
|
||
url = f"{API_BASE_URL}/api/v1/keyword/available"
|
||
crawler_id = f"boss-{os.getpid()}-{os.getenv('HOSTNAME', 'local')}"
|
||
r = requests.get(url, params={
|
||
"source": "boss", "limit": 1, "reserve": True,
|
||
"crawler_id": crawler_id, # 新增
|
||
}, timeout=10)
|
||
# ... 解析逻辑 ...
|
||
item = items[0]
|
||
# 不再需要 mark-used(get_available 已原子标记)
|
||
return {
|
||
"query": item["job"],
|
||
"city": item["city"],
|
||
"scene": 1,
|
||
"page": item.get("last_completed_page", 0) + 1, # 断点续爬
|
||
"keyword_id": item["id"], # 新增
|
||
}
|
||
except Exception:
|
||
return None
|
||
```
|
||
|
||
#### 6.2 主循环添加进度汇报
|
||
|
||
```python
|
||
# 在 get_job_list_multi_pages 的每页完成后回调中:
|
||
def on_page_complete(page_num, jobs_count, keyword_id):
|
||
try:
|
||
requests.post(f"{API_BASE_URL}/api/v1/keyword/page-progress", json={
|
||
"source": "boss",
|
||
"keyword_id": keyword_id,
|
||
"page": page_num,
|
||
"jobs_found": jobs_count,
|
||
}, timeout=5)
|
||
except Exception:
|
||
pass # 汇报失败不影响主流程
|
||
|
||
# 全部完成后:
|
||
def on_crawl_done(keyword_id, success, error_msg=""):
|
||
try:
|
||
requests.post(f"{API_BASE_URL}/api/v1/keyword/crawl-complete", json={
|
||
"source": "boss",
|
||
"keyword_id": keyword_id,
|
||
"status": "completed" if success else "failed",
|
||
"error_message": error_msg,
|
||
}, timeout=5)
|
||
except Exception:
|
||
pass
|
||
```
|
||
|
||
### Step 7: 修改爬虫 — 前程无忧
|
||
|
||
**修改** `jobs_spider/qcwy/qcwy.py`
|
||
|
||
同 Boss 结构:
|
||
- `fetch_service_params()` 增加 `keyword_id` 和 `last_completed_page` 返回
|
||
- `crawl_multiple_pages()` 的 `start_page` 从 `last_completed_page + 1` 开始
|
||
- 每页完成后调用 `page-progress` API
|
||
- 全部完成/失败后调用 `crawl-complete` API
|
||
|
||
### Step 8: 修改爬虫 — 智联招聘
|
||
|
||
**修改** `jobs_spider/zhilian/zhilian_single.py`
|
||
|
||
同 Boss 结构:
|
||
- `fetch_service_params()` 增加 `keyword_id` 和 `last_completed_page` 返回
|
||
- `crawl_pc()` 的起始页从 `last_completed_page + 1` 开始
|
||
- 每页完成后调用 `page-progress` API
|
||
- 全部完成/失败后调用 `crawl-complete` API
|
||
|
||
### Step 9: 统计接口增强
|
||
|
||
**修改** `app/controllers/keyword.py` 的 `get_stats()`
|
||
|
||
返回值增加爬取状态分布:
|
||
|
||
```python
|
||
async def get_stats(self, source, on_date=None):
|
||
# ... 现有逻辑 ...
|
||
# 新增状态分布
|
||
crawling = await model.filter(crawl_status="crawling", last_requested_date=d).count()
|
||
completed = await model.filter(crawl_status="completed", last_requested_date=d).count()
|
||
failed = await model.filter(crawl_status="failed", last_requested_date=d).count()
|
||
partial = await model.filter(crawl_status="partial", last_requested_date=d).count()
|
||
|
||
return {
|
||
"data": {
|
||
"date": str(d), "total": total, "used": used, "unused": unused,
|
||
"crawl_status": {
|
||
"crawling": crawling,
|
||
"completed": completed,
|
||
"failed": failed,
|
||
"partial": partial,
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Step 10: 数据库迁移
|
||
|
||
执行 Aerich 迁移以在 MySQL keyword 表中添加新字段:
|
||
|
||
```bash
|
||
aerich migrate --name add_crawl_state_fields
|
||
aerich upgrade
|
||
```
|
||
|
||
或在 `init_app.py` 的自动迁移中由 Aerich 自动处理(`RUN_MIGRATIONS_ON_STARTUP=True`)。
|
||
|
||
---
|
||
|
||
## 关键文件清单
|
||
|
||
| 文件 | 操作 | 说明 |
|
||
|------|------|------|
|
||
| `app/models/keyword.py` | 修改 | 添加 8 个爬取状态字段 |
|
||
| `app/controllers/keyword.py` | 修改 | 重写 get_available() 优先级调度 + 新增 2 个方法 |
|
||
| `app/api/v1/keyword/keyword.py` | 修改 | 新增 page-progress / crawl-complete 端点 |
|
||
| `app/schemas/keyword.py` | 新建 | PageProgressRequest / CrawlCompleteRequest |
|
||
| `app/core/scheduler.py` | 修改 | 新增 stale_crawl_cleanup 定时任务 |
|
||
| `jobs_spider/boss/boos_api.py` | 修改 | 断点续爬 + 进度汇报 |
|
||
| `jobs_spider/qcwy/qcwy.py` | 修改 | 断点续爬 + 进度汇报 |
|
||
| `jobs_spider/zhilian/zhilian_single.py` | 修改 | 断点续爬 + 进度汇报 |
|
||
|
||
---
|
||
|
||
## 效果
|
||
|
||
| 指标 | 改造前 | 改造后 |
|
||
|------|--------|--------|
|
||
| 崩溃恢复 | 关键词丢失,当天不可恢复 | 自动从断点续爬 |
|
||
| 页面重复爬取 | 100%(整个关键词重爬) | 0%(精确到页级别) |
|
||
| 僵死任务检测 | 无 | 30 分钟自动降级 |
|
||
| 失败重试 | 无(关键词当天报废) | 最多 3 次自动重试 |
|
||
| 爬取进度可见性 | 无 | 实时可查(stats API) |
|
||
|
||
## 风险与缓解
|
||
|
||
| 风险 | 缓解措施 |
|
||
|------|----------|
|
||
| 字段迁移影响现有数据 | 所有新字段都有 default 值,迁移无破坏性 |
|
||
| 进度汇报增加 API 压力 | 汇报请求轻量(仅 UPDATE 单行),每页仅 1 次,可设 timeout=5s |
|
||
| 爬虫不升级导致状态不一致 | 新旧爬虫可共存:旧爬虫不汇报进度,关键词仍按 date 逻辑工作 |
|
||
| retry_count 无上限 | 硬限 3 次,超过 3 次的 failed 不再自动重试 |
|
||
|
||
## 验证方式
|
||
|
||
1. 启动应用,确认 Aerich 自动迁移新增字段成功
|
||
2. 手动调用 `GET /api/v1/keyword/available?source=boss` 验证返回 `last_completed_page` 和 `crawl_status`
|
||
3. 模拟断点:手动设置某关键词 `crawl_status=partial, last_completed_page=2`,再次 `get_available` 应优先返回该关键词
|
||
4. 启动 Boss 爬虫,观察日志确认从 `last_completed_page + 1` 开始
|
||
5. 强制 kill 爬虫,等待 30 分钟后确认 `stale_crawl_cleanup` 将状态降级为 `partial`
|
||
6. 重启爬虫,确认自动续爬
|