pixivflow 2.19.4 → 2.20.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.
Files changed (44) hide show
  1. package/dist/commands/SchedulerCommand.js +36 -0
  2. package/dist/commands/SchedulerRunOnceCommand.d.ts +2 -3
  3. package/dist/commands/SchedulerRunOnceCommand.js +2 -3
  4. package/dist/commands/scheduler-runtime.js +17 -1
  5. package/dist/config/defaults.d.ts +1 -0
  6. package/dist/config/defaults.js +4 -0
  7. package/dist/config/environment.js +19 -0
  8. package/dist/config/types.d.ts +28 -0
  9. package/dist/config/validation.js +25 -0
  10. package/dist/delivery/DeliveryService.d.ts +30 -1
  11. package/dist/delivery/DeliveryService.js +13 -2
  12. package/dist/delivery/HttpMultipartDelivery.js +29 -8
  13. package/dist/delivery/OutboxWorker.d.ts +2 -0
  14. package/dist/delivery/OutboxWorker.js +3 -0
  15. package/dist/delivery/types.d.ts +18 -0
  16. package/dist/download/DownloadManager.js +12 -1
  17. package/dist/download/handlers/IllustrationTargetHandler.d.ts +42 -2
  18. package/dist/download/handlers/IllustrationTargetHandler.js +240 -50
  19. package/dist/download/handlers/NovelTargetHandler.d.ts +47 -0
  20. package/dist/download/handlers/NovelTargetHandler.js +213 -27
  21. package/dist/download/handlers/deliveryContext.d.ts +16 -0
  22. package/dist/download/handlers/deliveryContext.js +32 -0
  23. package/dist/download/pipeline/DownloadPipeline.d.ts +22 -3
  24. package/dist/download/pipeline/DownloadPipeline.js +109 -92
  25. package/dist/download/plan/DownloadPlanner.d.ts +47 -1
  26. package/dist/download/plan/DownloadPlanner.js +105 -15
  27. package/dist/notification/NotificationPolicy.d.ts +12 -0
  28. package/dist/notification/NotificationPolicy.js +86 -0
  29. package/dist/package.json +1 -1
  30. package/dist/scheduler/MultiScheduleManager.js +2 -0
  31. package/dist/scheduler/ScheduleTriggerServer.d.ts +58 -1
  32. package/dist/scheduler/ScheduleTriggerServer.js +207 -40
  33. package/dist/scheduler/SlotCoordinator.d.ts +80 -2
  34. package/dist/scheduler/SlotCoordinator.js +145 -3
  35. package/dist/scheduler/TargetOutcome.d.ts +195 -2
  36. package/dist/scheduler/TargetOutcome.js +202 -3
  37. package/dist/storage/DatabaseMigration.js +11 -0
  38. package/dist/storage/repositories/DeliveryRepository.d.ts +12 -0
  39. package/dist/storage/repositories/DeliveryRepository.js +18 -1
  40. package/dist/storage/repositories/SlotRepository.d.ts +8 -0
  41. package/dist/storage/repositories/SlotRepository.js +6 -2
  42. package/dist/version.js +1 -1
  43. package/dist/webui/package.json +1 -1
  44. package/package.json +1 -1
@@ -5,8 +5,11 @@ const logger_1 = require("../../logger");
5
5
  const errors_1 = require("../../utils/errors");
6
6
  const pixiv_date_utils_1 = require("../../utils/pixiv-date-utils");
7
7
  const pixiv_utils_1 = require("../../utils/pixiv-utils");
8
+ const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
8
9
  const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
9
10
  const target_label_1 = require("../../utils/target-label");
11
+ const DownloadPlanner_1 = require("../plan/DownloadPlanner");
12
+ const deliveryContext_1 = require("./deliveryContext");
10
13
  class NovelTargetHandler {
11
14
  client;
12
15
  database;
@@ -16,6 +19,34 @@ class NovelTargetHandler {
16
19
  topicPipelineFactory;
17
20
  deliveryService;
18
21
  outcomes = [];
22
+ /**
23
+ * Candidate scan of the last pipeline.run() of this handle() call: how many
24
+ * candidates were attempted, which were skipped and why, and whether any
25
+ * job-level outage appeared. This is what turns an empty result into an
26
+ * explicit verdict instead of an ambiguous "completed".
27
+ */
28
+ scan = null;
29
+ /**
30
+ * Global candidate-scan bound (`download.candidateScanLimit`), supplied by
31
+ * DownloadManager. This is what lets the FETCH stage ask for more than one
32
+ * candidate: a scheduled one-post-per-slot target has `limit: 1`, and asking
33
+ * the ranking API for exactly one work is the reason a single duplicate used
34
+ * to be unfixable downstream.
35
+ */
36
+ defaultCandidateScanLimit;
37
+ /** Publish the global candidate-scan bound for the fetch stage. */
38
+ setDefaultCandidateScanLimit(limit) {
39
+ this.defaultCandidateScanLimit = limit;
40
+ }
41
+ /**
42
+ * How many candidates to FETCH so the bounded scan has something to choose
43
+ * from. Same rule the planner applies to the attempt window, so fetch and
44
+ * scan agree on one bound.
45
+ */
46
+ candidateFetchLimit(target) {
47
+ const targetLimit = target.limit && target.limit > 0 ? target.limit : 10;
48
+ return Math.max(targetLimit, (0, DownloadPlanner_1.resolveCandidateScanLimit)(target, this.defaultCandidateScanLimit));
49
+ }
19
50
  /**
20
51
  * Cell identity for this handle() call. Set only for a single-work cell of a
21
52
  * scheduled occurrence; null for ad-hoc runs and N-works-per-run targets, which
@@ -33,6 +64,7 @@ class NovelTargetHandler {
33
64
  }
34
65
  async handle(target, execution) {
35
66
  this.outcomes = [];
67
+ this.scan = null;
36
68
  this.execution = execution && (0, WorkIdentity_1.isSingleWorkCell)(target) ? execution : null;
37
69
  // A cell that already owns a work is in RECOVERY, not in a new selection.
38
70
  // Crash/shutdown recovery is not an intentional second run: running the
@@ -72,6 +104,7 @@ class NovelTargetHandler {
72
104
  }
73
105
  const novels = await this.fetchNovels(target, mode);
74
106
  const result = await this.pipeline.run(novels, target, 'novel', (novel, tag) => this.downloadAndDeliver(novel, tag, target));
107
+ this.scan = result.scan;
75
108
  await this.handleDownloadResult(result, target, mode, novels.length);
76
109
  return this.summarize();
77
110
  }
@@ -79,26 +112,84 @@ class NovelTargetHandler {
79
112
  return this.classifyError(error, displayTag, mode);
80
113
  }
81
114
  }
115
+ /**
116
+ * Reduce the outcomes collected while processing one target to one verdict,
117
+ * including the bounded-scan bookkeeping.
118
+ *
119
+ * A `duplicate` is deliberately NOT a target verdict for a scan: a duplicate
120
+ * is a CANDIDATE problem (skip it and try the next), which is what the scan
121
+ * already did. Returning it here is the bug that made a scheduled slot report
122
+ * success after submitting nothing. It remains a verdict only for the
123
+ * single-work RECOVERY path, whose cell identity is fixed.
124
+ */
82
125
  summarize() {
83
- return (this.outcomes.find((o) => o.kind === 'submitted') ??
84
- this.outcomes.find((o) => o.kind === 'stored') ??
85
- this.outcomes.find((o) => o.kind === 'delivery_pending') ??
86
- this.outcomes.find((o) => o.kind === 'duplicate') ??
87
- this.outcomes.find((o) => o.kind === 'failed') ?? {
126
+ const scan = this.scan ?? undefined;
127
+ const submitted = this.outcomes.find((o) => o.kind === 'submitted');
128
+ if (submitted)
129
+ return scan ? { ...submitted, scan } : submitted;
130
+ const stored = this.outcomes.find((o) => o.kind === 'stored');
131
+ if (stored)
132
+ return scan ? { ...stored, scan } : stored;
133
+ const pending = this.outcomes.find((o) => o.kind === 'delivery_pending');
134
+ if (pending)
135
+ return scan ? { ...pending, scan } : pending;
136
+ const duplicate = this.outcomes.find((o) => o.kind === 'duplicate');
137
+ if (duplicate)
138
+ return scan ? { ...duplicate, scan } : duplicate;
139
+ const failed = this.outcomes.find((o) => o.kind === 'failed');
140
+ if (failed)
141
+ return scan ? { ...failed, scan } : failed;
142
+ if (scan && scan.outages.length > 0) {
143
+ return {
144
+ kind: 'failed',
145
+ retryable: true,
146
+ error: `job-level outage while scanning candidates: ${scan.outages.join(', ')}`,
147
+ scan,
148
+ };
149
+ }
150
+ if (scan && (0, TargetOutcome_1.hasTransientFailure)(scan)) {
151
+ return {
152
+ kind: 'failed',
153
+ retryable: true,
154
+ error: `candidate scan hit transient infrastructure failures ` +
155
+ `(${scan.skipped.filter((s) => s.retryable).length} of ${scan.attempted} attempted)`,
156
+ scan,
157
+ };
158
+ }
159
+ if (scan && (scan.attempted > 0 || scan.skipped.length > 0)) {
160
+ return { kind: 'no_candidate', reason: (0, TargetOutcome_1.noEligibleCandidateText)(scan), scan };
161
+ }
162
+ return {
88
163
  kind: 'no_candidate',
89
164
  reason: 'no matching novel after filtering/dedupe',
90
- });
165
+ ...(scan ? { scan } : {}),
166
+ };
91
167
  }
92
168
  classifyError(error, displayTag, mode) {
93
169
  const message = error instanceof Error ? error.message : String(error);
170
+ const scan = this.scan ?? undefined;
171
+ // A hard job-level outage is named as such and is never recorded as a
172
+ // no-candidate business outcome, whatever its message happens to look like.
173
+ const outage = (0, TargetOutcome_1.classifyJobLevelOutage)(error);
94
174
  this.database.logExecution(displayTag, 'novel', 'failed', message);
95
- logger_1.logger.error(`Novel ${mode === 'ranking' ? 'ranking' : 'tag'} ${displayTag} failed`, { error: message });
175
+ logger_1.logger.error(`Novel ${mode === 'ranking' ? 'ranking' : 'tag'} ${displayTag} failed`, {
176
+ error: message,
177
+ ...(outage ? { jobLevelOutage: outage } : {}),
178
+ });
179
+ if (outage) {
180
+ return {
181
+ kind: 'failed',
182
+ retryable: true,
183
+ error: `job-level outage (${outage}): ${message}`,
184
+ ...(scan ? { scan } : {}),
185
+ };
186
+ }
96
187
  if (/no matching|all .*filtered|no_candidate|language filter/i.test(message)) {
97
- return { kind: 'no_candidate', reason: message };
188
+ return { kind: 'no_candidate', reason: message, ...(scan ? { scan } : {}) };
98
189
  }
99
190
  const retryable = (0, errors_1.isRetryableNetworkError)(error) ||
100
191
  (error instanceof Error && /timeout|econn|enotfound|etimed|429|5\d\d/i.test(error.message));
101
- return { kind: 'failed', retryable, error: message };
192
+ return { kind: 'failed', retryable, error: message, ...(scan ? { scan } : {}) };
102
193
  }
103
194
  async fetchNovels(target, mode) {
104
195
  if (mode === 'topic') {
@@ -137,6 +228,7 @@ class NovelTargetHandler {
137
228
  skipped: 0,
138
229
  alreadyDownloaded: 0,
139
230
  filteredOut: 0,
231
+ scan: { bound: 0, attempted: 0, skipped: [], outages: [] },
140
232
  };
141
233
  let totalFound = 0;
142
234
  for (let offset = 0; offset <= additionalDays && aggregate.downloaded < requested; offset++) {
@@ -157,11 +249,20 @@ class NovelTargetHandler {
157
249
  const novels = await this.fetchTopicNovels(attemptTarget);
158
250
  totalFound += novels.length;
159
251
  const result = await this.pipeline.run(novels, attemptTarget, 'novel', (novel, tag) => this.downloadAndDeliver(novel, tag, attemptTarget));
252
+ // The lookback loop is ONE bounded scan: accumulate every day's
253
+ // skips/outages so the verdict covers all candidates attempted.
254
+ aggregate.scan = (0, TargetOutcome_1.mergeScanSummaries)(aggregate.scan, result.scan);
160
255
  aggregate.downloaded += result.downloaded;
161
256
  aggregate.skipped += result.skipped;
162
257
  aggregate.alreadyDownloaded += result.alreadyDownloaded;
163
258
  aggregate.filteredOut += result.filteredOut;
259
+ if (aggregate.scan.outages.length > 0) {
260
+ // A dead token / dead database / dead network is not "no matching
261
+ // novel": stop looking back and let the job fail/retry.
262
+ break;
263
+ }
164
264
  }
265
+ this.scan = aggregate.scan;
165
266
  await this.handleDownloadResult(aggregate, target, 'topic', totalFound, checkedDays);
166
267
  }
167
268
  resolveTopicDay(target) {
@@ -191,12 +292,14 @@ class NovelTargetHandler {
191
292
  endDate: rankingDate,
192
293
  limit: Math.max(targetLimit * 20, 100),
193
294
  };
295
+ const fetchLimit = this.candidateFetchLimit(target);
194
296
  let novels = await this.client.searchNovels(searchTarget);
195
297
  logger_1.logger.info(`Found ${novels.length} novel(s) for ${rankingDate}`);
196
- this.sortByPopularityAndLog(novels, targetLimit);
197
- if (novels.length > targetLimit) {
198
- novels = novels.slice(0, targetLimit);
199
- logger_1.logger.info(`Selected top ${novels.length} novel(s) by popularity`);
298
+ this.sortByPopularityAndLog(novels, fetchLimit);
299
+ if (novels.length > fetchLimit) {
300
+ novels = novels.slice(0, fetchLimit);
301
+ logger_1.logger.info(`Selected top ${novels.length} novel(s) by popularity ` +
302
+ `(to fill ${targetLimit}, candidate scan bound ${fetchLimit})`);
200
303
  }
201
304
  return novels;
202
305
  }
@@ -207,7 +310,9 @@ class NovelTargetHandler {
207
310
  rankingDate = (0, pixiv_date_utils_1.getYesterdayDate)();
208
311
  }
209
312
  logger_1.logger.info(`Fetching ranking novels (mode: ${rankingMode}, date: ${rankingDate})`);
210
- const novels = await this.rankingService.getRankingNovelsWithFallback(rankingMode, rankingDate, target.limit);
313
+ // Ask for the whole bounded scan window, not `limit`: the planner then
314
+ // narrows it to the candidates this run may attempt.
315
+ const novels = await this.rankingService.getRankingNovelsWithFallback(rankingMode, rankingDate, this.candidateFetchLimit(target));
211
316
  logger_1.logger.info(`Ranking API returned ${novels.length} novel(s)`);
212
317
  return novels;
213
318
  }
@@ -236,10 +341,19 @@ class NovelTargetHandler {
236
341
  const { downloaded, skipped, alreadyDownloaded, filteredOut } = result;
237
342
  const targetLimit = target.limit || 10;
238
343
  const tagForLog = (0, target_label_1.getTargetLabel)(target);
239
- if (downloaded === 0 && targetLimit > 0) {
344
+ // The bounded scan produced its own explicit verdict when it attempted
345
+ // candidates or pre-filtered some, so the legacy "zero downloads" reporter
346
+ // is only the fallback for a scan that had nothing at all to look at.
347
+ const scanOwnsVerdict = result.scan.attempted > 0 || result.scan.skipped.length > 0;
348
+ if (downloaded === 0 && targetLimit > 0 && !scanOwnsVerdict) {
240
349
  await this.handleZeroDownloads(alreadyDownloaded, skipped, filteredOut, targetLimit, tagForLog, mode, target, totalFound, checkedDays, result.skipDetails);
241
350
  return;
242
351
  }
352
+ if (scanOwnsVerdict && downloaded > 0) {
353
+ logger_1.logger.info(`Candidate scan verdict for ${tagForLog}: submitted after skipping ` +
354
+ `${result.scan.skipped.length} candidate(s) of ${result.scan.attempted} attempted ` +
355
+ `(bound ${result.scan.bound})`);
356
+ }
243
357
  if (downloaded > 0 && downloaded < targetLimit * 0.5 && skipped > 0) {
244
358
  logger_1.logger.warn(`Only downloaded ${downloaded} out of ${targetLimit} requested novel(s). ${skipped} novel(s) were skipped due to 404 errors or other issues.`);
245
359
  }
@@ -375,6 +489,7 @@ class NovelTargetHandler {
375
489
  return;
376
490
  }
377
491
  const result = await this.pipeline.run(novels, target, 'novel', (novel, tag) => this.downloadAndDeliver(novel, tag, target));
492
+ this.scan = result.scan;
378
493
  this.handleDownloadResult(result, target, 'user', novels.length);
379
494
  }
380
495
  catch (error) {
@@ -460,7 +575,15 @@ class NovelTargetHandler {
460
575
  user: detail.user,
461
576
  create_date: detail.create_date,
462
577
  };
463
- await this.downloadAndDeliver(novel, `novel-${novelId}`, target);
578
+ const attempt = await this.downloadAndDeliver(novel, `novel-${novelId}`, target);
579
+ if (attempt.kind === 'skipped') {
580
+ // A cell that already owns a work cannot advance to another candidate —
581
+ // its identity is fixed. An already-delivered locked work is therefore a
582
+ // terminal business duplicate for THIS cell (it published nothing new).
583
+ this.outcomes.push(attempt.skip.code === 'duplicate'
584
+ ? { kind: 'duplicate', workId: lockedWorkId, reason: attempt.skip.reason }
585
+ : { kind: 'failed', retryable: false, error: `LOCKED_WORK_UNAVAILABLE: ${attempt.skip.reason}` });
586
+ }
464
587
  }
465
588
  catch (error) {
466
589
  const message = error instanceof Error ? error.message : String(error);
@@ -478,6 +601,11 @@ class NovelTargetHandler {
478
601
  /**
479
602
  * Process ONE candidate work for this cell.
480
603
  *
604
+ * Returns what happened to THIS candidate, so the pipeline can advance to the
605
+ * next one when it was unusable. Throwing is reserved for JOB-level failures
606
+ * (dead database / dead token / dead delivery provider): a candidate-level
607
+ * failure is reported as a `skipped` attempt and never as a job verdict.
608
+ *
481
609
  * The cell is bound to `novel` BEFORE any side effect: it is the binding, not
482
610
  * the candidate list, that decides what a later recovery resumes. The binding
483
611
  * is only rolled back when the attempt produced no artifact at all, so in-run
@@ -498,7 +626,14 @@ class NovelTargetHandler {
498
626
  targetId: execution.targetId,
499
627
  boundWorkId: binding.workId,
500
628
  });
501
- return;
629
+ return {
630
+ kind: 'skipped',
631
+ skip: {
632
+ code: 'duplicate',
633
+ workId,
634
+ reason: `cell already owns work ${binding.workId} (concurrent selection)`,
635
+ },
636
+ };
502
637
  }
503
638
  }
504
639
  let artifact;
@@ -509,33 +644,84 @@ class NovelTargetHandler {
509
644
  // Nothing was persisted, so the cell may still pick another candidate.
510
645
  if (execution && !recovering)
511
646
  execution.release(workId);
512
- throw error;
647
+ const failure = (0, TargetOutcome_1.classifyCandidateFailure)(error, workId);
648
+ if (failure.scope === 'job' || !(0, TargetOutcome_1.skipCandidateWithoutRetry)(failure.skip)) {
649
+ // Job-level outage, or a transient candidate failure: re-thrown so the
650
+ // queue's existing retry/backoff owns it and the scheduler records a JOB
651
+ // failure — never an exhausted candidate list.
652
+ throw error;
653
+ }
654
+ this.logError(error, `Candidate novel ${workId} skipped (${failure.skip.code})`);
655
+ return { kind: 'skipped', skip: failure.skip };
513
656
  }
514
657
  if (!artifact) {
515
658
  if (execution && !recovering)
516
659
  execution.release(workId);
517
- return;
660
+ return {
661
+ kind: 'skipped',
662
+ skip: { code: 'filtered', workId, reason: 'downloader declined this candidate (no artifact)' },
663
+ };
518
664
  }
519
- // Committed: the artifact is durable and this work now defines the cell. Even
520
- // if the delivery below throws, recovery must resume THIS work never release.
665
+ // The artifact is durable, but this cell has NOT committed to it until the
666
+ // delivery below actually claims it. A candidate that turns out to be a
667
+ // duplicate must give the binding BACK, or `bind()` would refuse the next
668
+ // candidate and the scan could never advance — the whole point of this
669
+ // change. `releaseCellWork` itself refuses once the cell moved on
670
+ // (delivery_pending/submitted), so a committed identity stays stable.
671
+ const attempt = this.recordArtifactOutcome(artifact, target);
672
+ if (attempt.kind === 'skipped' && execution && !recovering) {
673
+ execution.release(workId);
674
+ }
675
+ return attempt;
676
+ }
677
+ /**
678
+ * Turn a downloaded novel artifact into the target's business outcome.
679
+ *
680
+ * An ALREADY-DELIVERED work is returned as a candidate SKIP, never as a
681
+ * target outcome: the run must try the next candidate instead of ending the
682
+ * slot as a `duplicate`. The delivery idempotency ledger is what makes the
683
+ * second concurrent worker lose this race instead of double-submitting.
684
+ */
685
+ recordArtifactOutcome(artifact, target) {
521
686
  const isDelivery = target.storageMode === 'cache' && target.delivery?.target?.trim();
522
687
  if (!isDelivery || !this.deliveryService) {
523
688
  this.outcomes.push({ kind: 'stored', workId: artifact.pixivId, workType: artifact.type });
524
- return;
689
+ return { kind: 'selected', workId: artifact.pixivId, workType: artifact.type };
525
690
  }
526
691
  const ec = target.delivery;
527
692
  const slotId = ec?.executionContext?.slotId;
528
693
  if (this.deliveryService.isAlreadyDelivered(target.delivery.target, artifact.type, artifact.pixivId)) {
529
- this.outcomes.push({ kind: 'duplicate', workId: artifact.pixivId, reason: 'already delivered to target (ledger)' });
530
- return;
694
+ return {
695
+ kind: 'skipped',
696
+ skip: {
697
+ code: 'duplicate',
698
+ workId: artifact.pixivId,
699
+ reason: 'already delivered to target (delivery ledger)',
700
+ },
701
+ };
531
702
  }
532
703
  const res = this.deliveryService.enqueue(artifact, target, {
533
704
  slotId,
534
705
  fields: target.delivery?.fields,
706
+ extraContext: (0, deliveryContext_1.deliveryContextFields)(target),
707
+ });
708
+ if (res.duplicate) {
709
+ return {
710
+ kind: 'skipped',
711
+ skip: {
712
+ code: 'duplicate',
713
+ workId: artifact.pixivId,
714
+ reason: 'already delivered (idempotency ledger)',
715
+ },
716
+ };
717
+ }
718
+ this.outcomes.push({
719
+ kind: 'delivery_pending',
720
+ workId: artifact.pixivId,
721
+ workType: artifact.type,
722
+ deliveryId: res.deliveryId,
535
723
  });
536
- this.outcomes.push(res.duplicate
537
- ? { kind: 'duplicate', workId: artifact.pixivId, reason: 'already delivered (ledger)' }
538
- : { kind: 'delivery_pending', workId: artifact.pixivId, workType: artifact.type, deliveryId: res.deliveryId });
724
+ return { kind: 'selected', workId: artifact.pixivId, workType: artifact.type };
539
725
  }
540
726
  }
541
727
  exports.NovelTargetHandler = NovelTargetHandler;
@@ -0,0 +1,16 @@
1
+ import { TargetConfig } from '../../config';
2
+ /**
3
+ * Delivery payload context for one target run, merging schedule/slot provenance
4
+ * with the optional remote manual replacement identity.
5
+ *
6
+ * Every key is ALWAYS present (empty string when absent) so delivery templates
7
+ * never render a literal `{{placeholder}}` into the submitted payload — a
8
+ * scheduled run and a manual refetch share the same field templates.
9
+ *
10
+ * `refetchRequestId` is the request UUID of a remote manual replacement ("重抓")
11
+ * that produced this delivery; it is empty for scheduled occurrences. The
12
+ * receiving service (TelePost) correlates the incoming review with the refetch
13
+ * attempt on this id.
14
+ */
15
+ export declare function deliveryContextFields(target: TargetConfig): Record<string, unknown>;
16
+ //# sourceMappingURL=deliveryContext.d.ts.map
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deliveryContextFields = deliveryContextFields;
4
+ /**
5
+ * Delivery payload context for one target run, merging schedule/slot provenance
6
+ * with the optional remote manual replacement identity.
7
+ *
8
+ * Every key is ALWAYS present (empty string when absent) so delivery templates
9
+ * never render a literal `{{placeholder}}` into the submitted payload — a
10
+ * scheduled run and a manual refetch share the same field templates.
11
+ *
12
+ * `refetchRequestId` is the request UUID of a remote manual replacement ("重抓")
13
+ * that produced this delivery; it is empty for scheduled occurrences. The
14
+ * receiving service (TelePost) correlates the incoming review with the refetch
15
+ * attempt on this id.
16
+ */
17
+ function deliveryContextFields(target) {
18
+ const ec = target.delivery;
19
+ const slotContext = ec?.slotContext;
20
+ const executionContext = ec?.executionContext;
21
+ return {
22
+ scheduleId: executionContext?.scheduleId ?? '',
23
+ executionId: executionContext?.slotId ?? '',
24
+ occurrenceAt: executionContext?.occurrenceAtIso ?? '',
25
+ triggerSource: executionContext?.triggerSource ?? '',
26
+ slotId: slotContext?.slotId ?? executionContext?.slotId ?? '',
27
+ slotName: slotContext?.slotName ?? executionContext?.slotName ?? '',
28
+ slotDate: slotContext?.slotDate ?? executionContext?.slotDate ?? '',
29
+ refetchRequestId: slotContext?.manualRequestId ?? '',
30
+ };
31
+ }
32
+ //# sourceMappingURL=deliveryContext.js.map
@@ -4,10 +4,18 @@ import { DownloadPlanner } from '../plan/DownloadPlanner';
4
4
  import { DownloadExecutor } from '../exec/DownloadExecutor';
5
5
  import { ProgressReporter } from '../report/ProgressReporter';
6
6
  import { ErrorRecoveryStrategy } from '../recovery/ErrorRecovery';
7
+ import { CandidateAttempt, CandidateScanSummary } from '../../scheduler/TargetOutcome';
7
8
  type DownloadItem = PixivIllust | PixivNovel;
8
9
  type ItemType = 'illustration' | 'novel';
9
10
  export interface DownloadPipelineResult {
11
+ /**
12
+ * Candidates that PRODUCED a business result for this target. A candidate
13
+ * that was skipped (duplicate, deleted, private, ...) does not count: that is
14
+ * precisely the accounting error that let a duplicate end a scheduled slot as
15
+ * a completed run.
16
+ */
10
17
  downloaded: number;
18
+ /** Candidates the executor skipped after an error (legacy error counter). */
11
19
  skipped: number;
12
20
  alreadyDownloaded: number;
13
21
  filteredOut: number;
@@ -16,6 +24,11 @@ export interface DownloadPipelineResult {
16
24
  id: string;
17
25
  error: string;
18
26
  }[];
27
+ /**
28
+ * Bounded candidate-scan bookkeeping. Always present, so the caller can state
29
+ * an explicit terminal outcome instead of "completed".
30
+ */
31
+ scan: CandidateScanSummary;
19
32
  }
20
33
  export interface DownloadPipelineDependencies {
21
34
  config: StandaloneConfig;
@@ -29,6 +42,14 @@ export interface DownloadPipelineDependencies {
29
42
  */
30
43
  isCancelled?: () => boolean;
31
44
  }
45
+ /**
46
+ * What a candidate hook must report back: did this candidate produce the
47
+ * target's business result, or was it unusable?
48
+ *
49
+ * `void` is accepted and treated as `selected`, so a hook with no
50
+ * candidate-level information keeps the historical meaning of "it resolved".
51
+ */
52
+ export type CandidateDownloadFn<T extends DownloadItem> = (item: T, tag: string) => Promise<CandidateAttempt | void>;
32
53
  export declare class DownloadPipeline {
33
54
  private readonly config;
34
55
  private readonly planner;
@@ -37,9 +58,7 @@ export declare class DownloadPipeline {
37
58
  private readonly recovery;
38
59
  private readonly isRunCancelled;
39
60
  constructor(deps: DownloadPipelineDependencies);
40
- run<T extends DownloadItem>(items: T[], target: TargetConfig, itemType: ItemType, downloadFn: (item: T, tag: string) => Promise<void>): Promise<DownloadPipelineResult>;
41
- private executeRandomMode;
42
- private executeSequentialMode;
61
+ run<T extends DownloadItem>(items: T[], target: TargetConfig, itemType: ItemType, downloadFn: CandidateDownloadFn<T>): Promise<DownloadPipelineResult>;
43
62
  private updateProgress;
44
63
  private logRecoveryDecision;
45
64
  }