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.
- package/dist/commands/SchedulerCommand.js +36 -0
- package/dist/commands/SchedulerRunOnceCommand.d.ts +2 -3
- package/dist/commands/SchedulerRunOnceCommand.js +2 -3
- package/dist/commands/scheduler-runtime.js +17 -1
- package/dist/config/defaults.d.ts +1 -0
- package/dist/config/defaults.js +4 -0
- package/dist/config/environment.js +19 -0
- package/dist/config/types.d.ts +28 -0
- package/dist/config/validation.js +25 -0
- package/dist/delivery/DeliveryService.d.ts +30 -1
- package/dist/delivery/DeliveryService.js +13 -2
- package/dist/delivery/HttpMultipartDelivery.js +29 -8
- package/dist/delivery/OutboxWorker.d.ts +2 -0
- package/dist/delivery/OutboxWorker.js +3 -0
- package/dist/delivery/types.d.ts +18 -0
- package/dist/download/DownloadManager.js +12 -1
- package/dist/download/handlers/IllustrationTargetHandler.d.ts +42 -2
- package/dist/download/handlers/IllustrationTargetHandler.js +240 -50
- package/dist/download/handlers/NovelTargetHandler.d.ts +47 -0
- package/dist/download/handlers/NovelTargetHandler.js +213 -27
- package/dist/download/handlers/deliveryContext.d.ts +16 -0
- package/dist/download/handlers/deliveryContext.js +32 -0
- package/dist/download/pipeline/DownloadPipeline.d.ts +22 -3
- package/dist/download/pipeline/DownloadPipeline.js +109 -92
- package/dist/download/plan/DownloadPlanner.d.ts +47 -1
- package/dist/download/plan/DownloadPlanner.js +105 -15
- package/dist/notification/NotificationPolicy.d.ts +12 -0
- package/dist/notification/NotificationPolicy.js +86 -0
- package/dist/package.json +1 -1
- package/dist/scheduler/MultiScheduleManager.js +2 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +58 -1
- package/dist/scheduler/ScheduleTriggerServer.js +207 -40
- package/dist/scheduler/SlotCoordinator.d.ts +80 -2
- package/dist/scheduler/SlotCoordinator.js +145 -3
- package/dist/scheduler/TargetOutcome.d.ts +195 -2
- package/dist/scheduler/TargetOutcome.js +202 -3
- package/dist/storage/DatabaseMigration.js +11 -0
- package/dist/storage/repositories/DeliveryRepository.d.ts +12 -0
- package/dist/storage/repositories/DeliveryRepository.js +18 -1
- package/dist/storage/repositories/SlotRepository.d.ts +8 -0
- package/dist/storage/repositories/SlotRepository.js +6 -2
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- 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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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`, {
|
|
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,
|
|
197
|
-
if (novels.length >
|
|
198
|
-
novels = novels.slice(0,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
520
|
-
//
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|