pixivflow 2.9.0 → 2.10.1

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 (62) hide show
  1. package/README.md +2 -0
  2. package/dist/commands/TopicCommand.d.ts +19 -0
  3. package/dist/commands/TopicCommand.d.ts.map +1 -0
  4. package/dist/commands/TopicCommand.js +114 -0
  5. package/dist/commands/TopicCommand.js.map +1 -0
  6. package/dist/commands/index.d.ts +2 -1
  7. package/dist/commands/index.d.ts.map +1 -1
  8. package/dist/commands/index.js +4 -1
  9. package/dist/commands/index.js.map +1 -1
  10. package/dist/config/types.d.ts +45 -2
  11. package/dist/config/types.d.ts.map +1 -1
  12. package/dist/config/validation.d.ts.map +1 -1
  13. package/dist/config/validation.js +40 -3
  14. package/dist/config/validation.js.map +1 -1
  15. package/dist/download/DownloadManager.d.ts.map +1 -1
  16. package/dist/download/DownloadManager.js +7 -2
  17. package/dist/download/DownloadManager.js.map +1 -1
  18. package/dist/download/handlers/IllustrationTargetHandler.d.ts +5 -1
  19. package/dist/download/handlers/IllustrationTargetHandler.d.ts.map +1 -1
  20. package/dist/download/handlers/IllustrationTargetHandler.js +26 -1
  21. package/dist/download/handlers/IllustrationTargetHandler.js.map +1 -1
  22. package/dist/download/handlers/NovelTargetHandler.d.ts +4 -1
  23. package/dist/download/handlers/NovelTargetHandler.d.ts.map +1 -1
  24. package/dist/download/handlers/NovelTargetHandler.js +23 -1
  25. package/dist/download/handlers/NovelTargetHandler.js.map +1 -1
  26. package/dist/package.json +1 -1
  27. package/dist/pixiv/PixivClient.d.ts +8 -2
  28. package/dist/pixiv/PixivClient.d.ts.map +1 -1
  29. package/dist/pixiv/PixivClient.js +6 -2
  30. package/dist/pixiv/PixivClient.js.map +1 -1
  31. package/dist/pixiv/types.d.ts +7 -0
  32. package/dist/pixiv/types.d.ts.map +1 -1
  33. package/dist/storage/Database.d.ts +2 -0
  34. package/dist/storage/Database.d.ts.map +1 -1
  35. package/dist/storage/Database.js +7 -0
  36. package/dist/storage/Database.js.map +1 -1
  37. package/dist/topic/TopicCache.d.ts +23 -0
  38. package/dist/topic/TopicCache.d.ts.map +1 -0
  39. package/dist/topic/TopicCache.js +78 -0
  40. package/dist/topic/TopicCache.js.map +1 -0
  41. package/dist/topic/TopicPipeline.d.ts +49 -0
  42. package/dist/topic/TopicPipeline.d.ts.map +1 -0
  43. package/dist/topic/TopicPipeline.js +224 -0
  44. package/dist/topic/TopicPipeline.js.map +1 -0
  45. package/dist/topic/TopicResolver.d.ts +31 -0
  46. package/dist/topic/TopicResolver.d.ts.map +1 -0
  47. package/dist/topic/TopicResolver.js +162 -0
  48. package/dist/topic/TopicResolver.js.map +1 -0
  49. package/dist/topic/TopicTagScorer.d.ts +13 -0
  50. package/dist/topic/TopicTagScorer.d.ts.map +1 -0
  51. package/dist/topic/TopicTagScorer.js +109 -0
  52. package/dist/topic/TopicTagScorer.js.map +1 -0
  53. package/dist/topic/createTopicPipeline.d.ts +16 -0
  54. package/dist/topic/createTopicPipeline.d.ts.map +1 -0
  55. package/dist/topic/createTopicPipeline.js +33 -0
  56. package/dist/topic/createTopicPipeline.js.map +1 -0
  57. package/dist/topic/types.d.ts +93 -0
  58. package/dist/topic/types.d.ts.map +1 -0
  59. package/dist/topic/types.js +8 -0
  60. package/dist/topic/types.js.map +1 -0
  61. package/dist/webui/package.json +1 -1
  62. package/package.json +1 -1
@@ -0,0 +1,49 @@
1
+ import type { TargetConfig } from '../config';
2
+ import type { TopicResolver } from './TopicResolver';
3
+ import type { TopicCandidate, TopicClient, TopicCollectOptions, TopicContentType, TopicDiscoveryOptions, WorkLike } from './types';
4
+ export interface TopicSelection {
5
+ candidates: TopicCandidate[];
6
+ selected: TopicCandidate[];
7
+ resolvedTagCount: number;
8
+ rawCount: number;
9
+ dedupedCount: number;
10
+ acceptedCount: number;
11
+ }
12
+ /**
13
+ * Resolves a topic to a tag space, collects that day's works across the tags,
14
+ * filters by lightweight metadata relevance and ranks by local popularity, then
15
+ * returns only the chosen full work objects for the existing download pipeline.
16
+ * Serial, bounded, low-memory: the retained set is `limit` items; the pool is
17
+ * capped at maxCandidates and released after selection.
18
+ */
19
+ export declare class TopicPipeline {
20
+ private readonly client;
21
+ private readonly resolver;
22
+ private readonly requestDelayMs;
23
+ constructor(client: TopicClient, resolver: TopicResolver, requestDelayMs?: number);
24
+ selectWorks<T extends WorkLike>(target: TargetConfig, contentType: TopicContentType, day: string, limit: number, discovery: TopicDiscoveryOptions, collect: TopicCollectOptions): Promise<{
25
+ works: T[];
26
+ selection: TopicSelection;
27
+ }>;
28
+ private searchDay;
29
+ private toCandidate;
30
+ /**
31
+ * Lightweight metadata relevance. Tags dominate (Pixiv's own taxonomy);
32
+ * title/caption add smaller boosts. The seed tag is strong evidence.
33
+ * No text model — case/symbol-insensitive substring matching only.
34
+ */
35
+ private metadataScore;
36
+ /**
37
+ * Rank primarily by topic relevance tier, then by local popularity within a
38
+ * tier. Relevance wins because the goal is "belongs to the topic", with
39
+ * popularity deciding which on-topic work to download.
40
+ */
41
+ private relevanceTier;
42
+ private rankCompare;
43
+ private topByPopularity;
44
+ private onDay;
45
+ private normalize;
46
+ private key;
47
+ private bound;
48
+ }
49
+ //# sourceMappingURL=TopicPipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TopicPipeline.d.ts","sourceRoot":"","sources":["../../src/topic/TopicPipeline.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EACV,cAAc,EACd,WAAW,EACX,mBAAmB,EACnB,gBAAgB,EAChB,qBAAqB,EACrB,QAAQ,EACT,MAAM,SAAS,CAAC;AAYjB,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;GAMG;AACH,qBAAa,aAAa;IAEtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,cAAc;gBAFd,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,aAAa,EACvB,cAAc,SAAM;IAGjC,WAAW,CAAC,CAAC,SAAS,QAAQ,EAClC,MAAM,EAAE,YAAY,EACpB,WAAW,EAAE,gBAAgB,EAC7B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,qBAAqB,EAChC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC;QAAE,KAAK,EAAE,CAAC,EAAE,CAAC;QAAC,SAAS,EAAE,cAAc,CAAA;KAAE,CAAC;YAgEvC,SAAS;IAmBvB,OAAO,CAAC,WAAW;IAenB;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAmCrB;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAMrB,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,eAAe;IAgBvB,OAAO,CAAC,KAAK;IAKb,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,KAAK;CAKd"}
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TopicPipeline = void 0;
4
+ const promises_1 = require("node:timers/promises");
5
+ const logger_1 = require("../logger");
6
+ const pixiv_utils_1 = require("../utils/pixiv-utils");
7
+ const COLLECT_DEFAULTS = { maxPerTag: 40, maxCandidates: 250, minMetadataScore: 0.35 };
8
+ /** Small, platform-level tags that are never evidence of topic membership. */
9
+ const STOP_TAGS = new Set([
10
+ 'r-18', 'r-18g', 'r-15', 'original', 'オリジナル', '創作', '女の子', '男の子',
11
+ 'イラスト', '漫画', 'manga', 'illustration', 'artwork', '落書き', 'らくがき',
12
+ '1000users入り', '5000users入り', '10000users入り', '500users入り', '100users入り',
13
+ 'pixiv', 'commission', 'skeb', '依頼絵', '仕事絵',
14
+ ]);
15
+ /**
16
+ * Resolves a topic to a tag space, collects that day's works across the tags,
17
+ * filters by lightweight metadata relevance and ranks by local popularity, then
18
+ * returns only the chosen full work objects for the existing download pipeline.
19
+ * Serial, bounded, low-memory: the retained set is `limit` items; the pool is
20
+ * capped at maxCandidates and released after selection.
21
+ */
22
+ class TopicPipeline {
23
+ client;
24
+ resolver;
25
+ requestDelayMs;
26
+ constructor(client, resolver, requestDelayMs = 500) {
27
+ this.client = client;
28
+ this.resolver = resolver;
29
+ this.requestDelayMs = requestDelayMs;
30
+ }
31
+ async selectWorks(target, contentType, day, limit, discovery, collect) {
32
+ const topic = (target.topic ?? '').trim();
33
+ const { space } = await this.resolver.resolve(topic, contentType, discovery);
34
+ const tagScores = new Map(space.tags.map((t) => [this.key(t.name), t.score]));
35
+ const maxPerTag = this.bound(collect.maxPerTag, COLLECT_DEFAULTS.maxPerTag, 5, 100);
36
+ const maxCandidates = this.bound(collect.maxCandidates, COLLECT_DEFAULTS.maxCandidates, 20, 500);
37
+ const minMetadataScore = collect.minMetadataScore ?? COLLECT_DEFAULTS.minMetadataScore;
38
+ const byId = new Map();
39
+ let rawCount = 0;
40
+ const tagNames = space.tags.map((t) => t.name);
41
+ for (let i = 0; i < tagNames.length; i++) {
42
+ if (byId.size >= maxCandidates)
43
+ break;
44
+ const tag = tagNames[i];
45
+ const works = await this.searchDay(contentType, tag, day, maxPerTag);
46
+ rawCount += works.length;
47
+ for (const work of works) {
48
+ if (byId.has(work.id))
49
+ continue;
50
+ byId.set(work.id, { work, candidate: this.toCandidate(work, contentType) });
51
+ if (byId.size >= maxCandidates)
52
+ break;
53
+ }
54
+ logger_1.logger.debug('[TopicCollector] type=' + contentType + ' tag=' + tag + ' day=' + day + ' fetched=' + works.length + ' pool=' + byId.size);
55
+ if (i < tagNames.length - 1 && this.requestDelayMs > 0)
56
+ await (0, promises_1.setTimeout)(this.requestDelayMs);
57
+ }
58
+ const dedupedCount = byId.size;
59
+ logger_1.logger.info('[TopicCollector] type=' + contentType + ' raw=' + rawCount + ' deduplicated=' + dedupedCount);
60
+ const seedKey = this.key(topic);
61
+ const accepted = [];
62
+ for (const entry of byId.values()) {
63
+ entry.candidate.metadataScore = this.metadataScore(entry.candidate, seedKey, tagScores);
64
+ if (entry.candidate.metadataScore >= minMetadataScore)
65
+ accepted.push(entry);
66
+ }
67
+ if (accepted.length === 0 && byId.size > 0) {
68
+ const fallback = [...byId.values()]
69
+ .filter((e) => e.candidate.tags.some((t) => this.key(t) === seedKey))
70
+ .sort((a, b) => b.candidate.popularity - a.candidate.popularity);
71
+ accepted.push(...fallback.slice(0, Math.max(limit, 1)));
72
+ logger_1.logger.warn('[MetadataTopicFilter] type=' + contentType + ' none above threshold ' + minMetadataScore + '; kept ' + accepted.length + ' seed-tag fallback');
73
+ }
74
+ const chosen = this.topByPopularity(accepted, limit);
75
+ const selected = chosen.map((e) => e.candidate);
76
+ logger_1.logger.info('[MetadataTopicFilter] accepted=' + accepted.length);
77
+ if (selected[0]) {
78
+ logger_1.logger.info('[PopularityRanker] selected=' + selected[0].id + ' popularity=' + selected[0].popularity.toFixed(1) + ' meta=' + selected[0].metadataScore.toFixed(2) + ' title=' + selected[0].title);
79
+ }
80
+ return {
81
+ works: chosen.map((e) => e.work),
82
+ selection: {
83
+ candidates: [...byId.values()].map((e) => e.candidate),
84
+ selected,
85
+ resolvedTagCount: space.tags.length,
86
+ rawCount,
87
+ dedupedCount,
88
+ acceptedCount: accepted.length,
89
+ },
90
+ };
91
+ }
92
+ async searchDay(contentType, tag, day, limit) {
93
+ try {
94
+ const opts = { startDate: day, endDate: day };
95
+ const works = contentType === 'illustration'
96
+ ? await this.client.searchIllustrationsForTags(tag, limit, opts)
97
+ : await this.client.searchNovelsForTags(tag, limit, opts);
98
+ // Date is already enforced server-side + pager stop; keep a cheap guard.
99
+ return works.filter((w) => this.onDay(w.create_date, day));
100
+ }
101
+ catch (error) {
102
+ logger_1.logger.warn('[TopicCollector] search failed tag=' + tag + ' type=' + contentType + ': ' + (error instanceof Error ? error.message : String(error)));
103
+ return [];
104
+ }
105
+ }
106
+ toCandidate(work, type) {
107
+ const popularity = (0, pixiv_utils_1.calculatePopularityScore)(work);
108
+ return {
109
+ id: work.id,
110
+ type,
111
+ title: work.title ?? '',
112
+ caption: work.caption ?? '',
113
+ tags: (work.tags ?? []).map((t) => t.name).filter(Boolean),
114
+ bookmarks: Number(work.total_bookmarks ?? work.bookmark_count ?? 0) || 0,
115
+ views: Number(work.total_view ?? work.view_count ?? 0) || 0,
116
+ popularity,
117
+ metadataScore: 0,
118
+ };
119
+ }
120
+ /**
121
+ * Lightweight metadata relevance. Tags dominate (Pixiv's own taxonomy);
122
+ * title/caption add smaller boosts. The seed tag is strong evidence.
123
+ * No text model — case/symbol-insensitive substring matching only.
124
+ */
125
+ metadataScore(candidate, seedKey, tagScores) {
126
+ let seedHit = false;
127
+ let relatedSum = 0;
128
+ let relatedHits = 0;
129
+ for (const tag of candidate.tags) {
130
+ const k = this.key(tag);
131
+ if (STOP_TAGS.has(k))
132
+ continue;
133
+ if (k === seedKey) {
134
+ seedHit = true;
135
+ continue;
136
+ }
137
+ const related = tagScores.get(k);
138
+ if (related !== undefined) {
139
+ relatedSum += Math.min(related, 0.6);
140
+ relatedHits += 1;
141
+ }
142
+ }
143
+ const hayTitle = this.normalize(candidate.title);
144
+ const hayCaption = this.normalize(candidate.caption);
145
+ const titleSeed = !!seedKey && hayTitle.includes(seedKey);
146
+ const captionSeed = !!seedKey && hayCaption.includes(seedKey);
147
+ if (seedHit) {
148
+ // Core topic work: the seed tag itself is the strongest evidence.
149
+ let score = 1.0 + Math.min(relatedSum, 0.5);
150
+ if (titleSeed)
151
+ score += 0.4;
152
+ return score;
153
+ }
154
+ // No seed tag: the work must show several independent related tags (or a
155
+ // high-weight one plus text) to count as on-topic. A single marginal tag
156
+ // deliberately does NOT clear the bar, so a hugely popular tangential work
157
+ // cannot crowd out core topic works.
158
+ let score = 0;
159
+ const strongRelated = [...tagScores.entries()].some(([k, w]) => w >= 0.6 && candidate.tags.some((t) => this.key(t) === k));
160
+ if (titleSeed)
161
+ score += 0.8;
162
+ if (captionSeed)
163
+ score += 0.4;
164
+ if (strongRelated)
165
+ score += 0.5;
166
+ score += Math.min(relatedSum, 0.6) * (relatedHits >= 2 ? 1 : 0.5);
167
+ return score;
168
+ }
169
+ /**
170
+ * Rank primarily by topic relevance tier, then by local popularity within a
171
+ * tier. Relevance wins because the goal is "belongs to the topic", with
172
+ * popularity deciding which on-topic work to download.
173
+ */
174
+ relevanceTier(c) {
175
+ if (c.metadataScore >= 1.0)
176
+ return 3; // carries the seed tag / strong evidence
177
+ if (c.metadataScore >= 0.7)
178
+ return 2;
179
+ return 1;
180
+ }
181
+ rankCompare(a, b) {
182
+ const tier = this.relevanceTier(b) - this.relevanceTier(a);
183
+ if (tier !== 0)
184
+ return tier;
185
+ return b.popularity - a.popularity;
186
+ }
187
+ topByPopularity(items, limit) {
188
+ if (items.length <= limit)
189
+ return items.sort((a, b) => this.rankCompare(a.candidate, b.candidate));
190
+ // O(n) top-`limit` selection (limit is tiny, e.g. 1); avoids a full sort.
191
+ const top = [];
192
+ for (const item of items) {
193
+ if (top.length < limit) {
194
+ top.push(item);
195
+ top.sort((a, b) => this.rankCompare(a.candidate, b.candidate));
196
+ }
197
+ else if (this.rankCompare(item.candidate, top[top.length - 1].candidate) < 0) {
198
+ top[top.length - 1] = item;
199
+ top.sort((a, b) => this.rankCompare(a.candidate, b.candidate));
200
+ }
201
+ }
202
+ return top;
203
+ }
204
+ onDay(createDate, day) {
205
+ if (!createDate)
206
+ return true;
207
+ return createDate.slice(0, 10) === day;
208
+ }
209
+ normalize(value) {
210
+ return value.toLowerCase().normalize('NFKC');
211
+ }
212
+ key(value) {
213
+ return value.trim().normalize('NFKC').toLocaleLowerCase();
214
+ }
215
+ bound(value, fallback, min, max) {
216
+ const resolved = value ?? fallback;
217
+ if (!Number.isInteger(resolved) || resolved < min || resolved > max)
218
+ return fallback;
219
+ return resolved;
220
+ }
221
+ }
222
+ exports.TopicPipeline = TopicPipeline;
223
+ function seedText(key) { return key; }
224
+ //# sourceMappingURL=TopicPipeline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TopicPipeline.js","sourceRoot":"","sources":["../../src/topic/TopicPipeline.ts"],"names":[],"mappings":";;;AAAA,mDAA2D;AAE3D,sCAAmC;AACnC,sDAAgE;AAYhE,MAAM,gBAAgB,GAAG,EAAE,SAAS,EAAE,EAAE,EAAE,aAAa,EAAE,GAAG,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC;AAEvF,8EAA8E;AAC9E,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC;IACxB,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK;IAChE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM;IAC/D,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY;IACxE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAC5C,CAAC,CAAC;AAWH;;;;;;GAMG;AACH,MAAa,aAAa;IAEL;IACA;IACA;IAHnB,YACmB,MAAmB,EACnB,QAAuB,EACvB,iBAAiB,GAAG;QAFpB,WAAM,GAAN,MAAM,CAAa;QACnB,aAAQ,GAAR,QAAQ,CAAe;QACvB,mBAAc,GAAd,cAAc,CAAM;IACpC,CAAC;IAEJ,KAAK,CAAC,WAAW,CACf,MAAoB,EACpB,WAA6B,EAC7B,GAAW,EACX,KAAa,EACb,SAAgC,EAChC,OAA4B;QAE5B,MAAM,KAAK,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC1C,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,CAAC,CAAC;QAC7E,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAE9E,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,EAAE,gBAAgB,CAAC,SAAS,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC;QACpF,MAAM,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,aAAa,EAAE,gBAAgB,CAAC,aAAa,EAAE,EAAE,EAAE,GAAG,CAAC,CAAC;QACjG,MAAM,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,IAAI,gBAAgB,CAAC,gBAAgB,CAAC;QAEvF,MAAM,IAAI,GAAG,IAAI,GAAG,EAAkD,CAAC;QACvE,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,MAAM,QAAQ,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAE/C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACzC,IAAI,IAAI,CAAC,IAAI,IAAI,aAAa;gBAAE,MAAM;YACtC,MAAM,GAAG,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;YACxB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,SAAS,CAAI,WAAW,EAAE,GAAG,EAAE,GAAG,EAAE,SAAS,CAAC,CAAC;YACxE,QAAQ,IAAI,KAAK,CAAC,MAAM,CAAC;YACzB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;gBACzB,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;oBAAE,SAAS;gBAChC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC,EAAE,CAAC,CAAC;gBAC5E,IAAI,IAAI,CAAC,IAAI,IAAI,aAAa;oBAAE,MAAM;YACxC,CAAC;YACD,eAAM,CAAC,KAAK,CAAC,wBAAwB,GAAG,WAAW,GAAG,OAAO,GAAG,GAAG,GAAG,OAAO,GAAG,GAAG,GAAG,WAAW,GAAG,KAAK,CAAC,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC;YACzI,IAAI,CAAC,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,cAAc,GAAG,CAAC;gBAAE,MAAM,IAAA,qBAAK,EAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC3F,CAAC;QAED,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC;QAC/B,eAAM,CAAC,IAAI,CAAC,wBAAwB,GAAG,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,gBAAgB,GAAG,YAAY,CAAC,CAAC;QAE3G,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAChC,MAAM,QAAQ,GAAkD,EAAE,CAAC;QACnE,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;YAClC,KAAK,CAAC,SAAS,CAAC,aAAa,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;YACxF,IAAI,KAAK,CAAC,SAAS,CAAC,aAAa,IAAI,gBAAgB;gBAAE,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9E,CAAC;QACD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC;YAC3C,MAAM,QAAQ,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;iBAChC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC;iBACpE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,UAAU,GAAG,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;YACnE,QAAQ,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;YACxD,eAAM,CAAC,IAAI,CAAC,6BAA6B,GAAG,WAAW,GAAG,wBAAwB,GAAG,gBAAgB,GAAG,SAAS,GAAG,QAAQ,CAAC,MAAM,GAAG,oBAAoB,CAAC,CAAC;QAC9J,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;QACrD,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QAChD,eAAM,CAAC,IAAI,CAAC,iCAAiC,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QACjE,IAAI,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YAChB,eAAM,CAAC,IAAI,CAAC,8BAA8B,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,cAAc,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,QAAQ,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,SAAS,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;QACtM,CAAC;QAED,OAAO;YACL,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;YAChC,SAAS,EAAE;gBACT,UAAU,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;gBACtD,QAAQ;gBACR,gBAAgB,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM;gBACnC,QAAQ;gBACR,YAAY;gBACZ,aAAa,EAAE,QAAQ,CAAC,MAAM;aAC/B;SACF,CAAC;IACJ,CAAC;IAEO,KAAK,CAAC,SAAS,CACrB,WAA6B,EAC7B,GAAW,EACX,GAAW,EACX,KAAa;QAEb,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC;YAC9C,MAAM,KAAK,GAAG,WAAW,KAAK,cAAc;gBAC1C,CAAC,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,0BAA0B,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,CAAC;gBAChE,CAAC,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,mBAAmB,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;YAC5D,yEAAyE;YACzE,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,CAAC,CAAmB,CAAC;QAC/E,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,eAAM,CAAC,IAAI,CAAC,qCAAqC,GAAG,GAAG,GAAG,QAAQ,GAAG,WAAW,GAAG,IAAI,GAAG,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YACpJ,OAAO,EAAE,CAAC;QACZ,CAAC;IACH,CAAC;IAEO,WAAW,CAAC,IAAc,EAAE,IAAsB;QACxD,MAAM,UAAU,GAAG,IAAA,sCAAwB,EAAC,IAAa,CAAC,CAAC;QAC3D,OAAO;YACL,EAAE,EAAE,IAAI,CAAC,EAAE;YACX,IAAI;YACJ,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,EAAE;YACvB,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,EAAE;YAC3B,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC;YAC1D,SAAS,EAAE,MAAM,CAAC,IAAI,CAAC,eAAe,IAAI,IAAI,CAAC,cAAc,IAAI,CAAC,CAAC,IAAI,CAAC;YACxE,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC,IAAI,CAAC;YAC3D,UAAU;YACV,aAAa,EAAE,CAAC;SACjB,CAAC;IACJ,CAAC;IAED;;;;OAIG;IACK,aAAa,CAAC,SAAyB,EAAE,OAAe,EAAE,SAA8B;QAC9F,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,IAAI,UAAU,GAAG,CAAC,CAAC;QACnB,IAAI,WAAW,GAAG,CAAC,CAAC;QACpB,KAAK,MAAM,GAAG,IAAI,SAAS,CAAC,IAAI,EAAE,CAAC;YACjC,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YACxB,IAAI,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;gBAAE,SAAS;YAC/B,IAAI,CAAC,KAAK,OAAO,EAAE,CAAC;gBAAC,OAAO,GAAG,IAAI,CAAC;gBAAC,SAAS;YAAC,CAAC;YAChD,MAAM,OAAO,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACjC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;gBAAC,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;gBAAC,WAAW,IAAI,CAAC,CAAC;YAAC,CAAC;QACxF,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;QACjD,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACrD,MAAM,SAAS,GAAG,CAAC,CAAC,OAAO,IAAI,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAC1D,MAAM,WAAW,GAAG,CAAC,CAAC,OAAO,IAAI,UAAU,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAE9D,IAAI,OAAO,EAAE,CAAC;YACZ,kEAAkE;YAClE,IAAI,KAAK,GAAG,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;YAC5C,IAAI,SAAS;gBAAE,KAAK,IAAI,GAAG,CAAC;YAC5B,OAAO,KAAK,CAAC;QACf,CAAC;QACD,yEAAyE;QACzE,yEAAyE;QACzE,2EAA2E;QAC3E,qCAAqC;QACrC,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,MAAM,aAAa,GAAG,CAAC,GAAG,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAC3H,IAAI,SAAS;YAAE,KAAK,IAAI,GAAG,CAAC;QAC5B,IAAI,WAAW;YAAE,KAAK,IAAI,GAAG,CAAC;QAC9B,IAAI,aAAa;YAAE,KAAK,IAAI,GAAG,CAAC;QAChC,KAAK,IAAI,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,GAAG,CAAC,WAAW,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QAClE,OAAO,KAAK,CAAC;IACf,CAAC;IAED;;;;OAIG;IACK,aAAa,CAAC,CAAiB;QACrC,IAAI,CAAC,CAAC,aAAa,IAAI,GAAG;YAAE,OAAO,CAAC,CAAC,CAAC,yCAAyC;QAC/E,IAAI,CAAC,CAAC,aAAa,IAAI,GAAG;YAAE,OAAO,CAAC,CAAC;QACrC,OAAO,CAAC,CAAC;IACX,CAAC;IAEO,WAAW,CAAC,CAAiB,EAAE,CAAiB;QACtD,MAAM,IAAI,GAAG,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAC3D,IAAI,IAAI,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAC5B,OAAO,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,CAAC;IACrC,CAAC;IAEO,eAAe,CAAI,KAAoD,EAAE,KAAa;QAC5F,IAAI,KAAK,CAAC,MAAM,IAAI,KAAK;YAAE,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;QACnG,0EAA0E;QAC1E,MAAM,GAAG,GAAkD,EAAE,CAAC;QAC9D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,GAAG,CAAC,MAAM,GAAG,KAAK,EAAE,CAAC;gBACvB,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBACf,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;YACjE,CAAC;iBAAM,IAAI,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC;gBAC/E,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC;gBAC3B,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;YACjE,CAAC;QACH,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAEO,KAAK,CAAC,UAA8B,EAAE,GAAW;QACvD,IAAI,CAAC,UAAU;YAAE,OAAO,IAAI,CAAC;QAC7B,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,GAAG,CAAC;IACzC,CAAC;IAEO,SAAS,CAAC,KAAa;QAC7B,OAAO,KAAK,CAAC,WAAW,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IAC/C,CAAC;IAEO,GAAG,CAAC,KAAa;QACvB,OAAO,KAAK,CAAC,IAAI,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,iBAAiB,EAAE,CAAC;IAC5D,CAAC;IAEO,KAAK,CAAC,KAAyB,EAAE,QAAgB,EAAE,GAAW,EAAE,GAAW;QACjF,MAAM,QAAQ,GAAG,KAAK,IAAI,QAAQ,CAAC;QACnC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,QAAQ,GAAG,GAAG,IAAI,QAAQ,GAAG,GAAG;YAAE,OAAO,QAAQ,CAAC;QACrF,OAAO,QAAQ,CAAC;IAClB,CAAC;CACF;AA3MD,sCA2MC;AAED,SAAS,QAAQ,CAAC,GAAW,IAAY,OAAO,GAAG,CAAC,CAAC,CAAC"}
@@ -0,0 +1,31 @@
1
+ import { TopicCache } from './TopicCache';
2
+ import type { TopicClient, TopicContentType, TopicDiscoveryOptions, TopicSpace } from './types';
3
+ /**
4
+ * Resolves a user topic into a bounded, cached set of related Pixiv tags.
5
+ *
6
+ * Discovery is intentionally one-hop around the ORIGINAL seed only (depth = 1):
7
+ * related tags never become new seeds, so the topic cannot drift outward. All
8
+ * network use is bounded and serial. Refresh failures degrade gracefully to a
9
+ * stale cache, then to the seed tag alone.
10
+ */
11
+ export declare class TopicResolver {
12
+ private readonly client;
13
+ private readonly cache;
14
+ private readonly requestDelayMs;
15
+ private readonly scorer;
16
+ constructor(client: TopicClient, cache: TopicCache, requestDelayMs?: number);
17
+ /** Resolve tags for a topic+type, using cache when fresh. Never throws for
18
+ * discovery errors: it always returns at least the seed tag. */
19
+ resolve(topic: string, contentType: TopicContentType, options?: TopicDiscoveryOptions): Promise<{
20
+ space: TopicSpace;
21
+ fromCache: boolean;
22
+ degraded: boolean;
23
+ }>;
24
+ private discover;
25
+ /** Serial, bounded fetches: autocomplete → topic sample → small background. */
26
+ private gatherSamples;
27
+ private seedOnly;
28
+ private ageDays;
29
+ private bound;
30
+ }
31
+ //# sourceMappingURL=TopicResolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TopicResolver.d.ts","sourceRoot":"","sources":["../../src/topic/TopicResolver.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,OAAO,KAAK,EAEV,WAAW,EACX,gBAAgB,EAChB,qBAAqB,EACrB,UAAU,EAEX,MAAM,SAAS,CAAC;AAiBjB;;;;;;;GAOG;AACH,qBAAa,aAAa;IAItB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,cAAc;IALjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAwB;gBAG5B,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,UAAU,EACjB,cAAc,SAAM;IAGvC;qEACiE;IAC3D,OAAO,CACX,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,gBAAgB,EAC7B,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC;QAAE,KAAK,EAAE,UAAU,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;YAyC1D,QAAQ;IA+CtB,+EAA+E;YACjE,aAAa;IAsB3B,OAAO,CAAC,QAAQ;IAchB,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,KAAK;CAOd"}
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TopicResolver = void 0;
4
+ const promises_1 = require("node:timers/promises");
5
+ const logger_1 = require("../logger");
6
+ const TopicTagScorer_1 = require("./TopicTagScorer");
7
+ /** Platform-wide tags never worth searching as topic keys or scoring metadata. */
8
+ const STOP_TAGS = new Set([
9
+ 'r-18', 'r-18g', 'r-15', 'original', 'オリジナル', '創作', '女の子', '男の子',
10
+ 'イラスト', '漫画', 'manga', 'illustration', 'artwork', '落書き', 'らくがき',
11
+ '1000users入り', '5000users入り', '10000users入り', '500users入り', '100users入り',
12
+ 'pixiv', 'commission', 'skeb', '依頼絵', '仕事絵', 'aiイラスト',
13
+ ]);
14
+ const DEFAULTS = {
15
+ maxTags: 12,
16
+ sampleWorks: 100,
17
+ cacheDays: 7,
18
+ minScore: 0.22,
19
+ };
20
+ /**
21
+ * Resolves a user topic into a bounded, cached set of related Pixiv tags.
22
+ *
23
+ * Discovery is intentionally one-hop around the ORIGINAL seed only (depth = 1):
24
+ * related tags never become new seeds, so the topic cannot drift outward. All
25
+ * network use is bounded and serial. Refresh failures degrade gracefully to a
26
+ * stale cache, then to the seed tag alone.
27
+ */
28
+ class TopicResolver {
29
+ client;
30
+ cache;
31
+ requestDelayMs;
32
+ scorer = new TopicTagScorer_1.TopicTagScorer();
33
+ constructor(client, cache, requestDelayMs = 500) {
34
+ this.client = client;
35
+ this.cache = cache;
36
+ this.requestDelayMs = requestDelayMs;
37
+ }
38
+ /** Resolve tags for a topic+type, using cache when fresh. Never throws for
39
+ * discovery errors: it always returns at least the seed tag. */
40
+ async resolve(topic, contentType, options = {}) {
41
+ const seed = topic.trim();
42
+ if (!seed)
43
+ throw new Error('Topic must not be empty');
44
+ const maxTags = this.bound(options.maxTags, DEFAULTS.maxTags, 1, 40, 'maxTags');
45
+ const sampleWorks = this.bound(options.sampleWorks, DEFAULTS.sampleWorks, 10, 200, 'sampleWorks');
46
+ const cacheDays = this.bound(options.cacheDays, DEFAULTS.cacheDays, 1, 90, 'cacheDays');
47
+ const minScore = options.minScore ?? DEFAULTS.minScore;
48
+ if (!options.refresh) {
49
+ const fresh = await this.cache.loadFresh(seed, contentType);
50
+ if (fresh) {
51
+ logger_1.logger.info(`[TopicResolver] topic=${seed} type=${contentType} cache hit age=${this.ageDays(fresh)}d tags=${fresh.tags.length}`);
52
+ return { space: fresh, fromCache: true, degraded: false };
53
+ }
54
+ }
55
+ logger_1.logger.info(`[TopicResolver] refreshing topic=${seed} type=${contentType}`);
56
+ try {
57
+ const space = await this.discover(seed, contentType, { maxTags, sampleWorks, cacheDays, minScore });
58
+ await this.cache.save(space);
59
+ logger_1.logger.info(`[TopicResolver] topic=${seed} type=${contentType} resolvedTags=${space.tags.length} sampled=${space.sampledWorks}`);
60
+ for (const tag of space.tags.slice(0, maxTags)) {
61
+ logger_1.logger.debug(`[TopicResolver] tag=${tag.name} score=${tag.score} occ=${tag.occurrences} spec=${tag.specificity}`);
62
+ }
63
+ return { space, fromCache: false, degraded: false };
64
+ }
65
+ catch (error) {
66
+ const stale = await this.cache.loadAny(seed, contentType);
67
+ if (stale) {
68
+ logger_1.logger.warn(`[TopicResolver] refresh failed, using stale cache topic=${seed}`, {
69
+ error: error instanceof Error ? error.message : String(error),
70
+ });
71
+ return { space: stale, fromCache: true, degraded: true };
72
+ }
73
+ logger_1.logger.warn(`[TopicResolver] refresh failed with no cache, falling back to seed topic=${seed}`, {
74
+ error: error instanceof Error ? error.message : String(error),
75
+ });
76
+ return { space: this.seedOnly(seed, contentType, cacheDays), fromCache: false, degraded: true };
77
+ }
78
+ }
79
+ async discover(seed, contentType, cfg) {
80
+ const [suggested, topicWorks, backgroundWorks] = await this.gatherSamples(seed, contentType, cfg.sampleWorks);
81
+ const suggestedNames = new Set(suggested.map((t) => this.scorer.key(t.name)).filter(Boolean));
82
+ const related = this.scorer.score({
83
+ seed,
84
+ topicWorks,
85
+ backgroundWorks,
86
+ suggestedNames,
87
+ });
88
+ // Seed tag always leads the space with score 1.0; keep related tags above
89
+ // the relevance floor and cap the total.
90
+ const seedTag = {
91
+ name: seed,
92
+ translatedName: suggested.find((t) => this.scorer.key(t.name) === this.scorer.key(seed))?.translated_name,
93
+ score: 1,
94
+ occurrences: topicWorks.length,
95
+ coverage: 1,
96
+ specificity: 1,
97
+ suggested: suggestedNames.has(this.scorer.key(seed)),
98
+ seed: true,
99
+ };
100
+ const accepted = related
101
+ .filter((tag) => tag.score >= cfg.minScore)
102
+ .filter((tag) => !STOP_TAGS.has(this.scorer.key(tag.name)))
103
+ .slice(0, cfg.maxTags - 1);
104
+ const tags = [seedTag, ...accepted];
105
+ const now = Date.now();
106
+ return {
107
+ version: 1,
108
+ topic: seed,
109
+ contentType,
110
+ createdAt: new Date(now).toISOString(),
111
+ expiresAt: new Date(now + cfg.cacheDays * 24 * 60 * 60_000).toISOString(),
112
+ sampleSize: cfg.sampleWorks,
113
+ sampledWorks: topicWorks.length,
114
+ tags,
115
+ };
116
+ }
117
+ /** Serial, bounded fetches: autocomplete → topic sample → small background. */
118
+ async gatherSamples(seed, contentType, sampleWorks) {
119
+ const suggested = await this.client.getTagAutocomplete(seed).catch(() => []);
120
+ if (this.requestDelayMs > 0)
121
+ await (0, promises_1.setTimeout)(this.requestDelayMs);
122
+ const topicWorks = contentType === 'illustration'
123
+ ? await this.client.searchIllustrationsForTags(seed, sampleWorks)
124
+ : await this.client.searchNovelsForTags(seed, sampleWorks);
125
+ // A small, cheap background sample (a common platform tag) estimates how
126
+ // generic a co-occurring tag is. Bounded to keep requests/memory low.
127
+ if (this.requestDelayMs > 0)
128
+ await (0, promises_1.setTimeout)(this.requestDelayMs);
129
+ const backgroundWorks = contentType === 'illustration'
130
+ ? await this.client.searchIllustrationsForTags('イラスト', 40).catch(() => [])
131
+ : await this.client.searchNovelsForTags('小説', 40).catch(() => []);
132
+ return [suggested, topicWorks, backgroundWorks];
133
+ }
134
+ seedOnly(seed, contentType, cacheDays) {
135
+ const now = Date.now();
136
+ return {
137
+ version: 1,
138
+ topic: seed,
139
+ contentType,
140
+ createdAt: new Date(now).toISOString(),
141
+ expiresAt: new Date(now + cacheDays * 24 * 60 * 60_000).toISOString(),
142
+ sampleSize: 0,
143
+ sampledWorks: 0,
144
+ tags: [{ name: seed, score: 1, occurrences: 0, coverage: 1, specificity: 1, suggested: false, seed: true }],
145
+ };
146
+ }
147
+ ageDays(space) {
148
+ const created = Date.parse(space.createdAt);
149
+ if (!Number.isFinite(created))
150
+ return 0;
151
+ return Math.max(0, Math.round((Date.now() - created) / 86_400_000));
152
+ }
153
+ bound(value, fallback, min, max, name) {
154
+ const resolved = value ?? fallback;
155
+ if (!Number.isInteger(resolved) || resolved < min || resolved > max) {
156
+ throw new Error(`topicDiscovery.${name} must be an integer between ${min} and ${max}`);
157
+ }
158
+ return resolved;
159
+ }
160
+ }
161
+ exports.TopicResolver = TopicResolver;
162
+ //# sourceMappingURL=TopicResolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TopicResolver.js","sourceRoot":"","sources":["../../src/topic/TopicResolver.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ import type { ResolvedTag, WorkLike } from './types';
2
+ export interface ScorerInput {
3
+ seed: string;
4
+ topicWorks: WorkLike[];
5
+ backgroundWorks: WorkLike[];
6
+ suggestedNames: Set<string>;
7
+ }
8
+ export declare class TopicTagScorer {
9
+ /** Normalize for matching/keys: trim, NFKC, lower-case. */
10
+ key(value: string): string;
11
+ score(input: ScorerInput): ResolvedTag[];
12
+ }
13
+ //# sourceMappingURL=TopicTagScorer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TopicTagScorer.d.ts","sourceRoot":"","sources":["../../src/topic/TopicTagScorer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAoCrD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,QAAQ,EAAE,CAAC;IACvB,eAAe,EAAE,QAAQ,EAAE,CAAC;IAC5B,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC7B;AAED,qBAAa,cAAc;IACzB,2DAA2D;IAC3D,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAI1B,KAAK,CAAC,KAAK,EAAE,WAAW,GAAG,WAAW,EAAE;CA+EzC"}
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TopicTagScorer = void 0;
4
+ /**
5
+ * Lightweight, explainable tag relatedness. No ML — pure co-occurrence stats
6
+ * over the small, bounded samples the resolver fetches.
7
+ *
8
+ * score = coverage * specificity * suggestionWeight
9
+ *
10
+ * - coverage: fraction of the seed-topic sample that carries the tag.
11
+ * - specificity: how much more often the tag appears with the topic than in
12
+ * the background. High-frequency but topic-agnostic tags
13
+ * (R-18, オリジナル, 女の子…) get pushed down automatically.
14
+ * - suggestionWeight: a modest boost when Pixiv autocomplete also returns it.
15
+ *
16
+ * The background document frequency is estimated from a cheap background
17
+ * sample (a recent, unrelated search) when available, falling back to a small
18
+ * curated set of platform-wide tags. We deliberately keep that list tiny — the
19
+ * specificity score does the real denoising.
20
+ */
21
+ /** Platform-level tags that are almost never topic-specific. */
22
+ const GENERIC_TAG_PENALTY = new Set([
23
+ 'r-18', 'r-18g', 'r-15', 'original', 'オリジナル', '創作', '女の子', '男の子',
24
+ 'イラスト', '漫画', 'manga', 'illustration', 'artwork', '落書き', 'らくがき',
25
+ '1000users入り', '5000users入り', '10000users入り', '500users入り', '100users入り',
26
+ 'pixiv', 'commission', 'skeb', '依頼絵', '仕事絵',
27
+ ]);
28
+ class TopicTagScorer {
29
+ /** Normalize for matching/keys: trim, NFKC, lower-case. */
30
+ key(value) {
31
+ return value.trim().normalize('NFKC').toLocaleLowerCase();
32
+ }
33
+ score(input) {
34
+ const seedKey = this.key(input.seed);
35
+ const topicSize = Math.max(input.topicWorks.length, 1);
36
+ const backgroundSize = Math.max(input.backgroundWorks.length, 1);
37
+ const stats = new Map();
38
+ const accumulate = (works, bucket) => {
39
+ for (const work of works) {
40
+ const seen = new Set();
41
+ for (const tag of work.tags ?? []) {
42
+ const name = tag.name?.trim();
43
+ const k = this.key(name ?? '');
44
+ if (!k || seen.has(k))
45
+ continue;
46
+ seen.add(k);
47
+ let stat = stats.get(k);
48
+ if (!stat) {
49
+ stat = { name: name, translatedName: tag.translated_name?.trim() || undefined, topicDocs: 0, backgroundDocs: 0, suggested: false };
50
+ stats.set(k, stat);
51
+ }
52
+ else if (!stat.translatedName && tag.translated_name?.trim()) {
53
+ stat.translatedName = tag.translated_name.trim();
54
+ }
55
+ stat[bucket] += 1;
56
+ }
57
+ }
58
+ };
59
+ accumulate(input.topicWorks, 'topicDocs');
60
+ accumulate(input.backgroundWorks, 'backgroundDocs');
61
+ for (const name of input.suggestedNames) {
62
+ const k = this.key(name);
63
+ const stat = stats.get(k);
64
+ if (stat)
65
+ stat.suggested = true;
66
+ }
67
+ const resolved = [];
68
+ for (const [k, stat] of stats) {
69
+ if (k === seedKey)
70
+ continue; // seed is added separately with score 1
71
+ const coverage = stat.topicDocs / topicSize;
72
+ if (stat.topicDocs <= 0)
73
+ continue;
74
+ // Background frequency of the tag in the unrelated sample. Tags that are
75
+ // common everywhere (R-18, オリジナル…) have high backgroundFreq; tags
76
+ // that only travel with the topic have ~0.
77
+ const backgroundFreq = stat.backgroundDocs / backgroundSize;
78
+ // PMI-style lift: how much more the tag appears with the topic than at
79
+ // large. Positive (and growing for background-absent tags), near/below 0
80
+ // for generic tags. Additive smoothing prevents divide-by-zero blowups.
81
+ const lift = Math.log((coverage + 0.03) / (backgroundFreq + 0.03));
82
+ // Map lift to a 0..1+ specificity via a logistic-ish squish; a lift of ~0
83
+ // (equally common in background) maps to ~0.5 and strongly generic tags
84
+ // fall below it.
85
+ const specificity = Math.max(0, Math.min(1.2, lift / 3 + 0.5));
86
+ // Final score rewards both co-occurrence (recall) and specificity
87
+ // (precision), so rare-but-exclusive tags can still surface while
88
+ // ubiquitous tags are suppressed.
89
+ const recall = Math.sqrt(coverage); // dampen so mid-coverage can win
90
+ const suggestionWeight = stat.suggested ? 1.1 : 1.0;
91
+ const genericPenalty = GENERIC_TAG_PENALTY.has(k) ? 0.4 : 1.0;
92
+ const raw = recall * specificity * suggestionWeight * genericPenalty;
93
+ resolved.push({
94
+ name: stat.name,
95
+ translatedName: stat.translatedName,
96
+ score: Number(raw.toFixed(4)),
97
+ occurrences: stat.topicDocs,
98
+ coverage: Number(coverage.toFixed(4)),
99
+ specificity: Number(specificity.toFixed(4)),
100
+ suggested: stat.suggested,
101
+ seed: false,
102
+ });
103
+ }
104
+ resolved.sort((a, b) => b.score - a.score || b.occurrences - a.occurrences || a.name.localeCompare(b.name));
105
+ return resolved;
106
+ }
107
+ }
108
+ exports.TopicTagScorer = TopicTagScorer;
109
+ //# sourceMappingURL=TopicTagScorer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TopicTagScorer.js","sourceRoot":"","sources":["../../src/topic/TopicTagScorer.ts"],"names":[],"mappings":"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"}