pixivflow 2.19.1 → 2.19.3

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.
Files changed (43) hide show
  1. package/dist/commands/ExecuteSlotCommand.js +1 -1
  2. package/dist/commands/SchedulerCommand.js +3 -1
  3. package/dist/commands/SchedulerRunOnceCommand.js +1 -1
  4. package/dist/commands/scheduler-runtime.d.ts +31 -2
  5. package/dist/commands/scheduler-runtime.js +63 -9
  6. package/dist/delivery/DeliveryLedgerPort.d.ts +14 -0
  7. package/dist/delivery/DeliveryLedgerPort.js +43 -0
  8. package/dist/delivery/OutboxWorker.d.ts +2 -0
  9. package/dist/delivery/OutboxWorker.js +25 -13
  10. package/dist/delivery/errorClass.d.ts +1 -1
  11. package/dist/delivery/errorClass.js +7 -0
  12. package/dist/download/DownloadManager.d.ts +5 -0
  13. package/dist/download/DownloadManager.js +12 -2
  14. package/dist/download/handlers/IllustrationTargetHandler.d.ts +27 -1
  15. package/dist/download/handlers/IllustrationTargetHandler.js +105 -7
  16. package/dist/download/handlers/NovelTargetHandler.d.ts +27 -1
  17. package/dist/download/handlers/NovelTargetHandler.js +107 -3
  18. package/dist/notification/NotificationPolicy.d.ts +11 -0
  19. package/dist/notification/NotificationPolicy.js +22 -5
  20. package/dist/package.json +1 -1
  21. package/dist/pixiv-client/TargetSearchRunner.js +24 -8
  22. package/dist/scheduler/SlotCoordinator.d.ts +74 -1
  23. package/dist/scheduler/SlotCoordinator.js +108 -1
  24. package/dist/scheduler/WorkIdentity.d.ts +49 -0
  25. package/dist/scheduler/WorkIdentity.js +31 -0
  26. package/dist/storage/repositories/DeliveryRepository.d.ts +9 -0
  27. package/dist/storage/repositories/DeliveryRepository.js +16 -0
  28. package/dist/storage/repositories/OutboxRepository.d.ts +15 -0
  29. package/dist/storage/repositories/OutboxRepository.js +31 -0
  30. package/dist/storage/repositories/SlotRepository.d.ts +27 -0
  31. package/dist/storage/repositories/SlotRepository.js +45 -0
  32. package/dist/topic/TopicPipeline.js +4 -0
  33. package/dist/utils/errors.d.ts +9 -0
  34. package/dist/utils/errors.js +14 -1
  35. package/dist/version.js +1 -1
  36. package/dist/webui/package.json +1 -1
  37. package/node_modules/@redtidev/pixiv-client/dist/transport/transport.d.ts +9 -0
  38. package/node_modules/@redtidev/pixiv-client/dist/transport/transport.d.ts.map +1 -1
  39. package/node_modules/@redtidev/pixiv-client/dist/transport/transport.js +66 -14
  40. package/node_modules/@redtidev/pixiv-client/dist/transport/transport.js.map +1 -1
  41. package/node_modules/@redtidev/pixiv-client/src/transport/__tests__/transport-cancellation.test.ts +257 -0
  42. package/node_modules/@redtidev/pixiv-client/src/transport/transport.ts +81 -18
  43. package/package.json +1 -1
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Per-request timeout *composition* with a caller/run AbortSignal.
3
+ *
4
+ * Contract: the signal handed to `fetch` must abort when EITHER
5
+ * - the caller's run signal aborts (scheduler cancellation), OR
6
+ * - the per-request timeout elapses,
7
+ * whichever fires first. A caller signal must never *disable* the timeout.
8
+ *
9
+ * `TargetSearchRunner` threads the scheduler run signal into the kit search
10
+ * calls on the documented assumption that "the transport already combines a
11
+ * signal with its per-request timeout". These tests pin that assumption down
12
+ * with zero network access: the fake fetch below never settles on its own.
13
+ *
14
+ * Fidelity note (Node 24 / undici, verified empirically):
15
+ * - `controller.abort(reason)` makes fetch reject with `reason` itself;
16
+ * - `controller.abort()` makes fetch reject with a DOMException AbortError.
17
+ * The fake fetch mirrors both shapes so the transport classifies what it
18
+ * actually sees in production.
19
+ */
20
+ import { PixivClient } from '../../client';
21
+ import { StaticTokenProvider } from '../../auth/types';
22
+ import type { FetchLike } from '../../types';
23
+ import { PixivAbortError, PixivNetworkError, PixivTimeoutError } from '../../errors/errors';
24
+
25
+ const PER_REQUEST_TIMEOUT_MS = 80;
26
+ /** Generous relative to PER_REQUEST_TIMEOUT_MS, tiny relative to jest's 15s. */
27
+ const SETTLE_BUDGET_MS = 1_200;
28
+
29
+ type SettleOutcome = { settled: true; error?: unknown } | { settled: false };
30
+
31
+ /** Resolves as soon as `p` settles; reports `settled: false` on the deadline. */
32
+ function settleWithin(p: Promise<unknown>, ms: number): Promise<SettleOutcome> {
33
+ return new Promise<SettleOutcome>((resolve) => {
34
+ const timer = setTimeout(() => resolve({ settled: false }), ms);
35
+ p.then(
36
+ () => {
37
+ clearTimeout(timer);
38
+ resolve({ settled: true });
39
+ },
40
+ (error) => {
41
+ clearTimeout(timer);
42
+ resolve({ settled: true, error });
43
+ }
44
+ );
45
+ });
46
+ }
47
+
48
+ /**
49
+ * A fetch that hangs until its signal aborts, then rejects exactly the way
50
+ * undici rejects (with the signal reason, or a DOMException named AbortError).
51
+ * It never applies a timeout of its own: the transport must supply one.
52
+ */
53
+ function hangingFetch(counter: { calls: number }): FetchLike {
54
+ return async (_url, init) => {
55
+ counter.calls++;
56
+ const signal = init.signal ?? undefined;
57
+ await new Promise<never>((_resolve, reject) => {
58
+ const rejectLikeUndici = (): void => {
59
+ const reason: unknown = signal?.reason;
60
+ if (reason !== undefined && reason !== null) {
61
+ reject(reason);
62
+ return;
63
+ }
64
+ const e = new Error('This operation was aborted');
65
+ e.name = 'AbortError';
66
+ reject(e);
67
+ };
68
+ if (signal?.aborted) {
69
+ rejectLikeUndici();
70
+ return;
71
+ }
72
+ signal?.addEventListener('abort', rejectLikeUndici, { once: true });
73
+ });
74
+ throw new Error('unreachable');
75
+ };
76
+ }
77
+
78
+ function makeClient(fetchImpl: FetchLike, retries = 0): PixivClient {
79
+ return new PixivClient({
80
+ auth: new StaticTokenProvider('test-token'),
81
+ fetchImpl,
82
+ retries,
83
+ timeoutMs: PER_REQUEST_TIMEOUT_MS,
84
+ rateLimit: {
85
+ minIntervalMs: 0,
86
+ jitterRatio: 0,
87
+ initialCooldownMs: 60_000,
88
+ maxCooldownMs: 900_000,
89
+ openThreshold: 10,
90
+ decaySuccesses: 99,
91
+ random: () => 0,
92
+ },
93
+ });
94
+ }
95
+
96
+ function errorOf(outcome: SettleOutcome): unknown {
97
+ return outcome.settled ? outcome.error : undefined;
98
+ }
99
+
100
+ describe('per-request timeout survives a caller/run signal', () => {
101
+ it('a hung search request still times out while a run signal is attached', async () => {
102
+ const counter = { calls: 0 };
103
+ const client = makeClient(hangingFetch(counter));
104
+ const run = new AbortController(); // attached, NOT aborted
105
+
106
+ const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
107
+ const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
108
+
109
+ // Regression: with a caller signal the per-request timeout used to be
110
+ // skipped entirely, so this promise stayed pending until the whole
111
+ // schedule timed out.
112
+ expect(outcome.settled).toBe(true);
113
+ expect(errorOf(outcome)).toBeInstanceOf(PixivTimeoutError);
114
+ });
115
+
116
+ it('a hung media fetch still times out while a run signal is attached', async () => {
117
+ const counter = { calls: 0 };
118
+ const client = makeClient(hangingFetch(counter));
119
+
120
+ const call = client.media.fetch('https://i.pximg.net/img-original/img/x.jpg', {
121
+ signal: new AbortController().signal,
122
+ });
123
+ const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
124
+
125
+ expect(outcome.settled).toBe(true);
126
+ expect(errorOf(outcome)).toBeInstanceOf(PixivTimeoutError);
127
+ });
128
+
129
+ it('aborting the run signal still cancels the request, with no timeout masking it', async () => {
130
+ const counter = { calls: 0 };
131
+ const client = makeClient(hangingFetch(counter));
132
+ const run = new AbortController();
133
+
134
+ const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
135
+ setTimeout(() => run.abort(), 10);
136
+
137
+ const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
138
+ expect(outcome.settled).toBe(true);
139
+ expect(errorOf(outcome)).toBeInstanceOf(PixivAbortError);
140
+ expect(counter.calls).toBe(1);
141
+ });
142
+
143
+ it('cancels promptly when the run signal aborts during retry back-off', async () => {
144
+ let calls = 0;
145
+ const client = makeClient(
146
+ async () => {
147
+ calls++;
148
+ throw Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' });
149
+ },
150
+ 3
151
+ );
152
+
153
+ const run = new AbortController();
154
+ const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
155
+ setTimeout(() => run.abort(), 30);
156
+
157
+ const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
158
+ expect(outcome.settled).toBe(true);
159
+ expect(errorOf(outcome)).toBeInstanceOf(PixivAbortError);
160
+ // The back-off must not be waited out and the retry budget must not be burned.
161
+ expect(calls).toBe(1);
162
+ });
163
+
164
+ it('keeps retrying transient network failures while the run signal stays active', async () => {
165
+ let calls = 0;
166
+ const client = makeClient(async () => {
167
+ calls++;
168
+ throw Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' });
169
+ }, 2);
170
+
171
+ const run = new AbortController();
172
+ const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
173
+ const outcome = await settleWithin(call, 4_000);
174
+
175
+ expect(outcome.settled).toBe(true);
176
+ expect(errorOf(outcome)).toBeInstanceOf(PixivNetworkError);
177
+ expect(calls).toBe(3); // retries honoured: an attached signal is not a cancellation
178
+ });
179
+
180
+ it('removes its abort listener from the caller signal once the request settles', async () => {
181
+ const counter = { calls: 0 };
182
+ const client = makeClient(hangingFetch(counter));
183
+ const run = new AbortController();
184
+ const added = jest.spyOn(run.signal, 'addEventListener');
185
+ const removed = jest.spyOn(run.signal, 'removeEventListener');
186
+
187
+ const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
188
+ const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
189
+ expect(outcome.settled).toBe(true);
190
+
191
+ expect(added).toHaveBeenCalled();
192
+ // No listener leak: every abort listener added is taken off again.
193
+ expect(removed.mock.calls.length).toBe(added.mock.calls.length);
194
+
195
+ // Already settled: aborting afterwards must not reject anything a second time.
196
+ expect(() => run.abort()).not.toThrow();
197
+ });
198
+
199
+ it('without a caller signal the per-request timeout is unchanged', async () => {
200
+ const counter = { calls: 0 };
201
+ const client = makeClient(hangingFetch(counter));
202
+
203
+ const call = client.illustrations.searchPage({ word: 'test', limit: 1 });
204
+ const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
205
+
206
+ expect(outcome.settled).toBe(true);
207
+ expect(errorOf(outcome)).toBeInstanceOf(PixivTimeoutError);
208
+ });
209
+
210
+ it('clears the per-request timer once the request settles', async () => {
211
+ jest.useFakeTimers();
212
+ try {
213
+ const counter = { calls: 0 };
214
+ const client = makeClient(hangingFetch(counter));
215
+ const baseline = jest.getTimerCount();
216
+
217
+ const call = client.illustrations.searchPage({ word: 'test', limit: 1 });
218
+ void call.catch(() => undefined); // observed below via rejects
219
+ expect(jest.getTimerCount()).toBeGreaterThan(baseline); // timeout armed
220
+
221
+ await jest.advanceTimersByTimeAsync(PER_REQUEST_TIMEOUT_MS);
222
+
223
+ await expect(call).rejects.toBeInstanceOf(PixivTimeoutError);
224
+ // No leaked timer: `cancel()` in the finally block cleared it.
225
+ expect(jest.getTimerCount()).toBe(baseline);
226
+ } finally {
227
+ jest.useRealTimers();
228
+ }
229
+ });
230
+
231
+ it('settles once and rejects nothing else when a run signal aborts first', async () => {
232
+ const counter = { calls: 0 };
233
+ const client = makeClient(hangingFetch(counter));
234
+ const run = new AbortController();
235
+ const unhandled: unknown[] = [];
236
+ const onUnhandled = (reason: unknown): void => {
237
+ unhandled.push(reason);
238
+ };
239
+ process.on('unhandledRejection', onUnhandled);
240
+ try {
241
+ // A plain string reason, exactly as DownloadManager.cancel() passes it.
242
+ const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
243
+ run.abort('run cancelled');
244
+
245
+ const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
246
+ expect(outcome.settled).toBe(true);
247
+ expect(errorOf(outcome)).toBeInstanceOf(PixivAbortError);
248
+
249
+ // Outlive the per-request deadline: the cleared timer must not abort (or
250
+ // reject) anything a second time.
251
+ await new Promise((resolve) => setTimeout(resolve, PER_REQUEST_TIMEOUT_MS * 2));
252
+ expect(unhandled).toEqual([]);
253
+ } finally {
254
+ process.off('unhandledRejection', onUnhandled);
255
+ }
256
+ });
257
+ });
@@ -46,6 +46,23 @@ const APP_HEADERS = {
46
46
  'App-Version': '7.13.3',
47
47
  };
48
48
 
49
+ /**
50
+ * Marker reason for the per-request timeout abort. `withTimeout()` aborts its
51
+ * own controller with it so the retry path can tell our own timeout apart from
52
+ * a caller/run cancellation: the caller reason is opaque (the downloader passes
53
+ * a plain string) and must not be inspected.
54
+ */
55
+ const TIMEOUT_REASON = '__pixiv_timeout__';
56
+
57
+ /** Structural AbortError test: also matches DOMException (not an Error subclass). */
58
+ function isAbortErrorLike(e: unknown): boolean {
59
+ return typeof e === 'object' && e !== null && (e as { name?: unknown }).name === 'AbortError';
60
+ }
61
+
62
+ function isTimeoutReason(e: unknown): boolean {
63
+ return e instanceof Error && e.message === TIMEOUT_REASON;
64
+ }
65
+
49
66
 
50
67
  function proxyUrl(p: ProxyOptions): string {
51
68
  const protocol = (p.protocol ?? 'http').toLowerCase();
@@ -419,22 +436,36 @@ export class Transport {
419
436
  return { action: 'throw', error: e };
420
437
  }
421
438
 
422
- // AbortError from fetch/axios: distinguish our own timeout abort from a
423
- // caller-provided signal abort. The internal timeout controller has NO
424
- // external signal, so callerAborted cleanly separates the two cases
425
- // (undici/axios attach different reasons; do not rely on their internals).
426
- if (e instanceof Error && e.name === 'AbortError') {
427
- const reason = (e as { cause?: unknown }).cause;
428
- const isOurTimeout =
429
- !callerAborted ||
430
- (reason instanceof Error && reason.message === '__pixiv_timeout__');
431
- if (isOurTimeout) {
432
- return this.maybeTransientRetry(
433
- new PixivTimeoutError(`Request timeout after ${this.timeoutMs}ms`, { endpoint: url, cause: e, code: 'timeout' }),
434
- attempt,
435
- url
436
- );
437
- }
439
+ // Tell our own per-request timeout apart from a caller/run cancellation.
440
+ // The abort reason is authoritative, not the error shape:
441
+ // - our timeout aborts with TIMEOUT_REASON; undici rejects the fetch with
442
+ // that reason object directly (so it arrives as a plain Error, NOT an
443
+ // AbortError), while abort-wrapping runtimes expose it via `cause`;
444
+ // - the caller reason is opaque (a plain string from the downloader), so
445
+ // it must be classified from the signal state.
446
+ const abortCause = (e as { cause?: unknown } | null)?.cause;
447
+ if (isTimeoutReason(e) || isTimeoutReason(abortCause)) {
448
+ return this.maybeTransientRetry(
449
+ new PixivTimeoutError(`Request timeout after ${this.timeoutMs}ms`, { endpoint: url, cause: e, code: 'timeout' }),
450
+ attempt,
451
+ url
452
+ );
453
+ }
454
+
455
+ if (isAbortErrorLike(e) && !callerAborted) {
456
+ // An abort we cannot attribute to the caller can only be our own timeout
457
+ // (the only other abort source on this path).
458
+ return this.maybeTransientRetry(
459
+ new PixivTimeoutError(`Request timeout after ${this.timeoutMs}ms`, { endpoint: url, cause: e, code: 'timeout' }),
460
+ attempt,
461
+ url
462
+ );
463
+ }
464
+
465
+ if (callerAborted) {
466
+ // Run/scheduler cancellation is terminal by definition and must never be
467
+ // charged to the retry budget (waitRetry would otherwise sit out the
468
+ // back-off before the next attempt noticed the cancel).
438
469
  return { action: 'throw', error: new PixivAbortError('Request aborted', { cause: e, endpoint: url }) };
439
470
  }
440
471
 
@@ -467,6 +498,11 @@ export class Transport {
467
498
  }
468
499
 
469
500
  private async waitRetry(waitMs: number, signal?: AbortSignal): Promise<void> {
501
+ // An already-aborted signal never fires 'abort' again, so the sleeper would
502
+ // sit out the entire back-off before the next attempt noticed the cancel.
503
+ if (signal?.aborted) {
504
+ throw new PixivAbortError('aborted before retry back-off');
505
+ }
470
506
  if (waitMs <= 0) return;
471
507
  try {
472
508
  await this.sleeper(waitMs, signal);
@@ -477,15 +513,42 @@ export class Transport {
477
513
 
478
514
  // -- helpers ---------------------------------------------------------------
479
515
 
516
+ /**
517
+ * Effective per-attempt signal = the caller's/run cancellation **OR** the
518
+ * per-request timeout, whichever fires first.
519
+ *
520
+ * Both sources must stay armed. A caller signal may not replace the timeout:
521
+ * that would let a single hung socket wait for the whole schedule timeout
522
+ * (the caller only cancels at run level). The timeout may not replace the
523
+ * caller signal either: a cancelled run must not keep its request alive.
524
+ */
480
525
  private withTimeout(timeoutMs: number, external?: AbortSignal): { signal: AbortSignal; cancel: () => void } {
481
- if (external) return { signal: external, cancel: () => {} };
482
526
  const controller = new AbortController();
483
- const timer = setTimeout(() => controller.abort(new Error('__pixiv_timeout__')), timeoutMs);
527
+ const timer = setTimeout(() => controller.abort(new Error(TIMEOUT_REASON)), timeoutMs);
484
528
  if (typeof timer.unref === 'function') timer.unref();
529
+
530
+ if (!external) {
531
+ return {
532
+ signal: controller.signal,
533
+ cancel: () => {
534
+ clearTimeout(timer);
535
+ },
536
+ };
537
+ }
538
+
539
+ // Forward the caller reason verbatim so cancellation stays observable.
540
+ const onExternalAbort = (): void => controller.abort(external.reason);
541
+ if (external.aborted) {
542
+ onExternalAbort();
543
+ } else {
544
+ external.addEventListener('abort', onExternalAbort, { once: true });
545
+ }
546
+
485
547
  return {
486
548
  signal: controller.signal,
487
549
  cancel: () => {
488
550
  clearTimeout(timer);
551
+ external.removeEventListener('abort', onExternalAbort);
489
552
  },
490
553
  };
491
554
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixivflow",
3
- "version": "2.19.1",
3
+ "version": "2.19.3",
4
4
  "description": "🎨 智能的 Pixiv 自动化下载工具 - 支持批量下载插画和小说、定时任务、Docker部署 | Intelligent Pixiv Automation Downloader with batch download, scheduler, and Docker support",
5
5
  "repository": {
6
6
  "type": "git",