pixivflow 2.26.0 → 2.27.1

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.
@@ -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
  {
@@ -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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.26.0",
4
+ "version": "2.27.1",
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.26.0', commit: '299b0e29cecb' };
5
+ exports.BUILD = { version: '2.27.1', commit: 'f16587443719' };
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.26.0",
4
+ "version": "2.27.1",
5
5
  "description": "PixivFlow WebUI Backend - CommonJS module"
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pixivflow",
3
- "version": "2.26.0",
4
- "description": "🎨 Pixiv 下载、筛选与自动收集工具 - 批量下载插画和小说、按标签/热度/日期筛选、定时任务与可靠 HTTP 交付 | Pixiv downloader and automation toolkit with filtering, scheduling and reliable HTTP delivery",
3
+ "version": "2.27.1",
4
+ "description": "\ud83c\udfa8 Pixiv \u4e0b\u8f7d\u3001\u7b5b\u9009\u4e0e\u81ea\u52a8\u6536\u96c6\u5de5\u5177 - \u6279\u91cf\u4e0b\u8f7d\u63d2\u753b\u548c\u5c0f\u8bf4\u3001\u6309\u6807\u7b7e/\u70ed\u5ea6/\u65e5\u671f\u7b5b\u9009\u3001\u5b9a\u65f6\u4efb\u52a1\u4e0e\u53ef\u9760 HTTP \u4ea4\u4ed8 | 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"