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
|
@@ -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) {
|
|
@@ -33,6 +33,7 @@ export declare class NotificationPolicy {
|
|
|
33
33
|
hardFail: (slotId: string, targetId: string) => string;
|
|
34
34
|
summary: (slotId: string) => string;
|
|
35
35
|
dead: (slotId: string, targetId: string) => string;
|
|
36
|
+
refetchOutcome: (slotId: string, targetId: string) => string;
|
|
36
37
|
};
|
|
37
38
|
noteOutcome(slotId: string, slot: SlotContext, schedule: ScheduleConfig, target: TargetConfig, outcome: TargetOutcome): void;
|
|
38
39
|
/** One consolidated summary per slot, delivered to every notifying target's endpoint. */
|
|
@@ -45,5 +46,16 @@ export declare class NotificationPolicy {
|
|
|
45
46
|
error: string | null;
|
|
46
47
|
}>): void;
|
|
47
48
|
private send;
|
|
49
|
+
/**
|
|
50
|
+
* Report the terminal verdict of a REMOTE MANUAL replacement ("重抓") back to
|
|
51
|
+
* the requester through the durable outbox. Only terminal outcomes are
|
|
52
|
+
* reported (no_candidate / non-retryable failed); a successful replacement is
|
|
53
|
+
* correlated by the submission itself (its payload carries the request id).
|
|
54
|
+
*
|
|
55
|
+
* The key is anchored to the manual SLOT, so a slot recovered and re-run can
|
|
56
|
+
* never enqueue a second verdict for the same logical attempt, and helpers
|
|
57
|
+
* that already returned remain idempotent.
|
|
58
|
+
*/
|
|
59
|
+
noteRefetchOutcome(slot: SlotContext, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
|
|
48
60
|
}
|
|
49
61
|
//# sourceMappingURL=NotificationPolicy.d.ts.map
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.NotificationPolicy = void 0;
|
|
4
4
|
const DeliveryService_1 = require("../delivery/DeliveryService");
|
|
5
|
+
const logger_1 = require("../logger");
|
|
5
6
|
/**
|
|
6
7
|
* Central policy for all operational notifications. Handlers/scheduler do not
|
|
7
8
|
* decide how or when to notify — they emit domain outcomes and this policy
|
|
@@ -44,6 +45,7 @@ class NotificationPolicy {
|
|
|
44
45
|
hardFail: (slotId, targetId) => `notification:${slotId}:${targetId}:failed`,
|
|
45
46
|
summary: (slotId) => `notification:${slotId}:summary`,
|
|
46
47
|
dead: (slotId, targetId) => `notification:${slotId}:${targetId}:delivery-dead`,
|
|
48
|
+
refetchOutcome: (slotId, targetId) => `refetch-outcome:${slotId}:${targetId}`,
|
|
47
49
|
};
|
|
48
50
|
noteOutcome(slotId, slot, schedule, target, outcome) {
|
|
49
51
|
const name = this.targetName(target);
|
|
@@ -104,6 +106,90 @@ class NotificationPolicy {
|
|
|
104
106
|
console.warn('notification enqueue failed', { targetName, key, error: error.message });
|
|
105
107
|
}
|
|
106
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Report the terminal verdict of a REMOTE MANUAL replacement ("重抓") back to
|
|
111
|
+
* the requester through the durable outbox. Only terminal outcomes are
|
|
112
|
+
* reported (no_candidate / non-retryable failed); a successful replacement is
|
|
113
|
+
* correlated by the submission itself (its payload carries the request id).
|
|
114
|
+
*
|
|
115
|
+
* The key is anchored to the manual SLOT, so a slot recovered and re-run can
|
|
116
|
+
* never enqueue a second verdict for the same logical attempt, and helpers
|
|
117
|
+
* that already returned remain idempotent.
|
|
118
|
+
*/
|
|
119
|
+
noteRefetchOutcome(slot, _schedule, target, requestId, outcome) {
|
|
120
|
+
const name = this.targetName(target);
|
|
121
|
+
if (!name)
|
|
122
|
+
return;
|
|
123
|
+
const deliveryTarget = this.config.delivery?.targets?.[name];
|
|
124
|
+
if (!deliveryTarget || deliveryTarget.type !== 'httpMultipart')
|
|
125
|
+
return;
|
|
126
|
+
if (!deliveryTarget.refetchOutcomeUrl?.trim()) {
|
|
127
|
+
// No endpoint configured: skip the report (content is still durable).
|
|
128
|
+
logger_1.logger.info('Refetch outcome not reported: refetchOutcomeUrl unset', {
|
|
129
|
+
slot: slot.slotId,
|
|
130
|
+
target: target.id,
|
|
131
|
+
requestId,
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let payload;
|
|
136
|
+
if (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate') {
|
|
137
|
+
payload = {
|
|
138
|
+
requestId,
|
|
139
|
+
disposition: 'no_alternative',
|
|
140
|
+
reason: outcome.reason,
|
|
141
|
+
workId: outcome.kind === 'duplicate' ? outcome.workId : undefined,
|
|
142
|
+
...scanCounts(outcome.scan),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
else if (outcome.kind === 'failed' && !outcome.retryable) {
|
|
146
|
+
payload = {
|
|
147
|
+
requestId,
|
|
148
|
+
disposition: 'failed',
|
|
149
|
+
reason: outcome.error,
|
|
150
|
+
...scanCounts(outcome.scan),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
return; // not terminal for refetch purposes
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
new DeliveryService_1.DeliveryService(this.database).enqueueNotification(name, `refetch outcome: ${payload.disposition} (slot ${slot.slotId})`, NotificationPolicy.keys.refetchOutcome(slot.slotId, target.id ?? target.type), payload);
|
|
158
|
+
logger_1.logger.info('Refetch outcome enqueued for report', {
|
|
159
|
+
slot: slot.slotId,
|
|
160
|
+
target: target.id,
|
|
161
|
+
requestId,
|
|
162
|
+
disposition: payload.disposition,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
// A failed VERDICT report never changes the terminal content state; it is
|
|
167
|
+
// retried by the operator/resume path, and never unwinds the run.
|
|
168
|
+
logger_1.logger.warn('Refetch outcome report enqueue failed', {
|
|
169
|
+
slot: slot.slotId,
|
|
170
|
+
target: target.id,
|
|
171
|
+
error: error instanceof Error ? error.message : String(error),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
107
175
|
}
|
|
108
176
|
exports.NotificationPolicy = NotificationPolicy;
|
|
177
|
+
/** Fold a CandidateScanSummary into the refetch-outcome bookkeeping (bounded). */
|
|
178
|
+
function scanCounts(scan) {
|
|
179
|
+
if (!scan)
|
|
180
|
+
return {};
|
|
181
|
+
const skipped = scan.skipped ?? [];
|
|
182
|
+
const duplicate = skipped.filter((s) => s.code === 'duplicate').length;
|
|
183
|
+
const unavailable = skipped.filter((s) => s.code === 'unavailable').length;
|
|
184
|
+
const invalid = skipped.filter((s) => ['deleted', 'access_denied', 'unsupported_media', 'invalid_metadata', 'filtered'].includes(s.code)).length;
|
|
185
|
+
return {
|
|
186
|
+
scanned: scan.attempted,
|
|
187
|
+
skipped: {
|
|
188
|
+
total: skipped.length,
|
|
189
|
+
duplicate,
|
|
190
|
+
invalid,
|
|
191
|
+
unavailable,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
109
195
|
//# sourceMappingURL=NotificationPolicy.js.map
|