pixivflow 2.25.0 → 2.27.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/config/fly-two-bots.example.json +8 -1
- package/dist/config/types.d.ts +19 -0
- package/dist/config/validation.js +15 -0
- package/dist/delivery/TelePressRichNovel.d.ts +40 -0
- package/dist/delivery/TelePressRichNovel.js +167 -0
- package/dist/download/NovelDownloader.js +31 -1
- package/dist/download/handlers/NovelTargetHandler.d.ts +6 -0
- package/dist/download/handlers/NovelTargetHandler.js +44 -3
- package/dist/package.json +1 -1
- package/dist/utils/zip.d.ts +14 -0
- package/dist/utils/zip.js +44 -0
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/package.json +3 -2
|
@@ -168,7 +168,14 @@
|
|
|
168
168
|
"limit": 1,
|
|
169
169
|
"storageMode": "cache",
|
|
170
170
|
"delivery": {
|
|
171
|
-
"target": "telepost-bot1"
|
|
171
|
+
"target": "telepost-bot1",
|
|
172
|
+
"richNovelPreview": {
|
|
173
|
+
"url": "http://127.0.0.1:8000/publish/rich-novel",
|
|
174
|
+
"headers": {
|
|
175
|
+
"Authorization": "Bearer ${TELEPRESS_API_KEY}"
|
|
176
|
+
},
|
|
177
|
+
"field": "novel_preview_url"
|
|
178
|
+
}
|
|
172
179
|
}
|
|
173
180
|
},
|
|
174
181
|
{
|
package/dist/config/types.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface TargetDeliveryConfig {
|
|
|
43
43
|
target: string;
|
|
44
44
|
/** 覆盖该交付目标的表单字段,支持 {{title}} 等模板变量 */
|
|
45
45
|
fields?: Record<string, DeliveryFieldValue>;
|
|
46
|
+
/** Optional rich-novel preview enrichment before the submission. */
|
|
47
|
+
richNovelPreview?: RichNovelPreviewConfig;
|
|
46
48
|
/**
|
|
47
49
|
* Schedule slot provenance injected at runtime for scheduled/external runs
|
|
48
50
|
* (not authored in config). Rendered as {{slotId}}/{{slotName}}/{{slotDate}}.
|
|
@@ -538,6 +540,23 @@ export interface HttpMultipartSuccessConfig {
|
|
|
538
540
|
/** jsonPath 对应的期望值 */
|
|
539
541
|
equals?: string | number | boolean | null;
|
|
540
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* Optional rich-novel preview publish step performed BEFORE the multipart
|
|
545
|
+
* submission is enqueued. Widget is TelePress (/publish/rich-novel): the novel
|
|
546
|
+
* markdown sidecar + inline images are posted there and the returned Telegraph
|
|
547
|
+
* URL is injected into the submission's `fields` under `field`. It is an
|
|
548
|
+
* enrichment — a failure never fails the submission; TXT/ZIP stay authoritative.
|
|
549
|
+
*/
|
|
550
|
+
export interface RichNovelPreviewConfig {
|
|
551
|
+
/** TelePress `/publish/rich-novel` endpoint (http(s), supports ${ENV_NAME}). */
|
|
552
|
+
url: string;
|
|
553
|
+
/** Request headers (e.g. Authorization: Bearer ${TELEPRESS_API_KEY}). */
|
|
554
|
+
headers?: Record<string, string>;
|
|
555
|
+
/** Per-request timeout in milliseconds (default 60000). */
|
|
556
|
+
timeoutMs?: number;
|
|
557
|
+
/** Submission field receiving the Telegraph URL (default novel_preview_url). */
|
|
558
|
+
field?: string;
|
|
559
|
+
}
|
|
541
560
|
export interface HttpMultipartDeliveryConfig {
|
|
542
561
|
type: 'httpMultipart';
|
|
543
562
|
url: string;
|
|
@@ -269,6 +269,21 @@ function validateConfig(config, location, databasePath) {
|
|
|
269
269
|
errors.push(`targets[${index}].delivery.target: Unknown delivery target "${deliveryTarget}"`);
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
+
if (target.delivery?.richNovelPreview) {
|
|
273
|
+
if (!target.delivery.richNovelPreview.url?.trim()) {
|
|
274
|
+
errors.push(`targets[${index}].delivery.richNovelPreview.url: Required field is missing or empty`);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
try {
|
|
278
|
+
const url = new URL(target.delivery.richNovelPreview.url);
|
|
279
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
280
|
+
throw new Error('unsupported protocol');
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
errors.push(`targets[${index}].delivery.richNovelPreview.url: Must be a valid HTTP or HTTPS URL`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
272
287
|
});
|
|
273
288
|
}
|
|
274
289
|
for (const [name, delivery] of Object.entries(config.delivery?.targets ?? {})) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DownloadedArtifact } from './types';
|
|
2
|
+
export interface RichNovelPreviewResult {
|
|
3
|
+
/** Telegraph "read online" URL for the published page. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** Per-asset diagnostic returned by TelePress ({local, remote, status}). */
|
|
6
|
+
assets?: Array<{
|
|
7
|
+
local: string;
|
|
8
|
+
remote?: string | null;
|
|
9
|
+
status: string;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
export interface RichNovelPublishOptions {
|
|
13
|
+
url: string;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Locate the rich-markdown sidecar and its inline image directory for a novel
|
|
19
|
+
* artifact. Pure-text novels (no images dir / no sidecar) return undefined so
|
|
20
|
+
* the caller can skip enrichment with zero behaviour change.
|
|
21
|
+
*/
|
|
22
|
+
export declare function interpolateEnv(value: string): string;
|
|
23
|
+
export declare function findRichNovelSources(artifact: DownloadedArtifact): {
|
|
24
|
+
txtPath: string;
|
|
25
|
+
mdPath: string;
|
|
26
|
+
imagePaths: string[];
|
|
27
|
+
} | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Publish a rich novel (markdown + inline images) to TelePress and return the
|
|
30
|
+
* Telegraph URL. Client-side only — TelePress owns rendering/Catbox/Telegraph.
|
|
31
|
+
*
|
|
32
|
+
* Failures classify as retryable when the endpoint was reached but answered
|
|
33
|
+
* non-2xx (Transient), and non-retryable when local input is unusable.
|
|
34
|
+
*/
|
|
35
|
+
export declare function publishRichNovelPreview(artifact: DownloadedArtifact, options: RichNovelPublishOptions): Promise<{
|
|
36
|
+
url: string;
|
|
37
|
+
retryable: boolean;
|
|
38
|
+
operatorHint?: string;
|
|
39
|
+
}>;
|
|
40
|
+
//# sourceMappingURL=TelePressRichNovel.d.ts.map
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.interpolateEnv = interpolateEnv;
|
|
37
|
+
exports.findRichNovelSources = findRichNovelSources;
|
|
38
|
+
exports.publishRichNovelPreview = publishRichNovelPreview;
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const node_crypto_1 = require("node:crypto");
|
|
42
|
+
const logger_1 = require("../logger");
|
|
43
|
+
const redact_1 = require("../utils/redact");
|
|
44
|
+
/**
|
|
45
|
+
* Locate the rich-markdown sidecar and its inline image directory for a novel
|
|
46
|
+
* artifact. Pure-text novels (no images dir / no sidecar) return undefined so
|
|
47
|
+
* the caller can skip enrichment with zero behaviour change.
|
|
48
|
+
*/
|
|
49
|
+
function interpolateEnv(value) {
|
|
50
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
|
|
51
|
+
const resolved = process.env[name];
|
|
52
|
+
if (resolved === undefined) {
|
|
53
|
+
throw new Error(`Required TelePress environment variable is not set: ${name}`);
|
|
54
|
+
}
|
|
55
|
+
return resolved;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function findRichNovelSources(artifact) {
|
|
59
|
+
if (artifact.type !== 'novel')
|
|
60
|
+
return undefined;
|
|
61
|
+
const txtPath = artifact.files.find((f) => /\.txt$/i.test(f));
|
|
62
|
+
if (!txtPath)
|
|
63
|
+
return undefined;
|
|
64
|
+
const mdPath = txtPath.replace(/\.txt$/i, '.md');
|
|
65
|
+
if (!fs.existsSync(mdPath))
|
|
66
|
+
return undefined;
|
|
67
|
+
const imagesDir = path.join(path.dirname(txtPath), 'images');
|
|
68
|
+
let imagePaths = [];
|
|
69
|
+
if (fs.existsSync(imagesDir)) {
|
|
70
|
+
imagePaths = fs
|
|
71
|
+
.readdirSync(imagesDir)
|
|
72
|
+
.filter((name) => /\.(jpe?g|png|gif|webp|bmp)$/i.test(name))
|
|
73
|
+
.map((name) => path.join(imagesDir, name))
|
|
74
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
75
|
+
}
|
|
76
|
+
return { txtPath, mdPath, imagePaths };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Publish a rich novel (markdown + inline images) to TelePress and return the
|
|
80
|
+
* Telegraph URL. Client-side only — TelePress owns rendering/Catbox/Telegraph.
|
|
81
|
+
*
|
|
82
|
+
* Failures classify as retryable when the endpoint was reached but answered
|
|
83
|
+
* non-2xx (Transient), and non-retryable when local input is unusable.
|
|
84
|
+
*/
|
|
85
|
+
async function publishRichNovelPreview(artifact, options) {
|
|
86
|
+
const sources = findRichNovelSources(artifact);
|
|
87
|
+
if (!sources)
|
|
88
|
+
return { url: '', retryable: false, operatorHint: 'no_rich_novel_assets' };
|
|
89
|
+
if (sources.imagePaths.length === 0) {
|
|
90
|
+
return { url: '', retryable: false, operatorHint: 'no_rich_novel_assets' };
|
|
91
|
+
}
|
|
92
|
+
const mdName = path.basename(sources.mdPath);
|
|
93
|
+
const boundary = `telepress-${(0, node_crypto_1.randomUUID)()}`;
|
|
94
|
+
const fields = [];
|
|
95
|
+
// Text part for the markdown file.
|
|
96
|
+
fields.push(Buffer.from(`--${boundary}\r\n` +
|
|
97
|
+
`Content-Disposition: form-data; name="md"; filename="${escape(mdName)}"\r\n` +
|
|
98
|
+
`Content-Type: text/markdown\r\n\r\n`));
|
|
99
|
+
fields.push(await fs.promises.readFile(sources.mdPath));
|
|
100
|
+
fields.push(Buffer.from('\r\n'));
|
|
101
|
+
// File parts for each inline image, named with the `images/` prefix so the
|
|
102
|
+
// relative markdown refs resolve on the receiving side.
|
|
103
|
+
for (const imagePath of sources.imagePaths) {
|
|
104
|
+
const name = `images/${path.basename(imagePath)}`;
|
|
105
|
+
const stat = await fs.promises.stat(imagePath);
|
|
106
|
+
if (stat.size <= 0)
|
|
107
|
+
continue;
|
|
108
|
+
const header = Buffer.from(`--${boundary}\r\n` +
|
|
109
|
+
`Content-Disposition: form-data; name="images"; filename="${escape(name)}"\r\n` +
|
|
110
|
+
`Content-Type: application/octet-stream\r\n\r\n`);
|
|
111
|
+
const data = await fs.promises.readFile(imagePath);
|
|
112
|
+
fields.push(header, data, Buffer.from('\r\n'));
|
|
113
|
+
}
|
|
114
|
+
fields.push(Buffer.from(`--${boundary}--\r\n`));
|
|
115
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
116
|
+
const headers = {
|
|
117
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
118
|
+
'Content-Length': String(fields.reduce((n, b) => n + b.length, 0)),
|
|
119
|
+
...options.headers,
|
|
120
|
+
};
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
123
|
+
timer.unref?.();
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(options.url, { method: 'POST', headers, body: Buffer.concat(fields), signal: controller.signal });
|
|
126
|
+
const text = await response.text();
|
|
127
|
+
let body = text;
|
|
128
|
+
try {
|
|
129
|
+
body = JSON.parse(text);
|
|
130
|
+
}
|
|
131
|
+
catch { /* plain text */ }
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
logger_1.logger.warn('TelePress rich novel publish returned an error status', {
|
|
134
|
+
url: (0, redact_1.redactUrl)(options.url),
|
|
135
|
+
status: response.status,
|
|
136
|
+
body: String(body).slice(0, 300),
|
|
137
|
+
});
|
|
138
|
+
return { url: '', retryable: response.status >= 500 || response.status === 408 || response.status === 429, operatorHint: `telepress_http_${response.status}` };
|
|
139
|
+
}
|
|
140
|
+
const data = (body && typeof body === 'object' ? body : undefined);
|
|
141
|
+
const url = typeof data?.url === 'string' ? data.url : '';
|
|
142
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
143
|
+
return { url: '', retryable: true, operatorHint: 'telepress_invalid_response' };
|
|
144
|
+
}
|
|
145
|
+
logger_1.logger.info('TelePress rich novel publish succeeded', {
|
|
146
|
+
url: (0, redact_1.redactUrl)(url),
|
|
147
|
+
images: sources.imagePaths.length,
|
|
148
|
+
});
|
|
149
|
+
return { url, retryable: false };
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
const aborted = error?.name === 'AbortError';
|
|
153
|
+
logger_1.logger.warn('TelePress rich novel publish failed', {
|
|
154
|
+
url: (0, redact_1.redactUrl)(options.url),
|
|
155
|
+
retryable: !aborted,
|
|
156
|
+
reason: aborted ? 'timeout' : String(error),
|
|
157
|
+
});
|
|
158
|
+
return { url: '', retryable: !aborted, operatorHint: aborted ? 'telepress_timeout' : 'telepress_network_error' };
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function escape(value) {
|
|
165
|
+
return value.replace(/[\r\n]/g, ' ').replace(/"/g, '%22');
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=TelePressRichNovel.js.map
|
|
@@ -38,6 +38,7 @@ const logger_1 = require("../logger");
|
|
|
38
38
|
const node_path_1 = require("node:path");
|
|
39
39
|
const language_detection_1 = require("../utils/language-detection");
|
|
40
40
|
const novelMarkers_1 = require("./novelMarkers");
|
|
41
|
+
const zip_1 = require("../utils/zip");
|
|
41
42
|
const LANGUAGE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
42
43
|
class NovelDownloader {
|
|
43
44
|
client;
|
|
@@ -188,9 +189,11 @@ class NovelDownloader {
|
|
|
188
189
|
// Rich-media markdown sidecar (RFC 1 Phase 2): same path as the .txt
|
|
189
190
|
// (compat format stays), inline images become relative 
|
|
190
191
|
// refs so a later TelePress/TelePost phase can render the Telegraph page.
|
|
192
|
+
let richMediaPath;
|
|
191
193
|
if (assets.some((a) => a.status === 'downloaded')) {
|
|
192
194
|
try {
|
|
193
195
|
const mdPath = await this.fileService.saveText(`${header}\n${(0, novelMarkers_1.renderNovelMarkdown)(text, assets)}`, fileName.replace(/\.txt$/, '.md'), metadata);
|
|
196
|
+
richMediaPath = mdPath;
|
|
194
197
|
logger_1.logger.info(`Saved novel ${detail.id} rich-media markdown sidecar`, { filePath: mdPath });
|
|
195
198
|
}
|
|
196
199
|
catch (error) {
|
|
@@ -243,6 +246,33 @@ class NovelDownloader {
|
|
|
243
246
|
catch (error) {
|
|
244
247
|
logger_1.logger.warn(`Failed to save metadata for novel ${detail.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
245
248
|
}
|
|
249
|
+
// Rich-media ZIP archive (Phase 3): a save-only download package carrying
|
|
250
|
+
// txt + md + metadata + images. The md stays an internal render input, never
|
|
251
|
+
// a user-facing attachment; txt remains the authoritative/compat file.
|
|
252
|
+
let archivePath;
|
|
253
|
+
if (assets.some((a) => a.status === 'downloaded')) {
|
|
254
|
+
const zipName = this.fileService.sanitizeFileName(`${detail.id}_${detail.title}.zip`);
|
|
255
|
+
const dest = (0, node_path_1.join)((0, node_path_1.dirname)(filePath), zipName);
|
|
256
|
+
const entries = [
|
|
257
|
+
{ name: fileName, sourcePath: filePath },
|
|
258
|
+
];
|
|
259
|
+
if (richMediaPath)
|
|
260
|
+
entries.push({ name: `${fileName.replace(/\.txt$/, '.md')}`, sourcePath: richMediaPath });
|
|
261
|
+
if (metadataPath)
|
|
262
|
+
entries.push({ name: `${fileName}.json`, sourcePath: metadataPath });
|
|
263
|
+
for (const a of assets) {
|
|
264
|
+
if (a.status === 'downloaded' && a.localPath) {
|
|
265
|
+
entries.push({ name: `images/${(0, node_path_1.basename)(a.localPath)}`, sourcePath: a.localPath });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
archivePath = await (0, zip_1.createZipArchive)(dest, entries);
|
|
270
|
+
logger_1.logger.info(`Saved novel ${detail.id} zip archive`, { filePath: archivePath });
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
logger_1.logger.warn(`Failed to save zip archive for novel ${detail.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
246
276
|
this.database.insertDownload({
|
|
247
277
|
pixivId: String(detail.id),
|
|
248
278
|
type: 'novel',
|
|
@@ -264,7 +294,7 @@ class NovelDownloader {
|
|
|
264
294
|
type: 'novel',
|
|
265
295
|
title: detail.title,
|
|
266
296
|
tags: tags.map((item) => item.name).filter(Boolean),
|
|
267
|
-
files: [filePath],
|
|
297
|
+
files: archivePath ? [filePath, archivePath] : [filePath],
|
|
268
298
|
cleanupFiles: metadataPath ? [metadataPath] : [],
|
|
269
299
|
spoiler: (detail.x_restrict ?? 0) > 0,
|
|
270
300
|
xRestrict: detail.x_restrict,
|
|
@@ -108,5 +108,11 @@ export declare class NovelTargetHandler {
|
|
|
108
108
|
* second concurrent worker lose this race instead of double-submitting.
|
|
109
109
|
*/
|
|
110
110
|
private recordArtifactOutcome;
|
|
111
|
+
/**
|
|
112
|
+
* Optional rich-novel preview enrichment (TelePress /publish/rich-novel).
|
|
113
|
+
* Non-fatal by design: a preview failure never blocks the TXT/ZIP
|
|
114
|
+
* submission, but it is surfaced with the operational result contract.
|
|
115
|
+
*/
|
|
116
|
+
private enrichRichNovelPreview;
|
|
111
117
|
}
|
|
112
118
|
//# sourceMappingURL=NovelTargetHandler.d.ts.map
|
|
@@ -12,6 +12,7 @@ const observability_1 = require("../../observability");
|
|
|
12
12
|
const context_1 = require("../../observability/context");
|
|
13
13
|
const DownloadPlanner_1 = require("../plan/DownloadPlanner");
|
|
14
14
|
const deliveryContext_1 = require("./deliveryContext");
|
|
15
|
+
const TelePressRichNovel_1 = require("../../delivery/TelePressRichNovel");
|
|
15
16
|
class NovelTargetHandler {
|
|
16
17
|
client;
|
|
17
18
|
database;
|
|
@@ -678,7 +679,7 @@ class NovelTargetHandler {
|
|
|
678
679
|
// candidate and the scan could never advance — the whole point of this
|
|
679
680
|
// change. `releaseCellWork` itself refuses once the cell moved on
|
|
680
681
|
// (delivery_pending/submitted), so a committed identity stays stable.
|
|
681
|
-
const attempt = this.recordArtifactOutcome(artifact, target);
|
|
682
|
+
const attempt = await this.recordArtifactOutcome(artifact, target);
|
|
682
683
|
if (attempt.kind === 'skipped' && execution && !recovering) {
|
|
683
684
|
execution.release(workId);
|
|
684
685
|
}
|
|
@@ -692,7 +693,7 @@ class NovelTargetHandler {
|
|
|
692
693
|
* slot as a `duplicate`. The delivery idempotency ledger is what makes the
|
|
693
694
|
* second concurrent worker lose this race instead of double-submitting.
|
|
694
695
|
*/
|
|
695
|
-
recordArtifactOutcome(artifact, target) {
|
|
696
|
+
async recordArtifactOutcome(artifact, target) {
|
|
696
697
|
const isDelivery = target.storageMode === 'cache' && target.delivery?.target?.trim();
|
|
697
698
|
if (!isDelivery || !this.deliveryService) {
|
|
698
699
|
this.outcomes.push({ kind: 'stored', workId: artifact.pixivId, workType: artifact.type });
|
|
@@ -710,9 +711,10 @@ class NovelTargetHandler {
|
|
|
710
711
|
},
|
|
711
712
|
};
|
|
712
713
|
}
|
|
714
|
+
const fields = await this.enrichRichNovelPreview(artifact, target);
|
|
713
715
|
const res = this.deliveryService.enqueue(artifact, target, {
|
|
714
716
|
slotId,
|
|
715
|
-
fields: target.delivery?.fields,
|
|
717
|
+
fields: fields ?? target.delivery?.fields,
|
|
716
718
|
extraContext: (0, deliveryContext_1.deliveryContextFields)(target),
|
|
717
719
|
});
|
|
718
720
|
if (res.duplicate) {
|
|
@@ -733,6 +735,45 @@ class NovelTargetHandler {
|
|
|
733
735
|
});
|
|
734
736
|
return { kind: 'selected', workId: artifact.pixivId, workType: artifact.type };
|
|
735
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Optional rich-novel preview enrichment (TelePress /publish/rich-novel).
|
|
740
|
+
* Non-fatal by design: a preview failure never blocks the TXT/ZIP
|
|
741
|
+
* submission, but it is surfaced with the operational result contract.
|
|
742
|
+
*/
|
|
743
|
+
async enrichRichNovelPreview(artifact, target) {
|
|
744
|
+
const delivery = target.delivery;
|
|
745
|
+
if (!delivery?.richNovelPreview?.url)
|
|
746
|
+
return undefined;
|
|
747
|
+
if (!(0, TelePressRichNovel_1.findRichNovelSources)(artifact))
|
|
748
|
+
return undefined;
|
|
749
|
+
const cfg = delivery.richNovelPreview;
|
|
750
|
+
const headers = {};
|
|
751
|
+
for (const [key, value] of Object.entries(cfg.headers ?? {})) {
|
|
752
|
+
headers[key] = (0, TelePressRichNovel_1.interpolateEnv)(String(value));
|
|
753
|
+
}
|
|
754
|
+
const result = await (0, TelePressRichNovel_1.publishRichNovelPreview)(artifact, {
|
|
755
|
+
url: (0, TelePressRichNovel_1.interpolateEnv)(cfg.url),
|
|
756
|
+
headers,
|
|
757
|
+
timeoutMs: cfg.timeoutMs,
|
|
758
|
+
});
|
|
759
|
+
const field = cfg.field ?? 'novel_preview_url';
|
|
760
|
+
if (result.url) {
|
|
761
|
+
logger_1.logger.info('Rich novel preview URL ready', {
|
|
762
|
+
pixivId: artifact.pixivId,
|
|
763
|
+
field,
|
|
764
|
+
url: result.url.slice(0, 120),
|
|
765
|
+
});
|
|
766
|
+
return { ...(target.delivery?.fields ?? {}), [field]: result.url };
|
|
767
|
+
}
|
|
768
|
+
logger_1.logger.warn('Rich novel preview skipped', {
|
|
769
|
+
code: 'NOVEL_PUBLISH_FAILED',
|
|
770
|
+
stage: 'telepress_publish',
|
|
771
|
+
retryable: result.retryable,
|
|
772
|
+
operator_hint: result.operatorHint ?? 'unknown',
|
|
773
|
+
pixivId: artifact.pixivId,
|
|
774
|
+
});
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
736
777
|
}
|
|
737
778
|
exports.NovelTargetHandler = NovelTargetHandler;
|
|
738
779
|
//# sourceMappingURL=NovelTargetHandler.js.map
|
package/dist/package.json
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ArchiveEntry {
|
|
2
|
+
/** Path the file is stored under inside the zip (e.g. "images/001.jpg"). */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Absolute path of the file on disk. */
|
|
5
|
+
sourcePath: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Create a zip archive at `destPath` containing `entries` (order preserved).
|
|
9
|
+
* Returns destPath on success; on failure removes a partial destination so a
|
|
10
|
+
* caller never hands a half-written archive to a delivery. Pure FS helper — no
|
|
11
|
+
* domain logic.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createZipArchive(destPath: string, entries: ArchiveEntry[]): Promise<string>;
|
|
14
|
+
//# sourceMappingURL=zip.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createZipArchive = createZipArchive;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_fs_2 = require("node:fs");
|
|
6
|
+
const node_module_1 = require("node:module");
|
|
7
|
+
// archiver ships no matching TS factory types; bind it at runtime via
|
|
8
|
+
// createRequire and keep a minimal local contract (nothing Pixiv-specific).
|
|
9
|
+
const archiver = (0, node_module_1.createRequire)(__filename)('archiver');
|
|
10
|
+
/**
|
|
11
|
+
* Create a zip archive at `destPath` containing `entries` (order preserved).
|
|
12
|
+
* Returns destPath on success; on failure removes a partial destination so a
|
|
13
|
+
* caller never hands a half-written archive to a delivery. Pure FS helper — no
|
|
14
|
+
* domain logic.
|
|
15
|
+
*/
|
|
16
|
+
async function createZipArchive(destPath, entries) {
|
|
17
|
+
const output = (0, node_fs_1.createWriteStream)(destPath);
|
|
18
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
19
|
+
const settled = new Promise((resolve, reject) => {
|
|
20
|
+
output.on('close', () => resolve(destPath));
|
|
21
|
+
output.on('error', reject);
|
|
22
|
+
archive.on('error', reject);
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
archive.pipe(output);
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
archive.file(e.sourcePath, { name: e.name });
|
|
28
|
+
}
|
|
29
|
+
await archive.finalize();
|
|
30
|
+
await settled;
|
|
31
|
+
await node_fs_2.promises.access(destPath);
|
|
32
|
+
return destPath;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
try {
|
|
36
|
+
await node_fs_2.promises.rm(destPath, { force: true });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* best-effort cleanup */
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Failed to create zip archive: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=zip.js.map
|
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.27.0', commit: '491a2a8f7abd' };
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/webui/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pixivflow",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.27.0",
|
|
4
|
+
"description": "🎨 Pixiv 下载、筛选与自动收集工具 - 批量下载插画和小说、按标签/热度/日期筛选、定时任务与可靠 HTTP 交付 | Pixiv downloader and automation toolkit with filtering, scheduling and reliable HTTP delivery",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/redtidev1918/PixivFlow.git"
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
},
|
|
117
117
|
"dependencies": {
|
|
118
118
|
"@redtidev/pixiv-client": "0.1.0",
|
|
119
|
+
"archiver": "^7.0.1",
|
|
119
120
|
"axios": "^1.20.0",
|
|
120
121
|
"cors": "^2.8.6",
|
|
121
122
|
"cron-parser": "^4.9.0",
|