pixivflow 2.19.4 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/commands/SchedulerCommand.js +36 -0
  2. package/dist/commands/SchedulerRunOnceCommand.d.ts +2 -3
  3. package/dist/commands/SchedulerRunOnceCommand.js +2 -3
  4. package/dist/commands/scheduler-runtime.js +17 -1
  5. package/dist/config/defaults.d.ts +1 -0
  6. package/dist/config/defaults.js +4 -0
  7. package/dist/config/environment.js +19 -0
  8. package/dist/config/types.d.ts +28 -0
  9. package/dist/config/validation.js +25 -0
  10. package/dist/delivery/DeliveryService.d.ts +30 -1
  11. package/dist/delivery/DeliveryService.js +13 -2
  12. package/dist/delivery/HttpMultipartDelivery.js +29 -8
  13. package/dist/delivery/OutboxWorker.d.ts +2 -0
  14. package/dist/delivery/OutboxWorker.js +3 -0
  15. package/dist/delivery/types.d.ts +18 -0
  16. package/dist/download/DownloadManager.js +12 -1
  17. package/dist/download/handlers/IllustrationTargetHandler.d.ts +42 -2
  18. package/dist/download/handlers/IllustrationTargetHandler.js +240 -50
  19. package/dist/download/handlers/NovelTargetHandler.d.ts +47 -0
  20. package/dist/download/handlers/NovelTargetHandler.js +213 -27
  21. package/dist/download/handlers/deliveryContext.d.ts +16 -0
  22. package/dist/download/handlers/deliveryContext.js +32 -0
  23. package/dist/download/pipeline/DownloadPipeline.d.ts +22 -3
  24. package/dist/download/pipeline/DownloadPipeline.js +109 -92
  25. package/dist/download/plan/DownloadPlanner.d.ts +47 -1
  26. package/dist/download/plan/DownloadPlanner.js +105 -15
  27. package/dist/notification/NotificationPolicy.d.ts +12 -0
  28. package/dist/notification/NotificationPolicy.js +86 -0
  29. package/dist/package.json +1 -1
  30. package/dist/scheduler/MultiScheduleManager.js +2 -0
  31. package/dist/scheduler/ScheduleTriggerServer.d.ts +58 -1
  32. package/dist/scheduler/ScheduleTriggerServer.js +207 -40
  33. package/dist/scheduler/SlotCoordinator.d.ts +80 -2
  34. package/dist/scheduler/SlotCoordinator.js +145 -3
  35. package/dist/scheduler/TargetOutcome.d.ts +195 -2
  36. package/dist/scheduler/TargetOutcome.js +202 -3
  37. package/dist/storage/DatabaseMigration.js +11 -0
  38. package/dist/storage/repositories/DeliveryRepository.d.ts +12 -0
  39. package/dist/storage/repositories/DeliveryRepository.js +18 -1
  40. package/dist/storage/repositories/SlotRepository.d.ts +8 -0
  41. package/dist/storage/repositories/SlotRepository.js +6 -2
  42. package/dist/version.js +1 -1
  43. package/dist/webui/package.json +1 -1
  44. package/package.json +1 -1
@@ -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
- const concurrency = plan.mode === 'sequential' && plan.queue.length > targetLimit
37
- ? 1
38
- : itemType === 'novel' && target.languageFilter
39
- ? 1
40
- : (this.config.download?.concurrency || 3);
41
- if (plan.mode === 'random') {
42
- await this.executeRandomMode(plan, targetLimit, planAvailableCount, itemType, tagForLog, downloadFn, retryAttempts, concurrency, state);
43
- }
44
- else {
45
- await this.executeSequentialMode(plan, targetLimit, itemType, tagForLog, downloadFn, retryAttempts, concurrency, state);
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
- async executeRandomMode(plan, targetLimit, planAvailableCount, itemType, tagForLog, downloadFn, retryAttempts, concurrency, state) {
57
- const candidates = plan.queue;
58
- if (planAvailableCount === 0) {
59
- logger_1.logger.info('All search results have already been downloaded');
60
- return;
61
- }
62
- if (candidates.length === 0) {
63
- return;
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
- const selectionLimit = plan.random?.maxAttempts ?? candidates.length;
66
- let attemptCounter = 0;
67
- let completedForProgress = 0;
68
- const totalPlanned = Math.min(candidates.length, targetLimit);
69
- await this.executor.run({
70
- items: candidates,
71
- concurrency,
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
- const attempt = ++attemptCounter;
80
- const remaining = Math.max(0, candidates.length - attempt + 1);
81
- const typeLabel = itemType === 'illustration' ? 'Illustration' : 'Novel';
82
- logger_1.logger.info(`Randomly selected ${typeLabel.toLowerCase()} ${item.id} from ${remaining} remaining result(s) (attempt ${attempt}/${selectionLimit})`);
83
- await downloadFn(item, tagForLog);
84
- state.downloaded++;
85
- this.updateProgress(state.downloaded, targetLimit, `已下载 ${itemType === 'illustration' ? '插画' : '小说'} ${item.id} (${state.downloaded}/${targetLimit})`);
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
- if (totalPlanned < targetLimit && state.downloaded < targetLimit) {
108
- logger_1.logger.info(`Random mode ended after ${totalPlanned} attempt(s); downloaded ${state.downloaded}/${targetLimit} ${itemType}(s)`);
109
- }
110
- }
111
- async executeSequentialMode(plan, targetLimit, itemType, tagForLog, downloadFn, retryAttempts, concurrency, state) {
112
- const toProcess = plan.queue;
113
- const totalPlanned = toProcess.length;
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, `进行中(${completedForProgress}/${totalPlanned}) - 已下载 ${msgBase}: ${state.downloaded}`);
132
+ this.updateProgress(Math.min(state.downloaded, targetLimit), targetLimit, `进行中(${done}/${total}) - 已下载 ${msgBase}: ${state.downloaded}`);
136
133
  },
137
- onDecision: (decision, { item, error }) => {
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
- state.skippedCount++;
142
- const detail = (0, errors_1.getErrorMessage)(error) || decision.reason || 'download failed';
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
- constructor(database: IDatabase, deliveryDedupe?: DeliveryDedupeSource | undefined);
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
- constructor(database, deliveryDedupe) {
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 CONFIRMED delivered to THIS target so ranking falls through to
23
- // the next valid candidate instead of selecting a historical duplicate.
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
- this.deliveryDedupe?.deliveredIds &&
79
+ submittedQuery &&
29
80
  typeof this.database.deliveries === 'object') {
30
81
  try {
31
- const delivered = this.deliveryDedupe.deliveredIds(deliveryTarget, itemType, available.map((item) => String(item.id)));
82
+ const taken = submittedQuery.call(this.deliveryDedupe, deliveryTarget, itemType, available.map((item) => String(item.id)));
32
83
  const before = available.length;
33
- available = available.filter((item) => !delivered.has(String(item.id)));
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-delivered ${itemType}(s) for ${deliveryTarget}`);
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
- const maxAttempts = Math.min(shuffled.length, 50);
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
- // Language is detected from the full novel body during download. Keep a
82
- // bounded popularity-ordered retry pool so a non-matching Top-1 candidate
83
- // can be skipped and replaced by the next matching novel.
84
- const backfillLimit = itemType === 'novel' && target.languageFilter
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
- : limit;
89
- const queue = available.slice(0, Math.min(available.length, backfillLimit));
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
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.19.4",
4
+ "version": "2.20.0",
5
5
  "private": true
6
6
  }