pixivflow 2.19.4 → 2.19.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,7 @@ export declare const DEFAULT_CONFIG: {
38
38
  readonly maxRetries: 3;
39
39
  readonly retryDelay: 2000;
40
40
  readonly timeout: 60000;
41
+ readonly candidateScanLimit: 5;
41
42
  };
42
43
  readonly initialDelay: 0;
43
44
  };
@@ -41,6 +41,10 @@ exports.DEFAULT_CONFIG = {
41
41
  maxRetries: 3,
42
42
  retryDelay: 2000,
43
43
  timeout: 60000,
44
+ // Candidates one execution may ATTEMPT while looking for an eligible work.
45
+ // Bounded so a ranking page full of already-delivered works cannot loop,
46
+ // but large enough that a single duplicate no longer burns the slot.
47
+ candidateScanLimit: 5,
44
48
  },
45
49
  initialDelay: 0,
46
50
  };
@@ -101,6 +101,25 @@ function applyEnvironmentOverrides(config) {
101
101
  overridden.scheduler.enabled = process.env.PIXIV_SCHEDULER_ENABLED.toLowerCase() === 'true';
102
102
  }
103
103
  }
104
+ // Override the bounded candidate-scan window. A non-numeric or non-positive
105
+ // value is ignored (the config-file value stays authoritative) rather than
106
+ // silently becoming NaN, which would disable the bound entirely.
107
+ if (process.env.PIXIV_CANDIDATE_SCAN_LIMIT !== undefined) {
108
+ const parsed = Number.parseInt(process.env.PIXIV_CANDIDATE_SCAN_LIMIT, 10);
109
+ if (Number.isFinite(parsed) && parsed > 0) {
110
+ if (!overridden.download) {
111
+ overridden.download = { candidateScanLimit: parsed };
112
+ }
113
+ else {
114
+ overridden.download.candidateScanLimit = parsed;
115
+ }
116
+ }
117
+ else {
118
+ logger_1.logger.warn('Ignoring PIXIV_CANDIDATE_SCAN_LIMIT: expected a positive integer', {
119
+ value: process.env.PIXIV_CANDIDATE_SCAN_LIMIT,
120
+ });
121
+ }
122
+ }
104
123
  // Override proxy from environment variables
105
124
  // Priority: all_proxy > https_proxy > http_proxy
106
125
  const proxyUrl = process.env.all_proxy || process.env.ALL_PROXY ||
@@ -81,6 +81,20 @@ export interface TargetConfig {
81
81
  * Maximum works to download per execution for this tag.
82
82
  */
83
83
  limit?: number;
84
+ /**
85
+ * Maximum CANDIDATE works one execution may attempt (not produce) while
86
+ * looking for an eligible work. A scheduled "one post per slot" target has
87
+ * `limit: 1`, so without this the run would hold a single candidate: if that
88
+ * one candidate is already delivered / deleted / private, the slot ends with
89
+ * nothing submitted. The scan walks up to N candidates in ranking order,
90
+ * skipping unusable ones, and is strictly bounded so a page full of
91
+ * duplicates cannot loop.
92
+ *
93
+ * Overrides `download.candidateScanLimit` / `PIXIV_CANDIDATE_SCAN_LIMIT`.
94
+ * Clamped to 1..100 and never below `limit`, so a multi-work target can still
95
+ * fill its own limit.
96
+ */
97
+ candidateScanLimit?: number;
84
98
  /**
85
99
  * Search target parameter for Pixiv API.
86
100
  */
@@ -655,6 +669,13 @@ export interface StandaloneConfig {
655
669
  * Default: 60000
656
670
  */
657
671
  timeout?: number;
672
+ /**
673
+ * How many CANDIDATE works one execution may attempt while looking for an
674
+ * eligible one. Per-target `candidateScanLimit` overrides this.
675
+ *
676
+ * Default: 5 (clamped to 1..100)
677
+ */
678
+ candidateScanLimit?: number;
658
679
  };
659
680
  }
660
681
  //# sourceMappingURL=types.d.ts.map
@@ -469,6 +469,21 @@ function validateConfig(config, location, databasePath) {
469
469
  if (config.download.maxRetries !== undefined && (config.download.maxRetries < 0 || config.download.maxRetries > 10)) {
470
470
  warnings.push('download.maxRetries: Should be between 0 and 10');
471
471
  }
472
+ if (config.download.candidateScanLimit !== undefined &&
473
+ (!Number.isInteger(config.download.candidateScanLimit) ||
474
+ config.download.candidateScanLimit < 1 ||
475
+ config.download.candidateScanLimit > 100)) {
476
+ warnings.push('download.candidateScanLimit: Should be an integer between 1 and 100');
477
+ }
478
+ }
479
+ // The bounded candidate scan is what stops a page full of duplicates from
480
+ // burning a whole scheduled slot, so a non-integer value is reported here.
481
+ for (const target of config.targets ?? []) {
482
+ if (target.candidateScanLimit === undefined)
483
+ continue;
484
+ if (!Number.isInteger(target.candidateScanLimit) || target.candidateScanLimit < 1 || target.candidateScanLimit > 100) {
485
+ warnings.push(`targets.${target.id ?? target.tag ?? '?'}.candidateScanLimit: Should be an integer between 1 and 100`);
486
+ }
472
487
  }
473
488
  // Validate log level
474
489
  if (config.logLevel && !['debug', 'info', 'warn', 'error'].includes(config.logLevel)) {
@@ -35,6 +35,15 @@ export declare class DeliveryService {
35
35
  isAlreadyDelivered(deliveryTarget: string, workType: string, pixivId: string): boolean;
36
36
  /** Batch form for pre-lock candidate filtering. */
37
37
  deliveredIds(deliveryTarget: string, workType: string, pixivIds: string[]): Set<string>;
38
+ /**
39
+ * Bulk CANDIDATE SELECTION dedupe: works already delivered to this target, or
40
+ * whose review submission is still PENDING an answer. A pending work is
41
+ * already submitted for review, so it must not be selected (and submitted)
42
+ * again; the scan moves on to the next candidate instead. Within-slot RESUME
43
+ * keeps using `isAlreadyDelivered` — a cell continuing its OWN pending work is
44
+ * resuming it, not duplicating it.
45
+ */
46
+ submittedIds(deliveryTarget: string, workType: string, pixivIds: string[]): Set<string>;
38
47
  /**
39
48
  * Atomically create the delivery intent + outbox row and advance the cell.
40
49
  * Missing local artifact files are treated as a recoverable error (the
@@ -33,6 +33,17 @@ class DeliveryService {
33
33
  deliveredIds(deliveryTarget, workType, pixivIds) {
34
34
  return this.database.deliveries.deliveredIds(deliveryTarget, workType, pixivIds);
35
35
  }
36
+ /**
37
+ * Bulk CANDIDATE SELECTION dedupe: works already delivered to this target, or
38
+ * whose review submission is still PENDING an answer. A pending work is
39
+ * already submitted for review, so it must not be selected (and submitted)
40
+ * again; the scan moves on to the next candidate instead. Within-slot RESUME
41
+ * keeps using `isAlreadyDelivered` — a cell continuing its OWN pending work is
42
+ * resuming it, not duplicating it.
43
+ */
44
+ submittedIds(deliveryTarget, workType, pixivIds) {
45
+ return this.database.deliveries.submittedIds(deliveryTarget, workType, pixivIds);
46
+ }
36
47
  /**
37
48
  * Atomically create the delivery intent + outbox row and advance the cell.
38
49
  * Missing local artifact files are treated as a recoverable error (the
@@ -128,6 +128,9 @@ class DownloadManager {
128
128
  this.novelDownloader = new NovelDownloader_1.NovelDownloader(client, database, fileService, database);
129
129
  this.planner = new DownloadPlanner_1.DownloadPlanner(database, {
130
130
  deliveredIds: (target, type, ids) => this.deliveryService.deliveredIds(target, type, ids),
131
+ // CANDIDATE SELECTION dedupe: also treats a work whose review submission is
132
+ // still PENDING as taken, so it is skipped instead of submitted again.
133
+ submittedIds: (target, type, ids) => this.deliveryService.submittedIds(target, type, ids),
131
134
  // Durable duplicate history handed in by the caller (the batch runner asks
132
135
  // the control plane for it). Independent of any local delivery target, so it
133
136
  // also works for a shadow run that delivers nowhere.
@@ -137,7 +140,10 @@ class DownloadManager {
137
140
  return new Set();
138
141
  return new Set(ids.filter((id) => known.has(id)));
139
142
  },
140
- });
143
+ // Global bounded candidate-scan window (`download.candidateScanLimit`).
144
+ // Per-target `candidateScanLimit` overrides it. Without this the pipeline
145
+ // would only ever see as many candidates as the target's own `limit`.
146
+ }, config.download?.candidateScanLimit);
141
147
  this.executor = new DownloadExecutor_1.DownloadExecutor();
142
148
  const downloadConfig = config.download ?? {};
143
149
  const maxRetries = downloadConfig.maxRetries ?? 3;
@@ -166,6 +172,11 @@ class DownloadManager {
166
172
  : config.storage?.databasePath, config.download?.requestDelay ?? 500, this.abortController.signal);
167
173
  this.illustrationHandler = new IllustrationTargetHandler_1.IllustrationTargetHandler(client, database, this.rankingService, this.illustrationDownloader, this.pipeline, topicFactory, this.deliveryService);
168
174
  this.novelHandler = new NovelTargetHandler_1.NovelTargetHandler(client, database, this.rankingService, this.pipeline, this.novelDownloader, topicFactory, this.deliveryService);
175
+ // The FETCH stage needs the same bound the planner applies to the attempt
176
+ // window, or a `limit: 1` target would ask the ranking API for one work and
177
+ // leave the bounded scan nothing to scan.
178
+ this.illustrationHandler.setDefaultCandidateScanLimit(config.download?.candidateScanLimit);
179
+ this.novelHandler.setDefaultCandidateScanLimit(config.download?.candidateScanLimit);
169
180
  }
170
181
  setProgressCallback(callback) {
171
182
  this.progressReporter.setCallback(callback);
@@ -18,6 +18,29 @@ export declare class IllustrationTargetHandler {
18
18
  private readonly deliveryService?;
19
19
  /** Outcomes produced during this handle() call (deliveries + terminal non-matches). */
20
20
  private outcomes;
21
+ /**
22
+ * Candidate scan of the last pipeline.run() of this handle() call: how many
23
+ * candidates were attempted, which were skipped and why, and whether any
24
+ * job-level outage appeared. This is what turns an empty result into an
25
+ * explicit verdict instead of an ambiguous "completed".
26
+ */
27
+ private scan;
28
+ /**
29
+ * Global candidate-scan bound (`download.candidateScanLimit`), supplied by
30
+ * DownloadManager. This is what lets the FETCH stage ask for more than one
31
+ * candidate: a scheduled one-post-per-slot target has `limit: 1`, and asking
32
+ * the ranking API for exactly one work is the reason a single duplicate used
33
+ * to be unfixable downstream.
34
+ */
35
+ private defaultCandidateScanLimit?;
36
+ /** Publish the global candidate-scan bound for the fetch stage. */
37
+ setDefaultCandidateScanLimit(limit: number | undefined): void;
38
+ /**
39
+ * How many candidates to FETCH so the bounded scan has something to choose
40
+ * from. Same rule the planner applies to the attempt window, so fetch and
41
+ * scan agree on one bound.
42
+ */
43
+ private candidateFetchLimit;
21
44
  /**
22
45
  * Cell identity for this handle() call. Set only for a single-work cell of a
23
46
  * scheduled occurrence; null for ad-hoc runs and N-works-per-run targets, which
@@ -26,7 +49,15 @@ export declare class IllustrationTargetHandler {
26
49
  private execution;
27
50
  constructor(client: IPixivClient, database: IDatabase, rankingService: RankingService, illustrationDownloader: IllustrationDownloader, pipeline: DownloadPipeline, topicPipelineFactory?: TopicPipelineFactory | undefined, deliveryService?: DeliveryService | undefined);
28
51
  handle(target: TargetConfig, execution?: TargetExecutionContext): Promise<TargetOutcome>;
29
- /** Reduce the outcomes collected while processing one target to one cell result. */
52
+ /**
53
+ * Reduce the outcomes collected while processing one target to one cell
54
+ * result, including the bounded-scan bookkeeping.
55
+ *
56
+ * A `duplicate` is deliberately NOT a target verdict here: a duplicate is a
57
+ * CANDIDATE problem (skip it and try the next candidate), which is what the
58
+ * scan already did. Returning it as the target outcome is the bug that made a
59
+ * scheduled slot report success after submitting nothing.
60
+ */
30
61
  private summarize;
31
62
  private classifyError;
32
63
  private fetchIllustrations;
@@ -56,6 +87,11 @@ export declare class IllustrationTargetHandler {
56
87
  /**
57
88
  * Process ONE candidate work for this cell.
58
89
  *
90
+ * Returns what happened to THIS candidate, so the pipeline can advance to the
91
+ * next one when it was unusable. Throwing is reserved for JOB-level failures
92
+ * (dead database / dead token / dead delivery provider): a candidate-level
93
+ * failure is reported as a `skipped` attempt and never as a job verdict.
94
+ *
59
95
  * The cell is bound to `illust` BEFORE any side effect: it is the binding, not
60
96
  * the candidate list, that decides what a later recovery resumes. The binding
61
97
  * is only rolled back when the attempt produced no artifact at all, so in-run
@@ -67,6 +103,11 @@ export declare class IllustrationTargetHandler {
67
103
  * delivery mode the DeliveryService creates the durable intent atomically and
68
104
  * the result is 'delivery_pending' (NOT submitted — the OutboxWorker confirms
69
105
  * the ACK). Persistent/download-only runs are 'stored'.
106
+ *
107
+ * An ALREADY-DELIVERED work is returned as a candidate SKIP, never as a
108
+ * target outcome: the run must try the next candidate instead of ending the
109
+ * slot as a `duplicate`. The delivery idempotency ledger is what makes the
110
+ * second concurrent worker lose this race instead of double-submitting.
70
111
  */
71
112
  private recordArtifactOutcome;
72
113
  private executionContextFields;