netspy 1.0.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.
- netspy/__about__.py +3 -0
- netspy/__init__.py +81 -0
- netspy/buffer/__init__.py +8 -0
- netspy/buffer/item_buffer.py +304 -0
- netspy/buffer/request_buffer.py +140 -0
- netspy/commands/__init__.py +21 -0
- netspy/commands/cmdline.py +127 -0
- netspy/commands/create/__init__.py +181 -0
- netspy/commands/retry.py +134 -0
- netspy/commands/shell.py +38 -0
- netspy/core/__init__.py +5 -0
- netspy/core/base_parser.py +79 -0
- netspy/core/base_scheduler.py +397 -0
- netspy/core/batch_monitor.py +196 -0
- netspy/core/batch_store.py +386 -0
- netspy/core/collector.py +86 -0
- netspy/core/context.py +32 -0
- netspy/core/parser_control.py +332 -0
- netspy/core/redis_scheduler.py +437 -0
- netspy/core/redis_task_scheduler.py +112 -0
- netspy/core/scheduler.py +45 -0
- netspy/core/spiders/__init__.py +10 -0
- netspy/core/spiders/air_spider.py +50 -0
- netspy/core/spiders/batch_spider.py +200 -0
- netspy/core/spiders/spider.py +55 -0
- netspy/core/spiders/task_spider.py +96 -0
- netspy/core/task_queue.py +280 -0
- netspy/core/task_source.py +45 -0
- netspy/db/__init__.py +23 -0
- netspy/db/mysqldb.py +136 -0
- netspy/db/postgresdb.py +76 -0
- netspy/db/redisdb.py +50 -0
- netspy/dedup/__init__.py +127 -0
- netspy/dedup/bloom_filter.py +211 -0
- netspy/dedup/lite_filter.py +30 -0
- netspy/dedup/redis_filter.py +189 -0
- netspy/exceptions.py +95 -0
- netspy/network/__init__.py +8 -0
- netspy/network/antibot.py +90 -0
- netspy/network/cache.py +103 -0
- netspy/network/circuit.py +123 -0
- netspy/network/downloader/__init__.py +112 -0
- netspy/network/downloader/_async_httpx.py +240 -0
- netspy/network/downloader/_common.py +473 -0
- netspy/network/downloader/_curl.py +202 -0
- netspy/network/downloader/_httpx.py +188 -0
- netspy/network/downloader/_playwright.py +279 -0
- netspy/network/downloader/base.py +33 -0
- netspy/network/global_throttle.py +98 -0
- netspy/network/item.py +118 -0
- netspy/network/middleware.py +70 -0
- netspy/network/proxy_pool/__init__.py +34 -0
- netspy/network/proxy_pool/api.py +226 -0
- netspy/network/proxy_pool/base.py +31 -0
- netspy/network/request.py +191 -0
- netspy/network/response.py +188 -0
- netspy/network/robots.py +206 -0
- netspy/network/status.py +97 -0
- netspy/network/throttle.py +158 -0
- netspy/network/user_agent.py +23 -0
- netspy/network/user_pool/__init__.py +24 -0
- netspy/network/user_pool/base.py +39 -0
- netspy/network/user_pool/local.py +92 -0
- netspy/network/user_pool/middleware.py +94 -0
- netspy/network/user_pool/redis.py +82 -0
- netspy/pipelines/__init__.py +13 -0
- netspy/pipelines/_sql.py +96 -0
- netspy/pipelines/base.py +21 -0
- netspy/pipelines/console.py +23 -0
- netspy/pipelines/csv.py +62 -0
- netspy/pipelines/elasticsearch.py +66 -0
- netspy/pipelines/kafka.py +55 -0
- netspy/pipelines/mongo.py +56 -0
- netspy/pipelines/mysql.py +35 -0
- netspy/pipelines/postgres.py +57 -0
- netspy/py.typed +0 -0
- netspy/setting.py +500 -0
- netspy/templates/__init__.py +1 -0
- netspy/templates/air_spider.py.jinja +23 -0
- netspy/templates/item.py.jinja +13 -0
- netspy/templates/item_fields.py.jinja +21 -0
- netspy/templates/project/README.md.jinja +30 -0
- netspy/templates/project/main.py.jinja +6 -0
- netspy/templates/project/spider.py.jinja +21 -0
- netspy/templates/setting.py.jinja +52 -0
- netspy/utils/__init__.py +1 -0
- netspy/utils/alert.py +239 -0
- netspy/utils/log.py +80 -0
- netspy/utils/metrics.py +97 -0
- netspy/utils/stats.py +100 -0
- netspy/utils/tools.py +93 -0
- netspy-1.0.0.dist-info/METADATA +186 -0
- netspy-1.0.0.dist-info/RECORD +96 -0
- netspy-1.0.0.dist-info/WHEEL +4 -0
- netspy-1.0.0.dist-info/entry_points.txt +2 -0
- netspy-1.0.0.dist-info/licenses/LICENSE +21 -0
netspy/__about__.py
ADDED
netspy/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Netspy —— 一个上手简单、结构清晰的 Python 爬虫框架。
|
|
2
|
+
|
|
3
|
+
已提供:
|
|
4
|
+
- `setting` 分层配置(框架默认 ← 项目 setting.py ← 环境变量 NETSPY_*)
|
|
5
|
+
- `get_logger` 基于 loguru 的日志
|
|
6
|
+
- 异常层级 NetspyError 及其子类
|
|
7
|
+
- `Request` / `Response` 网络层
|
|
8
|
+
- `AirSpider` / `BaseParser` 轻量单机运行时(含浏览器渲染、中间件、代理池、指标、告警)
|
|
9
|
+
- `Spider` Redis 分布式(多进程 / 多机 + 断点续爬,需 netspy[redis])
|
|
10
|
+
- `TaskSpider` 从任务源(Redis / DB)持续拉任务来爬
|
|
11
|
+
- `BatchSpider` 周期性批次采集(MySQL 任务表 + 批次记录 + 进度 / 防丢)
|
|
12
|
+
- `Item` / `UpdateItem` 结构化数据 + 管道落库 + 去重
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from netspy import setting
|
|
18
|
+
from netspy.__about__ import __version__
|
|
19
|
+
from netspy.core.base_parser import BaseParser
|
|
20
|
+
from netspy.core.spiders.air_spider import AirSpider
|
|
21
|
+
from netspy.core.spiders.batch_spider import BatchSpider
|
|
22
|
+
from netspy.core.spiders.spider import Spider
|
|
23
|
+
from netspy.core.spiders.task_spider import TaskSpider
|
|
24
|
+
from netspy.exceptions import (
|
|
25
|
+
ConfigError,
|
|
26
|
+
DedupError,
|
|
27
|
+
ItemError,
|
|
28
|
+
NetspyError,
|
|
29
|
+
NotRetryError,
|
|
30
|
+
PipelineError,
|
|
31
|
+
RequestError,
|
|
32
|
+
ResponseError,
|
|
33
|
+
SpiderError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
)
|
|
36
|
+
from netspy.network.item import Item, UpdateItem
|
|
37
|
+
from netspy.network.request import Request
|
|
38
|
+
from netspy.network.response import Response
|
|
39
|
+
from netspy.network.user_pool import (
|
|
40
|
+
GuestUserPool,
|
|
41
|
+
LocalUserPool,
|
|
42
|
+
RedisUserPool,
|
|
43
|
+
User,
|
|
44
|
+
UserPool,
|
|
45
|
+
)
|
|
46
|
+
from netspy.utils.log import get_logger, log
|
|
47
|
+
|
|
48
|
+
# 应用项目 setting.py 与环境变量覆盖,并按最终配置初始化日志
|
|
49
|
+
setting.reload()
|
|
50
|
+
get_logger()
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"AirSpider",
|
|
54
|
+
"BaseParser",
|
|
55
|
+
"BatchSpider",
|
|
56
|
+
"ConfigError",
|
|
57
|
+
"DedupError",
|
|
58
|
+
"GuestUserPool",
|
|
59
|
+
"Item",
|
|
60
|
+
"ItemError",
|
|
61
|
+
"LocalUserPool",
|
|
62
|
+
"NetspyError",
|
|
63
|
+
"NotRetryError",
|
|
64
|
+
"PipelineError",
|
|
65
|
+
"RedisUserPool",
|
|
66
|
+
"Request",
|
|
67
|
+
"RequestError",
|
|
68
|
+
"Response",
|
|
69
|
+
"ResponseError",
|
|
70
|
+
"Spider",
|
|
71
|
+
"SpiderError",
|
|
72
|
+
"TaskSpider",
|
|
73
|
+
"UpdateItem",
|
|
74
|
+
"User",
|
|
75
|
+
"UserPool",
|
|
76
|
+
"ValidationError",
|
|
77
|
+
"__version__",
|
|
78
|
+
"get_logger",
|
|
79
|
+
"log",
|
|
80
|
+
"setting",
|
|
81
|
+
]
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""``ItemBuffer`` —— 收集 parse 产出的数据,批量去重后交给管道落库。
|
|
2
|
+
|
|
3
|
+
流程:``put`` 累积 → 定时 / 满量 ``flush`` → 按 (表, 是否 UpdateItem, 管道) 分组
|
|
4
|
+
→ Item 级去重(fingerprint)→ 逐管道 ``save_items`` / ``update_items``
|
|
5
|
+
→ 成功则写去重指纹;失败则 dump 到 ``FAILED_ITEM_PATH``。
|
|
6
|
+
|
|
7
|
+
**落库之后才给任务销账**:每条 item 记着产出它的那条请求(``owner``),一批写完
|
|
8
|
+
(入库成功、或 dump 进 failed_items —— 两者都落到了持久介质)才回调 ``ack``。
|
|
9
|
+
销账早于落库的话,节点被硬杀就会**静默丢数据**:数据只在内存里,而任务已经
|
|
10
|
+
销过账不会被回收,请求指纹又是入队前就写的,重跑一遍也补不回来。
|
|
11
|
+
|
|
12
|
+
给了 ``handler`` 时走调试快路径:直接把原始批次交给 handler,不去重、不落库。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
22
|
+
|
|
23
|
+
from netspy import setting
|
|
24
|
+
from netspy.exceptions import ItemError
|
|
25
|
+
from netspy.network.item import Item, UpdateItem
|
|
26
|
+
from netspy.utils import stats as sk
|
|
27
|
+
from netspy.utils import tools
|
|
28
|
+
from netspy.utils.log import get_logger
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from netspy.dedup import Dedup
|
|
32
|
+
from netspy.pipelines.base import BasePipeline
|
|
33
|
+
from netspy.utils.stats import Stats
|
|
34
|
+
|
|
35
|
+
log = get_logger("item_buffer")
|
|
36
|
+
|
|
37
|
+
ItemHandler = Callable[[list[Any]], None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Norm(NamedTuple):
|
|
41
|
+
table: str
|
|
42
|
+
is_update: bool
|
|
43
|
+
data: dict[str, Any]
|
|
44
|
+
fingerprint: str | None
|
|
45
|
+
update_keys: tuple[str, ...]
|
|
46
|
+
pipelines: tuple[str, ...] | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize(obj: Any) -> _Norm:
|
|
50
|
+
if isinstance(obj, Item):
|
|
51
|
+
obj.pre_to_db()
|
|
52
|
+
is_update = isinstance(obj, UpdateItem)
|
|
53
|
+
keys = tuple(obj.update_key) if isinstance(obj, UpdateItem) else ()
|
|
54
|
+
fp = obj.fingerprint if setting.ITEM_FILTER_ENABLE else None
|
|
55
|
+
pipes = tuple(obj.pipelines) if obj.pipelines else None
|
|
56
|
+
return _Norm(obj.table_name, is_update, obj.to_dict(), fp, keys, pipes)
|
|
57
|
+
if isinstance(obj, dict):
|
|
58
|
+
return _Norm(setting.ITEM_DEFAULT_TABLE, False, obj, None, (), None)
|
|
59
|
+
raise ItemError(f"无法入库的类型:{type(obj)!r}(需要 Item 或 dict)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ItemBuffer(threading.Thread):
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
stats: Stats,
|
|
66
|
+
*,
|
|
67
|
+
handler: ItemHandler | None = None,
|
|
68
|
+
pipelines: list[str] | None = None,
|
|
69
|
+
dedup: Dedup | None = None,
|
|
70
|
+
ack: Callable[[Any], None] | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(name="item-buffer", daemon=True)
|
|
73
|
+
self._stats = stats
|
|
74
|
+
self._handler = handler
|
|
75
|
+
self._pipeline_paths = pipelines
|
|
76
|
+
self._pipeline_cache: dict[tuple[str, ...], list[BasePipeline]] = {}
|
|
77
|
+
self._dedup = dedup
|
|
78
|
+
#: 落库成功后给任务销账。分布式下就是 ``Collector.done``
|
|
79
|
+
self._ack = ack
|
|
80
|
+
#: (item, 产出它的请求) —— owner 为 None 表示没人等着它销账
|
|
81
|
+
self._pending: list[tuple[Any, Any]] = []
|
|
82
|
+
#: 按 owner 挂的「落库之后再做」回调。BatchSpider 用它把任务表的
|
|
83
|
+
#: 「已完成」推迟到数据真的落库之后 —— 否则任务标了 DONE 而数据还在内存里,
|
|
84
|
+
#: 节点一死,防丢机制只回收 DOING,这些任务永远不会被重跑
|
|
85
|
+
self._after_persist: dict[int, list[Any]] = {}
|
|
86
|
+
self._lock = threading.Lock()
|
|
87
|
+
self._stop_event = threading.Event()
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
def owns(self, owner: Any) -> bool:
|
|
91
|
+
"""`owner` 还有 item 压在缓冲里没落库吗。
|
|
92
|
+
|
|
93
|
+
已经 flush 过就返回 False —— 那时数据已经在库里,调用方可以立刻动作。
|
|
94
|
+
"""
|
|
95
|
+
with self._lock:
|
|
96
|
+
return any(o is owner for _, o in self._pending)
|
|
97
|
+
|
|
98
|
+
def after_persist(self, owner: Any, fn: Any) -> None:
|
|
99
|
+
"""`owner` 产出的数据整批落库之后再执行 `fn`。
|
|
100
|
+
|
|
101
|
+
落库失败时**不执行** —— 让上游的状态留在「处理中」,由防丢机制回收重跑。
|
|
102
|
+
"""
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._after_persist.setdefault(id(owner), []).append(fn)
|
|
105
|
+
|
|
106
|
+
def put(self, item: Any, owner: Any = None) -> None:
|
|
107
|
+
with self._lock:
|
|
108
|
+
self._pending.append((item, owner))
|
|
109
|
+
size = len(self._pending)
|
|
110
|
+
if size >= setting.ITEM_MAX_CACHED_COUNT:
|
|
111
|
+
self.flush()
|
|
112
|
+
|
|
113
|
+
def is_empty(self) -> bool:
|
|
114
|
+
with self._lock:
|
|
115
|
+
return not self._pending
|
|
116
|
+
|
|
117
|
+
def pending_count(self) -> int:
|
|
118
|
+
with self._lock:
|
|
119
|
+
return len(self._pending)
|
|
120
|
+
|
|
121
|
+
def run(self) -> None:
|
|
122
|
+
while not self._stop_event.wait(setting.BUFFER_FLUSH_INTERVAL):
|
|
123
|
+
self.flush()
|
|
124
|
+
self.flush()
|
|
125
|
+
|
|
126
|
+
def stop(self) -> None:
|
|
127
|
+
self._stop_event.set()
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
for pipelines in self._pipeline_cache.values():
|
|
131
|
+
for pipeline in pipelines:
|
|
132
|
+
try:
|
|
133
|
+
pipeline.close()
|
|
134
|
+
except Exception:
|
|
135
|
+
log.exception("管道 {} close 异常", type(pipeline).__name__)
|
|
136
|
+
self._pipeline_cache.clear()
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
def flush(self) -> None:
|
|
140
|
+
with self._lock:
|
|
141
|
+
batch = self._pending
|
|
142
|
+
self._pending = []
|
|
143
|
+
if not batch:
|
|
144
|
+
return
|
|
145
|
+
objs = [obj for obj, _ in batch]
|
|
146
|
+
# 同一条请求可能产出多条 item,按 id 去重后只销一次账
|
|
147
|
+
owners = {id(owner): owner for _, owner in batch if owner is not None}
|
|
148
|
+
if self._handler is not None:
|
|
149
|
+
self._handler(objs)
|
|
150
|
+
self._stats.incr(sk.ITEM, len(objs))
|
|
151
|
+
self._ack_all(owners.values())
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
self._persist(objs)
|
|
155
|
+
except Exception:
|
|
156
|
+
# 销账早于落库正是这个模块要堵的洞,所以这里**不能**销账。
|
|
157
|
+
# 不销账的任务会在租约到期后被别的节点重抓 —— 数据至少还有第二次机会。
|
|
158
|
+
# 顺带保住 flush 线程:以前这里一抛,整个 ItemBuffer 线程就悄悄死了
|
|
159
|
+
log.exception("落库异常,本批 {} 条不销账,等租约到期重抓", len(objs))
|
|
160
|
+
return
|
|
161
|
+
self._ack_all(owners.values())
|
|
162
|
+
|
|
163
|
+
def _ack_all(self, owners: Any) -> None:
|
|
164
|
+
for owner in owners:
|
|
165
|
+
self._run_after_persist(owner)
|
|
166
|
+
if self._ack is None:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
self._ack(owner)
|
|
170
|
+
except Exception:
|
|
171
|
+
log.exception("任务销账失败")
|
|
172
|
+
|
|
173
|
+
def _run_after_persist(self, owner: Any) -> None:
|
|
174
|
+
with self._lock:
|
|
175
|
+
hooks = self._after_persist.pop(id(owner), None)
|
|
176
|
+
for fn in hooks or ():
|
|
177
|
+
try:
|
|
178
|
+
fn()
|
|
179
|
+
except Exception:
|
|
180
|
+
log.exception("落库后回调异常")
|
|
181
|
+
|
|
182
|
+
def _persist(self, batch: list[Any]) -> None:
|
|
183
|
+
dedup = self._get_dedup()
|
|
184
|
+
seen: set[str] = set()
|
|
185
|
+
groups: dict[tuple[str, bool, tuple[str, ...] | None], list[_Norm]] = defaultdict(list)
|
|
186
|
+
degraded = False
|
|
187
|
+
for obj in batch:
|
|
188
|
+
norm = _normalize(obj)
|
|
189
|
+
if norm.fingerprint is not None and dedup is not None:
|
|
190
|
+
try:
|
|
191
|
+
duplicate = norm.fingerprint in seen or dedup.get(norm.fingerprint)
|
|
192
|
+
except Exception:
|
|
193
|
+
# 查重也是一次 Redis 调用。抛出去的话异常会穿出这个循环,
|
|
194
|
+
# 后面的数据既不写库、也不 dump ——
|
|
195
|
+
# 实测 9 条数据在第 3 条上抖一次:整批 9 条全丢。
|
|
196
|
+
# 去重是优化,丢数据不是可选项:按「没见过」放行
|
|
197
|
+
degraded = True
|
|
198
|
+
duplicate = False
|
|
199
|
+
if duplicate:
|
|
200
|
+
self._stats.incr(sk.ITEM_DEDUP_DROPPED)
|
|
201
|
+
continue
|
|
202
|
+
seen.add(norm.fingerprint)
|
|
203
|
+
groups[(norm.table, norm.is_update, norm.pipelines)].append(norm)
|
|
204
|
+
|
|
205
|
+
for (table, is_update, pipe_paths), rows in groups.items():
|
|
206
|
+
datas = [row.data for row in rows]
|
|
207
|
+
update_keys = list(rows[0].update_keys)
|
|
208
|
+
pipelines = self._resolve_pipelines(pipe_paths)
|
|
209
|
+
ok = all(
|
|
210
|
+
self._write(pipeline, table, is_update, datas, update_keys)
|
|
211
|
+
for pipeline in pipelines
|
|
212
|
+
)
|
|
213
|
+
if ok:
|
|
214
|
+
self._stats.incr(sk.ITEM, len(datas))
|
|
215
|
+
if dedup is not None:
|
|
216
|
+
try:
|
|
217
|
+
for row in rows:
|
|
218
|
+
if row.fingerprint is not None:
|
|
219
|
+
dedup.add(row.fingerprint)
|
|
220
|
+
except Exception:
|
|
221
|
+
# 数据已经写进去了,指纹没记上 —— 重抓时会重复入库。
|
|
222
|
+
# 但让异常穿出去更糟:后面的分组会一起丢
|
|
223
|
+
# (实测第一组写成功、剩下 6 条凭空消失)
|
|
224
|
+
degraded = True
|
|
225
|
+
else:
|
|
226
|
+
self._dump_failed(
|
|
227
|
+
table,
|
|
228
|
+
datas,
|
|
229
|
+
update_keys=update_keys if is_update else None,
|
|
230
|
+
pipelines=pipe_paths,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if degraded:
|
|
234
|
+
# 按批记一次,别每条都刷屏;但必须记 —— 静默降级就是把一种无声换成另一种
|
|
235
|
+
self._stats.incr(sk.DEDUP_DEGRADED)
|
|
236
|
+
log.warning("Item 去重不可用,本批按「没见过」放行 —— 可能重复入库")
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
def _get_dedup(self) -> Dedup | None:
|
|
240
|
+
if not setting.ITEM_FILTER_ENABLE:
|
|
241
|
+
return None
|
|
242
|
+
if self._dedup is None:
|
|
243
|
+
from netspy.dedup import get_item_filter
|
|
244
|
+
|
|
245
|
+
self._dedup = get_item_filter()
|
|
246
|
+
return self._dedup
|
|
247
|
+
|
|
248
|
+
def _resolve_pipelines(self, paths: tuple[str, ...] | None) -> list[BasePipeline]:
|
|
249
|
+
key = (
|
|
250
|
+
paths
|
|
251
|
+
if paths is not None
|
|
252
|
+
else tuple(
|
|
253
|
+
self._pipeline_paths if self._pipeline_paths is not None else setting.ITEM_PIPELINES
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
cached = self._pipeline_cache.get(key)
|
|
257
|
+
if cached is None:
|
|
258
|
+
cached = [tools.load_object(path)() for path in key]
|
|
259
|
+
self._pipeline_cache[key] = cached
|
|
260
|
+
return cached
|
|
261
|
+
|
|
262
|
+
def _write(
|
|
263
|
+
self,
|
|
264
|
+
pipeline: BasePipeline,
|
|
265
|
+
table: str,
|
|
266
|
+
is_update: bool,
|
|
267
|
+
datas: list[dict[str, Any]],
|
|
268
|
+
update_keys: list[str],
|
|
269
|
+
) -> bool:
|
|
270
|
+
try:
|
|
271
|
+
if is_update:
|
|
272
|
+
return pipeline.update_items(table, datas, update_keys)
|
|
273
|
+
return pipeline.save_items(table, datas)
|
|
274
|
+
except Exception:
|
|
275
|
+
log.exception("管道 {} 写入异常", type(pipeline).__name__)
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
def _dump_failed(
|
|
279
|
+
self,
|
|
280
|
+
table: str,
|
|
281
|
+
datas: list[dict[str, Any]],
|
|
282
|
+
*,
|
|
283
|
+
update_keys: list[str] | None = None,
|
|
284
|
+
pipelines: tuple[str, ...] | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""把写失败的一批落到磁盘,**带上回放所需的全部信息**。
|
|
287
|
+
|
|
288
|
+
只记 table + data 是不够的:`UpdateItem` 回放时会退化成普通 INSERT,
|
|
289
|
+
而 PG 默认 `ON CONFLICT DO NOTHING` 会让这条 INSERT 什么都不做**并返回成功** ——
|
|
290
|
+
于是 retry 报告成功、删掉文件,那次更新永久消失。
|
|
291
|
+
|
|
292
|
+
字段是可选的:老的 dump 文件没有它们,读的时候按普通插入处理(即今天的行为)。
|
|
293
|
+
"""
|
|
294
|
+
path = Path(setting.FAILED_ITEM_PATH)
|
|
295
|
+
extra: dict[str, Any] = {}
|
|
296
|
+
if update_keys:
|
|
297
|
+
extra["update_keys"] = list(update_keys)
|
|
298
|
+
if pipelines:
|
|
299
|
+
extra["pipelines"] = list(pipelines)
|
|
300
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
301
|
+
for data in datas:
|
|
302
|
+
fh.write(tools.dumps_json({"table": table, "data": data, **extra}) + "\n")
|
|
303
|
+
self._stats.incr(sk.ITEM_FAILED, len(datas))
|
|
304
|
+
log.error("[{}] {} 条数据写入失败,已 dump 到 {}", table, len(datas), path)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""``RequestBuffer`` —— 收集 yield 出的 Request,去重后批量写入任务队列。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from netspy import setting
|
|
9
|
+
from netspy.dedup import get_request_filter
|
|
10
|
+
from netspy.utils import stats as stats_keys
|
|
11
|
+
from netspy.utils.log import get_logger
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from netspy.dedup import Filter
|
|
15
|
+
from netspy.network.request import Request
|
|
16
|
+
from netspy.utils.stats import Stats
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
log = get_logger("buffer")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RequestBuffer(threading.Thread):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
task_queue: Any,
|
|
26
|
+
stats: Stats,
|
|
27
|
+
*,
|
|
28
|
+
dedup: Filter | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(name="request-buffer", daemon=True)
|
|
31
|
+
self._queue = task_queue
|
|
32
|
+
self._stats = stats
|
|
33
|
+
self._dedup = dedup if dedup is not None else get_request_filter()
|
|
34
|
+
self._pending: list[Request] = []
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self._stop_event = threading.Event()
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def dedup(self) -> Filter:
|
|
42
|
+
"""实际在用的去重过滤器 —— 构造时传进来的可能是 None,这里给出真正生效的那个。"""
|
|
43
|
+
return self._dedup
|
|
44
|
+
|
|
45
|
+
def put(self, request: Request) -> None:
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._pending.append(request)
|
|
48
|
+
if len(self._pending) >= setting.REQUEST_BUFFER_MAX_CACHED:
|
|
49
|
+
self.flush()
|
|
50
|
+
|
|
51
|
+
def put_retry(self, request: Request) -> None:
|
|
52
|
+
"""重试请求:跳过去重再次入队。"""
|
|
53
|
+
request.filter_repeat = False
|
|
54
|
+
self.put(request)
|
|
55
|
+
|
|
56
|
+
def is_empty(self) -> bool:
|
|
57
|
+
with self._lock:
|
|
58
|
+
return not self._pending
|
|
59
|
+
|
|
60
|
+
def pending_count(self) -> int:
|
|
61
|
+
with self._lock:
|
|
62
|
+
return len(self._pending)
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
def flush(self) -> None:
|
|
66
|
+
"""把缓冲区里的请求推进队列。**出错时不丢**:没入队的放回去下轮重试。
|
|
67
|
+
|
|
68
|
+
原来是「先把整批从 `_pending` 摘走,再逐个 put」—— 队列一次抖动
|
|
69
|
+
(Redis 断连 / OOM / 网络闪断),剩下的请求既不在 `_pending`、
|
|
70
|
+
也没进队列、也没像 Item 那样 dump 到文件,**静默消失**。
|
|
71
|
+
实测 10 个请求、第 3 个上抖一次:丢 8 个。
|
|
72
|
+
|
|
73
|
+
放回去还有个坑:`dedup.add()` 是在入队**之前**写指纹的,所以重试时
|
|
74
|
+
会被自己刚写下的那条指纹挡掉、当成重复丢弃 —— 等于没救回来。
|
|
75
|
+
因此放回的请求要清掉 `filter_repeat`:它们**已经过过去重**了。
|
|
76
|
+
"""
|
|
77
|
+
with self._lock:
|
|
78
|
+
batch = self._pending
|
|
79
|
+
self._pending = []
|
|
80
|
+
for i, request in enumerate(batch):
|
|
81
|
+
try:
|
|
82
|
+
fresh = self._dedup.add(request.fingerprint) if request.filter_repeat else True
|
|
83
|
+
except Exception:
|
|
84
|
+
# 写指纹也是一次 Redis 调用,和下面的入队一样会抖。
|
|
85
|
+
# 这一条**没写成指纹**,所以放回时要保留 filter_repeat
|
|
86
|
+
self._requeue(batch[i:], first_passed_dedup=False)
|
|
87
|
+
raise
|
|
88
|
+
if not fresh:
|
|
89
|
+
self._stats.incr(stats_keys.DEDUP_DROPPED)
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
self._queue.put(request)
|
|
93
|
+
except Exception:
|
|
94
|
+
# 这一条和它后面的都还没入队,整体放回缓冲区。
|
|
95
|
+
# 这一条**已经写成指纹**了,放回时要清掉 filter_repeat
|
|
96
|
+
self._requeue(batch[i:], first_passed_dedup=True)
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
def _requeue(self, requests: list[Request], *, first_passed_dedup: bool) -> None:
|
|
100
|
+
"""把没能入队的请求放回缓冲区头部,保持原有顺序(优先级靠队列自己排)。
|
|
101
|
+
|
|
102
|
+
只有**第一条**可能已经过了去重 —— 它的指纹写成了、失败发生在入队那一步。
|
|
103
|
+
它必须清掉 `filter_repeat`,否则下一轮会被自己刚写的指纹挡掉(v4.7 修的就是这个)。
|
|
104
|
+
|
|
105
|
+
后面那些根本没走到去重。**它们必须保留 `filter_repeat`** ——
|
|
106
|
+
一起清掉的话,重复 URL 会绕过去重进队列。
|
|
107
|
+
实测:批次里两条相同 URL、`put` 抖一次,同一个 URL 进队列 2 次。
|
|
108
|
+
"""
|
|
109
|
+
if first_passed_dedup and requests:
|
|
110
|
+
requests[0].filter_repeat = False
|
|
111
|
+
with self._lock:
|
|
112
|
+
self._pending[:0] = requests
|
|
113
|
+
|
|
114
|
+
def drain_pending(self) -> list[Request]:
|
|
115
|
+
with self._lock:
|
|
116
|
+
batch = self._pending
|
|
117
|
+
self._pending = []
|
|
118
|
+
return batch
|
|
119
|
+
|
|
120
|
+
def run(self) -> None:
|
|
121
|
+
while not self._stop_event.wait(setting.BUFFER_FLUSH_INTERVAL):
|
|
122
|
+
self._flush_guarded()
|
|
123
|
+
self._flush_guarded()
|
|
124
|
+
|
|
125
|
+
def _flush_guarded(self) -> None:
|
|
126
|
+
"""后台线程里的 flush 必须兜住异常。
|
|
127
|
+
|
|
128
|
+
原来是裸调 `flush()` —— 队列一次抖动就把整个缓冲区线程打死,此后**所有**
|
|
129
|
+
请求都不再进队列,爬虫静默停止发现新页面(实测线程存活 False)。
|
|
130
|
+
|
|
131
|
+
但也不能静默吞掉:不留日志就只是把一种静默换成另一种。请求已经由
|
|
132
|
+
`flush` 放回缓冲区,下一轮自然重试。
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
self.flush()
|
|
136
|
+
except Exception:
|
|
137
|
+
log.exception("请求入队失败,{} 条留在缓冲区等下轮重试", len(self._pending))
|
|
138
|
+
|
|
139
|
+
def stop(self) -> None:
|
|
140
|
+
self._stop_event.set()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""命令行(阶段 05):typer 应用 + create / shell / retry 子命令。
|
|
2
|
+
|
|
3
|
+
需要 ``pip install netspy[cli]``(typer + jinja2)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""``netspy`` 控制台脚本入口。
|
|
11
|
+
|
|
12
|
+
脚本随核心包一起装,但 CLI 依赖只在 ``[cli]`` extra 里。缺依赖时给一句照着做就能
|
|
13
|
+
修好的提示,而不是甩一个 ``ModuleNotFoundError`` 堆栈给刚 ``pip install`` 完的人。
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
from netspy.commands.cmdline import main as _main
|
|
17
|
+
except ModuleNotFoundError as exc: # pragma: no cover - 走到这里说明没装 [cli]
|
|
18
|
+
raise SystemExit(
|
|
19
|
+
f'netspy 命令行需要额外依赖(缺少 "{exc.name}")。\n安装:pip install "netspy[cli]"'
|
|
20
|
+
) from exc
|
|
21
|
+
_main()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""``netspy`` 命令行入口(typer)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from netspy.__about__ import __version__
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
add_completion=False,
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
help="Netspy 爬虫框架命令行",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _version(value: bool) -> None:
|
|
19
|
+
if value:
|
|
20
|
+
typer.echo(f"netspy {__version__}")
|
|
21
|
+
raise typer.Exit
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def _root(
|
|
26
|
+
version: Annotated[
|
|
27
|
+
bool,
|
|
28
|
+
typer.Option("-V", "--version", callback=_version, is_eager=True, help="版本号"),
|
|
29
|
+
] = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def create(
|
|
36
|
+
project: Annotated[str | None, typer.Option("-p", "--project", help="生成项目脚手架")] = None,
|
|
37
|
+
spider: Annotated[str | None, typer.Option("-s", "--spider", help="生成一个 AirSpider")] = None,
|
|
38
|
+
item: Annotated[str | None, typer.Option("-i", "--item", help="生成一个 Item")] = None,
|
|
39
|
+
table: Annotated[
|
|
40
|
+
str | None,
|
|
41
|
+
typer.Option("--table", help="配合 -i:读该 MySQL 表结构反射字段"),
|
|
42
|
+
] = None,
|
|
43
|
+
mysql: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option("--mysql", help="MySQL 连接串 mysql://user:pass@host/db(默认取 setting)"),
|
|
46
|
+
] = None,
|
|
47
|
+
setting_file: Annotated[bool, typer.Option("--setting", help="生成 setting.py")] = False,
|
|
48
|
+
force: Annotated[bool, typer.Option("-f", "--force", help="覆盖已存在文件")] = False,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""生成项目 / 爬虫 / Item / 配置文件。"""
|
|
51
|
+
from netspy.commands import create as gen
|
|
52
|
+
|
|
53
|
+
if project:
|
|
54
|
+
root = gen.create_project(project, force=force)
|
|
55
|
+
typer.echo(f"✓ 项目已生成 → cd {root} && python main.py")
|
|
56
|
+
elif spider:
|
|
57
|
+
typer.echo(f"✓ {gen.create_spider(spider, force=force)}")
|
|
58
|
+
elif item:
|
|
59
|
+
typer.echo(f"✓ {gen.create_item(item, force=force, table=table, mysql=mysql)}")
|
|
60
|
+
elif setting_file:
|
|
61
|
+
typer.echo(f"✓ {gen.create_setting(force=force)}")
|
|
62
|
+
else:
|
|
63
|
+
typer.echo("指定 -p / -s / -i / --setting 之一", err=True)
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def shell(
|
|
69
|
+
url: Annotated[str, typer.Argument(help="要抓取的 URL")],
|
|
70
|
+
render: Annotated[bool, typer.Option("--render", help="用浏览器渲染")] = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""抓一个页面并进入交互式 shell(变量 request / response)。"""
|
|
73
|
+
from netspy.commands.shell import run_shell
|
|
74
|
+
|
|
75
|
+
run_shell(url, render=render)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def retry(
|
|
80
|
+
requests: Annotated[
|
|
81
|
+
bool, typer.Option("--requests", help="回放 failed_requests.jsonl")
|
|
82
|
+
] = False,
|
|
83
|
+
items: Annotated[bool, typer.Option("--items", help="回放 failed_items.jsonl")] = False,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""回放 dump 文件里的失败请求 / 数据(默认两者都回放)。"""
|
|
86
|
+
from netspy.commands.retry import retry_items, retry_requests
|
|
87
|
+
|
|
88
|
+
if not requests and not items:
|
|
89
|
+
requests = items = True
|
|
90
|
+
if items:
|
|
91
|
+
ok, failed = retry_items()
|
|
92
|
+
typer.echo(f"failed_items:成功 {ok},仍失败 {failed}")
|
|
93
|
+
if requests:
|
|
94
|
+
ok, failed = retry_requests()
|
|
95
|
+
typer.echo(f"failed_requests:恢复 {ok},仍失败 {failed}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def cache(
|
|
100
|
+
clear: Annotated[bool, typer.Option("--clear", help="清空响应缓存目录")] = False,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""查看 / 清理响应缓存(`RESPONSE_CACHE_PATH`)。
|
|
103
|
+
|
|
104
|
+
改了解析逻辑想重新抓一遍真实页面时,清一下就行 —— 不必等过期。
|
|
105
|
+
"""
|
|
106
|
+
from pathlib import Path
|
|
107
|
+
|
|
108
|
+
from netspy import setting
|
|
109
|
+
from netspy.network import cache as cache_mod
|
|
110
|
+
|
|
111
|
+
root = Path(setting.RESPONSE_CACHE_PATH)
|
|
112
|
+
if clear:
|
|
113
|
+
typer.echo(f"已清理 {cache_mod.clear()} 条缓存({root})")
|
|
114
|
+
return
|
|
115
|
+
entries = list(root.rglob("*.json")) if root.is_dir() else []
|
|
116
|
+
size = sum(e.stat().st_size for e in entries)
|
|
117
|
+
typer.echo(f"{root}:{len(entries)} 条,{size / 1024 / 1024:.1f}MB")
|
|
118
|
+
if not setting.RESPONSE_CACHE_ENABLE:
|
|
119
|
+
typer.echo("提示:RESPONSE_CACHE_ENABLE 当前是关的,缓存不会被读写")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main() -> None:
|
|
123
|
+
app()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|