pixivflow 3.3.0 → 3.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "3.3.0",
4
+ "version": "3.4.0",
5
5
  "private": true
6
6
  }
@@ -25,7 +25,23 @@ export interface CandidateSearchParams {
25
25
  platform?: string;
26
26
  account?: string;
27
27
  };
28
- query: {
28
+ /**
29
+ * Scope selector: which of the producer's CONFIGURED targets this job runs
30
+ * for. It selects an existing target — it never overrides that target's
31
+ * delivery wiring or plan identity (see `ManualJobAdmission.resolveTarget`).
32
+ * It is deliberately NOT part of the stored retrieval view: the admission
33
+ * lifts it into `CandidateSearchJobRequest.targetSelector`, so a job that only
34
+ * names a target runs that target exactly as configured. This is what makes
35
+ * the generic `POST /jobs` face equivalent to the legacy refetch entry point,
36
+ * whose target used to live in the request path.
37
+ */
38
+ target_id?: string;
39
+ /**
40
+ * Retrieval override. Optional: omitting it (with or without `target_id`)
41
+ * means "run the resolved target's own configured search", which is what every
42
+ * pre-protocol refetch did.
43
+ */
44
+ query?: {
29
45
  tags: string[];
30
46
  expand?: boolean;
31
47
  };
@@ -42,18 +42,27 @@ function clamp(value, min, max) {
42
42
  * where the target is known.
43
43
  */
44
44
  function applyCandidateSearchParams(target, params) {
45
- const tags = params.query.tags;
45
+ const tags = params.query?.tags;
46
46
  const limit = params.constraints?.limit !== undefined ? clamp(params.constraints.limit, 1, exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX) : target.limit;
47
47
  const scanLimit = params.constraints?.scan_limit !== undefined
48
48
  ? clamp(params.constraints.scan_limit, limit ?? 1, exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX)
49
49
  : target.candidateScanLimit;
50
- return {
50
+ const constrained = {
51
51
  ...target,
52
- tag: tags.join(' '),
53
- ...(tags.length > 1 && params.query.expand === true ? { tagRelation: 'or' } : {}),
54
52
  ...(limit !== undefined ? { limit } : {}),
55
53
  ...(scanLimit !== undefined ? { candidateScanLimit: scanLimit } : {}),
56
54
  };
55
+ // `query` is optional: a job may carry only a scope selector and/or
56
+ // constraints, in which case the retrieval side stays exactly as the target is
57
+ // configured. (`parseCandidateSearchParamsJson` already yields `null` for such
58
+ // a stored view, so this is the belt to that braces.)
59
+ if (!tags || tags.length === 0)
60
+ return constrained;
61
+ return {
62
+ ...constrained,
63
+ tag: tags.join(' '),
64
+ ...(tags.length > 1 && params.query?.expand === true ? { tagRelation: 'or' } : {}),
65
+ };
57
66
  }
58
67
  /**
59
68
  * Map `constraints.exclude` onto the existing per-run duplicate history.
@@ -128,6 +128,13 @@ export interface ParsedTask {
128
128
  correlationId?: string;
129
129
  /** `params.source.account` — the Pixiv resource identity to assert. */
130
130
  account?: string;
131
+ /**
132
+ * `params.target_id` — which CONFIGURED target this job runs for. A selector,
133
+ * never an override: the admission resolves it against the same enabled plans
134
+ * and targets the legacy path used. Absent means "the deployment must have
135
+ * exactly one manual-eligible target".
136
+ */
137
+ targetSelector?: string;
131
138
  /** `params` — the occurrence-scoped retrieval view. */
132
139
  params: CandidateSearchParams;
133
140
  /**
@@ -46,6 +46,7 @@ const MAX_CALLBACK_URL_LENGTH = 2000;
46
46
  const MAX_TAG_LENGTH = 100;
47
47
  const MAX_TAGS = 20;
48
48
  const MAX_EXCLUSIONS = 100;
49
+ const MAX_TARGET_ID_LENGTH = 200;
49
50
  /** The only job type this producer serves today. */
50
51
  exports.JOB_TYPE_CANDIDATE_SEARCH = 'candidate_search';
51
52
  /** `$defs/ProtocolVersion` — this producer speaks exactly one version. */
@@ -268,15 +269,44 @@ function parseTaskBody(body) {
268
269
  }
269
270
  }
270
271
  const params = parseCandidateSearchParams(body.params);
272
+ // `params.target_id` is a SCOPE selector, not part of the retrieval view: it
273
+ // selects one of the targets this deployment already configures and never
274
+ // rewrites the target's delivery wiring or plan identity. It is lifted out
275
+ // here so the stored `paramsJson` stays a pure retrieval view — a job carrying
276
+ // only a selector therefore behaves exactly like the legacy refetch route,
277
+ // which ran the target as configured.
278
+ const targetSelector = parseTargetSelector(body.params);
271
279
  return {
272
280
  idempotencyKey,
273
281
  ...(correlationId !== undefined ? { correlationId } : {}),
274
282
  ...(params.source?.account !== undefined ? { account: params.source.account } : {}),
283
+ ...(targetSelector !== undefined ? { targetSelector } : {}),
275
284
  params,
276
285
  ...(deadlineMs !== undefined ? { deadlineMs } : {}),
277
286
  ...(callbackUrl !== undefined ? { callbackUrl } : {}),
278
287
  };
279
288
  }
289
+ /**
290
+ * `params.target_id` — the explicit target scope of a generic `candidate_search`.
291
+ *
292
+ * Optional and additive: absent means "the deployment must have exactly one
293
+ * manual-eligible target", which is the pre-existing behaviour.
294
+ */
295
+ function parseTargetSelector(raw) {
296
+ if (!isPlainObject(raw))
297
+ return undefined;
298
+ const value = raw.target_id;
299
+ if (value === undefined || value === null)
300
+ return undefined;
301
+ if (typeof value !== 'string' || value.trim() === '') {
302
+ throw invalid('params.target_id must be a non-empty string');
303
+ }
304
+ const selector = value.trim();
305
+ if (selector.length > MAX_TARGET_ID_LENGTH) {
306
+ throw invalid(`params.target_id must be at most ${MAX_TARGET_ID_LENGTH} characters`);
307
+ }
308
+ return selector;
309
+ }
280
310
  /** `$defs/CandidateSearchParams`, validated field by field. */
281
311
  function parseCandidateSearchParams(raw) {
282
312
  if (!isPlainObject(raw)) {
@@ -302,32 +332,39 @@ function parseCandidateSearchParams(raw) {
302
332
  source = { platform };
303
333
  }
304
334
  }
305
- const query = raw.query;
306
- if (!isPlainObject(query)) {
307
- throw invalid('params.query must be a JSON object');
308
- }
309
- const tags = query.tags;
310
- if (!Array.isArray(tags) || tags.length === 0) {
311
- throw invalid('params.query.tags must be a non-empty array of strings');
312
- }
313
- if (tags.length > MAX_TAGS) {
314
- throw invalid(`params.query.tags must contain at most ${MAX_TAGS} tags`);
315
- }
316
- for (const tag of tags) {
317
- if (typeof tag !== 'string' || tag.trim() === '' || tag.length > MAX_TAG_LENGTH) {
335
+ // `query` is optional: a job may carry only a scope selector
336
+ // (`params.target_id`) and/or constraints, in which case the resolved target
337
+ // runs exactly as configured — the same semantics the legacy refetch route
338
+ // always had. When present it is validated exactly as before.
339
+ let query;
340
+ if (raw.query !== undefined && raw.query !== null) {
341
+ if (!isPlainObject(raw.query)) {
342
+ throw invalid('params.query must be a JSON object');
343
+ }
344
+ const tags = raw.query.tags;
345
+ if (!Array.isArray(tags) || tags.length === 0) {
318
346
  throw invalid('params.query.tags must be a non-empty array of strings');
319
347
  }
320
- }
321
- if (query.expand !== undefined && query.expand !== null && typeof query.expand !== 'boolean') {
322
- throw invalid('params.query.expand must be a boolean');
348
+ if (tags.length > MAX_TAGS) {
349
+ throw invalid(`params.query.tags must contain at most ${MAX_TAGS} tags`);
350
+ }
351
+ for (const tag of tags) {
352
+ if (typeof tag !== 'string' || tag.trim() === '' || tag.length > MAX_TAG_LENGTH) {
353
+ throw invalid('params.query.tags must be a non-empty array of strings');
354
+ }
355
+ }
356
+ if (raw.query.expand !== undefined && raw.query.expand !== null && typeof raw.query.expand !== 'boolean') {
357
+ throw invalid('params.query.expand must be a boolean');
358
+ }
359
+ query = {
360
+ tags: tags.map((tag) => tag.trim()),
361
+ ...(raw.query.expand === true ? { expand: true } : {}),
362
+ };
323
363
  }
324
364
  const constraints = parseConstraints(raw.constraints);
325
365
  return {
326
366
  ...(source !== undefined ? { source } : {}),
327
- query: {
328
- tags: tags.map((tag) => tag.trim()),
329
- ...(query.expand === true ? { expand: true } : {}),
330
- },
367
+ ...(query !== undefined ? { query } : {}),
331
368
  ...(constraints !== undefined ? { constraints } : {}),
332
369
  };
333
370
  }
@@ -395,7 +432,18 @@ function buildCapabilities(config, now = Date.now()) {
395
432
  // durable stream and `POST /jobs/:id/events/ack` persists the cursor
396
433
  // (`src/scheduler/JobEventStream.ts`), and a Task's `callback_url`
397
434
  // receives `$defs/Event` bodies through the existing outbox.
398
- features: ['events', 'progress', 'cancel', 'idempotency', 'exclude', 'tag_expansion'],
435
+ // `target_selector` declares `params.target_id`: a job may name which
436
+ // configured target it runs for, and may omit `params.query` entirely —
437
+ // both are what makes this face a drop-in for the legacy refetch route.
438
+ features: [
439
+ 'events',
440
+ 'progress',
441
+ 'cancel',
442
+ 'idempotency',
443
+ 'exclude',
444
+ 'tag_expansion',
445
+ 'target_selector',
446
+ ],
399
447
  queued_timeout_ms: budgets.queuedTimeoutMs,
400
448
  stall_timeout_ms: budgets.stallTimeoutMs,
401
449
  heartbeat_interval_ms: SlotCoordinator_1.SLOT_HEARTBEAT_MS,
@@ -30,8 +30,11 @@ export declare const MANUAL_CANDIDATE_SEARCH_SLOT_NAME = "\u5BA1\u6838\u7FA4\u91
30
30
  /**
31
31
  * A consumer-initiated candidate search, normalized from whichever adapter
32
32
  * received it. `targetId` exists only for the legacy path (the old endpoint
33
- * carries the target in its URL); the generic job surface resolves the target
34
- * from configuration instead, optionally narrowed by `account`.
33
+ * carries the target in its URL); the generic job surface instead names the
34
+ * target explicitly with `targetSelector` (`params.target_id`) or, when it names
35
+ * none, resolves the unique manual-eligible target from configuration. Both are
36
+ * SELECTORS over the configured targets — neither can override delivery wiring or
37
+ * plan identity.
35
38
  */
36
39
  export interface CandidateSearchJobRequest {
37
40
  /** Consumer idempotency key; becomes the durable `manual_request_id`. */
@@ -40,6 +43,8 @@ export interface CandidateSearchJobRequest {
40
43
  correlationId?: string;
41
44
  /** Legacy adapter only: target id taken from the request path. */
42
45
  targetId?: string;
46
+ /** Generic adapter only: `params.target_id`, an explicit target selector. */
47
+ targetSelector?: string;
43
48
  /** Generic adapter only: `params.source.account` resource identity. */
44
49
  account?: string;
45
50
  /**
@@ -90,8 +95,11 @@ export declare class ManualJobAdmission {
90
95
  /**
91
96
  * Legacy: the target comes from the URL and must resolve to exactly one
92
97
  * enabled plan (the historic `unknown target` / `ambiguous target` rules).
93
- * Generic: the target is discovered from configuration — every enabled plan's
94
- * selected target that actually wires manual candidate-search delivery.
98
+ * Generic with `params.target_id`: the same rules, applied to the selector the
99
+ * caller named — a selector picks an existing target, it never rewrites one.
100
+ * Generic without any selector: the target is discovered from configuration —
101
+ * every enabled plan's selected target that actually wires manual
102
+ * candidate-search delivery, which must be exactly one.
95
103
  */
96
104
  private resolveTarget;
97
105
  /**
@@ -60,7 +60,7 @@ class ManualJobAdmission {
60
60
  const legacy = request.targetId !== undefined;
61
61
  const config = this.deps.config();
62
62
  this.assertAccount(config, request.account);
63
- const admission = this.resolveTarget(config, request.targetId);
63
+ const admission = this.resolveTarget(config, request.targetId, request.targetSelector);
64
64
  const { plan, target } = admission;
65
65
  this.assertParamsApplyToTarget(target, request.params);
66
66
  const derivedSlotId = `${plan.id}@manual-${key.toLowerCase()}`;
@@ -109,34 +109,40 @@ class ManualJobAdmission {
109
109
  /**
110
110
  * Legacy: the target comes from the URL and must resolve to exactly one
111
111
  * enabled plan (the historic `unknown target` / `ambiguous target` rules).
112
- * Generic: the target is discovered from configuration — every enabled plan's
113
- * selected target that actually wires manual candidate-search delivery.
112
+ * Generic with `params.target_id`: the same rules, applied to the selector the
113
+ * caller named — a selector picks an existing target, it never rewrites one.
114
+ * Generic without any selector: the target is discovered from configuration —
115
+ * every enabled plan's selected target that actually wires manual
116
+ * candidate-search delivery, which must be exactly one.
114
117
  */
115
- resolveTarget(config, targetId) {
116
- if (targetId !== undefined) {
118
+ resolveTarget(config, targetId, targetSelector) {
119
+ const explicit = targetId ?? targetSelector;
120
+ if (explicit !== undefined) {
117
121
  const plans = (config.schedules ?? []).filter((plan) => plan.enabled !== false &&
118
- (0, schedules_1.selectScheduleTargets)(config.targets, plan).some((target) => target.id === targetId));
122
+ (0, schedules_1.selectScheduleTargets)(config.targets, plan).some((target) => target.id === explicit));
119
123
  if (plans.length === 0) {
120
124
  throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
121
125
  message: 'unknown target',
122
- detail: { reason: 'unknown_target', target_id: targetId },
126
+ detail: { reason: 'unknown_target', target_id: explicit },
123
127
  });
124
128
  }
125
129
  if (plans.length !== 1) {
126
130
  throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
127
131
  message: 'ambiguous target',
128
- detail: { reason: 'ambiguous_target', target_id: targetId },
132
+ detail: { reason: 'ambiguous_target', target_id: explicit },
129
133
  });
130
134
  }
131
135
  const plan = plans[0];
132
- const target = (0, schedules_1.selectScheduleTargets)(config.targets, plan).find((item) => item.id === targetId && Boolean(item.id));
136
+ const target = (0, schedules_1.selectScheduleTargets)(config.targets, plan).find((item) => item.id === explicit && Boolean(item.id));
133
137
  if (!target || !target.id) {
134
138
  throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
135
139
  message: 'unknown target',
136
- detail: { reason: 'unknown_target', target_id: targetId },
140
+ detail: { reason: 'unknown_target', target_id: explicit },
137
141
  });
138
142
  }
139
- this.assertManualWiring(config, target, target.id, true);
143
+ // `legacy` only selects the historic diagnostic vocabulary: the URL-borne
144
+ // target is the old path, `params.target_id` is the generic one.
145
+ this.assertManualWiring(config, target, target.id, targetId !== undefined);
140
146
  return { plan, target, targetId: target.id };
141
147
  }
142
148
  const candidates = [];
@@ -47,6 +47,7 @@ class ManualJobService {
47
47
  idempotencyKey: task.idempotencyKey,
48
48
  ...(task.correlationId !== undefined ? { correlationId: task.correlationId } : {}),
49
49
  ...(task.account !== undefined ? { account: task.account } : {}),
50
+ ...(task.targetSelector !== undefined ? { targetSelector: task.targetSelector } : {}),
50
51
  params: task.params,
51
52
  });
52
53
  // The admission event (and the declared callback_url) are recorded against
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BUILD = void 0;
4
4
  // GENERATED by scripts/write-version.js — do not edit manually.
5
- exports.BUILD = { version: '3.3.0', commit: 'ed16abe8e473' };
5
+ exports.BUILD = { version: '3.4.0', commit: '5d231179a9b3' };
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow-webui-backend",
4
- "version": "3.3.0",
4
+ "version": "3.4.0",
5
5
  "description": "PixivFlow WebUI Backend - CommonJS module"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixivflow",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "🎨 Pixiv 下载、筛选与自动收集工具 - 批量下载插画和小说、按标签/热度/日期筛选、定时任务与可靠 HTTP 交付 | Pixiv downloader and automation toolkit with filtering, scheduling and reliable HTTP delivery",
5
5
  "repository": {
6
6
  "type": "git",