pixivflow 2.19.3 → 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/commands/scheduler-runtime.js +4 -15
- 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/DeliveryAck.d.ts +14 -0
- package/dist/delivery/DeliveryAck.js +18 -0
- package/dist/delivery/DeliveryService.d.ts +9 -0
- package/dist/delivery/DeliveryService.js +11 -0
- package/dist/delivery/OutboxWorker.js +13 -0
- package/dist/delivery/settleDeliveryTerminal.d.ts +25 -0
- package/dist/delivery/settleDeliveryTerminal.js +40 -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
|
@@ -4,6 +4,7 @@ exports.DownloadPipeline = void 0;
|
|
|
4
4
|
const logger_1 = require("../../logger");
|
|
5
5
|
const target_label_1 = require("../../utils/target-label");
|
|
6
6
|
const errors_1 = require("../../utils/errors");
|
|
7
|
+
const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
|
|
7
8
|
class DownloadPipeline {
|
|
8
9
|
config;
|
|
9
10
|
planner;
|
|
@@ -33,119 +34,135 @@ class DownloadPipeline {
|
|
|
33
34
|
skippedCount: 0,
|
|
34
35
|
skipDetails: [],
|
|
35
36
|
};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
this.updateProgress(state.downloaded, targetLimit, `完成下载: ${state.downloaded} 个 ${itemType === 'illustration' ? '插画' : '小说'}`);
|
|
48
|
-
return {
|
|
49
|
-
downloaded: state.downloaded,
|
|
50
|
-
skipped: state.skippedCount,
|
|
51
|
-
alreadyDownloaded: alreadyDownloadedCount,
|
|
52
|
-
filteredOut: filteredOutCount,
|
|
53
|
-
skipDetails: state.skipDetails,
|
|
37
|
+
/**
|
|
38
|
+
* Bounded candidate scan bookkeeping. `bound` is the plan's window (the
|
|
39
|
+
* planner already clamped it to the configured limit) AND the item list
|
|
40
|
+
* length, so the scan is structurally unable to exceed it: a page full of
|
|
41
|
+
* duplicates terminates after at most `bound` attempts instead of looping.
|
|
42
|
+
*/
|
|
43
|
+
const scan = {
|
|
44
|
+
bound: plan.scanBound ?? plan.queue.length,
|
|
45
|
+
attempted: 0,
|
|
46
|
+
skipped: [...(plan.prefiltered ?? [])],
|
|
47
|
+
outages: [],
|
|
54
48
|
};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Candidate INDEXES this run has attempted. Retries of the same candidate
|
|
51
|
+
* (the executor's backoff path) must not count as new candidates, and must
|
|
52
|
+
* not be blocked by the bound: that would silently convert a retried failure
|
|
53
|
+
* into a success.
|
|
54
|
+
*/
|
|
55
|
+
const attemptedIndices = new Set();
|
|
56
|
+
if (scan.skipped.length > 0) {
|
|
57
|
+
logger_1.logger.info(`Candidate scan pre-filtered ${scan.skipped.length} candidate(s) before download ` +
|
|
58
|
+
`(${[...new Set(scan.skipped.map((s) => s.code))].join(', ')})`, { target: tagForLog });
|
|
64
59
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
60
|
+
/** Record a candidate-level skip: the scan advances to the next candidate. */
|
|
61
|
+
const recordCandidateSkip = (skip) => {
|
|
62
|
+
scan.skipped.push(skip);
|
|
63
|
+
if (scan.skipped.length <= 5) {
|
|
64
|
+
logger_1.logger.info(`Candidate ${skip.workId} skipped (${skip.code}${skip.retryable ? ', transient' : ''}): ${skip.reason}`, { target: tagForLog });
|
|
65
|
+
}
|
|
66
|
+
if (state.skipDetails.length < 3 && !state.skipDetails.some((d) => d.id === skip.workId)) {
|
|
67
|
+
state.skipDetails.push({ id: skip.workId, error: skip.reason });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Classify a candidate FAILURE. A dead database / dead token / dead
|
|
72
|
+
* delivery provider / dead network is a JOB problem and is recorded as an
|
|
73
|
+
* outage, so the target can fail instead of reporting "no eligible
|
|
74
|
+
* candidate". Anything else is a candidate problem: skip, try the next.
|
|
75
|
+
*/
|
|
76
|
+
const recordCandidateFailure = (error, workId) => {
|
|
77
|
+
const failure = (0, TargetOutcome_1.classifyCandidateFailure)(error, workId);
|
|
78
|
+
if (failure.scope === 'job') {
|
|
79
|
+
if (!scan.outages.includes(failure.outage)) {
|
|
80
|
+
scan.outages.push(failure.outage);
|
|
81
|
+
}
|
|
82
|
+
logger_1.logger.error(`Job-level outage while scanning (${failure.outage}): ${failure.error}`, {
|
|
83
|
+
target: tagForLog,
|
|
84
|
+
candidate: workId,
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
recordCandidateSkip(failure.skip);
|
|
89
|
+
state.skippedCount++;
|
|
90
|
+
};
|
|
91
|
+
const runOptions = {
|
|
92
|
+
concurrency: plan.mode === 'sequential' && plan.queue.length > targetLimit
|
|
93
|
+
? 1
|
|
94
|
+
: itemType === 'novel' && target.languageFilter
|
|
95
|
+
? 1
|
|
96
|
+
: this.config.download?.concurrency || 3,
|
|
72
97
|
maxAttempts: retryAttempts,
|
|
73
98
|
recovery: this.recovery,
|
|
74
99
|
contextProvider: () => ({ itemType }),
|
|
75
|
-
task: async (item) => {
|
|
100
|
+
task: async (item, index) => {
|
|
76
101
|
if (state.downloaded >= targetLimit || this.isRunCancelled()) {
|
|
77
102
|
return;
|
|
78
103
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (itemType === 'novel') {
|
|
87
|
-
logger_1.logger.info(`Successfully downloaded novel ${item.id} (${state.downloaded}/${targetLimit})`);
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
onProgress: (done, total) => {
|
|
91
|
-
completedForProgress = done;
|
|
92
|
-
const msgBase = itemType === 'illustration' ? '插画' : '小说';
|
|
93
|
-
this.updateProgress(Math.min(state.downloaded, targetLimit), targetLimit, `随机模式进行中(${completedForProgress}/${total}) - 已下载 ${msgBase}: ${state.downloaded}`);
|
|
94
|
-
},
|
|
95
|
-
onDecision: (decision, { item, error }) => {
|
|
96
|
-
const typedItem = item;
|
|
97
|
-
this.logRecoveryDecision(decision, error, typedItem.id, itemType, typedItem.title);
|
|
98
|
-
if (decision.action === 'skip') {
|
|
99
|
-
state.skippedCount++;
|
|
100
|
-
const detail = (0, errors_1.getErrorMessage)(error) || decision.reason || 'download failed';
|
|
101
|
-
if (state.skipDetails.length < 3 && !state.skipDetails.some((d) => d.id === String(typedItem.id))) {
|
|
102
|
-
state.skipDetails.push({ id: String(typedItem.id), error: detail });
|
|
104
|
+
// Strict bound, measured in CANDIDATES — not in task invocations. A
|
|
105
|
+
// retry of the same candidate (the executor's backoff path) must not
|
|
106
|
+
// consume a new slot, or the guard would silently turn a retried failure
|
|
107
|
+
// into a success and swallow it.
|
|
108
|
+
if (!attemptedIndices.has(index)) {
|
|
109
|
+
if (attemptedIndices.size >= scan.bound) {
|
|
110
|
+
return;
|
|
103
111
|
}
|
|
112
|
+
attemptedIndices.add(index);
|
|
113
|
+
scan.attempted = attemptedIndices.size;
|
|
104
114
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
let completedForProgress = 0;
|
|
115
|
-
await this.executor.run({
|
|
116
|
-
items: toProcess,
|
|
117
|
-
concurrency,
|
|
118
|
-
maxAttempts: retryAttempts,
|
|
119
|
-
recovery: this.recovery,
|
|
120
|
-
contextProvider: () => ({ itemType }),
|
|
121
|
-
task: async (item) => {
|
|
122
|
-
if (state.downloaded >= targetLimit || this.isRunCancelled()) {
|
|
123
|
-
return;
|
|
115
|
+
const attempt = await downloadFn(item, tagForLog);
|
|
116
|
+
if ((0, TargetOutcome_1.isSelectedAttempt)(attempt)) {
|
|
117
|
+
state.downloaded++;
|
|
118
|
+
}
|
|
119
|
+
else if (attempt?.kind === 'skipped') {
|
|
120
|
+
recordCandidateSkip(attempt.skip);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
state.downloaded++;
|
|
124
124
|
}
|
|
125
|
-
await downloadFn(item, tagForLog);
|
|
126
|
-
state.downloaded++;
|
|
127
125
|
this.updateProgress(state.downloaded, targetLimit, `已下载 ${itemType === 'illustration' ? '插画' : '小说'} ${item.id} (${state.downloaded}/${targetLimit})`);
|
|
128
|
-
if (itemType === 'novel') {
|
|
126
|
+
if (itemType === 'novel' && state.downloaded > 0) {
|
|
129
127
|
logger_1.logger.info(`Successfully downloaded novel ${item.id} (${state.downloaded}/${targetLimit})`);
|
|
130
128
|
}
|
|
131
129
|
},
|
|
132
130
|
onProgress: (done, total) => {
|
|
133
|
-
completedForProgress = done;
|
|
134
131
|
const msgBase = itemType === 'illustration' ? '插画' : '小说';
|
|
135
|
-
this.updateProgress(Math.min(state.downloaded, targetLimit), targetLimit, `进行中(${
|
|
132
|
+
this.updateProgress(Math.min(state.downloaded, targetLimit), targetLimit, `进行中(${done}/${total}) - 已下载 ${msgBase}: ${state.downloaded}`);
|
|
136
133
|
},
|
|
137
|
-
onDecision: (decision,
|
|
138
|
-
const typedItem = item;
|
|
139
|
-
this.logRecoveryDecision(decision, error, typedItem.id, itemType, typedItem.title);
|
|
134
|
+
onDecision: (decision, info) => {
|
|
135
|
+
const typedItem = info.item;
|
|
136
|
+
this.logRecoveryDecision(decision, info.error, typedItem.id, itemType, typedItem.title);
|
|
140
137
|
if (decision.action === 'skip') {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (state.skipDetails.length < 3 && !state.skipDetails.some((d) => d.id === String(typedItem.id))) {
|
|
144
|
-
state.skipDetails.push({ id: String(typedItem.id), error: detail });
|
|
145
|
-
}
|
|
138
|
+
const message = (0, errors_1.getErrorMessage)(info.error) || decision.reason || 'download failed';
|
|
139
|
+
recordCandidateFailure(info.error === undefined || info.error === null ? new Error(message) : info.error, String(typedItem.id));
|
|
146
140
|
}
|
|
147
141
|
},
|
|
148
|
-
}
|
|
142
|
+
};
|
|
143
|
+
if (plan.mode === 'random') {
|
|
144
|
+
if (planAvailableCount === 0) {
|
|
145
|
+
logger_1.logger.info('All search results have already been downloaded');
|
|
146
|
+
}
|
|
147
|
+
else if (plan.queue.length > 0) {
|
|
148
|
+
await this.executor.run({ ...runOptions, items: plan.queue });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (plan.queue.length > 0) {
|
|
152
|
+
await this.executor.run({ ...runOptions, items: plan.queue });
|
|
153
|
+
}
|
|
154
|
+
logger_1.logger.info(`Candidate scan ${tagForLog}: attempted ${scan.attempted}/${scan.bound}, produced ${state.downloaded} ` +
|
|
155
|
+
`${itemType}(s), skipped ${scan.skipped.length}` +
|
|
156
|
+
`${scan.outages.length > 0 ? `, job-level outage(s): ${scan.outages.join(', ')}` : ''}`);
|
|
157
|
+
this.updateProgress(state.downloaded, targetLimit, `完成下载: ${state.downloaded} 个 ${itemType === 'illustration' ? '插画' : '小说'}`);
|
|
158
|
+
return {
|
|
159
|
+
downloaded: state.downloaded,
|
|
160
|
+
skipped: state.skippedCount,
|
|
161
|
+
alreadyDownloaded: alreadyDownloadedCount,
|
|
162
|
+
filteredOut: filteredOutCount,
|
|
163
|
+
skipDetails: state.skipDetails,
|
|
164
|
+
scan,
|
|
165
|
+
};
|
|
149
166
|
}
|
|
150
167
|
updateProgress(current, total, message) {
|
|
151
168
|
this.progressReporter.update(current, total, message);
|
|
@@ -1,26 +1,62 @@
|
|
|
1
1
|
import type { TargetConfig } from '../../config';
|
|
2
2
|
import type { PixivIllust, PixivNovel } from '@redtidev/pixiv-client';
|
|
3
3
|
import type { IDatabase } from '../../interfaces/IDatabase';
|
|
4
|
+
import type { CandidateSkip } from '../../scheduler/TargetOutcome';
|
|
4
5
|
export type DownloadItem = PixivIllust | PixivNovel;
|
|
5
6
|
export interface PlannedDownload<T extends DownloadItem> {
|
|
6
7
|
queue: T[];
|
|
7
8
|
mode: 'sequential' | 'random';
|
|
8
9
|
limit: number;
|
|
10
|
+
/**
|
|
11
|
+
* Maximum candidates this plan may ATTEMPT. Always `queue.length`, so the
|
|
12
|
+
* bound is enforced structurally: the pipeline cannot scan further than the
|
|
13
|
+
* window it was handed. Reported in the terminal outcome as "scanning N".
|
|
14
|
+
*/
|
|
15
|
+
scanBound: number;
|
|
9
16
|
filteredOut: number;
|
|
10
17
|
deduplicated: number;
|
|
11
18
|
alreadyDownloaded: number;
|
|
12
19
|
availableCount: number;
|
|
13
20
|
originalCount: number;
|
|
21
|
+
/**
|
|
22
|
+
* Candidates the planner itself dropped BEFORE the download attempt —
|
|
23
|
+
* already in download history, already CONFIRMED delivered to this target, or
|
|
24
|
+
* already handled per durable history. They are skips, not failures, and are
|
|
25
|
+
* reported in the terminal outcome so "skipping Y candidates" includes the
|
|
26
|
+
* ones that never had to be downloaded.
|
|
27
|
+
*/
|
|
28
|
+
prefiltered: CandidateSkip[];
|
|
14
29
|
random?: {
|
|
15
30
|
maxAttempts: number;
|
|
16
31
|
};
|
|
17
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Default candidate-scan window. Five candidates is enough to survive a few
|
|
35
|
+
* already-delivered works on a ranking page without turning one slot into a
|
|
36
|
+
* long crawl; the operator can raise it per target or globally.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_CANDIDATE_SCAN_LIMIT = 5;
|
|
39
|
+
/** Hard ceiling on the scan so a misconfigured value cannot become a crawl. */
|
|
40
|
+
export declare const MAX_CANDIDATE_SCAN_LIMIT = 100;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the candidate-scan bound for one target: per-target override, then
|
|
43
|
+
* the global `download.candidateScanLimit`, then the default. Clamped so a bad
|
|
44
|
+
* value degrades to a working bound instead of disabling the bound.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveCandidateScanLimit(target: TargetConfig, fallback?: number): number;
|
|
18
47
|
/**
|
|
19
48
|
* Centralizes planning logic (filtering, deduplication, already-downloaded detection, random selection).
|
|
20
49
|
*/
|
|
21
50
|
export interface DeliveryDedupeSource {
|
|
22
51
|
/** Returns the subset of ids already CONFIRMED delivered to this target. */
|
|
23
52
|
deliveredIds?(deliveryTarget: string, workType: 'illustration' | 'novel', ids: string[]): Set<string>;
|
|
53
|
+
/**
|
|
54
|
+
* Returns the subset of ids already SUBMITTED for this target — delivered OR
|
|
55
|
+
* still awaiting a review answer. Candidate selection prefers this over
|
|
56
|
+
* `deliveredIds`: a work whose review submission is pending is already in the
|
|
57
|
+
* human queue, so selecting it again would submit it twice.
|
|
58
|
+
*/
|
|
59
|
+
submittedIds?(deliveryTarget: string, workType: 'illustration' | 'novel', ids: string[]): Set<string>;
|
|
24
60
|
/**
|
|
25
61
|
* Works this bot has already handled ANYWHERE — durable history owned by a
|
|
26
62
|
* control plane, supplied by the caller.
|
|
@@ -35,7 +71,17 @@ export interface DeliveryDedupeSource {
|
|
|
35
71
|
export declare class DownloadPlanner {
|
|
36
72
|
private readonly database;
|
|
37
73
|
private readonly deliveryDedupe?;
|
|
38
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Global candidate-scan bound (`download.candidateScanLimit`). A per-target
|
|
76
|
+
* `candidateScanLimit` overrides it.
|
|
77
|
+
*/
|
|
78
|
+
private readonly defaultCandidateScanLimit?;
|
|
79
|
+
constructor(database: IDatabase, deliveryDedupe?: DeliveryDedupeSource | undefined,
|
|
80
|
+
/**
|
|
81
|
+
* Global candidate-scan bound (`download.candidateScanLimit`). A per-target
|
|
82
|
+
* `candidateScanLimit` overrides it.
|
|
83
|
+
*/
|
|
84
|
+
defaultCandidateScanLimit?: number | undefined);
|
|
39
85
|
planDownloads<T extends DownloadItem>(items: T[], target: TargetConfig, itemType: 'illustration' | 'novel'): PlannedDownload<T>;
|
|
40
86
|
private filterItems;
|
|
41
87
|
private deduplicate;
|
|
@@ -1,39 +1,100 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DownloadPlanner = void 0;
|
|
3
|
+
exports.DownloadPlanner = exports.MAX_CANDIDATE_SCAN_LIMIT = exports.DEFAULT_CANDIDATE_SCAN_LIMIT = void 0;
|
|
4
|
+
exports.resolveCandidateScanLimit = resolveCandidateScanLimit;
|
|
4
5
|
const date_utils_1 = require("../../utils/date-utils");
|
|
5
6
|
const ai_detection_1 = require("../../utils/ai-detection");
|
|
6
7
|
const logger_1 = require("../../logger");
|
|
8
|
+
/**
|
|
9
|
+
* Default candidate-scan window. Five candidates is enough to survive a few
|
|
10
|
+
* already-delivered works on a ranking page without turning one slot into a
|
|
11
|
+
* long crawl; the operator can raise it per target or globally.
|
|
12
|
+
*/
|
|
13
|
+
exports.DEFAULT_CANDIDATE_SCAN_LIMIT = 5;
|
|
14
|
+
/** Hard ceiling on the scan so a misconfigured value cannot become a crawl. */
|
|
15
|
+
exports.MAX_CANDIDATE_SCAN_LIMIT = 100;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the candidate-scan bound for one target: per-target override, then
|
|
18
|
+
* the global `download.candidateScanLimit`, then the default. Clamped so a bad
|
|
19
|
+
* value degrades to a working bound instead of disabling the bound.
|
|
20
|
+
*/
|
|
21
|
+
function resolveCandidateScanLimit(target, fallback) {
|
|
22
|
+
const configured = target.candidateScanLimit ?? fallback ?? exports.DEFAULT_CANDIDATE_SCAN_LIMIT;
|
|
23
|
+
if (!Number.isFinite(configured))
|
|
24
|
+
return exports.DEFAULT_CANDIDATE_SCAN_LIMIT;
|
|
25
|
+
return Math.min(Math.max(Math.trunc(configured), 1), exports.MAX_CANDIDATE_SCAN_LIMIT);
|
|
26
|
+
}
|
|
7
27
|
class DownloadPlanner {
|
|
8
28
|
database;
|
|
9
29
|
deliveryDedupe;
|
|
10
|
-
|
|
30
|
+
defaultCandidateScanLimit;
|
|
31
|
+
constructor(database, deliveryDedupe,
|
|
32
|
+
/**
|
|
33
|
+
* Global candidate-scan bound (`download.candidateScanLimit`). A per-target
|
|
34
|
+
* `candidateScanLimit` overrides it.
|
|
35
|
+
*/
|
|
36
|
+
defaultCandidateScanLimit) {
|
|
11
37
|
this.database = database;
|
|
12
38
|
this.deliveryDedupe = deliveryDedupe;
|
|
39
|
+
this.defaultCandidateScanLimit = defaultCandidateScanLimit;
|
|
13
40
|
}
|
|
14
41
|
planDownloads(items, target, itemType) {
|
|
15
42
|
const filtered = this.filterItems(items, target, itemType);
|
|
16
43
|
const { items: deduplicatedItems, removed } = this.deduplicate(filtered.items);
|
|
44
|
+
/**
|
|
45
|
+
* Candidates dropped before any download attempt. Reported as explicit
|
|
46
|
+
* skips so the terminal outcome can say "skipping Y candidates" instead of
|
|
47
|
+
* claiming a run produced nothing for no stated reason.
|
|
48
|
+
*/
|
|
49
|
+
const prefiltered = [];
|
|
50
|
+
const ruleFiltered = new Set(deduplicatedItems.map((item) => String(item.id)));
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
const id = String(item.id);
|
|
53
|
+
if (!ruleFiltered.has(id)) {
|
|
54
|
+
// Excluded by the target's own rules (bookmarks/date/AI) — or a
|
|
55
|
+
// repeated id inside the page. Both are candidate-level skips.
|
|
56
|
+
prefiltered.push({ code: 'filtered', workId: id, reason: 'excluded by target filters (bookmarks/date/AI)' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
17
59
|
const itemIds = deduplicatedItems.map((item) => String(item.id));
|
|
18
60
|
const downloadedIds = itemIds.length > 0 ? this.database.getDownloadedIds(itemIds, itemType) : new Set();
|
|
19
61
|
let available = deduplicatedItems.filter((item) => !downloadedIds.has(String(item.id)));
|
|
20
62
|
const alreadyDownloadedCount = deduplicatedItems.length - available.length;
|
|
63
|
+
for (const item of deduplicatedItems) {
|
|
64
|
+
const id = String(item.id);
|
|
65
|
+
if (downloadedIds.has(id)) {
|
|
66
|
+
prefiltered.push({ code: 'duplicate', workId: id, reason: 'already in download history' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
21
69
|
// DELIVERY dedupe (pre-lock, distinct from download dedupe): skip works
|
|
22
|
-
// already
|
|
23
|
-
//
|
|
70
|
+
// already SUBMITTED for THIS target — confirmed delivered, or still awaiting
|
|
71
|
+
// a review answer — so ranking falls through to the next valid candidate
|
|
72
|
+
// instead of selecting a historical duplicate. `submittedIds` is preferred
|
|
73
|
+
// because a pending review submission is already in the human queue.
|
|
24
74
|
// Best-effort only: downstream reconciliation remains the final safety net.
|
|
25
75
|
const deliveryTarget = target.delivery?.target?.trim();
|
|
76
|
+
const submittedQuery = this.deliveryDedupe?.submittedIds ?? this.deliveryDedupe?.deliveredIds;
|
|
26
77
|
let deliveryDuplicateCount = 0;
|
|
27
78
|
if (deliveryTarget &&
|
|
28
|
-
|
|
79
|
+
submittedQuery &&
|
|
29
80
|
typeof this.database.deliveries === 'object') {
|
|
30
81
|
try {
|
|
31
|
-
const
|
|
82
|
+
const taken = submittedQuery.call(this.deliveryDedupe, deliveryTarget, itemType, available.map((item) => String(item.id)));
|
|
32
83
|
const before = available.length;
|
|
33
|
-
|
|
84
|
+
for (const item of available) {
|
|
85
|
+
const id = String(item.id);
|
|
86
|
+
if (taken.has(id)) {
|
|
87
|
+
prefiltered.push({
|
|
88
|
+
code: 'duplicate',
|
|
89
|
+
workId: id,
|
|
90
|
+
reason: `already submitted to ${deliveryTarget} (delivery ledger)`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
available = available.filter((item) => !taken.has(String(item.id)));
|
|
34
95
|
deliveryDuplicateCount = before - available.length;
|
|
35
96
|
if (deliveryDuplicateCount > 0) {
|
|
36
|
-
logger_1.logger.info(`Delivery dedupe skipped ${deliveryDuplicateCount} already-
|
|
97
|
+
logger_1.logger.info(`Delivery dedupe skipped ${deliveryDuplicateCount} already-submitted ${itemType}(s) for ${deliveryTarget}`);
|
|
37
98
|
}
|
|
38
99
|
}
|
|
39
100
|
catch (error) {
|
|
@@ -49,6 +110,12 @@ class DownloadPlanner {
|
|
|
49
110
|
try {
|
|
50
111
|
const processed = processedSource(itemType, available.map((item) => String(item.id)));
|
|
51
112
|
const before = available.length;
|
|
113
|
+
for (const item of available) {
|
|
114
|
+
const id = String(item.id);
|
|
115
|
+
if (processed.has(id)) {
|
|
116
|
+
prefiltered.push({ code: 'duplicate', workId: id, reason: 'already handled (durable history)' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
52
119
|
available = available.filter((item) => !processed.has(String(item.id)));
|
|
53
120
|
const skipped = before - available.length;
|
|
54
121
|
if (skipped > 0) {
|
|
@@ -62,40 +129,63 @@ class DownloadPlanner {
|
|
|
62
129
|
}
|
|
63
130
|
}
|
|
64
131
|
const limit = target.limit && target.limit > 0 ? target.limit : 10;
|
|
132
|
+
// How many candidates this run may ATTEMPT. Distinct from `limit`, which is
|
|
133
|
+
// how many it may PRODUCE. A scheduled one-post-per-slot target has
|
|
134
|
+
// `limit: 1`, so keying the candidate window off `limit` alone handed the
|
|
135
|
+
// pipeline exactly one candidate: the first duplicate ended the slot with
|
|
136
|
+
// nothing submitted. Never below `limit`, so a multi-work target can still
|
|
137
|
+
// fill its own limit.
|
|
138
|
+
const scanBound = Math.max(limit, resolveCandidateScanLimit(target, this.defaultCandidateScanLimit));
|
|
65
139
|
if (target.random) {
|
|
66
140
|
const shuffled = this.shuffle(available);
|
|
67
|
-
|
|
141
|
+
// Historical 50-attempt random pool, unless the operator set an explicit
|
|
142
|
+
// bound — in which case that bound is the contract.
|
|
143
|
+
const attemptPool = target.candidateScanLimit !== undefined ? scanBound : 50;
|
|
144
|
+
const maxAttempts = Math.min(shuffled.length, Math.max(limit, attemptPool));
|
|
68
145
|
const queue = shuffled.slice(0, maxAttempts);
|
|
69
146
|
return {
|
|
70
147
|
queue,
|
|
71
148
|
mode: 'random',
|
|
72
149
|
limit,
|
|
150
|
+
scanBound: queue.length,
|
|
73
151
|
filteredOut: filtered.filteredOut,
|
|
74
152
|
deduplicated: removed,
|
|
75
153
|
alreadyDownloaded: alreadyDownloadedCount,
|
|
76
154
|
availableCount: available.length,
|
|
77
155
|
originalCount: filtered.originalCount,
|
|
156
|
+
prefiltered,
|
|
78
157
|
random: { maxAttempts },
|
|
79
158
|
};
|
|
80
159
|
}
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
160
|
+
// How many candidates the run may ATTEMPT:
|
|
161
|
+
// window = clamp(candidateScanLimit, lower = limit, upper = pool)
|
|
162
|
+
// where `pool` is what the operator asked to consider — the whole page for a
|
|
163
|
+
// plain search/ranking target, and a deliberately deeper pool for full-text
|
|
164
|
+
// novel language filtering or topic discovery. `candidateScanLimit` is the
|
|
165
|
+
// hard cap; it is never exceeded, and it is never allowed below `limit`
|
|
166
|
+
// because a multi-work target must still be able to fill its own limit.
|
|
167
|
+
const pool = itemType === 'novel' && target.languageFilter
|
|
85
168
|
? Math.max(limit, Math.min(target.languageCandidateLimit ?? 20, 100))
|
|
86
169
|
: target.mode === 'topic'
|
|
87
170
|
? Math.max(limit, 20)
|
|
88
|
-
:
|
|
89
|
-
const
|
|
171
|
+
: available.length;
|
|
172
|
+
const windowSize = Math.max(limit, Math.min(pool, scanBound));
|
|
173
|
+
const queue = available.slice(0, Math.min(available.length, windowSize));
|
|
174
|
+
if (queue.length > limit) {
|
|
175
|
+
logger_1.logger.info(`Candidate scan window: up to ${queue.length} candidate(s) to fill ${limit} slot(s) ` +
|
|
176
|
+
`(bound ${scanBound}, pool ${pool}, ${available.length} available)`);
|
|
177
|
+
}
|
|
90
178
|
return {
|
|
91
179
|
queue,
|
|
92
180
|
mode: 'sequential',
|
|
93
181
|
limit,
|
|
182
|
+
scanBound: queue.length,
|
|
94
183
|
filteredOut: filtered.filteredOut,
|
|
95
184
|
deduplicated: removed,
|
|
96
185
|
alreadyDownloaded: alreadyDownloadedCount,
|
|
97
186
|
availableCount: available.length,
|
|
98
187
|
originalCount: filtered.originalCount,
|
|
188
|
+
prefiltered,
|
|
99
189
|
};
|
|
100
190
|
}
|
|
101
191
|
filterItems(items, target, itemType) {
|