mineworker 0.4.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.
- mineworker/__about__.py +3 -0
- mineworker/__init__.py +81 -0
- mineworker/buffer/__init__.py +8 -0
- mineworker/buffer/item_buffer.py +198 -0
- mineworker/buffer/request_buffer.py +77 -0
- mineworker/commands/__init__.py +22 -0
- mineworker/commands/cmdline.py +103 -0
- mineworker/commands/create/__init__.py +183 -0
- mineworker/commands/retry.py +99 -0
- mineworker/commands/shell.py +38 -0
- mineworker/core/__init__.py +5 -0
- mineworker/core/base_parser.py +74 -0
- mineworker/core/base_scheduler.py +223 -0
- mineworker/core/batch_monitor.py +165 -0
- mineworker/core/batch_store.py +364 -0
- mineworker/core/collector.py +55 -0
- mineworker/core/parser_control.py +211 -0
- mineworker/core/redis_scheduler.py +148 -0
- mineworker/core/redis_task_scheduler.py +112 -0
- mineworker/core/scheduler.py +44 -0
- mineworker/core/spiders/__init__.py +10 -0
- mineworker/core/spiders/air_spider.py +50 -0
- mineworker/core/spiders/batch_spider.py +177 -0
- mineworker/core/spiders/spider.py +55 -0
- mineworker/core/spiders/task_spider.py +96 -0
- mineworker/core/task_queue.py +92 -0
- mineworker/core/task_source.py +45 -0
- mineworker/db/__init__.py +7 -0
- mineworker/db/mysqldb.py +105 -0
- mineworker/db/redisdb.py +50 -0
- mineworker/dedup/__init__.py +110 -0
- mineworker/dedup/bloom_filter.py +66 -0
- mineworker/dedup/lite_filter.py +30 -0
- mineworker/dedup/redis_filter.py +71 -0
- mineworker/exceptions.py +43 -0
- mineworker/network/__init__.py +8 -0
- mineworker/network/downloader/__init__.py +69 -0
- mineworker/network/downloader/_async_httpx.py +119 -0
- mineworker/network/downloader/_common.py +53 -0
- mineworker/network/downloader/_httpx.py +88 -0
- mineworker/network/downloader/_playwright.py +233 -0
- mineworker/network/downloader/base.py +33 -0
- mineworker/network/item.py +97 -0
- mineworker/network/middleware.py +70 -0
- mineworker/network/proxy_pool/__init__.py +34 -0
- mineworker/network/proxy_pool/api.py +97 -0
- mineworker/network/proxy_pool/base.py +17 -0
- mineworker/network/request.py +177 -0
- mineworker/network/response.py +157 -0
- mineworker/network/user_agent.py +23 -0
- mineworker/network/user_pool/__init__.py +24 -0
- mineworker/network/user_pool/base.py +37 -0
- mineworker/network/user_pool/local.py +92 -0
- mineworker/network/user_pool/middleware.py +57 -0
- mineworker/network/user_pool/redis.py +82 -0
- mineworker/pipelines/__init__.py +9 -0
- mineworker/pipelines/base.py +19 -0
- mineworker/pipelines/console.py +23 -0
- mineworker/pipelines/csv.py +62 -0
- mineworker/pipelines/mongo.py +56 -0
- mineworker/pipelines/mysql.py +70 -0
- mineworker/py.typed +0 -0
- mineworker/setting.py +236 -0
- mineworker/templates/__init__.py +1 -0
- mineworker/templates/air_spider.py.jinja +23 -0
- mineworker/templates/item.py.jinja +13 -0
- mineworker/templates/item_fields.py.jinja +21 -0
- mineworker/templates/project/README.md.jinja +30 -0
- mineworker/templates/project/main.py.jinja +6 -0
- mineworker/templates/project/spider.py.jinja +21 -0
- mineworker/templates/setting.py.jinja +43 -0
- mineworker/utils/__init__.py +1 -0
- mineworker/utils/alert.py +118 -0
- mineworker/utils/log.py +58 -0
- mineworker/utils/metrics.py +97 -0
- mineworker/utils/stats.py +52 -0
- mineworker/utils/tools.py +93 -0
- mineworker-0.4.0.dist-info/METADATA +157 -0
- mineworker-0.4.0.dist-info/RECORD +82 -0
- mineworker-0.4.0.dist-info/WHEEL +4 -0
- mineworker-0.4.0.dist-info/entry_points.txt +2 -0
- mineworker-0.4.0.dist-info/licenses/LICENSE +21 -0
mineworker/__about__.py
ADDED
mineworker/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""MineWorker —— 一个上手简单、结构清晰的 Python 爬虫框架(对标 feapder)。
|
|
2
|
+
|
|
3
|
+
已提供:
|
|
4
|
+
- `setting` 分层配置(框架默认 ← 项目 setting.py ← 环境变量 MINEWORKER_*)
|
|
5
|
+
- `get_logger` 基于 loguru 的日志
|
|
6
|
+
- 异常层级 MineWorkerError 及其子类
|
|
7
|
+
- `Request` / `Response` 网络层
|
|
8
|
+
- `AirSpider` / `BaseParser` 轻量单机运行时(含浏览器渲染、中间件、代理池、指标、告警)
|
|
9
|
+
- `Spider` Redis 分布式(多进程 / 多机 + 断点续爬,需 mineworker[redis])
|
|
10
|
+
- `TaskSpider` 从任务源(Redis / DB)持续拉任务来爬
|
|
11
|
+
- `BatchSpider` 周期性批次采集(MySQL 任务表 + 批次记录 + 进度 / 防丢)
|
|
12
|
+
- `Item` / `UpdateItem` 结构化数据 + 管道落库 + 去重
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from mineworker import setting
|
|
18
|
+
from mineworker.__about__ import __version__
|
|
19
|
+
from mineworker.core.base_parser import BaseParser
|
|
20
|
+
from mineworker.core.spiders.air_spider import AirSpider
|
|
21
|
+
from mineworker.core.spiders.batch_spider import BatchSpider
|
|
22
|
+
from mineworker.core.spiders.spider import Spider
|
|
23
|
+
from mineworker.core.spiders.task_spider import TaskSpider
|
|
24
|
+
from mineworker.exceptions import (
|
|
25
|
+
ConfigError,
|
|
26
|
+
DedupError,
|
|
27
|
+
ItemError,
|
|
28
|
+
MineWorkerError,
|
|
29
|
+
NotRetryError,
|
|
30
|
+
PipelineError,
|
|
31
|
+
RequestError,
|
|
32
|
+
ResponseError,
|
|
33
|
+
SpiderError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
)
|
|
36
|
+
from mineworker.network.item import Item, UpdateItem
|
|
37
|
+
from mineworker.network.request import Request
|
|
38
|
+
from mineworker.network.response import Response
|
|
39
|
+
from mineworker.network.user_pool import (
|
|
40
|
+
GuestUserPool,
|
|
41
|
+
LocalUserPool,
|
|
42
|
+
RedisUserPool,
|
|
43
|
+
User,
|
|
44
|
+
UserPool,
|
|
45
|
+
)
|
|
46
|
+
from mineworker.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
|
+
"MineWorkerError",
|
|
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,198 @@
|
|
|
1
|
+
"""``ItemBuffer`` —— 收集 parse 产出的数据,批量去重后交给管道落库。
|
|
2
|
+
|
|
3
|
+
流程:``put`` 累积 → 定时 / 满量 ``flush`` → 按 (表, 是否 UpdateItem, 管道) 分组
|
|
4
|
+
→ Item 级去重(fingerprint)→ 逐管道 ``save_items`` / ``update_items``
|
|
5
|
+
→ 成功则写去重指纹;失败则 dump 到 ``FAILED_ITEM_PATH``。
|
|
6
|
+
|
|
7
|
+
给了 ``handler`` 时走调试快路径:直接把原始批次交给 handler,不去重、不落库。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
17
|
+
|
|
18
|
+
from mineworker import setting
|
|
19
|
+
from mineworker.exceptions import ItemError
|
|
20
|
+
from mineworker.network.item import Item, UpdateItem
|
|
21
|
+
from mineworker.utils import stats as sk
|
|
22
|
+
from mineworker.utils import tools
|
|
23
|
+
from mineworker.utils.log import get_logger
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from mineworker.dedup import Dedup
|
|
27
|
+
from mineworker.pipelines.base import BasePipeline
|
|
28
|
+
from mineworker.utils.stats import Stats
|
|
29
|
+
|
|
30
|
+
log = get_logger("item_buffer")
|
|
31
|
+
|
|
32
|
+
ItemHandler = Callable[[list[Any]], None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Norm(NamedTuple):
|
|
36
|
+
table: str
|
|
37
|
+
is_update: bool
|
|
38
|
+
data: dict[str, Any]
|
|
39
|
+
fingerprint: str | None
|
|
40
|
+
update_keys: tuple[str, ...]
|
|
41
|
+
pipelines: tuple[str, ...] | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalize(obj: Any) -> _Norm:
|
|
45
|
+
if isinstance(obj, Item):
|
|
46
|
+
obj.pre_to_db()
|
|
47
|
+
is_update = isinstance(obj, UpdateItem)
|
|
48
|
+
keys = tuple(obj.update_key) if isinstance(obj, UpdateItem) else ()
|
|
49
|
+
fp = obj.fingerprint if setting.ITEM_FILTER_ENABLE else None
|
|
50
|
+
pipes = tuple(obj.pipelines) if obj.pipelines else None
|
|
51
|
+
return _Norm(obj.table_name, is_update, obj.to_dict(), fp, keys, pipes)
|
|
52
|
+
if isinstance(obj, dict):
|
|
53
|
+
return _Norm(setting.ITEM_DEFAULT_TABLE, False, obj, None, (), None)
|
|
54
|
+
raise ItemError(f"无法入库的类型:{type(obj)!r}(需要 Item 或 dict)")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ItemBuffer(threading.Thread):
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
stats: Stats,
|
|
61
|
+
*,
|
|
62
|
+
handler: ItemHandler | None = None,
|
|
63
|
+
pipelines: list[str] | None = None,
|
|
64
|
+
dedup: Dedup | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
super().__init__(name="item-buffer", daemon=True)
|
|
67
|
+
self._stats = stats
|
|
68
|
+
self._handler = handler
|
|
69
|
+
self._pipeline_paths = pipelines
|
|
70
|
+
self._pipeline_cache: dict[tuple[str, ...], list[BasePipeline]] = {}
|
|
71
|
+
self._dedup = dedup
|
|
72
|
+
self._pending: list[Any] = []
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
self._stop_event = threading.Event()
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
def put(self, item: Any) -> None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._pending.append(item)
|
|
80
|
+
size = len(self._pending)
|
|
81
|
+
if size >= setting.ITEM_MAX_CACHED_COUNT:
|
|
82
|
+
self.flush()
|
|
83
|
+
|
|
84
|
+
def is_empty(self) -> bool:
|
|
85
|
+
with self._lock:
|
|
86
|
+
return not self._pending
|
|
87
|
+
|
|
88
|
+
def pending_count(self) -> int:
|
|
89
|
+
with self._lock:
|
|
90
|
+
return len(self._pending)
|
|
91
|
+
|
|
92
|
+
def run(self) -> None:
|
|
93
|
+
while not self._stop_event.wait(setting.BUFFER_FLUSH_INTERVAL):
|
|
94
|
+
self.flush()
|
|
95
|
+
self.flush()
|
|
96
|
+
|
|
97
|
+
def stop(self) -> None:
|
|
98
|
+
self._stop_event.set()
|
|
99
|
+
|
|
100
|
+
def close(self) -> None:
|
|
101
|
+
for pipelines in self._pipeline_cache.values():
|
|
102
|
+
for pipeline in pipelines:
|
|
103
|
+
try:
|
|
104
|
+
pipeline.close()
|
|
105
|
+
except Exception:
|
|
106
|
+
log.exception("管道 {} close 异常", type(pipeline).__name__)
|
|
107
|
+
self._pipeline_cache.clear()
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
def flush(self) -> None:
|
|
111
|
+
with self._lock:
|
|
112
|
+
batch = self._pending
|
|
113
|
+
self._pending = []
|
|
114
|
+
if not batch:
|
|
115
|
+
return
|
|
116
|
+
if self._handler is not None:
|
|
117
|
+
self._handler(batch)
|
|
118
|
+
self._stats.incr(sk.ITEM, len(batch))
|
|
119
|
+
return
|
|
120
|
+
self._persist(batch)
|
|
121
|
+
|
|
122
|
+
def _persist(self, batch: list[Any]) -> None:
|
|
123
|
+
dedup = self._get_dedup()
|
|
124
|
+
seen: set[str] = set()
|
|
125
|
+
groups: dict[tuple[str, bool, tuple[str, ...] | None], list[_Norm]] = defaultdict(list)
|
|
126
|
+
for obj in batch:
|
|
127
|
+
norm = _normalize(obj)
|
|
128
|
+
if norm.fingerprint is not None and dedup is not None:
|
|
129
|
+
if norm.fingerprint in seen or dedup.get(norm.fingerprint):
|
|
130
|
+
self._stats.incr(sk.ITEM_DEDUP_DROPPED)
|
|
131
|
+
continue
|
|
132
|
+
seen.add(norm.fingerprint)
|
|
133
|
+
groups[(norm.table, norm.is_update, norm.pipelines)].append(norm)
|
|
134
|
+
|
|
135
|
+
for (table, is_update, pipe_paths), rows in groups.items():
|
|
136
|
+
datas = [row.data for row in rows]
|
|
137
|
+
update_keys = list(rows[0].update_keys)
|
|
138
|
+
pipelines = self._resolve_pipelines(pipe_paths)
|
|
139
|
+
ok = all(
|
|
140
|
+
self._write(pipeline, table, is_update, datas, update_keys)
|
|
141
|
+
for pipeline in pipelines
|
|
142
|
+
)
|
|
143
|
+
if ok:
|
|
144
|
+
self._stats.incr(sk.ITEM, len(datas))
|
|
145
|
+
if dedup is not None:
|
|
146
|
+
for row in rows:
|
|
147
|
+
if row.fingerprint is not None:
|
|
148
|
+
dedup.add(row.fingerprint)
|
|
149
|
+
else:
|
|
150
|
+
self._dump_failed(table, datas)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
def _get_dedup(self) -> Dedup | None:
|
|
154
|
+
if not setting.ITEM_FILTER_ENABLE:
|
|
155
|
+
return None
|
|
156
|
+
if self._dedup is None:
|
|
157
|
+
from mineworker.dedup import get_item_filter
|
|
158
|
+
|
|
159
|
+
self._dedup = get_item_filter()
|
|
160
|
+
return self._dedup
|
|
161
|
+
|
|
162
|
+
def _resolve_pipelines(self, paths: tuple[str, ...] | None) -> list[BasePipeline]:
|
|
163
|
+
key = (
|
|
164
|
+
paths
|
|
165
|
+
if paths is not None
|
|
166
|
+
else tuple(
|
|
167
|
+
self._pipeline_paths if self._pipeline_paths is not None else setting.ITEM_PIPELINES
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
cached = self._pipeline_cache.get(key)
|
|
171
|
+
if cached is None:
|
|
172
|
+
cached = [tools.load_object(path)() for path in key]
|
|
173
|
+
self._pipeline_cache[key] = cached
|
|
174
|
+
return cached
|
|
175
|
+
|
|
176
|
+
def _write(
|
|
177
|
+
self,
|
|
178
|
+
pipeline: BasePipeline,
|
|
179
|
+
table: str,
|
|
180
|
+
is_update: bool,
|
|
181
|
+
datas: list[dict[str, Any]],
|
|
182
|
+
update_keys: list[str],
|
|
183
|
+
) -> bool:
|
|
184
|
+
try:
|
|
185
|
+
if is_update:
|
|
186
|
+
return pipeline.update_items(table, datas, update_keys)
|
|
187
|
+
return pipeline.save_items(table, datas)
|
|
188
|
+
except Exception:
|
|
189
|
+
log.exception("管道 {} 写入异常", type(pipeline).__name__)
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
def _dump_failed(self, table: str, datas: list[dict[str, Any]]) -> None:
|
|
193
|
+
path = Path(setting.FAILED_ITEM_PATH)
|
|
194
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
195
|
+
for data in datas:
|
|
196
|
+
fh.write(tools.dumps_json({"table": table, "data": data}) + "\n")
|
|
197
|
+
self._stats.incr(sk.ITEM_FAILED, len(datas))
|
|
198
|
+
log.error("[{}] {} 条数据写入失败,已 dump 到 {}", table, len(datas), path)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""``RequestBuffer`` —— 收集 yield 出的 Request,去重后批量写入任务队列。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from mineworker import setting
|
|
9
|
+
from mineworker.dedup import get_request_filter
|
|
10
|
+
from mineworker.utils import stats as stats_keys
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from mineworker.dedup import Filter
|
|
14
|
+
from mineworker.network.request import Request
|
|
15
|
+
from mineworker.utils.stats import Stats
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RequestBuffer(threading.Thread):
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
task_queue: Any,
|
|
22
|
+
stats: Stats,
|
|
23
|
+
*,
|
|
24
|
+
dedup: Filter | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(name="request-buffer", daemon=True)
|
|
27
|
+
self._queue = task_queue
|
|
28
|
+
self._stats = stats
|
|
29
|
+
self._dedup = dedup if dedup is not None else get_request_filter()
|
|
30
|
+
self._pending: list[Request] = []
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
self._stop_event = threading.Event()
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
def put(self, request: Request) -> None:
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._pending.append(request)
|
|
38
|
+
if len(self._pending) >= setting.REQUEST_BUFFER_MAX_CACHED:
|
|
39
|
+
self.flush()
|
|
40
|
+
|
|
41
|
+
def put_retry(self, request: Request) -> None:
|
|
42
|
+
"""重试请求:跳过去重再次入队。"""
|
|
43
|
+
request.filter_repeat = False
|
|
44
|
+
self.put(request)
|
|
45
|
+
|
|
46
|
+
def is_empty(self) -> bool:
|
|
47
|
+
with self._lock:
|
|
48
|
+
return not self._pending
|
|
49
|
+
|
|
50
|
+
def pending_count(self) -> int:
|
|
51
|
+
with self._lock:
|
|
52
|
+
return len(self._pending)
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
def flush(self) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
batch = self._pending
|
|
58
|
+
self._pending = []
|
|
59
|
+
for request in batch:
|
|
60
|
+
if request.filter_repeat and not self._dedup.add(request.fingerprint):
|
|
61
|
+
self._stats.incr(stats_keys.DEDUP_DROPPED)
|
|
62
|
+
continue
|
|
63
|
+
self._queue.put(request)
|
|
64
|
+
|
|
65
|
+
def drain_pending(self) -> list[Request]:
|
|
66
|
+
with self._lock:
|
|
67
|
+
batch = self._pending
|
|
68
|
+
self._pending = []
|
|
69
|
+
return batch
|
|
70
|
+
|
|
71
|
+
def run(self) -> None:
|
|
72
|
+
while not self._stop_event.wait(setting.BUFFER_FLUSH_INTERVAL):
|
|
73
|
+
self.flush()
|
|
74
|
+
self.flush()
|
|
75
|
+
|
|
76
|
+
def stop(self) -> None:
|
|
77
|
+
self._stop_event.set()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""命令行(阶段 05):typer 应用 + create / shell / retry 子命令。
|
|
2
|
+
|
|
3
|
+
需要 ``pip install mineworker[cli]``(typer + jinja2)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""``mineworker`` 控制台脚本入口。
|
|
11
|
+
|
|
12
|
+
脚本随核心包一起装,但 CLI 依赖只在 ``[cli]`` extra 里。缺依赖时给一句照着做就能
|
|
13
|
+
修好的提示,而不是甩一个 ``ModuleNotFoundError`` 堆栈给刚 ``pip install`` 完的人。
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
from mineworker.commands.cmdline import main as _main
|
|
17
|
+
except ModuleNotFoundError as exc: # pragma: no cover - 走到这里说明没装 [cli]
|
|
18
|
+
raise SystemExit(
|
|
19
|
+
f'mineworker 命令行需要额外依赖(缺少 "{exc.name}")。\n'
|
|
20
|
+
'安装:pip install "mineworker[cli]"'
|
|
21
|
+
) from exc
|
|
22
|
+
_main()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""``mineworker`` 命令行入口(typer)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from mineworker.__about__ import __version__
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
add_completion=False,
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
help="MineWorker 爬虫框架命令行",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _version(value: bool) -> None:
|
|
19
|
+
if value:
|
|
20
|
+
typer.echo(f"mineworker {__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 mineworker.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 mineworker.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 mineworker.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
|
+
def main() -> None:
|
|
99
|
+
app()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""脚手架生成:项目 / 爬虫 / Item / setting(`mineworker create`)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from jinja2 import Environment, StrictUndefined
|
|
11
|
+
|
|
12
|
+
from mineworker.utils.log import get_logger
|
|
13
|
+
|
|
14
|
+
log = get_logger("create")
|
|
15
|
+
|
|
16
|
+
_env = Environment( # 生成 .py 文件而非 HTML,无需 autoescape
|
|
17
|
+
undefined=StrictUndefined,
|
|
18
|
+
keep_trailing_newline=True,
|
|
19
|
+
autoescape=False,
|
|
20
|
+
)
|
|
21
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_snake(name: str) -> str:
|
|
25
|
+
name = re.sub(r"[\s-]+", "_", name.strip())
|
|
26
|
+
return _CAMEL_BOUNDARY.sub("_", name).lower().strip("_")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_camel(name: str) -> str:
|
|
30
|
+
parts = re.split(r"[\s_-]+", name.strip())
|
|
31
|
+
if len(parts) == 1 and not parts[0].islower():
|
|
32
|
+
return parts[0][:1].upper() + parts[0][1:]
|
|
33
|
+
return "".join(p[:1].upper() + p[1:] for p in parts if p)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _render(relpath: str, /, **ctx: Any) -> str:
|
|
37
|
+
source = (
|
|
38
|
+
importlib.resources.files("mineworker.templates")
|
|
39
|
+
.joinpath(relpath)
|
|
40
|
+
.read_text(encoding="utf-8")
|
|
41
|
+
)
|
|
42
|
+
return _env.from_string(source).render(**ctx)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write(path: Path, content: str, *, force: bool) -> None:
|
|
46
|
+
if path.exists() and not force:
|
|
47
|
+
raise FileExistsError(f"{path} 已存在(加 --force 覆盖)")
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
path.write_text(content, encoding="utf-8")
|
|
50
|
+
log.info("生成 {}", path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _spider_names(name: str) -> tuple[str, str]:
|
|
54
|
+
class_name = _to_camel(name)
|
|
55
|
+
if not class_name.endswith("Spider"):
|
|
56
|
+
class_name += "Spider"
|
|
57
|
+
return class_name, _to_snake(class_name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _item_names(name: str) -> tuple[str, str, str]:
|
|
61
|
+
class_name = _to_camel(name)
|
|
62
|
+
if not class_name.endswith("Item"):
|
|
63
|
+
class_name += "Item"
|
|
64
|
+
module = _to_snake(class_name)
|
|
65
|
+
table = re.sub(r"_item$", "", module) or module
|
|
66
|
+
return class_name, module, table
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ----------------------------------------------------------------------
|
|
70
|
+
def create_spider(name: str, *, target_dir: Path | None = None, force: bool = False) -> Path:
|
|
71
|
+
class_name, module = _spider_names(name)
|
|
72
|
+
_, _, table = _item_names(name)
|
|
73
|
+
path = (target_dir or Path()) / f"{module}.py"
|
|
74
|
+
_write(
|
|
75
|
+
path,
|
|
76
|
+
_render("air_spider.py.jinja", class_name=class_name, table_name=table),
|
|
77
|
+
force=force,
|
|
78
|
+
)
|
|
79
|
+
return path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_item(
|
|
83
|
+
name: str,
|
|
84
|
+
*,
|
|
85
|
+
target_dir: Path | None = None,
|
|
86
|
+
force: bool = False,
|
|
87
|
+
table: str | None = None,
|
|
88
|
+
mysql: Any = None,
|
|
89
|
+
) -> Path:
|
|
90
|
+
class_name, module, derived_table = _item_names(name)
|
|
91
|
+
path = (target_dir or Path()) / f"{module}.py"
|
|
92
|
+
|
|
93
|
+
if table:
|
|
94
|
+
db = _open_mysql(mysql)
|
|
95
|
+
try:
|
|
96
|
+
fields, unique = _reflect_table(db, table)
|
|
97
|
+
finally:
|
|
98
|
+
if mysql is None or isinstance(mysql, str):
|
|
99
|
+
db.close()
|
|
100
|
+
content = _render(
|
|
101
|
+
"item_fields.py.jinja",
|
|
102
|
+
class_name=class_name,
|
|
103
|
+
table_name=table,
|
|
104
|
+
fields=fields,
|
|
105
|
+
unique_key=repr(unique),
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
content = _render("item.py.jinja", class_name=class_name, table_name=derived_table)
|
|
109
|
+
|
|
110
|
+
_write(path, content, force=force)
|
|
111
|
+
return path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _open_mysql(mysql: Any) -> Any:
|
|
115
|
+
from mineworker.db.mysqldb import MysqlDB
|
|
116
|
+
|
|
117
|
+
if mysql is None:
|
|
118
|
+
return MysqlDB()
|
|
119
|
+
if isinstance(mysql, str):
|
|
120
|
+
return MysqlDB.from_url(mysql)
|
|
121
|
+
return mysql # 已经是一个 db 对象(测试注入)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _reflect_table(db: Any, table: str) -> tuple[list[tuple[str, str]], list[str]]:
|
|
125
|
+
"""读 ``SHOW FULL COLUMNS`` 返回 ``([(字段名, 注释), ...], [主键字段, ...])``。"""
|
|
126
|
+
rows = db.query(f"SHOW FULL COLUMNS FROM `{table}`")
|
|
127
|
+
if not rows:
|
|
128
|
+
raise ValueError(f"表 {table!r} 不存在或没有列")
|
|
129
|
+
fields = [(r["Field"], str(r.get("Comment") or "").replace("\n", " ").strip()) for r in rows]
|
|
130
|
+
unique = [r["Field"] for r in rows if str(r.get("Key")) == "PRI"]
|
|
131
|
+
return fields, unique
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create_setting(*, target_dir: Path | None = None, force: bool = False) -> Path:
|
|
135
|
+
path = (target_dir or Path()) / "setting.py"
|
|
136
|
+
_write(path, _render("setting.py.jinja", project_name="mineworker"), force=force)
|
|
137
|
+
return path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_project(name: str, *, force: bool = False) -> Path:
|
|
141
|
+
root = Path(name)
|
|
142
|
+
if root.exists() and any(root.iterdir()) and not force:
|
|
143
|
+
raise FileExistsError(f"{root} 已存在且非空(加 --force 覆盖)")
|
|
144
|
+
|
|
145
|
+
project = _to_snake(name)
|
|
146
|
+
spider_class, spider_module = _spider_names(name)
|
|
147
|
+
|
|
148
|
+
_write(
|
|
149
|
+
root / "main.py",
|
|
150
|
+
_render(
|
|
151
|
+
"project/main.py.jinja",
|
|
152
|
+
project_name=project,
|
|
153
|
+
spider_module=spider_module,
|
|
154
|
+
spider_class=spider_class,
|
|
155
|
+
),
|
|
156
|
+
force=True,
|
|
157
|
+
)
|
|
158
|
+
_write(
|
|
159
|
+
root / "setting.py",
|
|
160
|
+
_render("setting.py.jinja", project_name=project),
|
|
161
|
+
force=True,
|
|
162
|
+
)
|
|
163
|
+
_write(
|
|
164
|
+
root / "README.md",
|
|
165
|
+
_render(
|
|
166
|
+
"project/README.md.jinja",
|
|
167
|
+
project_name=project,
|
|
168
|
+
spider_module=spider_module,
|
|
169
|
+
),
|
|
170
|
+
force=True,
|
|
171
|
+
)
|
|
172
|
+
_write(root / "spiders" / "__init__.py", "", force=True)
|
|
173
|
+
_write(
|
|
174
|
+
root / "spiders" / f"{spider_module}.py",
|
|
175
|
+
_render(
|
|
176
|
+
"project/spider.py.jinja",
|
|
177
|
+
project_name=project,
|
|
178
|
+
spider_class=spider_class,
|
|
179
|
+
),
|
|
180
|
+
force=True,
|
|
181
|
+
)
|
|
182
|
+
_write(root / "items" / "__init__.py", "", force=True)
|
|
183
|
+
return root
|