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.
Files changed (82) hide show
  1. mineworker/__about__.py +3 -0
  2. mineworker/__init__.py +81 -0
  3. mineworker/buffer/__init__.py +8 -0
  4. mineworker/buffer/item_buffer.py +198 -0
  5. mineworker/buffer/request_buffer.py +77 -0
  6. mineworker/commands/__init__.py +22 -0
  7. mineworker/commands/cmdline.py +103 -0
  8. mineworker/commands/create/__init__.py +183 -0
  9. mineworker/commands/retry.py +99 -0
  10. mineworker/commands/shell.py +38 -0
  11. mineworker/core/__init__.py +5 -0
  12. mineworker/core/base_parser.py +74 -0
  13. mineworker/core/base_scheduler.py +223 -0
  14. mineworker/core/batch_monitor.py +165 -0
  15. mineworker/core/batch_store.py +364 -0
  16. mineworker/core/collector.py +55 -0
  17. mineworker/core/parser_control.py +211 -0
  18. mineworker/core/redis_scheduler.py +148 -0
  19. mineworker/core/redis_task_scheduler.py +112 -0
  20. mineworker/core/scheduler.py +44 -0
  21. mineworker/core/spiders/__init__.py +10 -0
  22. mineworker/core/spiders/air_spider.py +50 -0
  23. mineworker/core/spiders/batch_spider.py +177 -0
  24. mineworker/core/spiders/spider.py +55 -0
  25. mineworker/core/spiders/task_spider.py +96 -0
  26. mineworker/core/task_queue.py +92 -0
  27. mineworker/core/task_source.py +45 -0
  28. mineworker/db/__init__.py +7 -0
  29. mineworker/db/mysqldb.py +105 -0
  30. mineworker/db/redisdb.py +50 -0
  31. mineworker/dedup/__init__.py +110 -0
  32. mineworker/dedup/bloom_filter.py +66 -0
  33. mineworker/dedup/lite_filter.py +30 -0
  34. mineworker/dedup/redis_filter.py +71 -0
  35. mineworker/exceptions.py +43 -0
  36. mineworker/network/__init__.py +8 -0
  37. mineworker/network/downloader/__init__.py +69 -0
  38. mineworker/network/downloader/_async_httpx.py +119 -0
  39. mineworker/network/downloader/_common.py +53 -0
  40. mineworker/network/downloader/_httpx.py +88 -0
  41. mineworker/network/downloader/_playwright.py +233 -0
  42. mineworker/network/downloader/base.py +33 -0
  43. mineworker/network/item.py +97 -0
  44. mineworker/network/middleware.py +70 -0
  45. mineworker/network/proxy_pool/__init__.py +34 -0
  46. mineworker/network/proxy_pool/api.py +97 -0
  47. mineworker/network/proxy_pool/base.py +17 -0
  48. mineworker/network/request.py +177 -0
  49. mineworker/network/response.py +157 -0
  50. mineworker/network/user_agent.py +23 -0
  51. mineworker/network/user_pool/__init__.py +24 -0
  52. mineworker/network/user_pool/base.py +37 -0
  53. mineworker/network/user_pool/local.py +92 -0
  54. mineworker/network/user_pool/middleware.py +57 -0
  55. mineworker/network/user_pool/redis.py +82 -0
  56. mineworker/pipelines/__init__.py +9 -0
  57. mineworker/pipelines/base.py +19 -0
  58. mineworker/pipelines/console.py +23 -0
  59. mineworker/pipelines/csv.py +62 -0
  60. mineworker/pipelines/mongo.py +56 -0
  61. mineworker/pipelines/mysql.py +70 -0
  62. mineworker/py.typed +0 -0
  63. mineworker/setting.py +236 -0
  64. mineworker/templates/__init__.py +1 -0
  65. mineworker/templates/air_spider.py.jinja +23 -0
  66. mineworker/templates/item.py.jinja +13 -0
  67. mineworker/templates/item_fields.py.jinja +21 -0
  68. mineworker/templates/project/README.md.jinja +30 -0
  69. mineworker/templates/project/main.py.jinja +6 -0
  70. mineworker/templates/project/spider.py.jinja +21 -0
  71. mineworker/templates/setting.py.jinja +43 -0
  72. mineworker/utils/__init__.py +1 -0
  73. mineworker/utils/alert.py +118 -0
  74. mineworker/utils/log.py +58 -0
  75. mineworker/utils/metrics.py +97 -0
  76. mineworker/utils/stats.py +52 -0
  77. mineworker/utils/tools.py +93 -0
  78. mineworker-0.4.0.dist-info/METADATA +157 -0
  79. mineworker-0.4.0.dist-info/RECORD +82 -0
  80. mineworker-0.4.0.dist-info/WHEEL +4 -0
  81. mineworker-0.4.0.dist-info/entry_points.txt +2 -0
  82. mineworker-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """单一版本号来源(hatchling 从此处读取,`mineworker.__version__` 亦转发于此)。"""
2
+
3
+ __version__ = "0.4.0"
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,8 @@
1
+ """RequestBuffer / ItemBuffer(阶段 02–03):去重、批量入队、管道分发。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mineworker.buffer.item_buffer import ItemBuffer
6
+ from mineworker.buffer.request_buffer import RequestBuffer
7
+
8
+ __all__ = ["ItemBuffer", "RequestBuffer"]
@@ -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