ddys-search-index 0.1.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/src/client.js ADDED
@@ -0,0 +1,128 @@
1
+ import { createTimeoutSignal, joinUrl } from './utils.js';
2
+
3
+ export async function fetchJson(pathname, query, config, runtime = {}) {
4
+ const fetchImpl = runtime.fetch || globalThis.fetch;
5
+ if (typeof fetchImpl !== 'function') throw new Error('A fetch implementation is required.');
6
+ const url = buildApiUrl(pathname, query, config);
7
+ const timeout = createTimeoutSignal(config.requestTimeoutMs);
8
+ let response;
9
+ try {
10
+ response = await fetchImpl(url, {
11
+ method: 'GET',
12
+ headers: {
13
+ accept: 'application/json',
14
+ 'user-agent': config.userAgent
15
+ },
16
+ signal: timeout.signal
17
+ });
18
+ } catch (error) {
19
+ if (timeout.timedOut()) throw new Error(`DDYS request timed out after ${config.requestTimeoutMs}ms: ${pathname}`);
20
+ throw error;
21
+ } finally {
22
+ timeout.cancel();
23
+ }
24
+
25
+ const payload = await readJsonResponse(response);
26
+ if (!response.ok || payload?.success === false) {
27
+ throw new Error(`DDYS request failed: ${payload?.message || response.status}`);
28
+ }
29
+ return { url, payload };
30
+ }
31
+
32
+ export async function fetchSearchSource(source, config, runtime = {}) {
33
+ if (!source.paginated) {
34
+ const { url, payload } = await fetchJson(source.endpoint, { ...source.query, limit: source.limit }, config, runtime);
35
+ return { source, urls: [url], payloads: [payload], items: extractItems(payload) };
36
+ }
37
+
38
+ const payloads = [];
39
+ const urls = [];
40
+ const items = [];
41
+ for (let page = 1; page <= source.maxPages; page += 1) {
42
+ const { url, payload } = await fetchJson(source.endpoint, {
43
+ ...source.query,
44
+ page,
45
+ limit: source.limit
46
+ }, config, runtime);
47
+ urls.push(url);
48
+ payloads.push(payload);
49
+ const pageItems = extractItems(payload);
50
+ items.push(...pageItems);
51
+ if (!pageItems.length || pageItems.length < source.limit) break;
52
+ const totalPages = readTotalPages(payload);
53
+ if (totalPages && page >= totalPages) break;
54
+ }
55
+ return { source, urls, payloads, items };
56
+ }
57
+
58
+ export async function fetchSearchSources(sources, config, runtime = {}) {
59
+ const out = [];
60
+ for (const source of sources) out.push(await fetchSearchSource(source, config, runtime));
61
+ return out;
62
+ }
63
+
64
+ export function buildApiUrl(pathname, query = {}, config) {
65
+ const raw = String(pathname || '').trim();
66
+ const url = raw.startsWith('http://') || raw.startsWith('https://')
67
+ ? new URL(raw)
68
+ : new URL(joinUrl(config.apiBase, raw || '/'));
69
+ for (const [key, value] of Object.entries(query || {})) {
70
+ if (value === undefined || value === null || value === '') continue;
71
+ if (Array.isArray(value)) {
72
+ for (const item of value) url.searchParams.append(key, String(item));
73
+ continue;
74
+ }
75
+ url.searchParams.set(key, String(value));
76
+ }
77
+ return url.toString();
78
+ }
79
+
80
+ export function extractItems(payload) {
81
+ if (Array.isArray(payload)) return payload;
82
+ if (!payload || typeof payload !== 'object') return [];
83
+ for (const candidate of [
84
+ payload.items,
85
+ payload.results,
86
+ payload.list,
87
+ payload.records,
88
+ payload.data,
89
+ payload.data?.items,
90
+ payload.data?.results,
91
+ payload.data?.list,
92
+ payload.data?.records,
93
+ payload.result?.items,
94
+ payload.result?.results,
95
+ payload.result?.list,
96
+ payload.result?.records
97
+ ]) {
98
+ if (Array.isArray(candidate)) return candidate;
99
+ }
100
+ return [];
101
+ }
102
+
103
+ export function readTotalPages(payload) {
104
+ const candidates = [
105
+ payload?.meta?.total_pages,
106
+ payload?.meta?.totalPages,
107
+ payload?.meta?.pages,
108
+ payload?.pagination?.total_pages,
109
+ payload?.pagination?.totalPages,
110
+ payload?.data?.total_pages,
111
+ payload?.data?.totalPages
112
+ ];
113
+ for (const value of candidates) {
114
+ const number = Number(value);
115
+ if (Number.isInteger(number) && number > 0) return number;
116
+ }
117
+ return 0;
118
+ }
119
+
120
+ async function readJsonResponse(response) {
121
+ const text = await response.text();
122
+ if (!text) return {};
123
+ try {
124
+ return JSON.parse(text);
125
+ } catch (error) {
126
+ throw new Error(`Failed to parse DDYS API response as JSON: ${error.message}`);
127
+ }
128
+ }
package/src/config.js ADDED
@@ -0,0 +1,212 @@
1
+ import { hashString, normalizeBaseUrl, parseBoolean, parsePositiveInteger, sanitizeFilePart, splitList } from './utils.js';
2
+
3
+ export const VERSION = '0.1.0';
4
+
5
+ export const DEFAULT_CONFIG = {
6
+ version: VERSION,
7
+ apiBase: 'https://ddys.io/api/v1',
8
+ publicBase: 'https://ddys.io',
9
+ userAgent: `ddys-search-index/${VERSION}`,
10
+ source: 'latest',
11
+ sources: ['latest', 'hot'],
12
+ maxItems: 50,
13
+ maxPages: 2,
14
+ ttlSeconds: 600,
15
+ requestTimeoutMs: 10000,
16
+ minTokenLength: 1,
17
+ includeRaw: false,
18
+ workerCache: true,
19
+ fields: ['title', 'aliases', 'description', 'actors', 'directors', 'tags', 'category', 'region', 'language', 'year', 'status'],
20
+ facets: ['category', 'year', 'region', 'language', 'source']
21
+ };
22
+
23
+ export function loadConfigFromEnv(env = getProcessEnv()) {
24
+ return createConfig({}, env);
25
+ }
26
+
27
+ export function createConfig(input = {}, env = input.env || getProcessEnv()) {
28
+ const envConfig = readEnvConfig(env || {});
29
+ const merged = {
30
+ ...DEFAULT_CONFIG,
31
+ ...envConfig,
32
+ ...input
33
+ };
34
+
35
+ merged.apiBase = normalizeBaseUrl(merged.apiBase, 'apiBase');
36
+ merged.publicBase = normalizeBaseUrl(merged.publicBase, 'publicBase');
37
+ merged.maxItems = parsePositiveInteger(merged.maxItems, DEFAULT_CONFIG.maxItems, 'maxItems');
38
+ merged.maxPages = parsePositiveInteger(merged.maxPages, DEFAULT_CONFIG.maxPages, 'maxPages');
39
+ merged.ttlSeconds = parsePositiveInteger(merged.ttlSeconds, DEFAULT_CONFIG.ttlSeconds, 'ttlSeconds');
40
+ merged.requestTimeoutMs = parsePositiveInteger(merged.requestTimeoutMs, DEFAULT_CONFIG.requestTimeoutMs, 'requestTimeoutMs');
41
+ merged.minTokenLength = parsePositiveInteger(merged.minTokenLength, DEFAULT_CONFIG.minTokenLength, 'minTokenLength');
42
+ merged.includeRaw = parseBoolean(merged.includeRaw, DEFAULT_CONFIG.includeRaw);
43
+ merged.workerCache = parseBoolean(merged.workerCache, DEFAULT_CONFIG.workerCache);
44
+ merged.fields = normalizeNames(input.fields ?? envConfig.fields ?? DEFAULT_CONFIG.fields);
45
+ merged.facets = normalizeNames(input.facets ?? envConfig.facets ?? DEFAULT_CONFIG.facets);
46
+ merged.source = normalizeSource(input.source ?? envConfig.source ?? DEFAULT_CONFIG.source, merged);
47
+ merged.sources = normalizeSources(input.sources ?? envConfig.sources ?? [merged.source], merged);
48
+ return merged;
49
+ }
50
+
51
+ export function readEnvConfig(env = {}) {
52
+ const out = {};
53
+ if (env.DDYS_SEARCH_INDEX_API_BASE) out.apiBase = env.DDYS_SEARCH_INDEX_API_BASE;
54
+ if (env.DDYS_SEARCH_INDEX_PUBLIC_BASE) out.publicBase = env.DDYS_SEARCH_INDEX_PUBLIC_BASE;
55
+ if (env.DDYS_SEARCH_INDEX_USER_AGENT) out.userAgent = env.DDYS_SEARCH_INDEX_USER_AGENT;
56
+ if (env.DDYS_SEARCH_INDEX_SOURCE) out.source = env.DDYS_SEARCH_INDEX_SOURCE;
57
+ if (env.DDYS_SEARCH_INDEX_SOURCES) out.sources = env.DDYS_SEARCH_INDEX_SOURCES;
58
+ if (env.DDYS_SEARCH_INDEX_MAX_ITEMS) out.maxItems = env.DDYS_SEARCH_INDEX_MAX_ITEMS;
59
+ if (env.DDYS_SEARCH_INDEX_MAX_PAGES) out.maxPages = env.DDYS_SEARCH_INDEX_MAX_PAGES;
60
+ if (env.DDYS_SEARCH_INDEX_TTL_SECONDS) out.ttlSeconds = env.DDYS_SEARCH_INDEX_TTL_SECONDS;
61
+ if (env.DDYS_SEARCH_INDEX_TIMEOUT_MS) out.requestTimeoutMs = env.DDYS_SEARCH_INDEX_TIMEOUT_MS;
62
+ if (env.DDYS_SEARCH_INDEX_MIN_TOKEN_LENGTH) out.minTokenLength = env.DDYS_SEARCH_INDEX_MIN_TOKEN_LENGTH;
63
+ if (env.DDYS_SEARCH_INDEX_INCLUDE_RAW) out.includeRaw = env.DDYS_SEARCH_INDEX_INCLUDE_RAW;
64
+ if (env.DDYS_SEARCH_INDEX_WORKER_CACHE) out.workerCache = env.DDYS_SEARCH_INDEX_WORKER_CACHE;
65
+ if (env.DDYS_SEARCH_INDEX_FIELDS) out.fields = env.DDYS_SEARCH_INDEX_FIELDS;
66
+ if (env.DDYS_SEARCH_INDEX_FACETS) out.facets = env.DDYS_SEARCH_INDEX_FACETS;
67
+ return out;
68
+ }
69
+
70
+ export function normalizeSources(value, config = DEFAULT_CONFIG) {
71
+ const values = Array.isArray(value) ? value : splitList(value);
72
+ const sources = values.map((item) => normalizeSource(item, config)).filter(Boolean);
73
+ return sources.length ? sources : [normalizeSource('latest', config)];
74
+ }
75
+
76
+ export function normalizeSource(value, config = DEFAULT_CONFIG) {
77
+ if (!value && value !== 0) return null;
78
+ if (typeof value === 'object' && !Array.isArray(value)) {
79
+ const source = { ...value };
80
+ source.kind = normalizeSourceKind(source.kind || source.type || source.name || 'endpoint');
81
+ if (source.kind !== 'endpoint' && !source.endpoint) source.endpoint = endpointForKind(source.kind);
82
+ source.limit = parsePositiveInteger(source.limit ?? config.maxItems, config.maxItems, 'source.limit');
83
+ source.maxPages = parsePositiveInteger(source.maxPages ?? config.maxPages, config.maxPages, 'source.maxPages');
84
+ source.query = normalizeSourceQuery(source, config);
85
+ source.value = source.value || source.query?.q || '';
86
+ source.paginated = source.paginated ?? source.kind !== 'calendar';
87
+ source.label = source.label || labelForSource(source);
88
+ source.slug = source.slug || slugForSource(source);
89
+ return source;
90
+ }
91
+
92
+ const token = String(value || '').trim();
93
+ if (!token) return null;
94
+ const separator = token.indexOf(':');
95
+ const rawKind = separator >= 0 ? token.slice(0, separator) : token;
96
+ const rawValue = separator >= 0 ? token.slice(separator + 1) : '';
97
+ const kind = normalizeSourceKind(rawKind);
98
+ const source = {
99
+ kind,
100
+ value: rawValue,
101
+ endpoint: kind === 'endpoint' ? rawValue : endpointForKind(kind),
102
+ limit: config.maxItems,
103
+ maxPages: config.maxPages,
104
+ paginated: kind !== 'calendar'
105
+ };
106
+ source.query = normalizeSourceQuery(source, config);
107
+ source.label = labelForSource(source);
108
+ source.slug = slugForSource(source);
109
+ return source;
110
+ }
111
+
112
+ export function describeConfig(config) {
113
+ return {
114
+ version: config.version,
115
+ apiBase: config.apiBase,
116
+ publicBase: config.publicBase,
117
+ source: describeSource(config.source),
118
+ sources: config.sources.map((source) => describeSource(source)),
119
+ maxItems: config.maxItems,
120
+ maxPages: config.maxPages,
121
+ ttlSeconds: config.ttlSeconds,
122
+ requestTimeoutMs: config.requestTimeoutMs,
123
+ minTokenLength: config.minTokenLength,
124
+ includeRaw: config.includeRaw,
125
+ workerCache: config.workerCache,
126
+ fields: config.fields,
127
+ facets: config.facets
128
+ };
129
+ }
130
+
131
+ function normalizeNames(value) {
132
+ const names = (Array.isArray(value) ? value : splitList(value))
133
+ .map((item) => String(item || '').trim())
134
+ .filter(Boolean);
135
+ return names.length ? [...new Set(names)] : [];
136
+ }
137
+
138
+ function normalizeSourceKind(value) {
139
+ const kind = String(value || '').trim().toLowerCase();
140
+ const aliases = {
141
+ new: 'latest',
142
+ newest: 'latest',
143
+ popular: 'hot',
144
+ cal: 'calendar',
145
+ schedule: 'calendar',
146
+ find: 'search',
147
+ s: 'search',
148
+ api: 'endpoint',
149
+ path: 'endpoint',
150
+ custom: 'endpoint'
151
+ };
152
+ return aliases[kind] || kind || 'latest';
153
+ }
154
+
155
+ function endpointForKind(kind) {
156
+ if (kind === 'latest') return '/latest';
157
+ if (kind === 'hot') return '/hot';
158
+ if (kind === 'calendar') return '/calendar';
159
+ if (kind === 'search') return '/search';
160
+ if (kind === 'endpoint') return '';
161
+ throw new Error(`Unsupported source kind: ${kind}`);
162
+ }
163
+
164
+ function normalizeSourceQuery(source, config) {
165
+ const query = { ...(source.query || {}) };
166
+ if (!('limit' in query) && source.kind !== 'calendar') query.limit = source.limit || config.maxItems;
167
+ if (source.kind === 'search') {
168
+ const q = source.value || query.q || query.query || '';
169
+ if (!q) throw new Error('search source requires a keyword, for example search:keyword.');
170
+ query.q = q;
171
+ delete query.query;
172
+ }
173
+ if (source.kind === 'calendar') {
174
+ const value = String(source.value || '').trim();
175
+ if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(value) || /^\d{4}-\d{1,2}$/.test(value)) {
176
+ const [year, month] = value.split('-');
177
+ query.year = Number(year);
178
+ query.month = Number(month);
179
+ }
180
+ }
181
+ return query;
182
+ }
183
+
184
+ function labelForSource(source) {
185
+ if (source.label) return source.label;
186
+ if (source.kind === 'search') return `search:${source.value || source.query?.q || ''}`;
187
+ if (source.kind === 'endpoint') return source.endpoint || 'endpoint';
188
+ return source.kind;
189
+ }
190
+
191
+ function slugForSource(source) {
192
+ if (source.kind === 'search') return `search-${hashString(source.value || source.query?.q || '')}`;
193
+ if (source.kind === 'endpoint') return `endpoint-${hashString(source.endpoint || source.label || '')}`;
194
+ return sanitizeFilePart(source.label || source.kind, source.kind || 'source');
195
+ }
196
+
197
+ function describeSource(source) {
198
+ return {
199
+ kind: source.kind,
200
+ label: source.label,
201
+ slug: source.slug,
202
+ endpoint: source.endpoint,
203
+ query: source.query,
204
+ limit: source.limit,
205
+ maxPages: source.maxPages,
206
+ paginated: source.paginated
207
+ };
208
+ }
209
+
210
+ function getProcessEnv() {
211
+ return globalThis.process?.env || {};
212
+ }
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createConfig, describeConfig, loadConfigFromEnv } from './config.js';
2
+ export { buildApiUrl, extractItems, fetchJson, fetchSearchSource, fetchSearchSources } from './client.js';
3
+ export { normalizeDocument, normalizeRecords } from './normalize.js';
4
+ export { buildSearchIndex, searchIndex, tokenize, buildFacets } from './indexer.js';
5
+ export { buildSearchFiles, renderSearchDocs, renderSearchIndex, renderSearchMeta } from './renderers.js';
6
+ export { buildOutputHeaders, generateSearchBundle, generateSearchIndex, readInputFile, writeSearchBundle } from './runtime.js';
7
+ export { createWorkerHandler, handleWorkerRequest, routeForPath } from './worker.js';
package/src/indexer.js ADDED
@@ -0,0 +1,182 @@
1
+ import { VERSION } from './config.js';
2
+ import { normalizeRecords } from './normalize.js';
3
+ import { hashString, normalizeWhitespace, uniqueStrings } from './utils.js';
4
+
5
+ const FIELD_WEIGHTS = {
6
+ title: 10,
7
+ aliases: 8,
8
+ tags: 6,
9
+ category: 5,
10
+ actors: 4,
11
+ directors: 4,
12
+ region: 3,
13
+ language: 3,
14
+ year: 3,
15
+ status: 2,
16
+ description: 1,
17
+ text: 1
18
+ };
19
+
20
+ export function buildSearchIndex(records, config, runtime = {}) {
21
+ const now = new Date((runtime.now || (() => Date.now()))()).toISOString();
22
+ const documents = normalizeRecords(records, config);
23
+ const postings = {};
24
+ const documentTokens = {};
25
+
26
+ for (const doc of documents) {
27
+ const scores = buildTokenScores(doc, config);
28
+ documentTokens[doc.id] = Object.keys(scores);
29
+ for (const [token, score] of Object.entries(scores)) {
30
+ if (!postings[token]) postings[token] = [];
31
+ postings[token].push([doc.id, score]);
32
+ }
33
+ }
34
+
35
+ for (const token of Object.keys(postings)) {
36
+ postings[token].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])));
37
+ }
38
+
39
+ const facets = buildFacets(documents, config.facets);
40
+ const suggestions = buildSuggestions(documents);
41
+ const index = {
42
+ version: VERSION,
43
+ generatedAt: now,
44
+ config: {
45
+ apiBase: config.apiBase,
46
+ publicBase: config.publicBase,
47
+ sources: config.sources.map((source) => ({
48
+ kind: source.kind,
49
+ label: source.label,
50
+ slug: source.slug,
51
+ endpoint: source.endpoint,
52
+ query: source.query
53
+ })),
54
+ fields: config.fields,
55
+ facets: config.facets,
56
+ minTokenLength: config.minTokenLength
57
+ },
58
+ stats: {
59
+ documents: documents.length,
60
+ tokens: Object.keys(postings).length,
61
+ suggestions: suggestions.length
62
+ },
63
+ documents,
64
+ index: postings,
65
+ documentTokens,
66
+ facets,
67
+ suggestions
68
+ };
69
+ index.checksum = hashString(JSON.stringify({ documents, postings }), 16);
70
+ return index;
71
+ }
72
+
73
+ export function searchIndex(searchIndexValue, query, options = {}) {
74
+ const tokens = tokenize(query, options);
75
+ const docs = searchIndexValue.documents || [];
76
+ const byId = new Map(docs.map((doc) => [String(doc.id), doc]));
77
+ const scores = new Map();
78
+ for (const token of tokens) {
79
+ const postings = searchIndexValue.index?.[token] || [];
80
+ for (const [id, score] of postings) {
81
+ const key = String(id);
82
+ scores.set(key, (scores.get(key) || 0) + score);
83
+ }
84
+ }
85
+
86
+ const filters = options.filters || {};
87
+ const items = [...scores.entries()]
88
+ .map(([id, score]) => ({ score, document: byId.get(id) }))
89
+ .filter((item) => item.document && matchesFilters(item.document, filters))
90
+ .sort((a, b) => b.score - a.score || itemTitle(a).localeCompare(itemTitle(b)));
91
+
92
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 20;
93
+ return {
94
+ query: String(query || ''),
95
+ tokens,
96
+ total: items.length,
97
+ items: items.slice(0, limit)
98
+ };
99
+ }
100
+
101
+ export function tokenize(value, options = {}) {
102
+ const minTokenLength = options.minTokenLength || 1;
103
+ const normalized = normalizeWhitespace(String(value || '').toLowerCase())
104
+ .normalize('NFKC')
105
+ .replace(/[_-]+/g, ' ');
106
+ const tokens = new Set();
107
+
108
+ for (const match of normalized.matchAll(/[a-z0-9]+/g)) {
109
+ const token = match[0];
110
+ if (token.length >= minTokenLength) tokens.add(token);
111
+ }
112
+
113
+ for (const match of normalized.matchAll(/[\u4e00-\u9fff]+/g)) {
114
+ const text = match[0];
115
+ if (text.length >= minTokenLength) tokens.add(text);
116
+ for (const char of text) {
117
+ if (char.length >= minTokenLength) tokens.add(char);
118
+ }
119
+ for (let index = 0; index < text.length - 1; index += 1) {
120
+ tokens.add(text.slice(index, index + 2));
121
+ }
122
+ }
123
+
124
+ return [...tokens].filter((token) => token.length >= minTokenLength);
125
+ }
126
+
127
+ export function buildFacets(documents, facetNames) {
128
+ const facets = {};
129
+ for (const name of facetNames || []) {
130
+ const counts = new Map();
131
+ for (const doc of documents) {
132
+ const values = Array.isArray(doc[name]) ? doc[name] : [doc[name]];
133
+ for (const raw of values) {
134
+ const value = normalizeWhitespace(raw);
135
+ if (!value) continue;
136
+ counts.set(value, (counts.get(value) || 0) + 1);
137
+ }
138
+ }
139
+ facets[name] = [...counts.entries()]
140
+ .map(([value, count]) => ({ value, count }))
141
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
142
+ }
143
+ return facets;
144
+ }
145
+
146
+ function buildTokenScores(doc, config) {
147
+ const scores = {};
148
+ for (const field of config.fields || []) {
149
+ const weight = FIELD_WEIGHTS[field] || 1;
150
+ const values = Array.isArray(doc[field]) ? doc[field] : [doc[field]];
151
+ for (const value of values) {
152
+ for (const token of tokenize(value, { minTokenLength: config.minTokenLength })) {
153
+ scores[token] = Math.min(99, (scores[token] || 0) + weight);
154
+ }
155
+ }
156
+ }
157
+ return scores;
158
+ }
159
+
160
+ function buildSuggestions(documents) {
161
+ return uniqueStrings(documents.flatMap((doc) => [
162
+ doc.title,
163
+ ...(doc.aliases || []),
164
+ ...(doc.tags || []),
165
+ ...(doc.actors || []),
166
+ ...(doc.directors || [])
167
+ ])).slice(0, 200);
168
+ }
169
+
170
+ function matchesFilters(doc, filters) {
171
+ for (const [key, expected] of Object.entries(filters || {})) {
172
+ if (expected === undefined || expected === null || expected === '') continue;
173
+ const values = Array.isArray(doc[key]) ? doc[key] : [doc[key]];
174
+ const wanted = Array.isArray(expected) ? expected.map(String) : [String(expected)];
175
+ if (!values.some((value) => wanted.includes(String(value)))) return false;
176
+ }
177
+ return true;
178
+ }
179
+
180
+ function itemTitle(item) {
181
+ return item.document?.title || '';
182
+ }