paperlite 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paperlite-0.1.0/.gitignore +22 -0
- paperlite-0.1.0/LICENSE +21 -0
- paperlite-0.1.0/PKG-INFO +257 -0
- paperlite-0.1.0/README.md +230 -0
- paperlite-0.1.0/pyproject.toml +52 -0
- paperlite-0.1.0/src/paperlite/__init__.py +14 -0
- paperlite-0.1.0/src/paperlite/__main__.py +4 -0
- paperlite-0.1.0/src/paperlite/browser.py +123 -0
- paperlite-0.1.0/src/paperlite/classify.py +37 -0
- paperlite-0.1.0/src/paperlite/cli.py +370 -0
- paperlite-0.1.0/src/paperlite/config.example.toml +34 -0
- paperlite-0.1.0/src/paperlite/config.py +133 -0
- paperlite-0.1.0/src/paperlite/consumer.py +362 -0
- paperlite-0.1.0/src/paperlite/producer.py +151 -0
- paperlite-0.1.0/src/paperlite/proxy.py +125 -0
- paperlite-0.1.0/src/paperlite/store.py +241 -0
- paperlite-0.1.0/src/paperlite/tasks.py +427 -0
- paperlite-0.1.0/tests/__init__.py +0 -0
- paperlite-0.1.0/tests/test_paperlite.py +694 -0
paperlite-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 huangpd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
paperlite-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: paperlite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 大批量 PDF 下载:浏览器过盾 cookie 池(Redis)+ curl_cffi + SQLite 任务库
|
|
5
|
+
Project-URL: Homepage, https://github.com/huangpd/PaperLite
|
|
6
|
+
Project-URL: Repository, https://github.com/huangpd/PaperLite
|
|
7
|
+
Project-URL: Issues, https://github.com/huangpd/PaperLite/issues
|
|
8
|
+
Author-email: huangpd <1655979823@qq.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cloudflare,cookie-pool,curl_cffi,downloader,pdf,scraping
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: POSIX
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: certifi>=2024.2.2
|
|
23
|
+
Requires-Dist: cloakbrowser>=0.6.2
|
|
24
|
+
Requires-Dist: curl-cffi>=0.15.0
|
|
25
|
+
Requires-Dist: redis>=5.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# PaperLite:单站点大批量 PDF 下载
|
|
29
|
+
|
|
30
|
+
专门处理**单一站点、大量 URL**:用浏览器持续生产过 Cloudflare 的 cookie,curl_cffi 领用下载。生产与消费只通过 Redis 交互,可以分开跑在不同进程上;任务与结果存在站点输出目录的 SQLite 库 `tasks.db` 里。多站点、通用的路由和兜底由 `paper_downloader` 负责。
|
|
31
|
+
|
|
32
|
+
独立项目(原 `paper_downloader/cookie_pool/`,2026-10-25 拆出):自己的依赖和 venv,配置是运行目录的 `config.toml`,不依赖 `paper_downloader` 的任何代码,也不用 PostgreSQL。
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
需要:Python ≥ 3.11、Redis、一个短效代理提取接口(每次返回一个新 IP,青果格式)。
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install paperlite # 或 uv tool install paperlite;国内加 -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
40
|
+
mkdir work && cd work
|
|
41
|
+
paperlite init # 生成 ./config.toml(权限 600),填 [proxy] 的提取接口和代理账号密码
|
|
42
|
+
|
|
43
|
+
# Redis:只监听本机、不落盘(身份 10 分钟就过期,不需要持久化)
|
|
44
|
+
docker run -d --name cf-redis -p 127.0.0.1:6379:6379 redis:latest redis-server --save "" --appendonly no
|
|
45
|
+
|
|
46
|
+
# 导入并开跑:输出目录按主机自动定为 pdfs/dl.acm.org,下载完自动退出
|
|
47
|
+
paperlite import --urls acm.txt
|
|
48
|
+
paperlite run --workers 20
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
从源码装(开发、改代码):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/huangpd/PaperLite.git && cd PaperLite
|
|
55
|
+
uv sync # 国内服务器:uv venv && uv pip install -e . --index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
|
56
|
+
uv run paperlite init
|
|
57
|
+
uv run paperlite import --urls data/acm.txt
|
|
58
|
+
uv run paperlite run --workers 20
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
运行中每 30 秒打印一行进度(已处理条数、PDF 篇数、身份池、近 30 秒速度)。随时 Ctrl+C,在途任务会放回 pending,再执行同一条命令就从断点继续。
|
|
62
|
+
|
|
63
|
+
`paperlite` 是安装时生成的命令(`pyproject.toml` 的 `[project.scripts]`),后面的 `run` / `import` / `status` 是它的子命令。pip 安装的直接写 `paperlite ...`;源码目录里下面统一写成 `uv run paperlite ...`,这几种写法等价:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv run paperlite status # uv 自动用项目 venv,不用激活
|
|
67
|
+
.venv/bin/paperlite status # 直接调 venv 里的命令(服务器上推荐,见下)
|
|
68
|
+
source .venv/bin/activate && paperlite status
|
|
69
|
+
.venv/bin/python -m paperlite status
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
用清华源手动装的环境(`uv pip install`,不是 `uv sync`)请用 `.venv/bin/paperlite`:`uv run` 会按 `uv.lock` 去官方源重新同步依赖,国内服务器上会卡住。
|
|
73
|
+
|
|
74
|
+
## 命令一览
|
|
75
|
+
|
|
76
|
+
| 命令 | 作用 |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `uv run paperlite init [--force]` | 在当前目录生成配置模板 `config.toml`(或写到 `--config` 指定的路径) |
|
|
79
|
+
| `uv run paperlite import --urls <文件>` | 导入任务。txt 一行一个链接,或带 `pdf_url` 列(可选 `paper_uid` / `source` / `batch_no`)的 CSV。重复导入不重置已有任务;一个库只能有一个主机 |
|
|
80
|
+
| `uv run paperlite run [--urls <文件>] [--workers 20]` | 同进程生产身份 + 下载,下载完退出 |
|
|
81
|
+
| `uv run paperlite produce [--keep N]` | 只生产身份,常驻 |
|
|
82
|
+
| `uv run paperlite download [--workers 20]` | 只下载,需有生产者在跑 |
|
|
83
|
+
| `uv run paperlite status` | 各状态数量、失败 / non_pdf 按 `error_kind` 分组、被停卷期、身份池 |
|
|
84
|
+
| `uv run paperlite retry --status failed [--kind network]` | 把失败(或 `non_pdf`)重置为 pending |
|
|
85
|
+
| `uv run paperlite retry --stopped-volumes` | 重新核实按卷期停掉的任务 |
|
|
86
|
+
| `uv run paperlite export [--status non_pdf] [--file x.csv]` | 导出任务表为 CSV(默认输出到标准输出) |
|
|
87
|
+
|
|
88
|
+
所有命令都可以加 `--out` / `--host` / `--db` 指定站点和任务库;只有一个站点时都不用写:
|
|
89
|
+
|
|
90
|
+
- **站点输出目录**:`--out` > `--host`、`--urls` 里链接的主机、`--db` 记录的主机 → `<out_root>/<主机>`(`out_root` 默认 `pdfs`)> `out_root` 下唯一的站点。有多个站点时会列出来,用 `--host dl.acm.org` 选一个。
|
|
91
|
+
- **任务库**:`--db` > 配置 `[pool] db_dir`(`<db_dir>/<输出目录名>-<路径哈希>.db`)> `<输出目录>/tasks.db`。全局参数:`--config <文件>`、`--log-level`、`--version`。
|
|
92
|
+
|
|
93
|
+
## 常见场景
|
|
94
|
+
|
|
95
|
+
**PDF 存到 goofys / s3fs 等对象存储挂载上**:任务库必须放本地盘。SQLite 要随机写、文件锁和 mmap,FUSE 挂载不支持;PDF 是顺序写入,可以放在挂载上(每篇最后的改名在 S3 上是一次复制 + 删除)。推荐在配置里设一次任务库目录,之后只写 `--out`:
|
|
96
|
+
|
|
97
|
+
```toml
|
|
98
|
+
[pool]
|
|
99
|
+
db_dir = "/data/paperlite/db" # 任务库自动放这里:<目录>/<输出目录名>-<路径哈希>.db,如 acm-3f2a1c.db
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
uv run paperlite import --urls data/acm.txt --out /mnt/goofys/acm
|
|
104
|
+
uv run paperlite run --out /mnt/goofys/acm --workers 20 # 或在配置里设 out_root = "/mnt/goofys",之后不用写 --out
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
也可以不改配置、每次用 `--db /data/paperlite/acm.db` 指定(优先于 `db_dir`)。
|
|
108
|
+
|
|
109
|
+
**后台运行与停止**:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
setsid nohup .venv/bin/paperlite run --workers 20 > logs/acm.log 2>&1 < /dev/null &
|
|
113
|
+
tail -f logs/acm.log
|
|
114
|
+
kill <pid> # 或 pkill -f "paperlite run"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
SIGTERM 与 Ctrl+C 一样优雅退出:在途任务放回 pending、在用身份还回池里、生产者关掉浏览器并释放生产者锁。注意 `pkill -f` 的匹配串若也出现在执行它的 shell 命令行里(如 `ssh host '...paperlite run...; pkill -f paperlite'`),会连那个 shell 一起杀掉。
|
|
118
|
+
|
|
119
|
+
**生产与下载分开跑**:生产者常驻,下载进程随用随开;多台机器的下载进程可以共用一个 Redis(`redis_url` 指向同一台;默认只监听本机,跨机器要改监听地址并加密码)。同一主机同时只有一个生产者干活,多开的待命,前一个退出或崩溃后 15 秒内接手。
|
|
120
|
+
|
|
121
|
+
**省掉冷启动**:第一个身份要等一次过盾(约 20 秒)。下载前一两分钟先 `uv run paperlite produce --keep 20` 养着身份,代价是没人下载时也在消耗 IP。
|
|
122
|
+
|
|
123
|
+
**重跑**:`retry --status failed` 重跑失败的(可加 `--kind network` / `challenge_failed` 只重跑某一类);`retry --stopped-volumes` 让被停掉的卷期重新抽样核实。
|
|
124
|
+
|
|
125
|
+
**换别的站点**:在 `config.toml` 的 `[landing]` 加一行该主机的落地页(任意会触发挑战的普通页面),`[proxy] check_url` 换成该站可达的地址;`classify.py` 里的 non_pdf 判断是按 ACM 页面写的,换站点要看一下。
|
|
126
|
+
|
|
127
|
+
## 主要配置(config.toml)
|
|
128
|
+
|
|
129
|
+
| 配置 | 默认 | 说明 |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `[proxy] api_url` / `username` / `password` | | 提取接口与代理鉴权(必填) |
|
|
132
|
+
| `out_root` | `pdfs` | 不给 `--out` 时的输出根目录,站点目录为 `<out_root>/<主机>` |
|
|
133
|
+
| `db_dir` | 空 | 任务库目录(本地盘);留空则放 `<--out>/tasks.db`。输出目录在对象存储挂载上时必须设置 |
|
|
134
|
+
| `max_browsers` | 20 | 同时过盾的浏览器上限 |
|
|
135
|
+
| `max_identities` / `min_identities` | 20 / 0 | 健康身份上限 / 没人下载时也养着的身份数;实际按在线下载线程数生产 |
|
|
136
|
+
| `pass_timeout_s` / `hedge_after_s` | 90 / 45 | 单次过盾上限 / 过盾超过这么久就另开一个补位 |
|
|
137
|
+
| `request_gap_s` | [3, 5] | 同一 IP 两次请求的随机间隔 |
|
|
138
|
+
| `download_timeout_s` | 120 | 卡顿超时:连续这么久收不到数据才算超时(不限总时长) |
|
|
139
|
+
| `max_file_size_mb` | 200 | 单个 PDF 上限,超过中止 |
|
|
140
|
+
| `max_tries` | 3 | 网络错误重试次数(cookie 被拒不计) |
|
|
141
|
+
| `max_url_challenges` | 3 | 单条 URL 带有效 cookie 仍被挑战满这么多次 → `challenge_failed` |
|
|
142
|
+
| `volume_stop_after` | 3 | 同一卷期接连这么多篇没有 PDF 就停掉该卷期其余任务;0 关闭 |
|
|
143
|
+
| `max_fresh_rejects` / `breaker_pause_s` | 5 / 600 | 新身份接连一用就被拒时熔断暂停生产 |
|
|
144
|
+
| `debug_dir` | 空 | 过盾失败截图目录 |
|
|
145
|
+
|
|
146
|
+
完整列表和注释见 `paperlite init` 生成的模板(源码 `src/paperlite/config.example.toml`)。配置查找顺序:`--config` > 环境变量 `PAPERLITE_CONFIG` > 当前目录的 `config.toml`。
|
|
147
|
+
|
|
148
|
+
## 速度与 IP 用量(ACM 实测)
|
|
149
|
+
|
|
150
|
+
- 20 个下载线程:稳定后每分钟处理 120–160 条(PDF 约 140 篇),单篇下载平均 2.6 秒;线程数只影响速度,不影响 IP 用量。
|
|
151
|
+
- 每个身份(IP + cookie)用约 9 分钟,下 60–70 篇;加上过盾失败(成功率 60%–90%),**每 1 万篇约需 180–300 个 IP**。
|
|
152
|
+
- 换成 30 分钟时效的 IP,同样篇数只要约 1/3 的 IP。
|
|
153
|
+
|
|
154
|
+
## 测试
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
uv run pytest # Redis 相关的会跳过
|
|
158
|
+
PAPERLITE_TEST_REDIS=redis://127.0.0.1:6379/0 uv run pytest # 连真实 Redis 全跑
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## 为什么这样设计
|
|
162
|
+
|
|
163
|
+
- **cf_clearance 绑定 IP + UA**:同 IP 带 cookie → 200 PDF;同 IP 不带 cookie → 403;cookie 换到别的 IP → 403。所以身份 = IP + cookie + UA,三者一起存、一起用。
|
|
164
|
+
- **身份寿命 ≈ IP 寿命**:青果短效 IP 只有 10 分钟(接口 `deadline`),cookie 本身更长,身份过期取两者较早者。每个身份约 9 分钟后就要换新,必须持续生产。
|
|
165
|
+
- **cookie 在有效期内一直可用**:同一 IP 每 3–5 秒一次请求,下载 900+ 篇期间 cookie 被拒只有 3 次(都是复用上一轮的旧身份)。
|
|
166
|
+
- **过盾要么很快、要么卡死**:成功的多在 12–30 秒(个别 70 秒);失败的停在 Turnstile "Verifying…" 一直转圈,等到 120 秒也不会过(不是要手动勾选)。所以默认 `pass_timeout_s = 90`,并做**对冲**:过盾超过 `hedge_after_s = 45` 秒还没成功,就另开一个浏览器补位,旧的继续跑到超时。没有对冲时,卡住的过盾占着生产名额 2 分钟,6 个下载线程只等到 2 个身份(20 篇/分钟)。
|
|
167
|
+
- **机房 IP 会要求手动勾选**:hw1 直连出口拿到的是要手动勾选的 Turnstile 复选框,必须走代理。
|
|
168
|
+
- **站点会单独盯上某些 URL**(2026-10-25 实测):同一个新 cookie、同一 IP,`1010832.*` 正常拿到 PDF,`1005062.*`(14 篇,连摘要页)一律 403 挑战,多半是前一晚在这几条上反复请求了几百次。原先把"被挑战"一律当成 cookie 坏了:丢身份、任务放回队首,于是每个新身份第一次请求都撞上它们,一篇也下不了。现在遇到挑战先用落地页验身份:落地页正常 → 只是这条 URL 被盯上,`challenge_count+1` 排到后面;落地页也被挑战 → 身份坏了才丢。不用"下一条也被挑战"来判身份,因为被盯上的论文往往成批相邻,会把好身份接连误杀。
|
|
169
|
+
- **代理偶尔成批断连**:2026-10-25 00:28 有 13 个不同 IP 在 28 秒内先后报 `curl: (56) Connection closed abruptly`,原先一律丢身份,健康身份从 17 掉到 7、补了约 1.5 分钟。现在 (56) 先用同一身份重试一次。
|
|
170
|
+
- **收尾不再白补身份**:只剩别的线程的在途任务时,空等的下载线程注销需求并把身份还回池里(之前最后 1 条重试时生产者仍按 20 个线程补身份,停止时池里空闲 20 个)。
|
|
171
|
+
- **冷启动**:第一个身份要等一次过盾(实测约 20 秒)。想省掉就在下载前一两分钟先 `uv run paperlite produce --keep 20`,代价是没人下载时也在消耗 IP。
|
|
172
|
+
- **流式下载**:超时是卡顿超时(`download_timeout_s`:连续这么久收不到数据),不是总时长,2 Mbps 的代理上大文件也不会被掐断;内存里最多一个 256KB 的块。`%PDF-` 须在前 1024 字节内、之前只允许空白 / BOM(写盘时去掉),与 paper_downloader 一致;小于 `min_file_size`(1024 字节)计一次重试,超过 `max_file_size_mb`(200)中止判 `failed / extract_failed`。
|
|
173
|
+
- **熔断**:2026-10-24 22:38 实测,新过盾的身份全部一篇没下成就被 403 挑战(原因待查,同时代理配额耗尽),没有保护时 1 分钟烧掉 16 个 IP。现在连续 `max_fresh_rejects`(5)个新身份一用就被拒,生产者暂停 `breaker_pause_s`(600s)并告警,之后放一个身份试探,再被拒立刻重新暂停。网络错误不计入。
|
|
174
|
+
- **过盾失败直接换 IP**:同一 IP 重试很少成功,换 IP 更快。
|
|
175
|
+
|
|
176
|
+
## 目录
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
PaperLite/
|
|
180
|
+
pyproject.toml uv.lock README.md LICENSE .gitignore
|
|
181
|
+
config.toml # paperlite init 生成(含代理密码,不提交)
|
|
182
|
+
src/paperlite/ # 代码(src 布局:测试跑的是安装好的包,不会误用源码目录);config.example.toml 是配置模板
|
|
183
|
+
tests/
|
|
184
|
+
data/ # URL 清单等输入数据(不提交)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## 结构
|
|
188
|
+
|
|
189
|
+
| 模块 | 职责 |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `store.py` | Redis 身份池:`add` / `claim`(Lua 原子领用,独占)/ `heartbeat` / `release` / `discard` / `reap` / `stats` |
|
|
192
|
+
| `producer.py` | 按需生产:目标健康身份数 = clamp(在线下载线程数, `min_identities`, `max_identities`),最多 `max_browsers` 个浏览器同时过盾 |
|
|
193
|
+
| `browser.py` | 一次过盾:独立 CloakBrowser 进程(一 IP 一指纹)经代理打开落地页,等挑战消失并拿到 `cf_clearance` |
|
|
194
|
+
| `consumer.py` | 下载线程:从任务库领任务、从身份池领身份,同 IP 请求间隔 `request_gap_s`;**流式下载**(先读 1024 字节判文件头,是 PDF 就边下边写 `.part`、完成后原子改名;不是 PDF 只读前 256KB);cookie 被拒/IP 封禁/IP 掉线/寿命将尽就丢弃身份,任务放回不计重试 |
|
|
195
|
+
| `tasks.py` | SQLite 任务库(表结构见下) |
|
|
196
|
+
| `proxy.py` | 调短效代理提取接口拿新 IP 并体检(每个身份一个 IP,用完即弃) |
|
|
197
|
+
| `classify.py` | PDF 链接返回 HTML 时判 `error_kind`:`not_found` / `blocked` / `paywall` / `landing_page` |
|
|
198
|
+
| `config.py` | 读 `config.toml` 的 `[proxy]` / `[pool]` / `[landing]` |
|
|
199
|
+
| `cli.py` | 命令行(`paperlite` 命令 / `python -m paperlite`) |
|
|
200
|
+
|
|
201
|
+
Redis 键(`{prefix}:{host}:...`):`id:{id}` 身份 hash(EXPIREAT 到期自动删)、`ready` / `inuse` 两个按过期时间排序的 zset、`lease:{id}` 占用锁(带 TTL,下载进程崩溃后由 `reap` 归还)、`workers` 在线下载线程心跳、`fresh_rejects` / `paused` 熔断计数与暂停标记、`producer` 生产者锁(同一主机同时只有一个生产者干活,多开的待命,前一个退出或崩溃后 15 秒内接手)。**Redis 里不存代理密码**,只存 `ip:port`,消费者用本地 `config.toml` 补鉴权。
|
|
202
|
+
|
|
203
|
+
生产者只看两个数:健康身份数(剩余寿命 > `refill_before_s` 的空闲+占用)和在线下载线程数。所以:
|
|
204
|
+
- 在用身份临近过期前就会开始生产替补,下载线程几乎不空等;
|
|
205
|
+
- 下载结束、线程注销后目标降为 `min_identities`,不再白白过盾。
|
|
206
|
+
|
|
207
|
+
## 任务库 tasks.db
|
|
208
|
+
|
|
209
|
+
表 `paper_pdf_resources`,结构参照 paper_downloader 的 PostgreSQL 的同名表,同名列含义与取值一致:
|
|
210
|
+
|
|
211
|
+
| 列 | 说明 |
|
|
212
|
+
|---|---|
|
|
213
|
+
| `id` | 自增主键,按它的顺序领取 |
|
|
214
|
+
| `url_key` | sha256(规范化 `pdf_url`),32 字节,唯一:协议和域名转小写、去掉 `#片段` |
|
|
215
|
+
| `paper_uid` | 导入时给的,或从链接提取的 DOI(小写);都没有时 `url-<url_key 前 16 位>`。决定文件名 |
|
|
216
|
+
| `pdf_url` / `source` / `batch_no` | 导入时的原值 |
|
|
217
|
+
| `volume` | DOI 后缀点号前的卷期号(`10.1145/1012326.1012328` → `1012326`),导入时提取;新式纯数字 DOI 为空,不参与卷期停止 |
|
|
218
|
+
| `status` | `pending` → `running` → `success` / `non_pdf` / `failed`(CHECK 约束) |
|
|
219
|
+
| `error_kind` | 最近一次没成功的类别:`not_found` / `paywall` / `landing_page`(non_pdf);`network` / `server_error` / `extract_failed`(过小或超过大小上限);`challenge_failed`(URL 被单独挑战满次数);`blocked` 只用于丢弃身份,不落到最终结果 |
|
|
220
|
+
| `error_message` | non_pdf 时是页面标题;失败时是错误信息 |
|
|
221
|
+
| `http_status` / `file_size` / `elapsed_ms` | 最近一次尝试的 HTTP 状态、响应字节数、耗时(毫秒) |
|
|
222
|
+
| `exit_ip` | 最近一次尝试用的出口 IP(paper_downloader 没有这列) |
|
|
223
|
+
| `retry_count` | 网络错误、5xx 才计;cookie 被拒、IP 封禁不计 |
|
|
224
|
+
| `inferred` | 1 = 按卷期推断、没有实际请求(见下「卷期停止」) |
|
|
225
|
+
| `challenge_count` | 带有效 cookie 仍被站点挑战的次数(站点单独盯上了这条 URL);领取按它从小到大排,被挑战过的排到后面;满 `max_url_challenges`(3)判 `failed / challenge_failed`(PG 版没有这列) |
|
|
226
|
+
| `file_path` | 成功后的 PDF 路径:`<out>/<h[0:2]>/<h[2:4]>/<paper_uid 的 / 换成 _>.pdf`,`h = sha256(paper_uid)`,与 paper_downloader 同一规则 |
|
|
227
|
+
| `claimed_by` | 领取它的 `主机名-进程号-线程号` |
|
|
228
|
+
| `created_at` / `updated_at` | 北京时间文本 `YYYY-MM-DD HH:MM:SS` |
|
|
229
|
+
|
|
230
|
+
另有 `meta` 表记录本库的主机;`volume_stops` 表记录被停掉的卷期(判停类别、作为依据的几篇、停掉的任务数、时间)。直接查库:`sqlite3 pdfs/acm/tasks.db "SELECT status, error_kind, count(*) FROM paper_pdf_resources GROUP BY 1, 2"`。
|
|
231
|
+
|
|
232
|
+
## 卷期停止
|
|
233
|
+
|
|
234
|
+
ACM 的 404 和"没有 PDF"基本按卷期整组出现(2671 条实测:9 个 404 卷期、11 个无 PDF 卷期,组内 100% 一致;有 PDF 的卷期里只有零星卷首语、"(abstract)" 条目)。所以:
|
|
235
|
+
|
|
236
|
+
- 卷期还没下到过 PDF 时,同时最多放 `volume_stop_after`(默认 3)篇出去,其余线程去领别的卷期(同卷期的任务在表里挨着,不限流的话 20 个线程会同时冲进同一卷期)。
|
|
237
|
+
- 这几篇都是 `not_found` / `paywall` / `landing_page` → 停掉该卷期其余 pending:记 `non_pdf`、`inferred = 1`,`error_kind` 取样本里最多的类别,`error_message` 写明"按卷期推断…未请求"。
|
|
238
|
+
- 下到过一篇 PDF 的卷期永远不停;网络错误、被挑战不计。
|
|
239
|
+
- `uv run paperlite retry --stopped-volumes` 重新核实:被停卷期的 non_pdf(样本 + 推断)全部放回 pending,重新抽 3 篇。只放回推断的那部分没用,样本还在会立刻再停。
|
|
240
|
+
- `volume_stop_after = 0` 关闭。
|
|
241
|
+
|
|
242
|
+
70 万条 ACM 实测:导入 7.9 秒,63.7 万条有卷期号、22,926 个卷期;领取一次 0.5–5 ms。
|
|
243
|
+
|
|
244
|
+
## non_pdf 分类(ACM)
|
|
245
|
+
|
|
246
|
+
| error_kind | 判断依据 | 含义 |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| `not_found` | 标题含 "Error: 404" | DOI 在 ACM 上不存在 |
|
|
249
|
+
| `paywall` | 正文有 "Features requiring a subscription" 横幅 | 页面提示有订阅功能 |
|
|
250
|
+
| `landing_page` | 其余 | 普通摘要页 |
|
|
251
|
+
|
|
252
|
+
返回 HTML 的原始页面存在 `<out>/non_pdf/<文件名>.html`。
|
|
253
|
+
|
|
254
|
+
注意:
|
|
255
|
+
- ACM 每个页面的导航栏都有 "Basic Edition"(包括 404 页),不能据此判订阅。
|
|
256
|
+
- 实测 `paywall` 和 `landing_page` 两类页面上都**没有任何 PDF / 全文入口**。横幅只说明页面上有灰掉的订阅功能,不能证明订阅后就有 PDF。
|
|
257
|
+
- 标题含 "IP blocked" 时判 `blocked`:丢弃该身份、任务放回,不算 non_pdf。
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# PaperLite:单站点大批量 PDF 下载
|
|
2
|
+
|
|
3
|
+
专门处理**单一站点、大量 URL**:用浏览器持续生产过 Cloudflare 的 cookie,curl_cffi 领用下载。生产与消费只通过 Redis 交互,可以分开跑在不同进程上;任务与结果存在站点输出目录的 SQLite 库 `tasks.db` 里。多站点、通用的路由和兜底由 `paper_downloader` 负责。
|
|
4
|
+
|
|
5
|
+
独立项目(原 `paper_downloader/cookie_pool/`,2026-10-25 拆出):自己的依赖和 venv,配置是运行目录的 `config.toml`,不依赖 `paper_downloader` 的任何代码,也不用 PostgreSQL。
|
|
6
|
+
|
|
7
|
+
## 快速开始
|
|
8
|
+
|
|
9
|
+
需要:Python ≥ 3.11、Redis、一个短效代理提取接口(每次返回一个新 IP,青果格式)。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install paperlite # 或 uv tool install paperlite;国内加 -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
13
|
+
mkdir work && cd work
|
|
14
|
+
paperlite init # 生成 ./config.toml(权限 600),填 [proxy] 的提取接口和代理账号密码
|
|
15
|
+
|
|
16
|
+
# Redis:只监听本机、不落盘(身份 10 分钟就过期,不需要持久化)
|
|
17
|
+
docker run -d --name cf-redis -p 127.0.0.1:6379:6379 redis:latest redis-server --save "" --appendonly no
|
|
18
|
+
|
|
19
|
+
# 导入并开跑:输出目录按主机自动定为 pdfs/dl.acm.org,下载完自动退出
|
|
20
|
+
paperlite import --urls acm.txt
|
|
21
|
+
paperlite run --workers 20
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
从源码装(开发、改代码):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone https://github.com/huangpd/PaperLite.git && cd PaperLite
|
|
28
|
+
uv sync # 国内服务器:uv venv && uv pip install -e . --index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
|
29
|
+
uv run paperlite init
|
|
30
|
+
uv run paperlite import --urls data/acm.txt
|
|
31
|
+
uv run paperlite run --workers 20
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
运行中每 30 秒打印一行进度(已处理条数、PDF 篇数、身份池、近 30 秒速度)。随时 Ctrl+C,在途任务会放回 pending,再执行同一条命令就从断点继续。
|
|
35
|
+
|
|
36
|
+
`paperlite` 是安装时生成的命令(`pyproject.toml` 的 `[project.scripts]`),后面的 `run` / `import` / `status` 是它的子命令。pip 安装的直接写 `paperlite ...`;源码目录里下面统一写成 `uv run paperlite ...`,这几种写法等价:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv run paperlite status # uv 自动用项目 venv,不用激活
|
|
40
|
+
.venv/bin/paperlite status # 直接调 venv 里的命令(服务器上推荐,见下)
|
|
41
|
+
source .venv/bin/activate && paperlite status
|
|
42
|
+
.venv/bin/python -m paperlite status
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
用清华源手动装的环境(`uv pip install`,不是 `uv sync`)请用 `.venv/bin/paperlite`:`uv run` 会按 `uv.lock` 去官方源重新同步依赖,国内服务器上会卡住。
|
|
46
|
+
|
|
47
|
+
## 命令一览
|
|
48
|
+
|
|
49
|
+
| 命令 | 作用 |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `uv run paperlite init [--force]` | 在当前目录生成配置模板 `config.toml`(或写到 `--config` 指定的路径) |
|
|
52
|
+
| `uv run paperlite import --urls <文件>` | 导入任务。txt 一行一个链接,或带 `pdf_url` 列(可选 `paper_uid` / `source` / `batch_no`)的 CSV。重复导入不重置已有任务;一个库只能有一个主机 |
|
|
53
|
+
| `uv run paperlite run [--urls <文件>] [--workers 20]` | 同进程生产身份 + 下载,下载完退出 |
|
|
54
|
+
| `uv run paperlite produce [--keep N]` | 只生产身份,常驻 |
|
|
55
|
+
| `uv run paperlite download [--workers 20]` | 只下载,需有生产者在跑 |
|
|
56
|
+
| `uv run paperlite status` | 各状态数量、失败 / non_pdf 按 `error_kind` 分组、被停卷期、身份池 |
|
|
57
|
+
| `uv run paperlite retry --status failed [--kind network]` | 把失败(或 `non_pdf`)重置为 pending |
|
|
58
|
+
| `uv run paperlite retry --stopped-volumes` | 重新核实按卷期停掉的任务 |
|
|
59
|
+
| `uv run paperlite export [--status non_pdf] [--file x.csv]` | 导出任务表为 CSV(默认输出到标准输出) |
|
|
60
|
+
|
|
61
|
+
所有命令都可以加 `--out` / `--host` / `--db` 指定站点和任务库;只有一个站点时都不用写:
|
|
62
|
+
|
|
63
|
+
- **站点输出目录**:`--out` > `--host`、`--urls` 里链接的主机、`--db` 记录的主机 → `<out_root>/<主机>`(`out_root` 默认 `pdfs`)> `out_root` 下唯一的站点。有多个站点时会列出来,用 `--host dl.acm.org` 选一个。
|
|
64
|
+
- **任务库**:`--db` > 配置 `[pool] db_dir`(`<db_dir>/<输出目录名>-<路径哈希>.db`)> `<输出目录>/tasks.db`。全局参数:`--config <文件>`、`--log-level`、`--version`。
|
|
65
|
+
|
|
66
|
+
## 常见场景
|
|
67
|
+
|
|
68
|
+
**PDF 存到 goofys / s3fs 等对象存储挂载上**:任务库必须放本地盘。SQLite 要随机写、文件锁和 mmap,FUSE 挂载不支持;PDF 是顺序写入,可以放在挂载上(每篇最后的改名在 S3 上是一次复制 + 删除)。推荐在配置里设一次任务库目录,之后只写 `--out`:
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
[pool]
|
|
72
|
+
db_dir = "/data/paperlite/db" # 任务库自动放这里:<目录>/<输出目录名>-<路径哈希>.db,如 acm-3f2a1c.db
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uv run paperlite import --urls data/acm.txt --out /mnt/goofys/acm
|
|
77
|
+
uv run paperlite run --out /mnt/goofys/acm --workers 20 # 或在配置里设 out_root = "/mnt/goofys",之后不用写 --out
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
也可以不改配置、每次用 `--db /data/paperlite/acm.db` 指定(优先于 `db_dir`)。
|
|
81
|
+
|
|
82
|
+
**后台运行与停止**:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
setsid nohup .venv/bin/paperlite run --workers 20 > logs/acm.log 2>&1 < /dev/null &
|
|
86
|
+
tail -f logs/acm.log
|
|
87
|
+
kill <pid> # 或 pkill -f "paperlite run"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
SIGTERM 与 Ctrl+C 一样优雅退出:在途任务放回 pending、在用身份还回池里、生产者关掉浏览器并释放生产者锁。注意 `pkill -f` 的匹配串若也出现在执行它的 shell 命令行里(如 `ssh host '...paperlite run...; pkill -f paperlite'`),会连那个 shell 一起杀掉。
|
|
91
|
+
|
|
92
|
+
**生产与下载分开跑**:生产者常驻,下载进程随用随开;多台机器的下载进程可以共用一个 Redis(`redis_url` 指向同一台;默认只监听本机,跨机器要改监听地址并加密码)。同一主机同时只有一个生产者干活,多开的待命,前一个退出或崩溃后 15 秒内接手。
|
|
93
|
+
|
|
94
|
+
**省掉冷启动**:第一个身份要等一次过盾(约 20 秒)。下载前一两分钟先 `uv run paperlite produce --keep 20` 养着身份,代价是没人下载时也在消耗 IP。
|
|
95
|
+
|
|
96
|
+
**重跑**:`retry --status failed` 重跑失败的(可加 `--kind network` / `challenge_failed` 只重跑某一类);`retry --stopped-volumes` 让被停掉的卷期重新抽样核实。
|
|
97
|
+
|
|
98
|
+
**换别的站点**:在 `config.toml` 的 `[landing]` 加一行该主机的落地页(任意会触发挑战的普通页面),`[proxy] check_url` 换成该站可达的地址;`classify.py` 里的 non_pdf 判断是按 ACM 页面写的,换站点要看一下。
|
|
99
|
+
|
|
100
|
+
## 主要配置(config.toml)
|
|
101
|
+
|
|
102
|
+
| 配置 | 默认 | 说明 |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `[proxy] api_url` / `username` / `password` | | 提取接口与代理鉴权(必填) |
|
|
105
|
+
| `out_root` | `pdfs` | 不给 `--out` 时的输出根目录,站点目录为 `<out_root>/<主机>` |
|
|
106
|
+
| `db_dir` | 空 | 任务库目录(本地盘);留空则放 `<--out>/tasks.db`。输出目录在对象存储挂载上时必须设置 |
|
|
107
|
+
| `max_browsers` | 20 | 同时过盾的浏览器上限 |
|
|
108
|
+
| `max_identities` / `min_identities` | 20 / 0 | 健康身份上限 / 没人下载时也养着的身份数;实际按在线下载线程数生产 |
|
|
109
|
+
| `pass_timeout_s` / `hedge_after_s` | 90 / 45 | 单次过盾上限 / 过盾超过这么久就另开一个补位 |
|
|
110
|
+
| `request_gap_s` | [3, 5] | 同一 IP 两次请求的随机间隔 |
|
|
111
|
+
| `download_timeout_s` | 120 | 卡顿超时:连续这么久收不到数据才算超时(不限总时长) |
|
|
112
|
+
| `max_file_size_mb` | 200 | 单个 PDF 上限,超过中止 |
|
|
113
|
+
| `max_tries` | 3 | 网络错误重试次数(cookie 被拒不计) |
|
|
114
|
+
| `max_url_challenges` | 3 | 单条 URL 带有效 cookie 仍被挑战满这么多次 → `challenge_failed` |
|
|
115
|
+
| `volume_stop_after` | 3 | 同一卷期接连这么多篇没有 PDF 就停掉该卷期其余任务;0 关闭 |
|
|
116
|
+
| `max_fresh_rejects` / `breaker_pause_s` | 5 / 600 | 新身份接连一用就被拒时熔断暂停生产 |
|
|
117
|
+
| `debug_dir` | 空 | 过盾失败截图目录 |
|
|
118
|
+
|
|
119
|
+
完整列表和注释见 `paperlite init` 生成的模板(源码 `src/paperlite/config.example.toml`)。配置查找顺序:`--config` > 环境变量 `PAPERLITE_CONFIG` > 当前目录的 `config.toml`。
|
|
120
|
+
|
|
121
|
+
## 速度与 IP 用量(ACM 实测)
|
|
122
|
+
|
|
123
|
+
- 20 个下载线程:稳定后每分钟处理 120–160 条(PDF 约 140 篇),单篇下载平均 2.6 秒;线程数只影响速度,不影响 IP 用量。
|
|
124
|
+
- 每个身份(IP + cookie)用约 9 分钟,下 60–70 篇;加上过盾失败(成功率 60%–90%),**每 1 万篇约需 180–300 个 IP**。
|
|
125
|
+
- 换成 30 分钟时效的 IP,同样篇数只要约 1/3 的 IP。
|
|
126
|
+
|
|
127
|
+
## 测试
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
uv run pytest # Redis 相关的会跳过
|
|
131
|
+
PAPERLITE_TEST_REDIS=redis://127.0.0.1:6379/0 uv run pytest # 连真实 Redis 全跑
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## 为什么这样设计
|
|
135
|
+
|
|
136
|
+
- **cf_clearance 绑定 IP + UA**:同 IP 带 cookie → 200 PDF;同 IP 不带 cookie → 403;cookie 换到别的 IP → 403。所以身份 = IP + cookie + UA,三者一起存、一起用。
|
|
137
|
+
- **身份寿命 ≈ IP 寿命**:青果短效 IP 只有 10 分钟(接口 `deadline`),cookie 本身更长,身份过期取两者较早者。每个身份约 9 分钟后就要换新,必须持续生产。
|
|
138
|
+
- **cookie 在有效期内一直可用**:同一 IP 每 3–5 秒一次请求,下载 900+ 篇期间 cookie 被拒只有 3 次(都是复用上一轮的旧身份)。
|
|
139
|
+
- **过盾要么很快、要么卡死**:成功的多在 12–30 秒(个别 70 秒);失败的停在 Turnstile "Verifying…" 一直转圈,等到 120 秒也不会过(不是要手动勾选)。所以默认 `pass_timeout_s = 90`,并做**对冲**:过盾超过 `hedge_after_s = 45` 秒还没成功,就另开一个浏览器补位,旧的继续跑到超时。没有对冲时,卡住的过盾占着生产名额 2 分钟,6 个下载线程只等到 2 个身份(20 篇/分钟)。
|
|
140
|
+
- **机房 IP 会要求手动勾选**:hw1 直连出口拿到的是要手动勾选的 Turnstile 复选框,必须走代理。
|
|
141
|
+
- **站点会单独盯上某些 URL**(2026-10-25 实测):同一个新 cookie、同一 IP,`1010832.*` 正常拿到 PDF,`1005062.*`(14 篇,连摘要页)一律 403 挑战,多半是前一晚在这几条上反复请求了几百次。原先把"被挑战"一律当成 cookie 坏了:丢身份、任务放回队首,于是每个新身份第一次请求都撞上它们,一篇也下不了。现在遇到挑战先用落地页验身份:落地页正常 → 只是这条 URL 被盯上,`challenge_count+1` 排到后面;落地页也被挑战 → 身份坏了才丢。不用"下一条也被挑战"来判身份,因为被盯上的论文往往成批相邻,会把好身份接连误杀。
|
|
142
|
+
- **代理偶尔成批断连**:2026-10-25 00:28 有 13 个不同 IP 在 28 秒内先后报 `curl: (56) Connection closed abruptly`,原先一律丢身份,健康身份从 17 掉到 7、补了约 1.5 分钟。现在 (56) 先用同一身份重试一次。
|
|
143
|
+
- **收尾不再白补身份**:只剩别的线程的在途任务时,空等的下载线程注销需求并把身份还回池里(之前最后 1 条重试时生产者仍按 20 个线程补身份,停止时池里空闲 20 个)。
|
|
144
|
+
- **冷启动**:第一个身份要等一次过盾(实测约 20 秒)。想省掉就在下载前一两分钟先 `uv run paperlite produce --keep 20`,代价是没人下载时也在消耗 IP。
|
|
145
|
+
- **流式下载**:超时是卡顿超时(`download_timeout_s`:连续这么久收不到数据),不是总时长,2 Mbps 的代理上大文件也不会被掐断;内存里最多一个 256KB 的块。`%PDF-` 须在前 1024 字节内、之前只允许空白 / BOM(写盘时去掉),与 paper_downloader 一致;小于 `min_file_size`(1024 字节)计一次重试,超过 `max_file_size_mb`(200)中止判 `failed / extract_failed`。
|
|
146
|
+
- **熔断**:2026-10-24 22:38 实测,新过盾的身份全部一篇没下成就被 403 挑战(原因待查,同时代理配额耗尽),没有保护时 1 分钟烧掉 16 个 IP。现在连续 `max_fresh_rejects`(5)个新身份一用就被拒,生产者暂停 `breaker_pause_s`(600s)并告警,之后放一个身份试探,再被拒立刻重新暂停。网络错误不计入。
|
|
147
|
+
- **过盾失败直接换 IP**:同一 IP 重试很少成功,换 IP 更快。
|
|
148
|
+
|
|
149
|
+
## 目录
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
PaperLite/
|
|
153
|
+
pyproject.toml uv.lock README.md LICENSE .gitignore
|
|
154
|
+
config.toml # paperlite init 生成(含代理密码,不提交)
|
|
155
|
+
src/paperlite/ # 代码(src 布局:测试跑的是安装好的包,不会误用源码目录);config.example.toml 是配置模板
|
|
156
|
+
tests/
|
|
157
|
+
data/ # URL 清单等输入数据(不提交)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## 结构
|
|
161
|
+
|
|
162
|
+
| 模块 | 职责 |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `store.py` | Redis 身份池:`add` / `claim`(Lua 原子领用,独占)/ `heartbeat` / `release` / `discard` / `reap` / `stats` |
|
|
165
|
+
| `producer.py` | 按需生产:目标健康身份数 = clamp(在线下载线程数, `min_identities`, `max_identities`),最多 `max_browsers` 个浏览器同时过盾 |
|
|
166
|
+
| `browser.py` | 一次过盾:独立 CloakBrowser 进程(一 IP 一指纹)经代理打开落地页,等挑战消失并拿到 `cf_clearance` |
|
|
167
|
+
| `consumer.py` | 下载线程:从任务库领任务、从身份池领身份,同 IP 请求间隔 `request_gap_s`;**流式下载**(先读 1024 字节判文件头,是 PDF 就边下边写 `.part`、完成后原子改名;不是 PDF 只读前 256KB);cookie 被拒/IP 封禁/IP 掉线/寿命将尽就丢弃身份,任务放回不计重试 |
|
|
168
|
+
| `tasks.py` | SQLite 任务库(表结构见下) |
|
|
169
|
+
| `proxy.py` | 调短效代理提取接口拿新 IP 并体检(每个身份一个 IP,用完即弃) |
|
|
170
|
+
| `classify.py` | PDF 链接返回 HTML 时判 `error_kind`:`not_found` / `blocked` / `paywall` / `landing_page` |
|
|
171
|
+
| `config.py` | 读 `config.toml` 的 `[proxy]` / `[pool]` / `[landing]` |
|
|
172
|
+
| `cli.py` | 命令行(`paperlite` 命令 / `python -m paperlite`) |
|
|
173
|
+
|
|
174
|
+
Redis 键(`{prefix}:{host}:...`):`id:{id}` 身份 hash(EXPIREAT 到期自动删)、`ready` / `inuse` 两个按过期时间排序的 zset、`lease:{id}` 占用锁(带 TTL,下载进程崩溃后由 `reap` 归还)、`workers` 在线下载线程心跳、`fresh_rejects` / `paused` 熔断计数与暂停标记、`producer` 生产者锁(同一主机同时只有一个生产者干活,多开的待命,前一个退出或崩溃后 15 秒内接手)。**Redis 里不存代理密码**,只存 `ip:port`,消费者用本地 `config.toml` 补鉴权。
|
|
175
|
+
|
|
176
|
+
生产者只看两个数:健康身份数(剩余寿命 > `refill_before_s` 的空闲+占用)和在线下载线程数。所以:
|
|
177
|
+
- 在用身份临近过期前就会开始生产替补,下载线程几乎不空等;
|
|
178
|
+
- 下载结束、线程注销后目标降为 `min_identities`,不再白白过盾。
|
|
179
|
+
|
|
180
|
+
## 任务库 tasks.db
|
|
181
|
+
|
|
182
|
+
表 `paper_pdf_resources`,结构参照 paper_downloader 的 PostgreSQL 的同名表,同名列含义与取值一致:
|
|
183
|
+
|
|
184
|
+
| 列 | 说明 |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `id` | 自增主键,按它的顺序领取 |
|
|
187
|
+
| `url_key` | sha256(规范化 `pdf_url`),32 字节,唯一:协议和域名转小写、去掉 `#片段` |
|
|
188
|
+
| `paper_uid` | 导入时给的,或从链接提取的 DOI(小写);都没有时 `url-<url_key 前 16 位>`。决定文件名 |
|
|
189
|
+
| `pdf_url` / `source` / `batch_no` | 导入时的原值 |
|
|
190
|
+
| `volume` | DOI 后缀点号前的卷期号(`10.1145/1012326.1012328` → `1012326`),导入时提取;新式纯数字 DOI 为空,不参与卷期停止 |
|
|
191
|
+
| `status` | `pending` → `running` → `success` / `non_pdf` / `failed`(CHECK 约束) |
|
|
192
|
+
| `error_kind` | 最近一次没成功的类别:`not_found` / `paywall` / `landing_page`(non_pdf);`network` / `server_error` / `extract_failed`(过小或超过大小上限);`challenge_failed`(URL 被单独挑战满次数);`blocked` 只用于丢弃身份,不落到最终结果 |
|
|
193
|
+
| `error_message` | non_pdf 时是页面标题;失败时是错误信息 |
|
|
194
|
+
| `http_status` / `file_size` / `elapsed_ms` | 最近一次尝试的 HTTP 状态、响应字节数、耗时(毫秒) |
|
|
195
|
+
| `exit_ip` | 最近一次尝试用的出口 IP(paper_downloader 没有这列) |
|
|
196
|
+
| `retry_count` | 网络错误、5xx 才计;cookie 被拒、IP 封禁不计 |
|
|
197
|
+
| `inferred` | 1 = 按卷期推断、没有实际请求(见下「卷期停止」) |
|
|
198
|
+
| `challenge_count` | 带有效 cookie 仍被站点挑战的次数(站点单独盯上了这条 URL);领取按它从小到大排,被挑战过的排到后面;满 `max_url_challenges`(3)判 `failed / challenge_failed`(PG 版没有这列) |
|
|
199
|
+
| `file_path` | 成功后的 PDF 路径:`<out>/<h[0:2]>/<h[2:4]>/<paper_uid 的 / 换成 _>.pdf`,`h = sha256(paper_uid)`,与 paper_downloader 同一规则 |
|
|
200
|
+
| `claimed_by` | 领取它的 `主机名-进程号-线程号` |
|
|
201
|
+
| `created_at` / `updated_at` | 北京时间文本 `YYYY-MM-DD HH:MM:SS` |
|
|
202
|
+
|
|
203
|
+
另有 `meta` 表记录本库的主机;`volume_stops` 表记录被停掉的卷期(判停类别、作为依据的几篇、停掉的任务数、时间)。直接查库:`sqlite3 pdfs/acm/tasks.db "SELECT status, error_kind, count(*) FROM paper_pdf_resources GROUP BY 1, 2"`。
|
|
204
|
+
|
|
205
|
+
## 卷期停止
|
|
206
|
+
|
|
207
|
+
ACM 的 404 和"没有 PDF"基本按卷期整组出现(2671 条实测:9 个 404 卷期、11 个无 PDF 卷期,组内 100% 一致;有 PDF 的卷期里只有零星卷首语、"(abstract)" 条目)。所以:
|
|
208
|
+
|
|
209
|
+
- 卷期还没下到过 PDF 时,同时最多放 `volume_stop_after`(默认 3)篇出去,其余线程去领别的卷期(同卷期的任务在表里挨着,不限流的话 20 个线程会同时冲进同一卷期)。
|
|
210
|
+
- 这几篇都是 `not_found` / `paywall` / `landing_page` → 停掉该卷期其余 pending:记 `non_pdf`、`inferred = 1`,`error_kind` 取样本里最多的类别,`error_message` 写明"按卷期推断…未请求"。
|
|
211
|
+
- 下到过一篇 PDF 的卷期永远不停;网络错误、被挑战不计。
|
|
212
|
+
- `uv run paperlite retry --stopped-volumes` 重新核实:被停卷期的 non_pdf(样本 + 推断)全部放回 pending,重新抽 3 篇。只放回推断的那部分没用,样本还在会立刻再停。
|
|
213
|
+
- `volume_stop_after = 0` 关闭。
|
|
214
|
+
|
|
215
|
+
70 万条 ACM 实测:导入 7.9 秒,63.7 万条有卷期号、22,926 个卷期;领取一次 0.5–5 ms。
|
|
216
|
+
|
|
217
|
+
## non_pdf 分类(ACM)
|
|
218
|
+
|
|
219
|
+
| error_kind | 判断依据 | 含义 |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| `not_found` | 标题含 "Error: 404" | DOI 在 ACM 上不存在 |
|
|
222
|
+
| `paywall` | 正文有 "Features requiring a subscription" 横幅 | 页面提示有订阅功能 |
|
|
223
|
+
| `landing_page` | 其余 | 普通摘要页 |
|
|
224
|
+
|
|
225
|
+
返回 HTML 的原始页面存在 `<out>/non_pdf/<文件名>.html`。
|
|
226
|
+
|
|
227
|
+
注意:
|
|
228
|
+
- ACM 每个页面的导航栏都有 "Basic Edition"(包括 404 页),不能据此判订阅。
|
|
229
|
+
- 实测 `paywall` 和 `landing_page` 两类页面上都**没有任何 PDF / 全文入口**。横幅只说明页面上有灰掉的订阅功能,不能证明订阅后就有 PDF。
|
|
230
|
+
- 标题含 "IP blocked" 时判 `blocked`:丢弃该身份、任务放回,不算 non_pdf。
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "paperlite"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "大批量 PDF 下载:浏览器过盾 cookie 池(Redis)+ curl_cffi + SQLite 任务库"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "huangpd", email = "1655979823@qq.com" }]
|
|
10
|
+
keywords = ["pdf", "downloader", "cloudflare", "curl_cffi", "cookie-pool", "scraping"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Operating System :: POSIX",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"cloakbrowser>=0.6.2",
|
|
24
|
+
"curl-cffi>=0.15.0",
|
|
25
|
+
"redis>=5.0",
|
|
26
|
+
"certifi>=2024.2.2",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/huangpd/PaperLite"
|
|
31
|
+
Repository = "https://github.com/huangpd/PaperLite"
|
|
32
|
+
Issues = "https://github.com/huangpd/PaperLite/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
paperlite = "paperlite.cli:main"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = ["pytest>=8"]
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling>=1.27"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/paperlite"]
|
|
46
|
+
|
|
47
|
+
# sdist 只放这些:项目目录里的 config.toml(含代理密码)、data/、db/、pdfs/ 绝不能进包
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
only-include = ["src/paperlite", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PaperLite — 单站点大批量 PDF 下载:浏览器过盾 cookie 池(Redis)+ curl_cffi 下载 + SQLite 任务库,生产与消费解耦。
|
|
3
|
+
|
|
4
|
+
生产者(producer.py)按需提取新 IP、用独立 CloakBrowser 过 Cloudflare,把身份(IP+cookie+UA+过期时间)写进 Redis;
|
|
5
|
+
消费者(consumer.py)从 Redis 独占领用身份,经同一 IP 带 cookie 下载。两边只通过 store.py 交互。
|
|
6
|
+
配置模板见 config.example.toml(paperlite init 生成 config.toml),用法见 README.md。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("paperlite")
|
|
13
|
+
except PackageNotFoundError: # 未安装(直接从源码跑)
|
|
14
|
+
__version__ = "0.0.0+local"
|