qcrawler 0.4.3__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.
- qcrawl/__init__.py +31 -0
- qcrawl/cli.py +461 -0
- qcrawl/context.py +53 -0
- qcrawl/datalayer/__init__.py +19 -0
- qcrawl/datalayer/database.py +172 -0
- qcrawl/datalayer/minio_client.py +349 -0
- qcrawl/datalayer/report_item.py +95 -0
- qcrawl/datalayer/reporter.py +352 -0
- qcrawl/debug.py +184 -0
- qcrawl/downloader/__init__.py +6 -0
- qcrawl/downloader/base.py +21 -0
- qcrawl/downloader/curl.py +124 -0
- qcrawl/downloader/request_go.py +129 -0
- qcrawl/downloader/requests_downloader.py +126 -0
- qcrawl/engine.py +488 -0
- qcrawl/enums.py +25 -0
- qcrawl/exceptions.py +33 -0
- qcrawl/middlewares/__init__.py +7 -0
- qcrawl/middlewares/base.py +47 -0
- qcrawl/middlewares/cookie.py +154 -0
- qcrawl/middlewares/log.py +71 -0
- qcrawl/middlewares/proxy.py +220 -0
- qcrawl/middlewares/ratelimit.py +149 -0
- qcrawl/middlewares/redirect.py +95 -0
- qcrawl/middlewares/retry.py +57 -0
- qcrawl/middlewares/timeout.py +34 -0
- qcrawl/middlewares/useragent.py +42 -0
- qcrawl/process_manager.py +207 -0
- qcrawl/request.py +34 -0
- qcrawl/request_item.py +22 -0
- qcrawl/response.py +69 -0
- qcrawl/scheduler.py +197 -0
- qcrawl/seed_queue.py +123 -0
- qcrawl/selector.py +22 -0
- qcrawl/settings.py +143 -0
- qcrawl/spider.py +245 -0
- qcrawl/utils/__init__.py +1 -0
- qcrawl/utils/fingerprint.py +29 -0
- qcrawl/utils/import_helper.py +33 -0
- qcrawl/utils/lock.py +137 -0
- qcrawl/utils/log.py +56 -0
- qcrawl/utils/metrics.py +221 -0
- qcrawl/utils/network.py +15 -0
- qcrawler-0.4.3.dist-info/METADATA +337 -0
- qcrawler-0.4.3.dist-info/RECORD +48 -0
- qcrawler-0.4.3.dist-info/WHEEL +5 -0
- qcrawler-0.4.3.dist-info/entry_points.txt +2 -0
- qcrawler-0.4.3.dist-info/top_level.txt +1 -0
qcrawl/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QCrawl - 分布式 Python 爬虫框架
|
|
3
|
+
|
|
4
|
+
用法:
|
|
5
|
+
from qcrawl import QSpider, Request, ReportItem, send_seed
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from qcrawl.datalayer.report_item import ReportItem
|
|
9
|
+
from qcrawl.debug import send_seed
|
|
10
|
+
from qcrawl.engine import Engine
|
|
11
|
+
from qcrawl.enums import DataType, ProjectId
|
|
12
|
+
from qcrawl.request import Request
|
|
13
|
+
from qcrawl.request_item import RequestItem
|
|
14
|
+
from qcrawl.response import Response
|
|
15
|
+
from qcrawl.settings import Settings
|
|
16
|
+
from qcrawl.spider import QSpider
|
|
17
|
+
|
|
18
|
+
__version__ = "0.4.3"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"QSpider",
|
|
22
|
+
"Request",
|
|
23
|
+
"RequestItem",
|
|
24
|
+
"Response",
|
|
25
|
+
"ReportItem",
|
|
26
|
+
"Engine",
|
|
27
|
+
"Settings",
|
|
28
|
+
"send_seed",
|
|
29
|
+
"DataType",
|
|
30
|
+
"ProjectId",
|
|
31
|
+
]
|
qcrawl/cli.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QCrawl CLI 启动入口
|
|
3
|
+
|
|
4
|
+
安装后直接使用:
|
|
5
|
+
qcrawl -s spiders.ithome.ithome_spider.IthomeSpider --env dev
|
|
6
|
+
|
|
7
|
+
或以模块方式:
|
|
8
|
+
python -m qcrawl.cli -s spiders.ithome.ithome_spider.IthomeSpider --env dev
|
|
9
|
+
|
|
10
|
+
选项:
|
|
11
|
+
-s, --spider 爬虫类路径(必填),如 spiders.ithome.ithome_spider.IthomeSpider
|
|
12
|
+
--env 运行环境: dev | prod(默认 dev)
|
|
13
|
+
--project_id 项目 ID(必填,标识所属项目)
|
|
14
|
+
--processes 工作进程数(默认 1)
|
|
15
|
+
--threads 每进程并发线程数(默认 4)
|
|
16
|
+
--proxy_type 代理模式: off | static | api | redis(默认 off)
|
|
17
|
+
--proxy_url 代理 API 地址 / Redis 连接串
|
|
18
|
+
--timeout 请求超时秒数(默认 15)
|
|
19
|
+
--retry 重试次数(默认 3)
|
|
20
|
+
--delay 请求间隔秒数(默认 0)
|
|
21
|
+
--http_client HTTP 引擎: requests | curl | request-go(默认 requests)
|
|
22
|
+
--log_level 日志级别(默认 INFO)
|
|
23
|
+
--redis_url Redis 连接串(种子队列 + 去重 + 代理配置)
|
|
24
|
+
--db_url PostgreSQL 连接串(数据写入)
|
|
25
|
+
--seeds_file 种子文件路径(JSON/JSONL,启动时批量 LPUSH 到 Redis)
|
|
26
|
+
--settings 配置文件路径(默认 ./settings.yaml)
|
|
27
|
+
--local 本地模式(无需 Redis/PG,使用内存种子)
|
|
28
|
+
--migrate 仅执行表结构迁移(ALTER TABLE)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
from loguru import logger
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_args():
|
|
39
|
+
parser = argparse.ArgumentParser(description="QCrawl 爬虫框架启动器")
|
|
40
|
+
parser.add_argument("-s", "--spider", required=True, help="爬虫类路径")
|
|
41
|
+
parser.add_argument("--env", default="dev", choices=["dev", "prod"], help="运行环境")
|
|
42
|
+
parser.add_argument("--project_id", default="", help="项目 ID")
|
|
43
|
+
parser.add_argument("--processes", type=int, default=None, help="工作进程数")
|
|
44
|
+
parser.add_argument("--threads", type=int, default=None, help="每进程并发线程数")
|
|
45
|
+
parser.add_argument("--proxy_type", default=None, help="代理模式")
|
|
46
|
+
parser.add_argument("--proxy_url", default=None, help="代理地址")
|
|
47
|
+
parser.add_argument("--timeout", type=int, default=None, help="请求超时秒数")
|
|
48
|
+
parser.add_argument("--retry", type=int, default=None, help="重试次数")
|
|
49
|
+
parser.add_argument("--delay", type=float, default=None, help="请求间隔秒数")
|
|
50
|
+
parser.add_argument("--http_client", default=None, help="HTTP 引擎")
|
|
51
|
+
parser.add_argument("--log_level", default=None, help="日志级别")
|
|
52
|
+
parser.add_argument("--redis_url", default=None, help="Redis 连接串")
|
|
53
|
+
parser.add_argument("--db_url", default=None, help="PostgreSQL 连接串")
|
|
54
|
+
parser.add_argument("--seeds_file", default=None, help="种子文件路径 (JSON/JSONL)")
|
|
55
|
+
parser.add_argument("--settings", default="settings.yaml", help="配置文件路径")
|
|
56
|
+
parser.add_argument("--local", action="store_true", help="本地模式(无需 Redis/PG)")
|
|
57
|
+
parser.add_argument("--migrate", action="store_true", help="仅执行表结构迁移")
|
|
58
|
+
return parser.parse_args()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main():
|
|
62
|
+
# 将当前工作目录加入 sys.path,确保能导入用户的 spiders 模块
|
|
63
|
+
cwd = os.getcwd()
|
|
64
|
+
if cwd not in sys.path:
|
|
65
|
+
sys.path.insert(0, cwd)
|
|
66
|
+
|
|
67
|
+
args = parse_args()
|
|
68
|
+
|
|
69
|
+
# 1. 确定环境
|
|
70
|
+
env = args.env
|
|
71
|
+
|
|
72
|
+
# 2. 初始化日志
|
|
73
|
+
from qcrawl.utils.log import setup_logger
|
|
74
|
+
setup_logger(env=env, level=args.log_level)
|
|
75
|
+
|
|
76
|
+
# 3. 加载配置(四级优先级)
|
|
77
|
+
from qcrawl.settings import Settings
|
|
78
|
+
settings = Settings(env=env)
|
|
79
|
+
settings.load_defaults() # 第 4 级
|
|
80
|
+
settings.load_yaml(args.settings) # 第 3 级
|
|
81
|
+
|
|
82
|
+
# 4. 动态导入爬虫类
|
|
83
|
+
from qcrawl.utils.import_helper import import_class
|
|
84
|
+
spider_class = import_class(args.spider)
|
|
85
|
+
|
|
86
|
+
settings.load_spider(spider_class) # 第 2 级
|
|
87
|
+
|
|
88
|
+
# 命令行参数(第 1 级)
|
|
89
|
+
cli_args = {
|
|
90
|
+
"project_id": args.project_id,
|
|
91
|
+
"processes": args.processes,
|
|
92
|
+
"threads": args.threads,
|
|
93
|
+
"proxy_type": args.proxy_type,
|
|
94
|
+
"proxy_url": args.proxy_url,
|
|
95
|
+
"timeout": args.timeout,
|
|
96
|
+
"retry_times": args.retry,
|
|
97
|
+
"delay": args.delay,
|
|
98
|
+
"http_client": args.http_client,
|
|
99
|
+
"log_level": args.log_level,
|
|
100
|
+
"redis_url": args.redis_url,
|
|
101
|
+
"db_url": args.db_url,
|
|
102
|
+
}
|
|
103
|
+
settings.load_cli(cli_args)
|
|
104
|
+
|
|
105
|
+
# 5. 获取 pod_ip
|
|
106
|
+
from qcrawl.utils.network import get_pod_ip
|
|
107
|
+
pod_ip = get_pod_ip()
|
|
108
|
+
settings._data["pod_ip"] = pod_ip
|
|
109
|
+
|
|
110
|
+
# 6. 判断运行模式
|
|
111
|
+
num_processes = settings.get("processes", 1)
|
|
112
|
+
|
|
113
|
+
if args.migrate:
|
|
114
|
+
_run_migrate(settings, spider_class)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if args.seeds_file:
|
|
118
|
+
_import_seeds_file(settings, args.seeds_file, spider_class)
|
|
119
|
+
|
|
120
|
+
if args.local:
|
|
121
|
+
# 本地模式:无需 Redis/PG,使用内存种子
|
|
122
|
+
_run_local(settings, spider_class)
|
|
123
|
+
elif num_processes > 1:
|
|
124
|
+
# 多进程模式
|
|
125
|
+
_run_multiprocess(settings, spider_class, num_processes)
|
|
126
|
+
else:
|
|
127
|
+
# 单进程模式(直接在工作进程中运行)
|
|
128
|
+
_run_worker(settings, spider_class)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _run_local(settings, spider_class):
|
|
132
|
+
"""本地模式:无需 Redis/PG,使用内存种子"""
|
|
133
|
+
from qcrawl.engine import Engine
|
|
134
|
+
|
|
135
|
+
seeds = [{"seed_id": "cli-local-001"}]
|
|
136
|
+
engine = Engine(settings)
|
|
137
|
+
engine.add_crawl(spider_class, seeds=seeds)
|
|
138
|
+
|
|
139
|
+
spider_name = getattr(spider_class, "name", "") or spider_class.__name__
|
|
140
|
+
logger.info(
|
|
141
|
+
"🕷️ QCrawl 本地模式启动: spider={spider}, env={env}, threads={threads}",
|
|
142
|
+
spider=spider_name,
|
|
143
|
+
env=settings.get("env"),
|
|
144
|
+
threads=settings.get("threads"),
|
|
145
|
+
)
|
|
146
|
+
engine.start()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _run_multiprocess(settings, spider_class, num_processes: int):
|
|
150
|
+
"""多进程模式:ProcessManager fork/spawn 工作进程"""
|
|
151
|
+
from qcrawl.process_manager import ProcessManager
|
|
152
|
+
|
|
153
|
+
pm = ProcessManager(
|
|
154
|
+
num_processes=num_processes,
|
|
155
|
+
worker_func=_worker_entry,
|
|
156
|
+
worker_args=(settings, spider_class),
|
|
157
|
+
max_restart=3,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
logger.info(
|
|
161
|
+
"🕷️ QCrawl 多进程启动: spider={spider}, processes={procs}, threads={threads}",
|
|
162
|
+
spider=settings.get("project_id"),
|
|
163
|
+
procs=num_processes,
|
|
164
|
+
threads=settings.get("threads"),
|
|
165
|
+
)
|
|
166
|
+
pm.start()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _worker_entry(settings, spider_class):
|
|
170
|
+
"""工作进程入口(模块级函数,spawn 模式可 pickle)"""
|
|
171
|
+
_run_worker(settings, spider_class)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _run_worker(settings, spider_class):
|
|
175
|
+
"""
|
|
176
|
+
工作进程/单进程入口:
|
|
177
|
+
1. 连接 Redis(种子队列 + 去重)
|
|
178
|
+
2. 连接 PG(数据写入)
|
|
179
|
+
3. 初始化 Engine + Scheduler + Reporter + Metrics
|
|
180
|
+
4. engine.start()
|
|
181
|
+
"""
|
|
182
|
+
from qcrawl.engine import Engine
|
|
183
|
+
from qcrawl.scheduler import Scheduler
|
|
184
|
+
|
|
185
|
+
redis_url = settings.get("redis_url", "")
|
|
186
|
+
db_url = settings.get("db_url", "")
|
|
187
|
+
project_id = settings.get("project_id", "")
|
|
188
|
+
pod_ip = settings.get("pod_ip", "")
|
|
189
|
+
|
|
190
|
+
# --- 连接 Redis ---
|
|
191
|
+
redis_client = None
|
|
192
|
+
scheduler = None
|
|
193
|
+
if redis_url:
|
|
194
|
+
try:
|
|
195
|
+
import redis as redis_lib
|
|
196
|
+
redis_client = redis_lib.Redis.from_url(
|
|
197
|
+
redis_url,
|
|
198
|
+
decode_responses=True,
|
|
199
|
+
socket_connect_timeout=5,
|
|
200
|
+
socket_timeout=30, # 必须大于 BRPOP 阻塞时间(5s)
|
|
201
|
+
)
|
|
202
|
+
redis_client.ping()
|
|
203
|
+
logger.info("Redis 连接成功: {url}", url=redis_url)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.error("Redis 连接失败: {err},退化为本地模式", err=str(e))
|
|
206
|
+
redis_client = None
|
|
207
|
+
|
|
208
|
+
# --- 连接 PG + 初始化 DataLayer ---
|
|
209
|
+
reporter = None
|
|
210
|
+
db_engine = None
|
|
211
|
+
if db_url:
|
|
212
|
+
try:
|
|
213
|
+
from qcrawl.datalayer import create_db_engine_from_settings, ensure_partition, ensure_table
|
|
214
|
+
from qcrawl.datalayer.reporter import Reporter
|
|
215
|
+
|
|
216
|
+
db_engine = create_db_engine_from_settings(settings)
|
|
217
|
+
|
|
218
|
+
# 获取表名
|
|
219
|
+
table_name = getattr(spider_class, "table", "") or f"{getattr(spider_class, 'name', 'data')}_data"
|
|
220
|
+
|
|
221
|
+
# 自动建表(支持可选分区)
|
|
222
|
+
partition_by_month = settings.get("partition_by_month", False)
|
|
223
|
+
ensure_table(db_engine, table_name, partition_by_month=partition_by_month)
|
|
224
|
+
|
|
225
|
+
# 分区模式:预建当月+下月分区
|
|
226
|
+
if partition_by_month:
|
|
227
|
+
from datetime import datetime, timezone
|
|
228
|
+
now = datetime.now(timezone.utc)
|
|
229
|
+
ensure_partition(db_engine, table_name, now.year, now.month)
|
|
230
|
+
# 下月
|
|
231
|
+
nm = now.month + 1
|
|
232
|
+
ny = now.year + (1 if nm > 12 else 0)
|
|
233
|
+
nm = nm if nm <= 12 else 1
|
|
234
|
+
ensure_partition(db_engine, table_name, ny, nm)
|
|
235
|
+
|
|
236
|
+
# 初始化 Reporter
|
|
237
|
+
reporter = Reporter(
|
|
238
|
+
engine=db_engine,
|
|
239
|
+
table_name=table_name,
|
|
240
|
+
project_id=project_id,
|
|
241
|
+
spider_name=getattr(spider_class, "name", "") or spider_class.__name__.lower(),
|
|
242
|
+
pod_ip=pod_ip,
|
|
243
|
+
batch_size=settings.get("batch_size", 100),
|
|
244
|
+
flush_interval=settings.get("flush_interval", 5.0),
|
|
245
|
+
max_buffer_size=settings.get("max_buffer_size", 10000),
|
|
246
|
+
)
|
|
247
|
+
logger.info("PostgreSQL 连接成功,目标表: {table}", table=table_name)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.error("PostgreSQL 连接失败: {err},数据将打印到终端", err=str(e))
|
|
250
|
+
reporter = None
|
|
251
|
+
|
|
252
|
+
# --- 初始化 MinIO ---
|
|
253
|
+
minio_client = None
|
|
254
|
+
try:
|
|
255
|
+
from qcrawl.datalayer.minio_client import MinioClient
|
|
256
|
+
minio_client = MinioClient.from_settings(settings)
|
|
257
|
+
if minio_client:
|
|
258
|
+
logger.info("MinIO 配置已加载")
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
# --- 初始化 Scheduler(需要 Redis + Spider 实例)---
|
|
263
|
+
spider_name = getattr(spider_class, "name", "") or spider_class.__name__.lower()
|
|
264
|
+
if redis_client:
|
|
265
|
+
# 创建临时 spider 实例用于 Scheduler(获取 spider_name)
|
|
266
|
+
tmp_spider = spider_class()
|
|
267
|
+
tmp_spider.project_id = project_id
|
|
268
|
+
tmp_spider.pod_ip = pod_ip
|
|
269
|
+
scheduler = Scheduler(
|
|
270
|
+
redis_client=redis_client,
|
|
271
|
+
spider=tmp_spider,
|
|
272
|
+
project_id=project_id,
|
|
273
|
+
pod_ip=pod_ip,
|
|
274
|
+
dedup_ttl=settings.get("dedup_ttl", 0),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# --- 初始化指标采集 ---
|
|
278
|
+
metrics = None
|
|
279
|
+
if redis_client:
|
|
280
|
+
from qcrawl.utils.metrics import MetricsCollector
|
|
281
|
+
metrics = MetricsCollector(
|
|
282
|
+
redis_client=redis_client,
|
|
283
|
+
project_id=project_id,
|
|
284
|
+
spider_name=spider_name,
|
|
285
|
+
pod_ip=pod_ip,
|
|
286
|
+
flush_interval=settings.get("metrics_flush_interval", 5.0),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# --- 关联 Reporter 与 Metrics(刷盘成功时通知指标采集器)---
|
|
290
|
+
if reporter and metrics:
|
|
291
|
+
reporter._metrics = metrics
|
|
292
|
+
|
|
293
|
+
# --- 初始化引擎 ---
|
|
294
|
+
engine = Engine(settings, scheduler=scheduler)
|
|
295
|
+
engine.set_reporter(reporter)
|
|
296
|
+
engine.set_metrics(metrics)
|
|
297
|
+
engine.set_minio_client(minio_client)
|
|
298
|
+
engine.set_redis_client(redis_client)
|
|
299
|
+
|
|
300
|
+
if scheduler:
|
|
301
|
+
engine.add_crawl(spider_class)
|
|
302
|
+
logger.info(
|
|
303
|
+
"🕷️ QCrawl 启动: spider={spider}, env={env}, threads={threads}, "
|
|
304
|
+
"project={pid}, mode=distributed",
|
|
305
|
+
spider=spider_name,
|
|
306
|
+
env=settings.get("env"),
|
|
307
|
+
threads=settings.get("threads"),
|
|
308
|
+
pid=project_id,
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
# 退化为本地模式
|
|
312
|
+
seeds = [{"seed_id": "cli-local-001"}]
|
|
313
|
+
engine.add_crawl(spider_class, seeds=seeds)
|
|
314
|
+
logger.info(
|
|
315
|
+
"🕷️ QCrawl 启动: spider={spider}, env={env}, threads={threads}, "
|
|
316
|
+
"project={pid}, mode=local",
|
|
317
|
+
spider=spider_name,
|
|
318
|
+
env=settings.get("env"),
|
|
319
|
+
threads=settings.get("threads"),
|
|
320
|
+
pid=project_id,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
engine.start()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _import_seeds_file(settings, seeds_file: str, spider_class):
|
|
327
|
+
"""种子文件导入:读取 JSON/JSONL 文件,批量 LPUSH 到 Redis"""
|
|
328
|
+
import json
|
|
329
|
+
from pathlib import Path
|
|
330
|
+
|
|
331
|
+
redis_url = settings.get("redis_url", "")
|
|
332
|
+
if not redis_url:
|
|
333
|
+
logger.error("种子文件导入需要 Redis: 请指定 --redis_url")
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
file_path = Path(seeds_file)
|
|
337
|
+
if not file_path.exists():
|
|
338
|
+
logger.error("种子文件不存在: {path}", path=seeds_file)
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
spider_name = getattr(spider_class, "name", "") or spider_class.__name__.lower()
|
|
342
|
+
queue_key = f"qcrawl:seeds:{spider_name}"
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
import redis as redis_lib
|
|
346
|
+
r = redis_lib.Redis.from_url(redis_url, decode_responses=True)
|
|
347
|
+
r.ping()
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.error("种子导入 Redis 连接失败: {err}", err=str(e))
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
# 读取种子
|
|
353
|
+
seeds = []
|
|
354
|
+
content = file_path.read_text(encoding="utf-8").strip()
|
|
355
|
+
|
|
356
|
+
if file_path.suffix == ".jsonl" or "\n" in content[:200]:
|
|
357
|
+
# JSONL 格式:每行一个 JSON
|
|
358
|
+
for line in content.splitlines():
|
|
359
|
+
line = line.strip()
|
|
360
|
+
if line:
|
|
361
|
+
try:
|
|
362
|
+
seeds.append(json.loads(line))
|
|
363
|
+
except json.JSONDecodeError:
|
|
364
|
+
logger.warning("跳过无效 JSON 行: {line}", line=line[:80])
|
|
365
|
+
else:
|
|
366
|
+
# JSON 格式:单个对象或数组
|
|
367
|
+
data = json.loads(content)
|
|
368
|
+
if isinstance(data, list):
|
|
369
|
+
seeds = data
|
|
370
|
+
elif isinstance(data, dict):
|
|
371
|
+
seeds = [data]
|
|
372
|
+
|
|
373
|
+
if not seeds:
|
|
374
|
+
logger.warning("种子文件为空: {path}", path=seeds_file)
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
# 批量 LPUSH
|
|
378
|
+
pipe = r.pipeline(transaction=False)
|
|
379
|
+
for seed in seeds:
|
|
380
|
+
pipe.lpush(queue_key, json.dumps(seed, ensure_ascii=False))
|
|
381
|
+
pipe.execute()
|
|
382
|
+
|
|
383
|
+
logger.info(
|
|
384
|
+
"✅ 种子导入完成: {count} 条 -> {key}",
|
|
385
|
+
count=len(seeds),
|
|
386
|
+
key=queue_key,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _run_migrate(settings, spider_class):
|
|
391
|
+
"""表结构迁移:ALTER TABLE 统一升级"""
|
|
392
|
+
db_url = settings.get("db_url", "")
|
|
393
|
+
if not db_url:
|
|
394
|
+
logger.error("迁移需要数据库: 请指定 --db_url")
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
table_name = getattr(spider_class, "table", "") or f"{getattr(spider_class, 'name', 'data')}_data"
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
from qcrawl.datalayer import create_db_engine
|
|
401
|
+
from sqlalchemy import text
|
|
402
|
+
|
|
403
|
+
db_engine = create_db_engine(db_url)
|
|
404
|
+
|
|
405
|
+
with db_engine.connect() as conn:
|
|
406
|
+
# 检查表是否存在
|
|
407
|
+
result = conn.execute(text(
|
|
408
|
+
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :t)"
|
|
409
|
+
), {"t": table_name})
|
|
410
|
+
exists = result.scalar()
|
|
411
|
+
|
|
412
|
+
if not exists:
|
|
413
|
+
# 表不存在,直接建表
|
|
414
|
+
from qcrawl.datalayer import ensure_table
|
|
415
|
+
ensure_table(db_engine, table_name)
|
|
416
|
+
logger.info("✅ 表不存在,已创建: {table}", table=table_name)
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
# 表已存在,检查并添加缺失列
|
|
420
|
+
migrations = [
|
|
421
|
+
("s3_addr", "ALTER TABLE {t} ADD COLUMN IF NOT EXISTS s3_addr JSONB"),
|
|
422
|
+
("url", "ALTER TABLE {t} ADD COLUMN IF NOT EXISTS url TEXT"),
|
|
423
|
+
("spider_name", "ALTER TABLE {t} ADD COLUMN IF NOT EXISTS spider_name VARCHAR(64) NOT NULL DEFAULT ''"),
|
|
424
|
+
("crawl_time", "ALTER TABLE {t} ADD COLUMN IF NOT EXISTS crawl_time TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
|
|
425
|
+
]
|
|
426
|
+
|
|
427
|
+
applied = 0
|
|
428
|
+
for col_name, sql in migrations:
|
|
429
|
+
try:
|
|
430
|
+
conn.execute(text(sql.format(t=table_name)))
|
|
431
|
+
conn.commit()
|
|
432
|
+
applied += 1
|
|
433
|
+
except Exception:
|
|
434
|
+
pass # 列已存在
|
|
435
|
+
|
|
436
|
+
# 确保索引存在
|
|
437
|
+
indexes = [
|
|
438
|
+
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_project ON {table_name}(project_id, data_type)",
|
|
439
|
+
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_seed ON {table_name}(seed_id)",
|
|
440
|
+
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_crawl ON {table_name}(crawl_time)",
|
|
441
|
+
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_ext ON {table_name} USING GIN(ext)",
|
|
442
|
+
]
|
|
443
|
+
for idx_sql in indexes:
|
|
444
|
+
try:
|
|
445
|
+
conn.execute(text(idx_sql))
|
|
446
|
+
conn.commit()
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
logger.info(
|
|
451
|
+
"✅ 迁移完成: table={table}, applied={n}",
|
|
452
|
+
table=table_name,
|
|
453
|
+
n=applied,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
except Exception as e:
|
|
457
|
+
logger.error("迁移失败: {err}", err=str(e))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
main()
|
qcrawl/context.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""运行时上下文(线程级)
|
|
2
|
+
|
|
3
|
+
通过 threading.local 实现请求级 seed_id 的线程安全传递。
|
|
4
|
+
Engine 在处理每个请求时设置上下文,report_data / logger 从中读取。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
# 线程局部存储
|
|
12
|
+
_local = threading.local()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_context(
|
|
16
|
+
seed_id: str = "",
|
|
17
|
+
spider_name: str = "",
|
|
18
|
+
project_id: str = "",
|
|
19
|
+
pod_ip: str = "",
|
|
20
|
+
):
|
|
21
|
+
"""设置当前线程的运行时上下文(Engine 在请求进入回调前调用)"""
|
|
22
|
+
_local.seed_id = seed_id
|
|
23
|
+
_local.spider_name = spider_name
|
|
24
|
+
_local.project_id = project_id
|
|
25
|
+
_local.pod_ip = pod_ip
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_seed_id() -> str:
|
|
29
|
+
"""获取当前线程的 seed_id"""
|
|
30
|
+
return getattr(_local, "seed_id", "")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_spider_name() -> str:
|
|
34
|
+
"""获取当前线程的 spider_name"""
|
|
35
|
+
return getattr(_local, "spider_name", "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_project_id() -> str:
|
|
39
|
+
"""获取当前线程的 project_id"""
|
|
40
|
+
return getattr(_local, "project_id", "")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_pod_ip() -> str:
|
|
44
|
+
"""获取当前线程的 pod_ip"""
|
|
45
|
+
return getattr(_local, "pod_ip", "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def clear_context():
|
|
49
|
+
"""清除当前线程上下文(请求处理完毕后调用)"""
|
|
50
|
+
_local.seed_id = ""
|
|
51
|
+
_local.spider_name = ""
|
|
52
|
+
_local.project_id = ""
|
|
53
|
+
_local.pod_ip = ""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""数据层"""
|
|
2
|
+
|
|
3
|
+
from qcrawl.datalayer.database import (
|
|
4
|
+
create_db_engine,
|
|
5
|
+
create_db_engine_from_settings,
|
|
6
|
+
ensure_partition,
|
|
7
|
+
ensure_table,
|
|
8
|
+
)
|
|
9
|
+
from qcrawl.datalayer.report_item import ReportItem
|
|
10
|
+
from qcrawl.datalayer.reporter import Reporter
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ReportItem",
|
|
14
|
+
"Reporter",
|
|
15
|
+
"create_db_engine",
|
|
16
|
+
"create_db_engine_from_settings",
|
|
17
|
+
"ensure_partition",
|
|
18
|
+
"ensure_table",
|
|
19
|
+
]
|