pixivflow 2.36.0 → 2.38.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.
@@ -20,11 +20,20 @@ export interface RichNovelPublishOptions {
20
20
  * the caller can skip enrichment with zero behaviour change.
21
21
  */
22
22
  export declare function interpolateEnv(value: string): string;
23
- export declare function findRichNovelSources(artifact: DownloadedArtifact): {
23
+ export interface RichNovelManifestEntry {
24
+ /** Markdown-relative path, e.g. ``images/001.jpg``. */
25
+ local: string;
26
+ /** Original Pixiv CDN source URL (``https://i.pximg.net/...``). */
27
+ source: string;
28
+ }
29
+ export interface RichNovelSources {
24
30
  txtPath: string;
25
31
  mdPath: string;
26
32
  imagePaths: string[];
27
- } | undefined;
33
+ /** Optional Pixiv CDN source map derived from the novel metadata file. */
34
+ manifest: RichNovelManifestEntry[];
35
+ }
36
+ export declare function findRichNovelSources(artifact: DownloadedArtifact): RichNovelSources | undefined;
28
37
  /**
29
38
  * Publish a rich novel (markdown + inline images) to TelePress and return the
30
39
  * Telegraph URL. Client-side only — TelePress owns rendering/Catbox/Telegraph.
@@ -55,6 +55,31 @@ function interpolateEnv(value) {
55
55
  return resolved;
56
56
  });
57
57
  }
58
+ /** Read ``{local, source}`` entries from the novel's metadata sidecar. */
59
+ function readNovelManifest(artifact) {
60
+ const metadataFile = (artifact.cleanupFiles ?? []).find((f) => /\.json$/i.test(f));
61
+ if (!metadataFile || !fs.existsSync(metadataFile))
62
+ return [];
63
+ try {
64
+ const meta = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
65
+ if (!Array.isArray(meta.assets))
66
+ return [];
67
+ const entries = [];
68
+ for (const asset of meta.assets) {
69
+ if (!asset || asset.status !== 'downloaded' || !asset.url || !asset.localPath)
70
+ continue;
71
+ const source = String(asset.url);
72
+ const localPath = String(asset.localPath);
73
+ if (!source || !localPath)
74
+ continue;
75
+ entries.push({ local: `images/${path.basename(localPath)}`, source });
76
+ }
77
+ return entries;
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ }
58
83
  function findRichNovelSources(artifact) {
59
84
  if (artifact.type !== 'novel')
60
85
  return undefined;
@@ -73,7 +98,12 @@ function findRichNovelSources(artifact) {
73
98
  .map((name) => path.join(imagesDir, name))
74
99
  .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
75
100
  }
76
- return { txtPath, mdPath, imagePaths };
101
+ return {
102
+ txtPath,
103
+ mdPath,
104
+ imagePaths,
105
+ manifest: readNovelManifest(artifact),
106
+ };
77
107
  }
78
108
  /**
79
109
  * Publish a rich novel (markdown + inline images) to TelePress and return the
@@ -98,6 +128,15 @@ async function publishRichNovelPreview(artifact, options) {
98
128
  `Content-Type: text/markdown\r\n\r\n`));
99
129
  fields.push(await fs.promises.readFile(sources.mdPath));
100
130
  fields.push(Buffer.from('\r\n'));
131
+ // Optional manifest: lets TelePress rewrite Pixiv CDN sources to the media
132
+ // proxy instead of uploading local files to an image host.
133
+ if (sources.manifest.length) {
134
+ fields.push(Buffer.from(`--${boundary}\r\n` +
135
+ `Content-Disposition: form-data; name="manifest"\r\n` +
136
+ `Content-Type: application/json\r\n\r\n`));
137
+ fields.push(Buffer.from(JSON.stringify(sources.manifest)));
138
+ fields.push(Buffer.from('\r\n'));
139
+ }
101
140
  // File parts for each inline image, named with the `images/` prefix so the
102
141
  // relative markdown refs resolve on the receiving side.
103
142
  for (const imagePath of sources.imagePaths) {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.36.0",
4
+ "version": "2.38.0",
5
5
  "private": true
6
6
  }
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.36.0', commit: '00a29ea41205' };
5
+ exports.BUILD = { version: '2.38.0', commit: '66e1379b588c' };
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow-webui-backend",
4
- "version": "2.36.0",
4
+ "version": "2.38.0",
5
5
  "description": "PixivFlow WebUI Backend - CommonJS module"
6
6
  }
@@ -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
- targets: database.slots.getCells(slot.id).map((cell) => ({
86
- targetId: cell.targetId,
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.36.0",
3
+ "version": "2.38.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",