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.
@@ -0,0 +1,203 @@
1
+ import {
2
+ compactObject,
3
+ firstValue,
4
+ hashString,
5
+ joinUrl,
6
+ normalizeWhitespace,
7
+ stripHtml,
8
+ toArray,
9
+ uniqueStrings
10
+ } from './utils.js';
11
+
12
+ export function normalizeRecords(records, config) {
13
+ const out = [];
14
+ const seen = new Set();
15
+ for (const record of records || []) {
16
+ const item = record?.item ?? record;
17
+ const source = record?.source || record?.sourceConfig || null;
18
+ const doc = normalizeDocument(item, source, config);
19
+ if (!doc?.id || !doc.title) continue;
20
+ const key = doc.id || doc.url || doc.title.toLowerCase();
21
+ if (seen.has(key)) continue;
22
+ seen.add(key);
23
+ out.push(doc);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function normalizeDocument(item, source, config) {
29
+ if (!item || typeof item !== 'object') return null;
30
+
31
+ const title = firstValue(
32
+ item.title,
33
+ item.name,
34
+ item.vod_name,
35
+ item.post_title,
36
+ item.original_title,
37
+ item.originalName,
38
+ item.cn_name
39
+ );
40
+ if (!title) return null;
41
+
42
+ const aliases = uniqueStrings([
43
+ ...toArray(item.aliases),
44
+ ...toArray(item.alias),
45
+ ...toArray(item.sub_title),
46
+ ...toArray(item.vod_sub),
47
+ ...toArray(item.original_name),
48
+ ...toArray(item.en_title),
49
+ ...toArray(item.names)
50
+ ]).filter((value) => value.toLowerCase() !== title.toLowerCase());
51
+
52
+ const description = normalizeWhitespace(stripHtml(firstValue(
53
+ item.description,
54
+ item.desc,
55
+ item.summary,
56
+ item.content,
57
+ item.vod_content,
58
+ item.excerpt,
59
+ item.intro
60
+ )));
61
+
62
+ const actors = uniqueStrings([
63
+ ...toArray(item.actors),
64
+ ...toArray(item.actor),
65
+ ...toArray(item.cast),
66
+ ...toArray(item.vod_actor),
67
+ ...toArray(item.stars)
68
+ ]);
69
+
70
+ const directors = uniqueStrings([
71
+ ...toArray(item.directors),
72
+ ...toArray(item.director),
73
+ ...toArray(item.vod_director)
74
+ ]);
75
+
76
+ const category = uniqueStrings([
77
+ ...toArray(item.category),
78
+ ...toArray(item.categories),
79
+ ...toArray(item.type),
80
+ ...toArray(item.type_name),
81
+ ...toArray(item.vod_class),
82
+ ...toArray(item.class)
83
+ ]);
84
+
85
+ const tags = uniqueStrings([
86
+ ...toArray(item.tags),
87
+ ...toArray(item.tag),
88
+ ...toArray(item.genres),
89
+ ...toArray(item.genre),
90
+ ...category
91
+ ]);
92
+
93
+ const region = uniqueStrings([
94
+ ...toArray(item.region),
95
+ ...toArray(item.area),
96
+ ...toArray(item.country),
97
+ ...toArray(item.vod_area)
98
+ ]);
99
+
100
+ const language = uniqueStrings([
101
+ ...toArray(item.language),
102
+ ...toArray(item.lang),
103
+ ...toArray(item.vod_lang)
104
+ ]);
105
+
106
+ const id = normalizeId(item, title, source);
107
+ const url = buildDocumentUrl(item, config);
108
+ const poster = buildAssetUrl(firstValue(
109
+ item.poster,
110
+ item.cover,
111
+ item.image,
112
+ item.pic,
113
+ item.vod_pic,
114
+ item.thumbnail
115
+ ), config);
116
+
117
+ const year = normalizeYear(firstValue(item.year, item.vod_year, item.release_year, item.date, item.release_date));
118
+ const date = normalizeDate(firstValue(item.date, item.pubdate, item.updated_at, item.update_time, item.vod_time, item.created_at));
119
+ const score = normalizeNumber(firstValue(item.score, item.rating, item.rate, item.vod_score));
120
+ const status = firstValue(item.status, item.vod_remarks, item.remarks, item.update_status);
121
+ const sourceName = source?.label || source?.kind || firstValue(item.source, item.source_name) || 'local';
122
+ const text = uniqueStrings([
123
+ title,
124
+ ...aliases,
125
+ description,
126
+ ...actors,
127
+ ...directors,
128
+ ...tags,
129
+ ...region,
130
+ ...language,
131
+ year,
132
+ status,
133
+ sourceName
134
+ ]).join(' ');
135
+
136
+ return compactObject({
137
+ id,
138
+ title,
139
+ aliases,
140
+ description,
141
+ url,
142
+ poster,
143
+ year,
144
+ date,
145
+ category,
146
+ tags,
147
+ actors,
148
+ directors,
149
+ region,
150
+ language,
151
+ score,
152
+ status,
153
+ source: sourceName,
154
+ text,
155
+ raw: config.includeRaw ? item : undefined
156
+ });
157
+ }
158
+
159
+ export function buildDocumentUrl(item, config) {
160
+ const raw = firstValue(item.url, item.link, item.permalink, item.htmlUrl, item.href);
161
+ if (raw) return buildAssetUrl(raw, config);
162
+ const path = firstValue(item.path, item.pathname, item.route);
163
+ if (path) return buildAssetUrl(path, config);
164
+ const slug = firstValue(item.slug, item.id, item.vod_id);
165
+ if (!slug) return '';
166
+ return joinUrl(config.publicBase, `detail/${encodeURIComponent(String(slug))}`);
167
+ }
168
+
169
+ export function buildAssetUrl(value, config) {
170
+ const raw = String(value || '').trim();
171
+ if (!raw) return '';
172
+ if (/^https?:\/\//i.test(raw)) return raw;
173
+ if (raw.startsWith('//')) return `https:${raw}`;
174
+ return joinUrl(config.publicBase, raw);
175
+ }
176
+
177
+ function normalizeId(item, title, source) {
178
+ const explicit = firstValue(item.id, item.vod_id, item.video_id, item.uuid, item.slug, item.code, item.imdb_id, item.douban_id);
179
+ if (explicit) return String(explicit);
180
+ return `${source?.slug || 'local'}-${hashString(`${title}:${firstValue(item.url, item.link, item.path)}`)}`;
181
+ }
182
+
183
+ function normalizeYear(value) {
184
+ const match = String(value || '').match(/\b(19\d{2}|20\d{2})\b/);
185
+ return match ? Number(match[1]) : undefined;
186
+ }
187
+
188
+ function normalizeDate(value) {
189
+ if (!value) return '';
190
+ const text = String(value).trim();
191
+ const number = Number(text);
192
+ const date = Number.isFinite(number) && text.length >= 10
193
+ ? new Date(number > 10_000_000_000 ? number : number * 1000)
194
+ : new Date(text);
195
+ if (Number.isNaN(date.getTime())) return text;
196
+ return date.toISOString();
197
+ }
198
+
199
+ function normalizeNumber(value) {
200
+ if (value === undefined || value === null || value === '') return undefined;
201
+ const number = Number(value);
202
+ return Number.isFinite(number) ? number : undefined;
203
+ }
@@ -0,0 +1,40 @@
1
+ export function renderSearchIndex(searchIndex, options = {}) {
2
+ return `${JSON.stringify(searchIndex, null, options.pretty === false ? 0 : 2)}\n`;
3
+ }
4
+
5
+ export function renderSearchDocs(searchIndex, options = {}) {
6
+ return `${JSON.stringify(searchIndex.documents || [], null, options.pretty === false ? 0 : 2)}\n`;
7
+ }
8
+
9
+ export function renderSearchMeta(searchIndex, options = {}) {
10
+ const meta = {
11
+ version: searchIndex.version,
12
+ generatedAt: searchIndex.generatedAt,
13
+ checksum: searchIndex.checksum,
14
+ config: searchIndex.config,
15
+ stats: searchIndex.stats,
16
+ facets: searchIndex.facets,
17
+ suggestions: searchIndex.suggestions
18
+ };
19
+ return `${JSON.stringify(meta, null, options.pretty === false ? 0 : 2)}\n`;
20
+ }
21
+
22
+ export function buildSearchFiles(searchIndex, options = {}) {
23
+ return [
24
+ {
25
+ path: 'search-index.json',
26
+ contentType: 'application/json; charset=utf-8',
27
+ contents: renderSearchIndex(searchIndex, options)
28
+ },
29
+ {
30
+ path: 'search-docs.json',
31
+ contentType: 'application/json; charset=utf-8',
32
+ contents: renderSearchDocs(searchIndex, options)
33
+ },
34
+ {
35
+ path: 'search-meta.json',
36
+ contentType: 'application/json; charset=utf-8',
37
+ contents: renderSearchMeta(searchIndex, options)
38
+ }
39
+ ];
40
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,79 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createConfig, describeConfig } from './config.js';
4
+ import { extractItems, fetchSearchSources } from './client.js';
5
+ import { buildSearchIndex } from './indexer.js';
6
+ import { buildSearchFiles } from './renderers.js';
7
+
8
+ export async function generateSearchIndex(input = {}, runtime = {}) {
9
+ const config = createConfig(input, input.env || runtime.env);
10
+ const records = [];
11
+ const sourceResults = [];
12
+
13
+ if (input.items) {
14
+ for (const item of input.items) records.push({ item, source: { label: 'local', slug: 'local', kind: 'local' } });
15
+ } else if (input.inputFile) {
16
+ const items = await readInputFile(input.inputFile);
17
+ for (const item of items) records.push({ item, source: { label: 'local', slug: 'local', kind: 'local' } });
18
+ } else {
19
+ const results = await fetchSearchSources(config.sources, config, runtime);
20
+ sourceResults.push(...results);
21
+ for (const result of results) {
22
+ for (const item of result.items) records.push({ item, source: result.source });
23
+ }
24
+ }
25
+
26
+ const index = buildSearchIndex(records, config, runtime);
27
+ return {
28
+ ok: true,
29
+ config: describeConfig(config),
30
+ sources: sourceResults.map((result) => ({
31
+ source: result.source.label,
32
+ urls: result.urls,
33
+ items: result.items.length
34
+ })),
35
+ index
36
+ };
37
+ }
38
+
39
+ export async function generateSearchBundle(input = {}, runtime = {}) {
40
+ const result = await generateSearchIndex(input, runtime);
41
+ const files = buildSearchFiles(result.index, input);
42
+ return {
43
+ ok: true,
44
+ config: result.config,
45
+ sources: result.sources,
46
+ index: result.index,
47
+ files
48
+ };
49
+ }
50
+
51
+ export async function writeSearchBundle(bundle, outDir) {
52
+ await mkdir(outDir, { recursive: true });
53
+ const written = [];
54
+ for (const file of bundle.files) {
55
+ const target = path.join(outDir, file.path);
56
+ await mkdir(path.dirname(target), { recursive: true });
57
+ await writeFile(target, file.contents, 'utf8');
58
+ written.push(target);
59
+ }
60
+ return written;
61
+ }
62
+
63
+ export async function readInputFile(filePath) {
64
+ const payload = JSON.parse(await readFile(filePath, 'utf8'));
65
+ return extractItems(payload);
66
+ }
67
+
68
+ export function buildOutputHeaders(type, searchIndexValue, config) {
69
+ const maxAge = config?.ttlSeconds || 600;
70
+ const headers = {
71
+ 'cache-control': `public, max-age=${maxAge}`,
72
+ 'content-type': 'application/json; charset=utf-8',
73
+ 'x-ddys-search-index-version': searchIndexValue.version,
74
+ 'x-ddys-search-index-documents': String(searchIndexValue.stats?.documents || 0)
75
+ };
76
+ if (searchIndexValue.checksum) headers.etag = `"${searchIndexValue.checksum}"`;
77
+ if (type) headers['x-ddys-search-index-type'] = type;
78
+ return headers;
79
+ }
package/src/utils.js ADDED
@@ -0,0 +1,146 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function normalizeBaseUrl(value, name = 'url') {
4
+ const raw = String(value || '').trim();
5
+ if (!raw) throw new Error(`${name} is required.`);
6
+ let url;
7
+ try {
8
+ url = new URL(raw);
9
+ } catch {
10
+ throw new Error(`${name} must be a valid URL.`);
11
+ }
12
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${name} must use http or https.`);
13
+ return url.toString().replace(/\/+$/, '');
14
+ }
15
+
16
+ export function joinUrl(base, pathname = '') {
17
+ const left = String(base || '').replace(/\/+$/, '');
18
+ const right = String(pathname || '').replace(/^\/+/, '');
19
+ return right ? `${left}/${right}` : left;
20
+ }
21
+
22
+ export function splitList(value) {
23
+ if (Array.isArray(value)) return value.flatMap((item) => splitList(item));
24
+ return String(value || '')
25
+ .split(/[,\n;]/)
26
+ .map((item) => item.trim())
27
+ .filter(Boolean);
28
+ }
29
+
30
+ export function parseBoolean(value, fallback = false) {
31
+ if (value === undefined || value === null || value === '') return fallback;
32
+ if (typeof value === 'boolean') return value;
33
+ const normalized = String(value).trim().toLowerCase();
34
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
35
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
36
+ return fallback;
37
+ }
38
+
39
+ export function parsePositiveInteger(value, fallback, name = 'value') {
40
+ const number = Number(value);
41
+ if (Number.isInteger(number) && number > 0) return number;
42
+ if (fallback !== undefined) return fallback;
43
+ throw new Error(`${name} must be a positive integer.`);
44
+ }
45
+
46
+ export function sanitizeFilePart(value, fallback = 'file') {
47
+ const text = String(value || '')
48
+ .trim()
49
+ .toLowerCase()
50
+ .replace(/https?:\/\//g, '')
51
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/gi, '-')
52
+ .replace(/^-+|-+$/g, '');
53
+ return text || fallback;
54
+ }
55
+
56
+ export function hashString(value, length = 10) {
57
+ return createHash('sha1').update(String(value || '')).digest('hex').slice(0, length);
58
+ }
59
+
60
+ export function createEtag(value) {
61
+ return `"${hashString(typeof value === 'string' ? value : stableJson(value), 16)}"`;
62
+ }
63
+
64
+ export function stableJson(value) {
65
+ return JSON.stringify(value, Object.keys(flattenKeys(value)).sort());
66
+ }
67
+
68
+ function flattenKeys(value, out = {}) {
69
+ if (!value || typeof value !== 'object') return out;
70
+ for (const key of Object.keys(value)) {
71
+ out[key] = true;
72
+ flattenKeys(value[key], out);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ export function uniqueStrings(values) {
78
+ const seen = new Set();
79
+ const out = [];
80
+ for (const value of values || []) {
81
+ const text = normalizeWhitespace(value);
82
+ if (!text) continue;
83
+ const key = text.toLowerCase();
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ out.push(text);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ export function toArray(value) {
92
+ if (Array.isArray(value)) return value;
93
+ if (value === undefined || value === null || value === '') return [];
94
+ if (typeof value === 'string') return splitList(value.replace(/[|/]/g, ','));
95
+ return [value];
96
+ }
97
+
98
+ export function firstValue(...values) {
99
+ for (const value of values.flat()) {
100
+ if (value === undefined || value === null) continue;
101
+ const text = typeof value === 'string' ? normalizeWhitespace(value) : value;
102
+ if (text !== '') return text;
103
+ }
104
+ return '';
105
+ }
106
+
107
+ export function stripHtml(value) {
108
+ return String(value || '')
109
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
110
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
111
+ .replace(/<[^>]+>/g, ' ')
112
+ .replace(/&nbsp;/gi, ' ')
113
+ .replace(/&amp;/gi, '&')
114
+ .replace(/&lt;/gi, '<')
115
+ .replace(/&gt;/gi, '>')
116
+ .replace(/&quot;/gi, '"')
117
+ .replace(/&#39;/g, "'");
118
+ }
119
+
120
+ export function normalizeWhitespace(value) {
121
+ return String(value || '').replace(/\s+/g, ' ').trim();
122
+ }
123
+
124
+ export function createTimeoutSignal(ms) {
125
+ const controller = new AbortController();
126
+ let didTimeout = false;
127
+ const timer = setTimeout(() => {
128
+ didTimeout = true;
129
+ controller.abort();
130
+ }, ms);
131
+ return {
132
+ signal: controller.signal,
133
+ timedOut: () => didTimeout,
134
+ cancel: () => clearTimeout(timer)
135
+ };
136
+ }
137
+
138
+ export function compactObject(value) {
139
+ const out = {};
140
+ for (const [key, item] of Object.entries(value || {})) {
141
+ if (item === undefined || item === null || item === '') continue;
142
+ if (Array.isArray(item) && item.length === 0) continue;
143
+ out[key] = item;
144
+ }
145
+ return out;
146
+ }
package/src/worker.js ADDED
@@ -0,0 +1,89 @@
1
+ import { createConfig } from './config.js';
2
+ import { generateSearchBundle, buildOutputHeaders } from './runtime.js';
3
+ import { renderSearchDocs, renderSearchIndex, renderSearchMeta } from './renderers.js';
4
+ import { searchIndex } from './indexer.js';
5
+
6
+ export function createWorkerHandler(input = {}) {
7
+ let cache = null;
8
+ return async function fetch(request, env = {}, ctx = {}) {
9
+ return handleWorkerRequest(request, {
10
+ ...input,
11
+ env,
12
+ ctx,
13
+ cacheRef: {
14
+ get: () => cache,
15
+ set: (value) => {
16
+ cache = value;
17
+ }
18
+ }
19
+ });
20
+ };
21
+ }
22
+
23
+ export async function handleWorkerRequest(request, runtime = {}) {
24
+ const url = new URL(request.url);
25
+ const route = routeForPath(url.pathname);
26
+ const config = createConfig(runtime, runtime.env || {});
27
+
28
+ if (route === 'not-found') {
29
+ return jsonResponse({ ok: false, message: 'Not found' }, 404, { 'cache-control': 'no-store' });
30
+ }
31
+
32
+ if (route === 'health') {
33
+ return jsonResponse({ ok: true, service: 'ddys-search-index', version: config.version }, 200, { 'cache-control': 'no-store' });
34
+ }
35
+
36
+ const bundle = await getBundle(config, runtime);
37
+ const index = bundle.index;
38
+ const etag = `"${index.checksum}"`;
39
+ if (request.headers.get('if-none-match') === etag && route !== 'search') {
40
+ return new Response(null, { status: 304, headers: buildOutputHeaders(route, index, config) });
41
+ }
42
+
43
+ if (route === 'search') {
44
+ const q = url.searchParams.get('q') || '';
45
+ const limit = Number(url.searchParams.get('limit') || 20);
46
+ const result = q ? searchIndex(index, q, { limit: Number.isInteger(limit) && limit > 0 ? limit : 20 }) : { query: q, tokens: [], total: 0, items: [] };
47
+ return jsonResponse({ ok: true, ...result }, 200, buildOutputHeaders(route, index, config));
48
+ }
49
+
50
+ if (route === 'docs') return textResponse(renderSearchDocs(index), buildOutputHeaders(route, index, config));
51
+ if (route === 'meta') return textResponse(renderSearchMeta(index), buildOutputHeaders(route, index, config));
52
+ return textResponse(renderSearchIndex(index), buildOutputHeaders(route, index, config));
53
+ }
54
+
55
+ export function routeForPath(pathname) {
56
+ const path = String(pathname || '/').replace(/\/+$/, '') || '/';
57
+ if (path === '/health') return 'health';
58
+ if (path === '/search') return 'search';
59
+ if (path === '/search-index.json' || path === '/index.json' || path === '/') return 'index';
60
+ if (path === '/search-docs.json' || path === '/docs.json') return 'docs';
61
+ if (path === '/search-meta.json' || path === '/meta.json') return 'meta';
62
+ return 'not-found';
63
+ }
64
+
65
+ async function getBundle(config, runtime) {
66
+ const now = Date.now();
67
+ const cache = runtime.cacheRef?.get?.();
68
+ if (config.workerCache && cache && cache.expiresAt > now) return cache.bundle;
69
+ const bundle = await generateSearchBundle(config, runtime);
70
+ runtime.cacheRef?.set?.({
71
+ expiresAt: now + config.ttlSeconds * 1000,
72
+ bundle
73
+ });
74
+ return bundle;
75
+ }
76
+
77
+ function textResponse(body, headers) {
78
+ return new Response(body, { status: 200, headers });
79
+ }
80
+
81
+ function jsonResponse(body, status = 200, headers = {}) {
82
+ return new Response(`${JSON.stringify(body, null, 2)}\n`, {
83
+ status,
84
+ headers: {
85
+ 'content-type': 'application/json; charset=utf-8',
86
+ ...headers
87
+ }
88
+ });
89
+ }