Metadata-Version: 2.4
Name: qcrawler
Version: 0.4.7
Summary: 分布式 Python 爬虫框架
License: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.28
Requires-Dist: loguru>=0.7
Requires-Dist: parsel>=1.8
Requires-Dist: pyyaml>=6.0
Requires-Dist: pydantic>=2.0
Requires-Dist: redis>=4.0
Requires-Dist: sqlalchemy>=2.0
Requires-Dist: psycopg[binary]>=3.1
Requires-Dist: minio>=7.0
Provides-Extra: curl
Requires-Dist: curl_cffi>=0.5; extra == "curl"
Provides-Extra: all
Requires-Dist: qcrawler[curl]; extra == "all"

# QCrawl

分布式 Python 爬虫框架，开箱即用。

## 特性

- **极简开发** — 继承 `QSpider`，实现 `parse` 即可运行
- **分布式调度** — Redis 种子队列 + 双模式去重 + 多进程/多线程并发
- **多级联爬取** — `upload_seed` 跨爬虫、跨节点流转种子，支持 1→多→多 级联（列表→详情→下载）
- **多下载引擎** — requests / curl_cffi（TLS 指纹伪装）/ request-go（Go 子进程）
- **统一数据层** — Pydantic 模型 + PostgreSQL 批量写入 + MinIO 文件存储
- **生产级能力** — 代理池、域名限速、Cookie 管理、自动重试、死信队列
- **可观测指标** — Redis 指标采集（供爬虫管理平台消费）+ 结构化日志
- **本地调试** — `send_seed` 一条命令跑通全流程，无需 Redis/PG

## 安装

```bash
# 从 PyPI 安装（推荐）
pip install qcrawler

# 可选：TLS 指纹伪装
pip install "qcrawler[curl]"

# 全部安装
pip install "qcrawler[all]"
```

> 注：发行包名为 `qcrawler`，导入名仍为 `qcrawl`（`from qcrawl import ...`）；zsh 下方括号需加引号，如 `"qcrawler[all]"`

## 快速开始

### 1. 编写爬虫

```python
# spiders/ithome/ithome_spider.py
from qcrawl import QSpider, Request

class IthomeSpider(QSpider):
    name = "ithome"
    data_type = "news"
    table = "ithome_data"

    def start_request(self, request_item):
        url = request_item.seed_dict.get("url") or "https://www.ithome.com/"
        yield Request(url, meta={"seed_id": request_item.seed_id})

    def parse(self, response):
        links = response.css("a[href*='/0/']::attr(href)").getall()
        for link in links[:10]:
            yield Request(response.urljoin(link), callback="parse_detail")

    def parse_detail(self, response):
        title = response.css("h1::text").get() or ""
        if not title:
            return
        self.report_data({
            "data_type": self.data_type,
            "url": response.url,
            "ext": {"title": title.strip()},
        })
```

### 2. 本地调试（无需 Redis/PG）

```python
from qcrawl import send_seed
from spiders.ithome.ithome_spider import IthomeSpider

send_seed(IthomeSpider, {"url": "https://www.ithome.com/"})
```

### 3. CLI 启动

安装后直接使用 `qcrawl` 命令（无需 main.py）。示例爬虫已拆分至独立项目 **qcrawl_crawler**（爬虫业务仓库），切换到该目录即可体验：

```bash
cd qcrawl_crawler

# 本地模式
qcrawl -s spiders.ithome.ithome_spider.IthomeSpider --local

# 分布式模式（读取 settings.yaml）
qcrawl -s spiders.ithome.ithome_spider.IthomeSpider --env dev --project_id my_project

# 手动指定连接
qcrawl -s spiders.ithome.ithome_spider.IthomeSpider \
    --project_id my_project \
    --redis_url redis://127.0.0.1:6379/0 \
    --db_url postgresql+psycopg://user:pass@localhost:5432/crawl \
    --processes 2 \
    --threads 8

# 使用 curl_cffi 绕过 TLS 指纹检测
qcrawl -s spiders.ithome.ithome_spider.IthomeSpider --http_client curl --local

# 导入种子文件
qcrawl -s spiders.ithome.ithome_spider.IthomeSpider --seeds_file seeds.jsonl --env dev
```

## CLI 参数

| 参数 | 说明 | 默认值 |
|------|------|--------|
| `-s, --spider` | 爬虫类路径（必填） | — |
| `--env` | 运行环境 dev/prod | dev |
| `--project_id` | 项目 ID | — |
| `--processes` | 工作进程数 | 1 |
| `--threads` | 每进程并发线程数 | 4 |
| `--proxy_type` | 代理模式 off/redis（redis 直读 proxy_pool 的 ZSet） | off |
| `--proxy_url` | 代理地址 | — |
| `--timeout` | 请求超时秒数 | 15 |
| `--retry` | 重试次数 | 3 |
| `--delay` | 请求间隔秒数 | 0 |
| `--http_client` | HTTP 引擎 requests/curl/request-go | requests |
| `--redis_url` | Redis 连接串 | — |
| `--db_url` | PostgreSQL 连接串 | — |
| `--seeds_file` | 种子文件路径 (JSON/JSONL) | — |
| `--local` | 本地模式（无需 Redis/PG） | — |
| `--migrate` | 仅执行表结构迁移 | — |

## 架构

```
┌─────────────────────────────────────────────────┐
│              CLI (qcrawl 命令)                   │
├─────────────────────────────────────────────────┤
│  Engine（引擎）                                  │
│  ├── Scheduler（Redis 种子消费 + 去重）          │
│  ├── Middleware Chain（中间件链）                 │
│  │   Log → UA → Proxy → Retry → Cookie         │
│  │   → Redirect → Timeout → RateLimit          │
│  ├── Downloader（下载器）                        │
│  │   requests / curl_cffi / request-go         │
│  └── Worker Thread Pool（并发执行）              │
├─────────────────────────────────────────────────┤
│  DataLayer（数据层）                             │
│  ├── Reporter → PostgreSQL 批量写入             │
│  ├── MinIO → 文件存储                           │
│  └── MetricsCollector → Redis 指标采集          │
└─────────────────────────────────────────────────┘
```

## 配置优先级

```
命令行参数 > 爬虫类 settings > settings.yaml > 框架默认值
```

### settings.yaml 示例

```yaml
common:
  threads: 4
  timeout: 15
  retry_times: 3

dev:
  redis_url: redis://127.0.0.1:6379/0
  log_level: DEBUG

prod:
  redis_url: ${REDIS_URL}
  db_url: ${DB_URL}
  threads: 16
  processes: 4
  log_level: INFO
```

## 数据上报

```python
from qcrawl import ReportItem
from datetime import date

# 方式一：dict（简单场景）
self.report_data({
    "data_type": "news",
    "url": response.url,
    "dt": date.today().isoformat(),  # 自定义日期标识
    "ext": {"title": "hello"},
})

# 方式二：ReportItem（推荐，有类型提示）
self.report_data(ReportItem(
    data_type="news",
    url=response.url,
    dt="2026-07-30",
    ext={"title": "hello", "content": "..."},
))
```

框架自动注入 `project_id`、`spider_name`、`seed_id`、`pod_ip`、`crawl_time` 字段。

**字段说明：**

| 字段 | 类型 | 来源 | 说明 |
|------|------|------|------|
| `project_id` | str | 框架注入 | 项目 ID |
| `spider_name` | str | 框架注入 | 爬虫名称 |
| `seed_id` | str | 框架注入 | 种子 ID（链路追踪） |
| `pod_ip` | str | 框架注入 | 执行节点 IP |
| `crawl_time` | datetime | 框架注入 | 抓取时间（UTC） |
| `data_type` | str | 开发者填 | 数据类型（news/article/product 等） |
| `dt` | str | 开发者填 | 自定义日期标识（如 "2026-07-30"） |
| `url` | str | 开发者填 | 数据来源 URL |
| `s3_addr` | list[str] | 开发者填 | MinIO 文件地址列表 |
| `ext` | JSONB | 开发者填 | 站点特有字段，自由写入 |

## 去重（双模式）

框架提供**自动去重**（默认）与**手动去重**（显式 API）两套独立体系，命名空间隔离、互不干扰：

- **自动去重**：管「种子」，Redis **Set**，消费种子时原子 SADD 拦截重复推送/重放
- **手动去重**：管「业务数据」，Redis **String**（每 key 一条），由爬虫自行控制标记时机，支持成员级独立过期

### 自动去重（默认）

消费种子时原子 SADD 判重（多节点并发安全），重复推送/重放的种子被拦截。失败种子自动移除标记（死信重放不失效）。

```python
class MySpider(QSpider):
    dedup_enabled = True   # 默认开启；False 则完全手动控制

    def get_seed_key(self, seed_dict):
        """自定义去重 key（可选），默认用 url SHA1 指纹"""
        return f"{seed_dict['url']}:{seed_dict.get('page', '')}"
```

种子级查询/管理 API（操作自动去重 Set，多用于运维或特殊流程）：

| API | 说明 |
|-----|------|
| `is_seed_exist(seed_key)` | 判断种子是否已处理（SISMEMBER） |
| `add_seed_key(seed_key)` | 标记种子为已处理（SADD） |
| `remove_seed_key(seed_key)` | 移除标记（SREM，允许重新抓取） |

### 手动去重（显式 API）

爬虫自行控制标记时机（如“只有下载成功才算完成”），key 为自定义指纹，支持成员级独立过期；实际存储 key 自动加前缀 `qcrawl:dedup:{project_id}:{spider_name}:`，无需自行拼接：

```python
class MySpider(QSpider):
    dedup_enabled = False

    def parse_detail(self, response):
        article_id = response.css(".article-id::text").get()
        if self.is_key_exist(article_id):   # 存在返回 True，否则 False
            return
        # ... 解析、上报 ...
        self.add_key(article_id, expire=7 * 86400)  # 标记 + 7 天过期
        # self.remove_key(article_id)               # 移除标记（重新抓取）
```

| API | 说明 |
|-----|------|
| `add_key(key, expire=None)` | 写标记；expire 为过期秒数（None 用类属性 `dedup_expire`，0=永不过期） |
| `is_key_exist(key)` | 存在返回 True，否则 False |
| `remove_key(key)` | 移除标记 |

### 两套 API 区别

| 维度 | 自动去重（种子级） | 手动去重（业务级） |
|------|--------------------|--------------------|
| 管什么 | 种子是否已被消费 | 业务对象是否已处理（如文章 ID） |
| Redis 结构 | Set（`qcrawl:dedup:{project}:{spider}`） | String（`qcrawl:dedup:{project}:{spider}:{key}`） |
| key 来源 | `get_seed_key()`（默认 URL SHA1，可覆写） | 开发者自定义 |
| TTL | 整个集合统一 `dedup_ttl` | 每 key 独立 `expire` |
| 配套 API | `is_seed_exist` / `add_seed_key` / `remove_seed_key` | `is_key_exist` / `add_key` / `remove_key` |
| 执行时机 | Scheduler 消费种子时自动执行 | 爬虫代码中显式调用 |

> 本地模式（send_seed）下去重 API 全部 no-op（本地不需要去重）。

## 种子流转（1→多→多 级联）

`upload_seed` 将解析出的子种子推送到目标爬虫的 Redis 队列，各节点 BRPOP 竞争消费，天然分布式瓜分：

```bash
# 部署形态：每爬虫一进程（可多节点多开）
qcrawl -s ListSpider    # 列表页：消费 qcrawl:seeds:list
qcrawl -s DetailSpider  # 详情页：消费 qcrawl:seeds:detail
qcrawl -s VideoSpider   # 下载器：消费 qcrawl:seeds:video
```

```python
class ListSpider(QSpider):
    name = "list"

    def parse(self, response):
        detail_urls = response.css("a.detail::attr(href)").getall()
        self.upload_seed("detail", [{"url": u} for u in detail_urls])

class DetailSpider(QSpider):
    name = "detail"

    def parse_detail(self, response):
        video_urls = response.css("video source::attr(src)").getall()
        self.upload_seed("video", [{"url": v} for v in video_urls])
```

子种子默认继承当前请求的 `seed_id`，全链路可追溯；上传不去重（由目标爬虫的 Scheduler 拦截重复）。

## 文件上传

```python
def parse_detail(self, response):
    img_bytes = self.download(response.css("img::attr(src)").get())
    s3_url = self.upload_file(img_bytes, key="images/abc.png", content_type="image/png")
    self.report_data({"data_type": "image", "s3_addr": [s3_url], "ext": {}})
```

## 监控指标

QCrawl 运行时将指标写入 Redis（`qcrawl:metrics:{project}:{spider}:*`），不包含内置监控 API 与 Dashboard。可视化展示、死信重放等运维能力由**爬虫管理平台**（独立项目）直接读取 Redis / PostgreSQL 承接，详见 DESIGN.md 第 17 章。

## 告警通知（钉钉）

爬虫**正常运行完成**时可自动推送运行统计到钉钉群机器人（开关默认关闭）。

### 开启方式（二选一）

**方式一：爬虫类属性（推荐，每爬虫独立控制）**

```python
class MySpider(QSpider):
    alert_enabled = True     # 运行完成后发送告警
```

**方式二：全局 settings 兜底**（所有爬虫生效）

```yaml
# settings.yaml（prod 段）
prod:
  alert_enabled: true
```

### 配置 Webhook（.env 注入）

```yaml
# settings.yaml（prod 段）
prod:
  dingtalk_webhook: ${DINGTALK_WEBHOOK}   # 从 .env 注入，不硬编码
  dingtalk_secret: ${DINGTALK_SECRET}     # 可选：机器人「加签」密钥
```

```bash
# .env（四项目共享，爬虫启动目录向上查找）
DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=xxx
DINGTALK_SECRET=SECxxx                    # 机器人未设加签则留空
```

钉钉机器人创建：钉钉群 → 群设置 → 智能群助手 → 添加机器人 → 自定义机器人，安全设置可选「加签」。

### 默认消息内容

```
## 🕷️ QCrawl 爬虫运行完成
- 项目: ithome
- 爬虫: ithome
- 运行环境: prod
- 节点: 172.16.0.10
- 开始时间: 2026-08-13T13:00:00+00:00
- 结束时间: 2026-08-13T14:30:00+00:00
- 耗时: 5400.0s
- 上报数据: 1234 条
- 死信种子: 2 条
```

### 自定义告警（覆写钩子）

```python
class MySpider(QSpider):
    def send_finish_alert(self, stats: dict) -> bool:
        # stats: project_id/spider_name/pod_ip/env/started_at/finished_at/
        #        duration_seconds/reported_count/dead_count
        # 自定义渠道/内容；或调 super() 发钉钉后再追加飞书等
        return super().send_finish_alert(stats)
```

> 开关为爬虫类属性 `alert_enabled`（默认关）或全局 settings `alert_enabled`；webhook 未配置时告警跳过并记录警告；发送失败仅记日志，不影响爬虫主流程。请求级异常告警钩子见 `on_alert(request, exception, error_type)`（中间件未处理时调用，子类可覆写）。

## 新项目使用

安装 qcrawl 后，你的爬虫项目只需如下结构：

```
my_crawler/
├── settings.yaml          # 环境配置（redis/pg/minio）
└── spiders/
    ├── __init__.py
    └── ithome/
        ├── __init__.py
        └── ithome_spider.py
```

无需 `main.py`，直接在项目根目录执行：

```bash
cd my_crawler
qcrawl -s spiders.ithome.ithome_spider.IthomeSpider --env dev --project_id my_project
```

## 开发

```bash
# 克隆 & 安装开发依赖
git clone https://gitee.com/yezhian/qcrawl.git && cd qcrawl
python -m venv .venv && source .venv/bin/activate
pip install -e ".[all]"

# 运行测试
pytest tests/ -v
```

### 发布到公共 PyPI

> 发行包名 `qcrawler`，导入名 `qcrawl`；发布凭据写在 `~/.pypirc`（`[pypi]` 段，`username = __token__`，password 为 PyPI API token）。

```bash
# 1. 升级版本（两处必须一致）：pyproject.toml 的 version + qcrawl/__init__.py 的 __version__

# 2. 清理旧产物并构建（生成 qcrawler-x.y.z.tar.gz + .whl）
pip install build twine
rm -rf dist/ build/ qcrawler.egg-info/
python -m build

# 3. 校验产物元数据
twine check dist/*

# 4. 发布（可选先发 TestPyPI 验证：twine upload --repository testpypi dist/*）
twine upload dist/*

# 5. 验证安装
pip install qcrawler && python -c "import qcrawl; print(qcrawl.__version__)"
```

## 项目结构

```
qcrawl/
├── qcrawl/                  # 框架源码包
│   ├── __init__.py          # 公共 API 导出
│   ├── cli.py               # CLI 入口（console_scripts）
│   ├── engine.py            # 引擎（线程池 + 中间件链）
│   ├── spider.py            # QSpider 基类（去重 API + upload_seed 种子流转）
│   ├── scheduler.py         # Redis 种子调度 + 去重
│   ├── request.py           # Request 模型
│   ├── response.py          # Response 封装
│   ├── request_item.py      # 种子封装
│   ├── settings.py          # 四级配置系统
│   ├── context.py           # 线程上下文（seed_id 传递）
│   ├── debug.py             # send_seed 本地调试
│   ├── enums.py             # 框架枚举（ProjectId / DataType）
│   ├── process_manager.py   # 多进程管理
│   ├── datalayer/           # 数据层
│   │   ├── database.py      # 数据库连接 + 自动建表
│   │   ├── report_item.py   # ReportItem 统一模型
│   │   ├── reporter.py      # PG 批量写入
│   │   └── minio_client.py  # MinIO 文件存储
│   ├── downloader/          # 下载器
│   │   ├── requests_downloader.py  # requests（默认）
│   │   ├── curl.py          # curl_cffi（TLS 伪装）
│   │   └── request_go.py    # Go 子进程
│   ├── middlewares/         # 中间件
│   │   ├── log.py           # 日志（priority=50）
│   │   ├── useragent.py     # UA 轮换（100）
│   │   ├── proxy.py         # 代理分配（200）
│   │   ├── retry.py         # 自动重试（300）
│   │   ├── cookie.py        # Cookie 管理（400）
│   │   ├── redirect.py      # 重定向跟随（500）
│   │   ├── timeout.py       # 超时控制（600）
│   │   └── ratelimit.py     # 域名限速（700）
│   ├── seed_queue.py        # Redis 种子队列（FIFO + 死信）
│   └── utils/               # 工具
│       ├── fingerprint.py   # URL 去重指纹
│       ├── lock.py          # 分布式锁
│       ├── metrics.py       # 指标采集器
│       ├── log.py           # 日志初始化
│       └── network.py       # 网络工具
└── tests/                   # 单元测试
```

> 爬虫业务代码（spiders/settings.yaml/推种子脚本）已独立为 **qcrawl_crawler** 仓库维护，本仓库仅保留框架本体。

## License

MIT
