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