paperlite 0.1.0__py3-none-any.whl
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/__init__.py +14 -0
- paperlite/__main__.py +4 -0
- paperlite/browser.py +123 -0
- paperlite/classify.py +37 -0
- paperlite/cli.py +370 -0
- paperlite/config.example.toml +34 -0
- paperlite/config.py +133 -0
- paperlite/consumer.py +362 -0
- paperlite/producer.py +151 -0
- paperlite/proxy.py +125 -0
- paperlite/store.py +241 -0
- paperlite/tasks.py +427 -0
- paperlite-0.1.0.dist-info/METADATA +257 -0
- paperlite-0.1.0.dist-info/RECORD +17 -0
- paperlite-0.1.0.dist-info/WHEEL +4 -0
- paperlite-0.1.0.dist-info/entry_points.txt +2 -0
- paperlite-0.1.0.dist-info/licenses/LICENSE +21 -0
paperlite/__init__.py
ADDED
|
@@ -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"
|
paperlite/__main__.py
ADDED
paperlite/browser.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
paperlite/browser.py
|
|
5
|
+
──────────────────────
|
|
6
|
+
用一个独立 CloakBrowser 进程经指定代理打开落地页,等 Cloudflare 挑战通过后导出 cookie。
|
|
7
|
+
|
|
8
|
+
每次过盾单独 launch:CloakBrowser 的指纹种子(--fingerprint)是启动参数,同一进程内所有 context 共用,
|
|
9
|
+
独立进程才能做到一 IP 一指纹。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from paperlite.proxy import Lease, ProxySettings
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
CLEARANCE = "cf_clearance"
|
|
25
|
+
|
|
26
|
+
# Cloudflare 挑战页特征(按脚本/元素判断;标题会被本地化,如"请稍候…")
|
|
27
|
+
CHALLENGE_JS = """() => !!(window._cf_chl_opt || document.querySelector(
|
|
28
|
+
'#challenge-form, #challenge-stage, .cf-turnstile, script[src*="/cdn-cgi/challenge-platform/"]'))"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def browser_env() -> dict[str, str]:
|
|
32
|
+
# macOS 上 DYLD_LIBRARY_PATH=/opt/homebrew/lib 会让 Chromium 启动即 SIGBUS,浏览器进程一律剔除 DYLD_*
|
|
33
|
+
return {k: v for k, v in os.environ.items() if not k.startswith("DYLD_")}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PassResult:
|
|
38
|
+
ok: bool
|
|
39
|
+
seconds: float
|
|
40
|
+
user_agent: str = ""
|
|
41
|
+
cookies: list[dict] = field(default_factory=list)
|
|
42
|
+
fingerprint: str = ""
|
|
43
|
+
challenged: bool = False
|
|
44
|
+
error: str = ""
|
|
45
|
+
|
|
46
|
+
def clearance_expires(self) -> Optional[float]:
|
|
47
|
+
c = next((c for c in self.cookies if c["name"] == CLEARANCE), None)
|
|
48
|
+
return c["expires"] if c and c.get("expires", -1) > 0 else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def pass_challenge(
|
|
52
|
+
landing: str,
|
|
53
|
+
lease: Lease,
|
|
54
|
+
proxy: ProxySettings,
|
|
55
|
+
timeout_s: float,
|
|
56
|
+
headed: bool = False,
|
|
57
|
+
debug_dir: str = "",
|
|
58
|
+
) -> PassResult:
|
|
59
|
+
"""
|
|
60
|
+
打开 landing 直到挑战脚本消失且拿到 cf_clearance;超时返回 ok=False。
|
|
61
|
+
没有挑战(该 IP 直接放行)时 ok=True 但 cookies 里无 cf_clearance,由调用方决定是否采用。
|
|
62
|
+
"""
|
|
63
|
+
from cloakbrowser import get_default_stealth_args, launch_async
|
|
64
|
+
|
|
65
|
+
t0 = time.monotonic()
|
|
66
|
+
args = [a for a in get_default_stealth_args() if a.split("=", 1)[0] != "--no-sandbox"]
|
|
67
|
+
fingerprint = next((a.split("=", 1)[1] for a in args if a.startswith("--fingerprint=")), "")
|
|
68
|
+
result = PassResult(ok=False, seconds=0, fingerprint=fingerprint)
|
|
69
|
+
browser = None
|
|
70
|
+
page = None
|
|
71
|
+
try:
|
|
72
|
+
browser = await launch_async(
|
|
73
|
+
headless=not headed, proxy=lease.browser_proxy(proxy), stealth_args=False,
|
|
74
|
+
args=args + ["--disable-dev-shm-usage", "--window-size=1440,900"], env=browser_env(),
|
|
75
|
+
)
|
|
76
|
+
context = await browser.new_context()
|
|
77
|
+
page = await context.new_page()
|
|
78
|
+
deadline = t0 + timeout_s
|
|
79
|
+
await page.goto(landing, wait_until="domcontentloaded", timeout=int(timeout_s * 1000))
|
|
80
|
+
while True:
|
|
81
|
+
try:
|
|
82
|
+
challenged = bool(await page.evaluate(CHALLENGE_JS))
|
|
83
|
+
except Exception:
|
|
84
|
+
challenged = True # 挑战通过后的跳转会销毁执行上下文,下一轮再看
|
|
85
|
+
result.challenged |= challenged
|
|
86
|
+
if not challenged:
|
|
87
|
+
cookies = await context.cookies([landing])
|
|
88
|
+
if any(c["name"] == CLEARANCE for c in cookies) or not result.challenged:
|
|
89
|
+
result.cookies = cookies
|
|
90
|
+
break
|
|
91
|
+
if time.monotonic() > deadline:
|
|
92
|
+
raise TimeoutError(f"{timeout_s:.0f}s 内未通过挑战({await _title(page)!r})")
|
|
93
|
+
await asyncio.sleep(0.5)
|
|
94
|
+
result.user_agent = await page.evaluate("navigator.userAgent")
|
|
95
|
+
result.ok = True
|
|
96
|
+
except Exception as e:
|
|
97
|
+
first = str(e).splitlines()[0][:160] if str(e) else ""
|
|
98
|
+
result.error = f"{type(e).__name__}: {first}"
|
|
99
|
+
if debug_dir and page is not None:
|
|
100
|
+
await _screenshot(page, Path(debug_dir) / f"{lease.exit_ip or lease.server}_{int(time.time())}.png")
|
|
101
|
+
finally:
|
|
102
|
+
result.seconds = round(time.monotonic() - t0, 1)
|
|
103
|
+
if browser is not None:
|
|
104
|
+
try:
|
|
105
|
+
await browser.close()
|
|
106
|
+
except Exception:
|
|
107
|
+
logger.debug("关闭浏览器异常", exc_info=True)
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _title(page) -> str:
|
|
112
|
+
try:
|
|
113
|
+
return (await page.title())[:60]
|
|
114
|
+
except Exception:
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _screenshot(page, path: Path) -> None:
|
|
119
|
+
try:
|
|
120
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
await page.screenshot(path=str(path))
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
paperlite/classify.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
paperlite/classify.py
|
|
5
|
+
───────────────────────
|
|
6
|
+
PDF 链接返回 HTML 时判断原因,写进 error_kind(取值与 paper_downloader 站点监控一致):
|
|
7
|
+
|
|
8
|
+
not_found 站点 404 页(DOI 不存在)
|
|
9
|
+
blocked 站点封了该 IP(ACM:"IP blocked")
|
|
10
|
+
paywall 页面提示需要订阅(ACM:"Features requiring a subscription")
|
|
11
|
+
landing_page 普通摘要页,没有全文入口
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
_TITLE_RE = re.compile(r"<title>([^<]*)", re.IGNORECASE)
|
|
17
|
+
|
|
18
|
+
# ACM 所有页面导航栏都带 "Basic Edition"(包括 404 页),不能据此判订阅;只认正文横幅。
|
|
19
|
+
# 实测 paywall 与 landing_page 两类 ACM 页面上都没有 PDF / 全文入口,横幅只说明页面有灰掉的订阅功能。
|
|
20
|
+
_PAYWALL_MARKERS = ("Features requiring a subscription",)
|
|
21
|
+
_BLOCKED_MARKERS = ("IP blocked",)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def page_title(html: str) -> str:
|
|
25
|
+
m = _TITLE_RE.search(html)
|
|
26
|
+
return " ".join(m.group(1).split()) if m else ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def classify_html(html: str) -> str:
|
|
30
|
+
title = page_title(html)
|
|
31
|
+
if "Error: 404" in title or title.startswith("404"):
|
|
32
|
+
return "not_found"
|
|
33
|
+
if any(m in title for m in _BLOCKED_MARKERS):
|
|
34
|
+
return "blocked"
|
|
35
|
+
if any(m in html for m in _PAYWALL_MARKERS):
|
|
36
|
+
return "paywall"
|
|
37
|
+
return "landing_page"
|
paperlite/cli.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
PaperLite 命令行。一个站点(主机)一个输出目录,默认 <out_root>/<主机>(out_root 默认 pdfs),任务库是其中的 tasks.db。
|
|
5
|
+
生产与消费可以分开跑(不同进程,只共享 Redis),也可以 run 一起跑。
|
|
6
|
+
|
|
7
|
+
paperlite init # 在当前目录生成 config.toml 模板
|
|
8
|
+
paperlite import --urls acm.txt # 导入任务(txt 一行一个,或带 pdf_url 列的 CSV)→ pdfs/dl.acm.org/
|
|
9
|
+
paperlite run --workers 20 # 同进程生产 + 消费,下载完退出
|
|
10
|
+
paperlite produce # 只生产身份,常驻
|
|
11
|
+
paperlite download --workers 20 # 只消费(需有生产者在跑)
|
|
12
|
+
paperlite status # 任务统计 + 身份池
|
|
13
|
+
paperlite retry --status failed # 把失败的重置为 pending
|
|
14
|
+
paperlite export --status non_pdf > non_pdf.csv
|
|
15
|
+
|
|
16
|
+
站点:--out > --host / --urls 的主机 / --db 记录的主机 → <out_root>/<主机> > out_root 下唯一的站点;多个站点时须指定。
|
|
17
|
+
任务库:--db > 配置 [pool] db_dir > <输出目录>/tasks.db(PDF 输出到 goofys / s3fs 这类对象存储挂载时,
|
|
18
|
+
任务库必须放本地盘:SQLite 要随机写、文件锁和 mmap,FUSE 挂载不支持)。
|
|
19
|
+
|
|
20
|
+
配置查找顺序:--config > 环境变量 PAPERLITE_CONFIG > 当前目录的 config.toml(paperlite init 生成)。
|
|
21
|
+
也可以 python -m paperlite ...。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import asyncio
|
|
26
|
+
import csv
|
|
27
|
+
import dataclasses
|
|
28
|
+
import logging
|
|
29
|
+
import signal
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from paperlite import __version__
|
|
37
|
+
from paperlite import config as config_mod
|
|
38
|
+
from paperlite.config import PoolConfig
|
|
39
|
+
from paperlite.consumer import Downloader
|
|
40
|
+
from paperlite.producer import Producer
|
|
41
|
+
from paperlite.proxy import IpSource, ProxySettings
|
|
42
|
+
from paperlite.store import IdentityStore
|
|
43
|
+
from paperlite.tasks import ERROR_KINDS, Status, TaskDB, url_host
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("paperlite")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load(path: str | None) -> tuple[PoolConfig, ProxySettings]:
|
|
50
|
+
try:
|
|
51
|
+
return config_mod.load(config_mod.resolve_path(path))
|
|
52
|
+
except FileNotFoundError as e:
|
|
53
|
+
raise SystemExit(str(e))
|
|
54
|
+
except (TypeError, ValueError) as e:
|
|
55
|
+
raise SystemExit(f"配置错误: {e}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pool_cfg(args) -> PoolConfig:
|
|
59
|
+
"""只读 [pool](out_root / db_dir 等);没有配置文件时用默认值,import / status / retry / export 不必有代理配置。"""
|
|
60
|
+
try:
|
|
61
|
+
return config_mod.load_pool(config_mod.resolve_path(args.config))
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return PoolConfig()
|
|
64
|
+
except (TypeError, ValueError) as e:
|
|
65
|
+
raise SystemExit(f"配置错误: {e}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _host_from_urls(path: str | None) -> str | None:
|
|
69
|
+
if not path:
|
|
70
|
+
return None
|
|
71
|
+
first = next(iter(_read_url_file(path)), None)
|
|
72
|
+
return url_host(first["pdf_url"]) if first else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_out(args, pool: PoolConfig) -> Path:
|
|
76
|
+
"""站点输出目录:--out > --host / --urls / --db 的主机 → <out_root>/<主机> > out_root 下唯一的站点。"""
|
|
77
|
+
if getattr(args, "out", None):
|
|
78
|
+
return Path(args.out).expanduser()
|
|
79
|
+
host = getattr(args, "host", None) or _host_from_urls(getattr(args, "urls", None))
|
|
80
|
+
if not host and getattr(args, "db", None) and Path(args.db).expanduser().exists():
|
|
81
|
+
db = TaskDB(Path(args.db).expanduser())
|
|
82
|
+
host = db.host()
|
|
83
|
+
db.close()
|
|
84
|
+
root = Path(pool.out_root).expanduser()
|
|
85
|
+
if host:
|
|
86
|
+
return root / host
|
|
87
|
+
sites = sorted(d.name for d in root.iterdir() if d.is_dir()) if root.is_dir() else []
|
|
88
|
+
if len(sites) == 1:
|
|
89
|
+
return root / sites[0]
|
|
90
|
+
if not sites:
|
|
91
|
+
raise SystemExit(f"{root.resolve()} 下还没有站点:先 import --urls <文件>,或用 --out 指定输出目录")
|
|
92
|
+
raise SystemExit(f"{root.resolve()} 下有多个站点:{', '.join(sites)};用 --host 或 --out 指定")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _open_site(args, create: bool = False) -> tuple[Path, TaskDB]:
|
|
96
|
+
"""返回 (输出目录, 任务库)。create=True(import / download / run)时建好输出目录。"""
|
|
97
|
+
pool = _pool_cfg(args)
|
|
98
|
+
out = _resolve_out(args, pool)
|
|
99
|
+
path = config_mod.task_db_path(str(out), getattr(args, "db", None), pool.db_dir)
|
|
100
|
+
if create:
|
|
101
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
elif not path.exists():
|
|
103
|
+
raise SystemExit(f"任务库不存在: {path}(输出目录 {out});先 import,或检查 --out / --db / db_dir")
|
|
104
|
+
logger.debug("输出目录 %s,任务库 %s", out, path)
|
|
105
|
+
return out, TaskDB(path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_url_file(path: str):
|
|
109
|
+
"""txt:一行一个链接;CSV:表头含 pdf_url(可选 paper_uid / source / batch_no),与 paper_downloader 导入格式相同。"""
|
|
110
|
+
with open(path, encoding="utf-8-sig", newline="") as f:
|
|
111
|
+
first = f.readline()
|
|
112
|
+
f.seek(0)
|
|
113
|
+
if "pdf_url" in first:
|
|
114
|
+
yield from (r for r in csv.DictReader(f) if (r.get("pdf_url") or "").strip())
|
|
115
|
+
else:
|
|
116
|
+
yield from ({"pdf_url": line.strip()} for line in f if line.strip().startswith("http"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _import(db: TaskDB, path: str, source: str = "", batch: str = "") -> None:
|
|
120
|
+
rows = ({**r, "source": r.get("source") or source, "batch_no": r.get("batch_no") or batch}
|
|
121
|
+
for r in _read_url_file(path))
|
|
122
|
+
try:
|
|
123
|
+
added, existed = db.import_urls(rows)
|
|
124
|
+
except ValueError as e:
|
|
125
|
+
raise SystemExit(str(e))
|
|
126
|
+
logger.info("导入 %s:新增 %d,已存在 %d(已存在的不重置)", path, added, existed)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _ProducerThread:
|
|
130
|
+
"""后台线程跑生产者。结束用 finished 事件判断:信号打断 Thread.join() 后 is_alive() 可能误报已结束。"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, cfg, store, proxy, host) -> None:
|
|
133
|
+
self.stop = threading.Event()
|
|
134
|
+
self.finished = threading.Event()
|
|
135
|
+
self._producer = Producer(cfg, store, IpSource(proxy), host)
|
|
136
|
+
threading.Thread(target=self._run, name="producer", daemon=True).start()
|
|
137
|
+
|
|
138
|
+
def _run(self) -> None:
|
|
139
|
+
try:
|
|
140
|
+
asyncio.run(self._producer.run(self.stop))
|
|
141
|
+
finally:
|
|
142
|
+
self.finished.set()
|
|
143
|
+
|
|
144
|
+
def shutdown(self, timeout_s: float) -> None:
|
|
145
|
+
self.stop.set()
|
|
146
|
+
if not self.finished.wait(timeout_s):
|
|
147
|
+
logger.warning("生产者 %.0fs 内未退出", timeout_s)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _download(args, cfg, proxy, store, with_producer: bool) -> None:
|
|
151
|
+
out, db = _open_site(args, create=True)
|
|
152
|
+
if args.urls:
|
|
153
|
+
_import(db, args.urls)
|
|
154
|
+
try:
|
|
155
|
+
reclaimed = db.lock_for_download()
|
|
156
|
+
except RuntimeError as e:
|
|
157
|
+
raise SystemExit(str(e))
|
|
158
|
+
if reclaimed:
|
|
159
|
+
logger.info("上次中断残留的 %d 个 running 任务已放回 pending", reclaimed)
|
|
160
|
+
try:
|
|
161
|
+
dl = Downloader(cfg, store, proxy, db, out, args.workers)
|
|
162
|
+
except ValueError as e:
|
|
163
|
+
raise SystemExit(str(e))
|
|
164
|
+
c = db.counts()
|
|
165
|
+
logger.info("输出目录 %s,任务库 %s", out, db.path)
|
|
166
|
+
logger.info("%s:待下载 %d,已成功 %d,non_pdf %d,失败 %d;下载线程 %d", dl.host, c[Status.PENDING],
|
|
167
|
+
c[Status.SUCCESS], c[Status.NON_PDF], c[Status.FAILED], args.workers)
|
|
168
|
+
if not c[Status.PENDING]:
|
|
169
|
+
return
|
|
170
|
+
producer = _ProducerThread(cfg, store, proxy, dl.host) if with_producer else None
|
|
171
|
+
stop = threading.Event()
|
|
172
|
+
threading.Thread(target=_report, args=(dl, store, cfg, stop), daemon=True, name="report").start()
|
|
173
|
+
t0 = time.monotonic()
|
|
174
|
+
try:
|
|
175
|
+
dl.run(stop)
|
|
176
|
+
except KeyboardInterrupt:
|
|
177
|
+
logger.info("中断:在途任务已放回 pending,可重跑继续")
|
|
178
|
+
finally:
|
|
179
|
+
stop.set()
|
|
180
|
+
if producer is not None:
|
|
181
|
+
producer.shutdown(cfg.pass_timeout_s + 30)
|
|
182
|
+
db.close()
|
|
183
|
+
s = dl.stats
|
|
184
|
+
logger.info("结束: 用时 %.1f 分钟,success %d,non_pdf %d,failed %d,用身份 %d 个,丢弃身份 %d 次,"
|
|
185
|
+
"URL 被单独挑战 %d 次,停掉卷期 %d 个(%d 篇未请求)", (time.monotonic() - t0) / 60, s.success,
|
|
186
|
+
s.non_pdf, s.failed, s.identities, s.rejected, s.challenged, s.volumes_stopped, s.inferred)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _report(dl: Downloader, store: IdentityStore, cfg: PoolConfig, stop: threading.Event) -> None:
|
|
190
|
+
"""每 30s 一行。速度按"已处理"(PDF + non_pdf + failed)算:non_pdf 成片时只看 PDF 数会误以为变慢。"""
|
|
191
|
+
s = dl.stats
|
|
192
|
+
last_done, last_pdf, last_t = 0, 0, time.monotonic()
|
|
193
|
+
while not stop.wait(30):
|
|
194
|
+
st = store.stats(dl.host, cfg.refill_before_s)
|
|
195
|
+
c = dl.db.counts()
|
|
196
|
+
now = time.monotonic()
|
|
197
|
+
done = s.success + s.non_pdf + s.failed
|
|
198
|
+
minutes = (now - last_t) / 60
|
|
199
|
+
logger.info("进度: 已处理 %d(PDF %d,non_pdf %d,失败 %d),待下载 %d | 身份 空闲 %d 占用 %d "
|
|
200
|
+
"| 近 30s 处理 %.0f 条/分钟,其中 PDF %.0f 篇/分钟",
|
|
201
|
+
done, s.success, s.non_pdf, s.failed, c[Status.PENDING] + c[Status.RUNNING],
|
|
202
|
+
st.ready, st.inuse, (done - last_done) / minutes, (s.success - last_pdf) / minutes)
|
|
203
|
+
last_done, last_pdf, last_t = done, s.success, now
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _status(args, cfg) -> None:
|
|
207
|
+
host = args.host
|
|
208
|
+
try:
|
|
209
|
+
_, db = _open_site(args)
|
|
210
|
+
except SystemExit as e:
|
|
211
|
+
if not host: # 只给了 --host 而本地没有这个站点的任务库:只看身份池
|
|
212
|
+
raise
|
|
213
|
+
print(e)
|
|
214
|
+
db = None
|
|
215
|
+
if db is not None:
|
|
216
|
+
host = host or db.host()
|
|
217
|
+
c = db.counts()
|
|
218
|
+
print(f"任务库 {db.path}({host or '空'}): 共 {sum(c.values())} " +
|
|
219
|
+
" ".join(f"{s} {c[s]}" for s in Status.ALL))
|
|
220
|
+
for status, kind, n, inferred in db.kind_counts():
|
|
221
|
+
print(f" {status:8s} {kind or '-':16s} {n}" + (f"(其中按卷期推断 {inferred})" if inferred else ""))
|
|
222
|
+
v, n = db.volume_summary()
|
|
223
|
+
if v:
|
|
224
|
+
print(f" 按卷期停掉 {v} 个卷期,{n} 篇未请求(retry --stopped-volumes 可重新核实)")
|
|
225
|
+
db.close()
|
|
226
|
+
if not host:
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
store = IdentityStore(cfg.redis_url, cfg.key_prefix)
|
|
230
|
+
store.reap(host)
|
|
231
|
+
st = store.stats(host, cfg.refill_before_s)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
print(f"身份池: Redis 不可用({e})")
|
|
234
|
+
return
|
|
235
|
+
print(f"身份池 {host}: 空闲 {st.ready} 占用 {st.inuse} 健康 {st.healthy} 在线下载线程 {st.workers} "
|
|
236
|
+
f"生产者 {store.producer_holder(host) or '无'}")
|
|
237
|
+
for ident in store.list(host):
|
|
238
|
+
print(f" {ident.exit_ip:15s} 剩余 {ident.left():4.0f}s 过盾 {ident.pass_s:.0f}s "
|
|
239
|
+
f"创建 {datetime.fromtimestamp(ident.created_at):%H:%M:%S} 指纹 {ident.fingerprint}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _export(args) -> None:
|
|
243
|
+
_, db = _open_site(args)
|
|
244
|
+
rows = db.rows(args.status)
|
|
245
|
+
out = open(args.file, "w", newline="", encoding="utf-8") if args.file != "-" else sys.stdout
|
|
246
|
+
w = csv.writer(out)
|
|
247
|
+
if rows:
|
|
248
|
+
w.writerow(rows[0].keys())
|
|
249
|
+
w.writerows(tuple(r) for r in rows)
|
|
250
|
+
if out is not sys.stdout:
|
|
251
|
+
out.close()
|
|
252
|
+
print(f"导出 {len(rows)} 行到 {args.file}")
|
|
253
|
+
db.close()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _raise_interrupt(signum, frame):
|
|
257
|
+
raise KeyboardInterrupt
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def main() -> None:
|
|
261
|
+
ap = argparse.ArgumentParser(prog="paperlite", description="浏览器过盾 cookie 池 + curl_cffi 批量下载")
|
|
262
|
+
ap.add_argument("--config", help=f"配置文件,默认取环境变量 {config_mod.CONFIG_ENV},再默认当前目录的 config.toml")
|
|
263
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
264
|
+
ap.add_argument("--log-level", default="INFO")
|
|
265
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
266
|
+
|
|
267
|
+
def site_args(p, out_required: bool = False):
|
|
268
|
+
p.add_argument("--out", required=out_required,
|
|
269
|
+
help="站点输出目录(PDF、non_pdf/;默认 <out_root>/<主机>,见配置 out_root)")
|
|
270
|
+
p.add_argument("--host", help="站点主机,如 dl.acm.org(输出目录取 <out_root>/<主机>)")
|
|
271
|
+
p.add_argument("--db", help="任务库路径(优先于配置 db_dir;默认 <输出目录>/tasks.db);"
|
|
272
|
+
"输出目录在 goofys 等对象存储挂载上时须在本地盘")
|
|
273
|
+
|
|
274
|
+
p = sub.add_parser("init", help="生成配置模板(默认 ./config.toml,或 --config 指定的路径)")
|
|
275
|
+
p.add_argument("--force", action="store_true", help="已存在也覆盖")
|
|
276
|
+
p = sub.add_parser("import", help="导入任务")
|
|
277
|
+
p.add_argument("--urls", required=True, help="txt 一行一个链接,或带 pdf_url 列的 CSV;须同一主机")
|
|
278
|
+
site_args(p)
|
|
279
|
+
p.add_argument("--source", default="")
|
|
280
|
+
p.add_argument("--batch", default="", help="写入 batch_no")
|
|
281
|
+
p = sub.add_parser("produce", help="只生产身份(常驻,Ctrl+C / SIGTERM 退出)")
|
|
282
|
+
site_args(p)
|
|
283
|
+
p.add_argument("--keep", type=int, help="没有下载线程时也养着这么多身份(覆盖 min_identities),"
|
|
284
|
+
"下载前一两分钟先启动可省掉冷启动的过盾等待")
|
|
285
|
+
for name in ("download", "run"):
|
|
286
|
+
p = sub.add_parser(name, help="只消费" if name == "download" else "生产 + 消费,下载完退出")
|
|
287
|
+
site_args(p)
|
|
288
|
+
p.add_argument("--urls", help="先导入这个文件(可选)")
|
|
289
|
+
p.add_argument("--workers", type=int, default=20, help="下载线程数 = 同时在用的身份数")
|
|
290
|
+
p = sub.add_parser("status", help="任务统计与身份池")
|
|
291
|
+
site_args(p)
|
|
292
|
+
p = sub.add_parser("retry", help="把 failed / non_pdf 的任务重置为 pending")
|
|
293
|
+
site_args(p)
|
|
294
|
+
g = p.add_mutually_exclusive_group(required=True)
|
|
295
|
+
g.add_argument("--status", choices=[Status.FAILED, Status.NON_PDF])
|
|
296
|
+
g.add_argument("--stopped-volumes", action="store_true",
|
|
297
|
+
help="重新核实按卷期停掉的:这些卷期的 non_pdf(样本 + 推断)全部放回 pending")
|
|
298
|
+
p.add_argument("--kind", choices=ERROR_KINDS, help="配合 --status,只重置该 error_kind")
|
|
299
|
+
p = sub.add_parser("export", help="导出任务为 CSV")
|
|
300
|
+
site_args(p)
|
|
301
|
+
p.add_argument("--status", choices=Status.ALL)
|
|
302
|
+
p.add_argument("--file", default="-", help="输出文件,默认标准输出")
|
|
303
|
+
args = ap.parse_args()
|
|
304
|
+
|
|
305
|
+
logging.basicConfig(level=args.log_level.upper(), stream=sys.stderr if args.cmd == "export" else sys.stdout,
|
|
306
|
+
format="%(asctime)s %(levelname)s [%(threadName)s] %(message)s", datefmt="%H:%M:%S")
|
|
307
|
+
for noisy in ("asyncio", "curl_cffi"):
|
|
308
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
309
|
+
|
|
310
|
+
# SIGTERM(pkill / systemd / docker stop)与 Ctrl+C 一样优雅退出;
|
|
311
|
+
# 非交互 shell 用 & 启动的进程 SIGINT 被设为忽略,这里显式恢复
|
|
312
|
+
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
313
|
+
signal.signal(signal.SIGTERM, _raise_interrupt)
|
|
314
|
+
|
|
315
|
+
if args.cmd == "init":
|
|
316
|
+
path = config_mod.resolve_path(args.config)
|
|
317
|
+
try:
|
|
318
|
+
config_mod.write_example(path, args.force)
|
|
319
|
+
except FileExistsError as e:
|
|
320
|
+
sys.exit(str(e))
|
|
321
|
+
logger.info("已生成 %s:填好 [proxy] 的提取接口和代理账号密码再用", path.resolve())
|
|
322
|
+
return
|
|
323
|
+
if args.cmd == "import":
|
|
324
|
+
out, db = _open_site(args, create=True)
|
|
325
|
+
_import(db, args.urls, args.source, args.batch)
|
|
326
|
+
logger.info("输出目录 %s,任务库 %s", out, db.path)
|
|
327
|
+
db.close()
|
|
328
|
+
return
|
|
329
|
+
if args.cmd == "retry":
|
|
330
|
+
_, db = _open_site(args)
|
|
331
|
+
if args.stopped_volumes:
|
|
332
|
+
v, n = db.reset_stopped_volumes()
|
|
333
|
+
print(f"已重置 {v} 个被停卷期的 {n} 个任务为 pending")
|
|
334
|
+
else:
|
|
335
|
+
print(f"已重置 {db.reset(args.status, args.kind)} 个任务为 pending")
|
|
336
|
+
db.close()
|
|
337
|
+
return
|
|
338
|
+
if args.cmd == "export":
|
|
339
|
+
_export(args)
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
cfg, proxy = _load(args.config)
|
|
343
|
+
if args.cmd == "status":
|
|
344
|
+
_status(args, cfg)
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
store = IdentityStore(cfg.redis_url, cfg.key_prefix)
|
|
348
|
+
store.ping()
|
|
349
|
+
if args.cmd == "produce":
|
|
350
|
+
host = args.host
|
|
351
|
+
if not host:
|
|
352
|
+
_, db = _open_site(args)
|
|
353
|
+
host = db.host()
|
|
354
|
+
db.close()
|
|
355
|
+
if not host:
|
|
356
|
+
raise SystemExit("任务库是空的,先 import")
|
|
357
|
+
if args.keep is not None:
|
|
358
|
+
cfg = dataclasses.replace(cfg, min_identities=args.keep)
|
|
359
|
+
producer = _ProducerThread(cfg, store, proxy, host)
|
|
360
|
+
try:
|
|
361
|
+
while not producer.finished.wait(1):
|
|
362
|
+
pass
|
|
363
|
+
except KeyboardInterrupt:
|
|
364
|
+
producer.shutdown(cfg.pass_timeout_s + 30)
|
|
365
|
+
else:
|
|
366
|
+
_download(args, cfg, proxy, store, with_producer=args.cmd == "run")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# PaperLite 配置。paperlite init 生成为 ./config.toml 再填写(在当前目录运行时自动读取;别处运行用 --config 或 PAPERLITE_CONFIG)。
|
|
2
|
+
# config.toml 含代理密码,不要提交、不要同步到别处公开。
|
|
3
|
+
|
|
4
|
+
[proxy]
|
|
5
|
+
api_url = "" # 短效代理提取接口(每次返回一个 IP,青果格式 JSON),必填
|
|
6
|
+
scheme = "http"
|
|
7
|
+
username = ""
|
|
8
|
+
password = ""
|
|
9
|
+
lease_s = 600 # 单个 IP 最长使用时间(秒),与接口返回的 deadline 取较早者
|
|
10
|
+
check_url = "https://dl.acm.org/robots.txt" # 新 IP 体检地址,收到任意 HTTP 响应即合格
|
|
11
|
+
|
|
12
|
+
[pool]
|
|
13
|
+
redis_url = "redis://127.0.0.1:6379/0"
|
|
14
|
+
out_root = "pdfs" # 不给 --out 时,站点输出目录为 <out_root>/<主机>,如 pdfs/dl.acm.org
|
|
15
|
+
db_dir = "" # 任务库目录(本地盘)。设置后任务库放这里:<目录>/<输出目录名>-<路径哈希>.db;
|
|
16
|
+
# 留空则放输出目录里的 tasks.db。PDF 输出到 goofys 等挂载上时必须设置(或每次用 --db)
|
|
17
|
+
max_browsers = 20 # 同时运行的浏览器上限
|
|
18
|
+
max_identities = 20 # 健康身份上限;实际按在线下载线程数生产
|
|
19
|
+
min_identities = 0 # 没有下载线程时也保持的预热身份数
|
|
20
|
+
refill_before_s = 90 # 身份剩余寿命低于此值就提前生产替补
|
|
21
|
+
pass_timeout_s = 90 # 单次过盾等待上限
|
|
22
|
+
hedge_after_s = 45 # 过盾超过此时长还没成功,就另开一个浏览器补位(成功的多在 30s 内)
|
|
23
|
+
debug_dir = "" # 过盾失败截图目录,空则不截图
|
|
24
|
+
max_fresh_rejects = 5 # 连续这么多个新身份一篇没下成就被拒 → 暂停生产(站点规则变了,别烧代理配额)
|
|
25
|
+
breaker_pause_s = 600 # 暂停时长(秒),之后放一个身份试探
|
|
26
|
+
request_gap_s = [3, 5] # 同一 IP 两次请求的随机间隔(秒)
|
|
27
|
+
download_timeout_s = 120 # 卡顿超时:连续这么久收不到数据才算超时(不限总时长,大文件慢慢下也行)
|
|
28
|
+
max_file_size_mb = 200 # 单个 PDF 超过就中止
|
|
29
|
+
max_tries = 3 # 单条 URL 网络错误重试次数(cookie 被拒不计)
|
|
30
|
+
max_url_challenges = 3 # 单条 URL 带有效 cookie 仍被挑战满这么多次 → failed / challenge_failed
|
|
31
|
+
volume_stop_after = 3 # 同一卷期(DOI 点号前那段)还没下到 PDF、已有这么多篇 404/订阅/摘要页 → 停掉该卷期其余任务;0 关闭
|
|
32
|
+
|
|
33
|
+
[landing] # 各主机的过盾落地页(任意会触发挑战的普通页面);未配置的用主机首页
|
|
34
|
+
"dl.acm.org" = "https://dl.acm.org/doi/10.1145/3779295"
|