pixivflow 2.36.0 → 2.37.0
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.
- package/dist/package.json +1 -1
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/dist/webui/routes/handlers/scheduler-handlers.d.ts +27 -0
- package/dist/webui/routes/handlers/scheduler-handlers.js +186 -8
- package/dist/webui/routes/scheduler.js +2 -0
- package/package.json +1 -1
package/dist/package.json
CHANGED
package/dist/version.js
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BUILD = void 0;
|
|
4
4
|
// GENERATED by scripts/write-version.js — do not edit manually.
|
|
5
|
-
exports.BUILD = { version: '2.
|
|
5
|
+
exports.BUILD = { version: '2.37.0', commit: 'a17343519198' };
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/webui/package.json
CHANGED
|
@@ -5,6 +5,33 @@ import { Request, Response } from 'express';
|
|
|
5
5
|
* projection of the existing Slot Ledger, not a second state system.
|
|
6
6
|
*/
|
|
7
7
|
export declare function listRecentSlots(req: Request, res: Response): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Recovery admission projection (read-only). Retry semantics still live in the
|
|
10
|
+
* scheduler's own admission rules; this only labels what the WebUI may offer.
|
|
11
|
+
* - system `failed` → retryable (normal / relaxed)
|
|
12
|
+
* - `no_candidate`/`duplicate` → normal business outcome; relaxed retry is the
|
|
13
|
+
* only semantically useful action (soft-scope widening), never a blind retry
|
|
14
|
+
* - `submitted`/`pending`/`running` → not retryable
|
|
15
|
+
*/
|
|
16
|
+
export declare function recoveryAdmission(status: string, terminalReasonCode: string | null): {
|
|
17
|
+
retryable: boolean;
|
|
18
|
+
relaxedRetryAllowed: boolean;
|
|
19
|
+
retryableReason: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* GET /api/scheduler/executions — read-only Execution projection over the
|
|
23
|
+
* durable Slot Ledger. Execution Truth stays in schedule_slots + items; this
|
|
24
|
+
* endpoint only shapes it for the WebUI (no new state source).
|
|
25
|
+
*/
|
|
26
|
+
export declare function listExecutions(req: Request, res: Response): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* GET /api/scheduler/slots/:slotId/logs
|
|
29
|
+
*
|
|
30
|
+
* Correlated log view: filters the process log file by the slot id and its
|
|
31
|
+
* target ids so operators can replay exactly the lines for one occurrence.
|
|
32
|
+
* Pure filtering of existing structured logger output, no new store.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getSlotLogs(req: Request, res: Response): Promise<void>;
|
|
8
35
|
/**
|
|
9
36
|
* POST /api/scheduler/targets/:targetId/recover
|
|
10
37
|
*
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.listRecentSlots = listRecentSlots;
|
|
7
|
+
exports.recoveryAdmission = recoveryAdmission;
|
|
8
|
+
exports.listExecutions = listExecutions;
|
|
9
|
+
exports.getSlotLogs = getSlotLogs;
|
|
4
10
|
exports.recoverTarget = recoverTarget;
|
|
5
11
|
exports.recoverStatus = recoverStatus;
|
|
12
|
+
const fs_1 = require("fs");
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
6
14
|
const Database_1 = require("../../../storage/Database");
|
|
7
15
|
const config_1 = require("../../../config");
|
|
8
16
|
const logger_1 = require("../../../logger");
|
|
@@ -82,14 +90,8 @@ async function listRecentSlots(req, res) {
|
|
|
82
90
|
recoveryMode: slot.recoveryMode ?? null,
|
|
83
91
|
startedAt: slot.startedAt,
|
|
84
92
|
completedAt: slot.completedAt,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
workType: cell.workType,
|
|
88
|
-
status: cell.status,
|
|
89
|
-
workId: cell.workId,
|
|
90
|
-
terminalReasonCode: cell.terminalReasonCode,
|
|
91
|
-
reason: cell.terminalReasonMessage ?? null,
|
|
92
|
-
})),
|
|
93
|
+
lastError: slot.lastError,
|
|
94
|
+
targets: database.slots.getCells(slot.id).map(cellProjection),
|
|
93
95
|
}));
|
|
94
96
|
database.close();
|
|
95
97
|
database = null;
|
|
@@ -109,6 +111,182 @@ async function listRecentSlots(req, res) {
|
|
|
109
111
|
res.status(500).json({ errorCode: error_codes_1.ErrorCode.SCHEDULER_LIST_FAILED });
|
|
110
112
|
}
|
|
111
113
|
}
|
|
114
|
+
function parseCandidateReport(report) {
|
|
115
|
+
return report && typeof report === 'object' ? report : null;
|
|
116
|
+
}
|
|
117
|
+
function cellProjection(cell) {
|
|
118
|
+
return {
|
|
119
|
+
targetId: cell.targetId,
|
|
120
|
+
workType: cell.workType,
|
|
121
|
+
status: cell.status,
|
|
122
|
+
workId: cell.workId,
|
|
123
|
+
terminalReasonCode: cell.terminalReasonCode,
|
|
124
|
+
reason: cell.terminalReasonMessage ?? null,
|
|
125
|
+
candidateReport: parseCandidateReport(cell.candidateReport),
|
|
126
|
+
attemptCount: cell.attemptCount,
|
|
127
|
+
fallbackStage: cell.fallback_stage,
|
|
128
|
+
completedAt: cell.completedAt,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Recovery admission projection (read-only). Retry semantics still live in the
|
|
133
|
+
* scheduler's own admission rules; this only labels what the WebUI may offer.
|
|
134
|
+
* - system `failed` → retryable (normal / relaxed)
|
|
135
|
+
* - `no_candidate`/`duplicate` → normal business outcome; relaxed retry is the
|
|
136
|
+
* only semantically useful action (soft-scope widening), never a blind retry
|
|
137
|
+
* - `submitted`/`pending`/`running` → not retryable
|
|
138
|
+
*/
|
|
139
|
+
function recoveryAdmission(status, terminalReasonCode) {
|
|
140
|
+
const t = (terminalReasonCode ?? '').toLowerCase();
|
|
141
|
+
if (status === 'failed') {
|
|
142
|
+
return { retryable: true, relaxedRetryAllowed: true, retryableReason: 'system failure' };
|
|
143
|
+
}
|
|
144
|
+
if (status === 'no_candidate' || status === 'duplicate') {
|
|
145
|
+
const noContent = t === 'duplicate_exhausted' || t === 'no_candidate' || t === 'duplicate';
|
|
146
|
+
return {
|
|
147
|
+
retryable: !noContent,
|
|
148
|
+
relaxedRetryAllowed: true,
|
|
149
|
+
retryableReason: noContent
|
|
150
|
+
? 'non-retryable business outcome (no new content)'
|
|
151
|
+
: 'normal retry applies',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { retryable: false, relaxedRetryAllowed: false, retryableReason: 'non-terminal or already-submitted' };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* GET /api/scheduler/executions — read-only Execution projection over the
|
|
158
|
+
* durable Slot Ledger. Execution Truth stays in schedule_slots + items; this
|
|
159
|
+
* endpoint only shapes it for the WebUI (no new state source).
|
|
160
|
+
*/
|
|
161
|
+
async function listExecutions(req, res) {
|
|
162
|
+
const limit = Math.min(Math.max(Number(req.query.limit ?? 20) || 20, 1), 100);
|
|
163
|
+
const targetFilter = typeof req.query.targetId === 'string' ? req.query.targetId.trim() : '';
|
|
164
|
+
const statusFilter = typeof req.query.status === 'string' ? req.query.status.trim().toLowerCase() : '';
|
|
165
|
+
let database = null;
|
|
166
|
+
try {
|
|
167
|
+
const configPath = (0, config_1.getConfigPath)();
|
|
168
|
+
const config = (0, config_1.loadConfig)(configPath);
|
|
169
|
+
if (!config.storage?.databasePath) {
|
|
170
|
+
res.status(400).json({ errorCode: error_codes_1.ErrorCode.SCHEDULER_LIST_FAILED, message: 'database not configured' });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
database = new Database_1.Database(config.storage.databasePath);
|
|
174
|
+
database.migrate();
|
|
175
|
+
const slots = database.slots.getRecentSlots(Math.max(limit, 50));
|
|
176
|
+
const executions = slots
|
|
177
|
+
.flatMap((slot) => database.slots.getCells(slot.id).map((cell) => {
|
|
178
|
+
const cellView = cellProjection(cell);
|
|
179
|
+
const admission = recoveryAdmission(cell.status, cell.terminalReasonCode);
|
|
180
|
+
return {
|
|
181
|
+
executionId: `${slot.id}:${cell.targetId}`,
|
|
182
|
+
slotId: slot.id,
|
|
183
|
+
scheduleId: slot.scheduleId,
|
|
184
|
+
targetId: cell.targetId,
|
|
185
|
+
workType: cell.workType,
|
|
186
|
+
status: cell.status,
|
|
187
|
+
terminalReasonCode: cell.terminalReasonCode,
|
|
188
|
+
message: cell.terminalReasonMessage,
|
|
189
|
+
startedAt: slot.startedAt,
|
|
190
|
+
endedAt: cell.completedAt ?? slot.completedAt,
|
|
191
|
+
triggerSource: slot.triggerSource,
|
|
192
|
+
recoveryRequestId: slot.recoveryRequestId ?? null,
|
|
193
|
+
recoveryMode: slot.recoveryMode ?? null,
|
|
194
|
+
occurrenceAt: slot.occurrenceAt,
|
|
195
|
+
candidateReport: cellView.candidateReport,
|
|
196
|
+
attemptCount: cell.attemptCount,
|
|
197
|
+
fallbackStage: cell.fallback_stage,
|
|
198
|
+
recovery: admission,
|
|
199
|
+
operatorHint: admission.retryable
|
|
200
|
+
? '可重试'
|
|
201
|
+
: admission.relaxedRetryAllowed
|
|
202
|
+
? '正常完成的无新内容结果,仅在人工判断后可放宽条件重试'
|
|
203
|
+
: '非终态或已成功,无需重试',
|
|
204
|
+
};
|
|
205
|
+
}))
|
|
206
|
+
.filter((e) => {
|
|
207
|
+
if (targetFilter && e.targetId !== targetFilter)
|
|
208
|
+
return false;
|
|
209
|
+
if (statusFilter && e.status.toLowerCase() !== statusFilter)
|
|
210
|
+
return false;
|
|
211
|
+
return true;
|
|
212
|
+
})
|
|
213
|
+
.slice(0, limit);
|
|
214
|
+
database.close();
|
|
215
|
+
database = null;
|
|
216
|
+
res.json({ data: { executions } });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (database) {
|
|
220
|
+
try {
|
|
221
|
+
database.close();
|
|
222
|
+
}
|
|
223
|
+
catch { /* ignore */ }
|
|
224
|
+
}
|
|
225
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
226
|
+
logger_1.logger.error('Failed to list scheduler executions', { error: { message } });
|
|
227
|
+
res.status(500).json({ errorCode: error_codes_1.ErrorCode.SCHEDULER_LIST_FAILED });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* GET /api/scheduler/slots/:slotId/logs
|
|
232
|
+
*
|
|
233
|
+
* Correlated log view: filters the process log file by the slot id and its
|
|
234
|
+
* target ids so operators can replay exactly the lines for one occurrence.
|
|
235
|
+
* Pure filtering of existing structured logger output, no new store.
|
|
236
|
+
*/
|
|
237
|
+
async function getSlotLogs(req, res) {
|
|
238
|
+
const slotId = req.params.slotId;
|
|
239
|
+
if (!slotId || slotId.length > 200) {
|
|
240
|
+
res.status(400).json({ errorCode: error_codes_1.ErrorCode.SCHEDULER_LIST_FAILED, message: 'invalid slotId' });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
let database = null;
|
|
244
|
+
try {
|
|
245
|
+
const configPath = (0, config_1.getConfigPath)();
|
|
246
|
+
const config = (0, config_1.loadConfig)(configPath);
|
|
247
|
+
if (!config.storage?.databasePath) {
|
|
248
|
+
res.status(400).json({ errorCode: error_codes_1.ErrorCode.SCHEDULER_LIST_FAILED, message: 'database not configured' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
database = new Database_1.Database(config.storage.databasePath);
|
|
252
|
+
database.migrate();
|
|
253
|
+
const slot = database.slots.getSlot(slotId);
|
|
254
|
+
const targets = slot ? database.slots.getCells(slotId).map((c) => c.targetId) : [];
|
|
255
|
+
database.close();
|
|
256
|
+
database = null;
|
|
257
|
+
let logFile = '';
|
|
258
|
+
const dataDir = path_1.default.dirname(config.storage.databasePath);
|
|
259
|
+
for (const candidate of [
|
|
260
|
+
path_1.default.join(dataDir, 'pixiv-downloader.log'),
|
|
261
|
+
path_1.default.resolve(process.cwd(), 'data', 'pixiv-downloader.log'),
|
|
262
|
+
]) {
|
|
263
|
+
if ((0, fs_1.existsSync)(candidate)) {
|
|
264
|
+
logFile = candidate;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!logFile) {
|
|
269
|
+
res.json({ data: { logs: [], total: 0, slotId, targets } });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const keywords = [slotId, ...targets];
|
|
273
|
+
const lines = (0, fs_1.readFileSync)(logFile, 'utf-8')
|
|
274
|
+
.split('\n')
|
|
275
|
+
.filter((line) => line.trim() && keywords.some((k) => line.toLowerCase().includes(k.toLowerCase())));
|
|
276
|
+
res.json({ data: { logs: lines.slice(-500), total: lines.length, slotId, targets } });
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (database) {
|
|
280
|
+
try {
|
|
281
|
+
database.close();
|
|
282
|
+
}
|
|
283
|
+
catch { /* ignore */ }
|
|
284
|
+
}
|
|
285
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
286
|
+
logger_1.logger.error('Failed to read slot logs', { slotId, error: { message } });
|
|
287
|
+
res.status(500).json({ errorCode: error_codes_1.ErrorCode.LOGS_GET_FAILED });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
112
290
|
/**
|
|
113
291
|
* POST /api/scheduler/targets/:targetId/recover
|
|
114
292
|
*
|
|
@@ -4,6 +4,8 @@ const express_1 = require("express");
|
|
|
4
4
|
const scheduler_handlers_1 = require("./handlers/scheduler-handlers");
|
|
5
5
|
const router = (0, express_1.Router)();
|
|
6
6
|
router.get('/', scheduler_handlers_1.listRecentSlots);
|
|
7
|
+
router.get('/executions', scheduler_handlers_1.listExecutions);
|
|
8
|
+
router.get('/slots/:slotId/logs', scheduler_handlers_1.getSlotLogs);
|
|
7
9
|
router.post('/targets/:targetId/recover', scheduler_handlers_1.recoverTarget);
|
|
8
10
|
router.get('/targets/:targetId/recover/:requestId', scheduler_handlers_1.recoverStatus);
|
|
9
11
|
exports.default = router;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pixivflow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.37.0",
|
|
4
4
|
"description": "🎨 Pixiv 下载、筛选与自动收集工具 - 批量下载插画和小说、按标签/热度/日期筛选、定时任务与可靠 HTTP 交付 | Pixiv downloader and automation toolkit with filtering, scheduling and reliable HTTP delivery",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|