viruagent-cli 0.3.2 → 0.3.4
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/package.json +1 -1
- package/src/providers/tistory/auth.js +167 -0
- package/src/providers/tistory/browserHelpers.js +91 -0
- package/src/providers/tistory/chromeImport.js +745 -0
- package/src/providers/tistory/fetchLayer.js +237 -0
- package/src/providers/tistory/imageEnrichment.js +574 -0
- package/src/providers/tistory/imageNormalization.js +301 -0
- package/src/providers/tistory/imageSources.js +270 -0
- package/src/providers/tistory/index.js +561 -0
- package/src/providers/tistory/selectors.js +51 -0
- package/src/providers/tistory/session.js +117 -0
- package/src/providers/tistory/utils.js +235 -0
- package/src/services/providerManager.js +1 -1
- package/src/providers/tistoryProvider.js +0 -2862
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const normalizeKageFromUrl = (value) => {
|
|
6
|
+
const trimmed = String(value || '').trim();
|
|
7
|
+
if (!trimmed) return null;
|
|
8
|
+
|
|
9
|
+
if (trimmed.startsWith('kage@')) {
|
|
10
|
+
return trimmed.replace(/["'`> )\]]+$/u, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(trimmed);
|
|
15
|
+
const urlPath = parsed.pathname || '';
|
|
16
|
+
const dnaIndex = urlPath.indexOf('/dna/');
|
|
17
|
+
if (dnaIndex >= 0) {
|
|
18
|
+
const keyPath = urlPath.slice(dnaIndex + '/dna/'.length).replace(/^\/+/, '');
|
|
19
|
+
if (keyPath) {
|
|
20
|
+
return `kage@${keyPath}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// URL 파싱이 실패하면 기존 정규식 경로로 폴백
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const directKageMatch = trimmed.match(/kage@([^|\s\]>"']+)/u);
|
|
28
|
+
if (directKageMatch?.[1]) {
|
|
29
|
+
return `kage@${directKageMatch[1]}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dnaMatch = trimmed.match(/\/dna\/([^?#\s]+)/u);
|
|
33
|
+
if (dnaMatch?.[1]) {
|
|
34
|
+
return `kage@${dnaMatch[1].replace(/["'`> )\]]+$/u, '')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (/^[A-Za-z0-9_-]{10,}$/u.test(trimmed)) {
|
|
38
|
+
return `kage@${trimmed}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rawPathMatch = trimmed.match(/([^/?#\s]+\.[A-Za-z0-9]+)$/u);
|
|
42
|
+
if (rawPathMatch?.[0] && !trimmed.includes('://') && trimmed.includes('/')) {
|
|
43
|
+
return `kage@${trimmed}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!trimmed.includes('://') && !trimmed.includes(' ')) {
|
|
47
|
+
if (trimmed.startsWith('kage@') || trimmed.includes('/')) {
|
|
48
|
+
return `kage@${trimmed}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const normalizeImageUrlForThumbnail = (value) => {
|
|
56
|
+
const trimmed = String(value || '').trim();
|
|
57
|
+
if (!trimmed) return null;
|
|
58
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (trimmed.includes('data:image')) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (trimmed.includes(' ') || trimmed.length < 10) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const imageExtensionMatch = trimmed.match(/\.(?:jpg|jpeg|png|gif|webp|bmp|avif|svg)(?:$|\?|#)/i);
|
|
68
|
+
return imageExtensionMatch ? trimmed : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const normalizeThumbnailForPublish = (value) => {
|
|
72
|
+
const normalized = normalizeKageFromUrl(value);
|
|
73
|
+
if (!normalized) {
|
|
74
|
+
return normalizeImageUrlForThumbnail(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const body = normalized.replace(/^kage@/i, '').split(/[?#]/)[0];
|
|
78
|
+
const pathPart = body?.trim();
|
|
79
|
+
if (!pathPart) return null;
|
|
80
|
+
const hasImageFile = /\/[^/]+\.[A-Za-z0-9]+$/u.test(pathPart);
|
|
81
|
+
if (hasImageFile) {
|
|
82
|
+
return `kage@${pathPart}`;
|
|
83
|
+
}
|
|
84
|
+
const suffix = pathPart.endsWith('/') ? 'img.jpg' : '/img.jpg';
|
|
85
|
+
return `kage@${pathPart}${suffix}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const extractKageFromCandidate = (value) => {
|
|
89
|
+
const normalized = normalizeThumbnailForPublish(value);
|
|
90
|
+
if (normalized) {
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof value !== 'string') {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const trimmed = value.trim();
|
|
99
|
+
if (!trimmed) return null;
|
|
100
|
+
|
|
101
|
+
const imageTagMatch = trimmed.match(/\[##_Image\|([^|]+)\|/);
|
|
102
|
+
if (imageTagMatch?.[1]) {
|
|
103
|
+
return normalizeKageFromUrl(imageTagMatch[1]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!trimmed.includes('://') && trimmed.includes('|')) {
|
|
107
|
+
const match = trimmed.match(/kage@[^\s|]+/);
|
|
108
|
+
if (match?.[0]) {
|
|
109
|
+
return match[0];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const normalizeUploadedImageThumbnail = (uploadedImage) => {
|
|
117
|
+
const candidates = [
|
|
118
|
+
uploadedImage?.uploadedKage,
|
|
119
|
+
uploadedImage?.raw?.kage,
|
|
120
|
+
uploadedImage?.raw?.uploadedKage,
|
|
121
|
+
uploadedImage?.uploadedKey,
|
|
122
|
+
uploadedImage?.raw?.key,
|
|
123
|
+
uploadedImage?.raw?.attachmentKey,
|
|
124
|
+
uploadedImage?.raw?.imageKey,
|
|
125
|
+
uploadedImage?.raw?.id,
|
|
126
|
+
uploadedImage?.raw?.url,
|
|
127
|
+
uploadedImage?.raw?.attachmentUrl,
|
|
128
|
+
uploadedImage?.raw?.thumbnail,
|
|
129
|
+
uploadedImage?.url,
|
|
130
|
+
uploadedImage?.uploadedUrl,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
const normalized = extractKageFromCandidate(candidate);
|
|
135
|
+
if (normalized) {
|
|
136
|
+
const final = normalizeThumbnailForPublish(normalized);
|
|
137
|
+
if (final) {
|
|
138
|
+
return final;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const buildTistoryImageTag = (uploadedImage, keyword) => {
|
|
147
|
+
const alt = String(keyword || '').replace(/"/g, '"');
|
|
148
|
+
const normalizedKage = normalizeUploadedImageThumbnail(uploadedImage);
|
|
149
|
+
if (normalizedKage) {
|
|
150
|
+
return `<p data-ke-size="size16">[##_Image|${normalizedKage}|CDM|1.3|{"originWidth":0,"originHeight":0,"style":"alignCenter"}_##]</p>`;
|
|
151
|
+
}
|
|
152
|
+
if (uploadedImage?.uploadedKage) {
|
|
153
|
+
return `<p data-ke-size="size16">[##_Image|${uploadedImage.uploadedKage}|CDM|1.3|{"originWidth":0,"originHeight":0,"style":"alignCenter"}_##]</p>`;
|
|
154
|
+
}
|
|
155
|
+
if (uploadedImage?.uploadedUrl) {
|
|
156
|
+
return `<p data-ke-size="size16"><img src="${uploadedImage.uploadedUrl}" alt="${alt}" /></p>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return `<p data-ke-size="size16"><img src="${uploadedImage.uploadedUrl}" alt="${alt}" /></p>`;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const guessExtensionFromContentType = (contentType = '') => {
|
|
163
|
+
const normalized = contentType.toLowerCase();
|
|
164
|
+
if (normalized.includes('png')) return 'png';
|
|
165
|
+
if (normalized.includes('webp')) return 'webp';
|
|
166
|
+
if (normalized.includes('gif')) return 'gif';
|
|
167
|
+
if (normalized.includes('bmp')) return 'bmp';
|
|
168
|
+
if (normalized.includes('jpeg') || normalized.includes('jpg')) return 'jpg';
|
|
169
|
+
return 'jpg';
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const isImageContentType = (contentType = '') => {
|
|
173
|
+
const normalized = contentType.toLowerCase();
|
|
174
|
+
return normalized.startsWith('image/') || normalized.includes('application/octet-stream') || normalized.includes('binary/octet-stream');
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const guessExtensionFromUrl = (rawUrl) => {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = new URL(rawUrl);
|
|
180
|
+
const match = parsed.pathname.match(/\.([a-zA-Z0-9]+)(?:$|\?)/);
|
|
181
|
+
if (!match) return null;
|
|
182
|
+
const ext = match[1].toLowerCase();
|
|
183
|
+
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif', 'ico'].includes(ext)) {
|
|
184
|
+
return ext === 'jpeg' ? 'jpg' : ext;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const getImageSignatureExtension = (buffer) => {
|
|
193
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
|
|
194
|
+
const magic4 = buffer.slice(0, 4).toString('hex');
|
|
195
|
+
const magic2 = buffer.slice(0, 2).toString('hex');
|
|
196
|
+
const magic8 = buffer.slice(8, 12).toString('hex');
|
|
197
|
+
if (magic2 === 'ffd8') return 'jpg';
|
|
198
|
+
if (magic4 === '89504e47') return 'png';
|
|
199
|
+
if (magic4 === '47494638') return 'gif';
|
|
200
|
+
if (magic4 === '52494646' && magic8 === '57454250') return 'webp';
|
|
201
|
+
if (magic2 === '424d') return 'bmp';
|
|
202
|
+
return null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const resolveLocalImagePath = (value) => {
|
|
206
|
+
if (typeof value !== 'string') {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const trimmed = value.trim();
|
|
211
|
+
if (!trimmed) return null;
|
|
212
|
+
|
|
213
|
+
if (trimmed.startsWith('file://')) {
|
|
214
|
+
try {
|
|
215
|
+
const filePath = decodeURIComponent(new URL(trimmed).pathname);
|
|
216
|
+
if (fs.existsSync(filePath)) {
|
|
217
|
+
const stat = fs.statSync(filePath);
|
|
218
|
+
if (stat.isFile()) return filePath;
|
|
219
|
+
}
|
|
220
|
+
} catch {}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const expanded = trimmed.startsWith('~')
|
|
225
|
+
? path.join(os.homedir(), trimmed.slice(1))
|
|
226
|
+
: trimmed;
|
|
227
|
+
const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(process.cwd(), expanded);
|
|
228
|
+
if (!fs.existsSync(candidate)) return null;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const stat = fs.statSync(candidate);
|
|
232
|
+
return stat.isFile() ? candidate : null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const normalizeImageInput = (value) => {
|
|
239
|
+
if (typeof value !== 'string') {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const text = value.trim();
|
|
243
|
+
if (!text) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const localPath = resolveLocalImagePath(text);
|
|
247
|
+
if (localPath) {
|
|
248
|
+
return localPath;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const parsed = new URL(text);
|
|
253
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
return parsed.toString();
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const normalizeImageInputs = (inputs) => {
|
|
263
|
+
if (typeof inputs === 'string') {
|
|
264
|
+
return inputs.split(',').map((item) => normalizeImageInput(item)).filter(Boolean);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!Array.isArray(inputs)) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return inputs.map(normalizeImageInput).filter(Boolean);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const extractThumbnailFromContent = (content = '') => {
|
|
275
|
+
const match = String(content).match(/\[##_Image\|([^|]+)\|/);
|
|
276
|
+
if (!match?.[1]) {
|
|
277
|
+
const imgMatch = String(content).match(/<img[^>]+src=["']([^"']+)["'][^>]*>/i);
|
|
278
|
+
if (!imgMatch?.[1]) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
return normalizeImageUrlForThumbnail(imgMatch[1]);
|
|
282
|
+
}
|
|
283
|
+
return extractKageFromCandidate(match[1]);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
module.exports = {
|
|
287
|
+
normalizeKageFromUrl,
|
|
288
|
+
normalizeImageUrlForThumbnail,
|
|
289
|
+
normalizeThumbnailForPublish,
|
|
290
|
+
extractKageFromCandidate,
|
|
291
|
+
normalizeUploadedImageThumbnail,
|
|
292
|
+
buildTistoryImageTag,
|
|
293
|
+
guessExtensionFromContentType,
|
|
294
|
+
isImageContentType,
|
|
295
|
+
guessExtensionFromUrl,
|
|
296
|
+
getImageSignatureExtension,
|
|
297
|
+
resolveLocalImagePath,
|
|
298
|
+
normalizeImageInput,
|
|
299
|
+
normalizeImageInputs,
|
|
300
|
+
extractThumbnailFromContent,
|
|
301
|
+
};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const { imageTrace } = require('./utils');
|
|
3
|
+
const { fetchText, fetchTextWithHeaders, normalizeAbsoluteUrl, extractDuckDuckGoVqd } = require('./fetchLayer');
|
|
4
|
+
|
|
5
|
+
const sanitizeImageQueryForProvider = (value = '') => {
|
|
6
|
+
return String(value || '')
|
|
7
|
+
.trim()
|
|
8
|
+
.replace(/[^a-z0-9가-힣\s]/gi, ' ')
|
|
9
|
+
.replace(/\s+/g, ' ')
|
|
10
|
+
.trim();
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const buildLoremFlickrImageCandidates = (keyword = '') => {
|
|
14
|
+
const safeKeyword = sanitizeImageQueryForProvider(keyword);
|
|
15
|
+
if (!safeKeyword) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const encoded = encodeURIComponent(safeKeyword.replace(/\s+/g, ','));
|
|
19
|
+
return [
|
|
20
|
+
`https://loremflickr.com/1200/800/${encoded}`,
|
|
21
|
+
`https://loremflickr.com/g/1200/800/${encoded}`,
|
|
22
|
+
];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const buildPicsumImageCandidates = (keyword = '') => {
|
|
26
|
+
const safeKeyword = sanitizeImageQueryForProvider(keyword);
|
|
27
|
+
const hash = safeKeyword
|
|
28
|
+
? crypto.createHash('md5').update(safeKeyword).digest('hex').slice(0, 10)
|
|
29
|
+
: 'default';
|
|
30
|
+
return [
|
|
31
|
+
`https://picsum.photos/seed/${hash}/1200/800`,
|
|
32
|
+
`https://picsum.photos/1200/800`,
|
|
33
|
+
];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const buildPlaceholderImageCandidates = () => {
|
|
37
|
+
return [
|
|
38
|
+
'https://placehold.co/1200x800.png',
|
|
39
|
+
'https://via.placeholder.com/1200x800.jpg',
|
|
40
|
+
'https://dummyimage.com/1200x800/000/fff.png&text=thumbnail',
|
|
41
|
+
];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const buildWikimediaImageCandidates = async (keyword = '') => {
|
|
45
|
+
const safeKeyword = sanitizeImageQueryForProvider(keyword);
|
|
46
|
+
if (!safeKeyword) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const query = encodeURIComponent(`${safeKeyword} file`);
|
|
51
|
+
const apiUrl = `https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrsearch=${query}&gsrnamespace=6&gsrlimit=8&prop=imageinfo&iiprop=url&iiurlwidth=1200&format=json&origin=*`;
|
|
52
|
+
imageTrace('wikimedia.request', { keyword: safeKeyword, apiUrl });
|
|
53
|
+
const raw = await fetchText(apiUrl);
|
|
54
|
+
const parsed = JSON.parse(raw || '{}');
|
|
55
|
+
const pages = parsed?.query?.pages || {};
|
|
56
|
+
const candidates = [];
|
|
57
|
+
for (const page of Object.values(pages)) {
|
|
58
|
+
const imageInfo = Array.isArray(page?.imageinfo) ? page.imageinfo : [];
|
|
59
|
+
if (imageInfo.length === 0) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const first = imageInfo[0];
|
|
63
|
+
if (first?.thumburl) {
|
|
64
|
+
candidates.push(first.thumburl);
|
|
65
|
+
} else if (first?.url) {
|
|
66
|
+
candidates.push(first.url);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
imageTrace('wikimedia.response', { keyword: safeKeyword, count: candidates.length });
|
|
70
|
+
return candidates;
|
|
71
|
+
} catch {
|
|
72
|
+
imageTrace('wikimedia.error', { keyword: safeKeyword });
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const fetchDuckDuckGoImageResults = async (query = '') => {
|
|
78
|
+
try {
|
|
79
|
+
const safeKeyword = String(query || '').trim();
|
|
80
|
+
if (!safeKeyword) return [];
|
|
81
|
+
const searchUrl = `https://duckduckgo.com/?ia=images&origin=funnel_home_google&t=h_&q=${encodeURIComponent(safeKeyword)}&chip-select=search&iax=images`;
|
|
82
|
+
imageTrace('duckduckgo.searchPage', { query: safeKeyword, searchUrl });
|
|
83
|
+
const searchText = await fetchTextWithHeaders(searchUrl, {
|
|
84
|
+
Accept: 'text/html,application/xhtml+xml',
|
|
85
|
+
Referer: 'https://duckduckgo.com/',
|
|
86
|
+
});
|
|
87
|
+
const vqd = extractDuckDuckGoVqd(searchText);
|
|
88
|
+
if (!vqd) return [];
|
|
89
|
+
|
|
90
|
+
const apiCandidates = [
|
|
91
|
+
`https://duckduckgo.com/i.js?l=wt-wt&o=json&q=${encodeURIComponent(safeKeyword)}&vqd=${encodeURIComponent(vqd)}&ia=images&iax=images`,
|
|
92
|
+
`https://duckduckgo.com/i.js?l=wt-wt&o=json&q=${encodeURIComponent(safeKeyword)}&ia=images&iax=images&vqd=${encodeURIComponent(vqd)}&s=0`,
|
|
93
|
+
`https://duckduckgo.com/i.js?o=json&q=${encodeURIComponent(safeKeyword)}&ia=images&iax=images&vqd=${encodeURIComponent(vqd)}&p=1`,
|
|
94
|
+
`https://duckduckgo.com/i.js?l=en-gb&o=json&q=${encodeURIComponent(safeKeyword)}&ia=images&iax=images&vqd=${encodeURIComponent(vqd)}`,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const jsonHeaders = {
|
|
98
|
+
Accept: 'application/json, text/javascript, */*; q=0.01',
|
|
99
|
+
'Referer': `https://duckduckgo.com/?q=${encodeURIComponent(safeKeyword)}&ia=images&iax=images`,
|
|
100
|
+
'Origin': 'https://duckduckgo.com',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let parsed = null;
|
|
104
|
+
let apiUrl = null;
|
|
105
|
+
for (const candidate of apiCandidates) {
|
|
106
|
+
try {
|
|
107
|
+
imageTrace('duckduckgo.apiUrl', { query: safeKeyword, apiUrl: candidate });
|
|
108
|
+
const apiText = await fetchTextWithHeaders(candidate, jsonHeaders);
|
|
109
|
+
const safeText = String(apiText || '').trim();
|
|
110
|
+
if (!safeText) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!safeText.startsWith('{') && !safeText.startsWith('[')) {
|
|
114
|
+
imageTrace('duckduckgo.apiParseSkipped', { query: safeKeyword, apiUrl: candidate, reason: 'nonJsonStart' });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
parsed = JSON.parse(safeText);
|
|
118
|
+
if (Array.isArray(parsed.results) && parsed.results.length > 0) {
|
|
119
|
+
apiUrl = candidate;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
imageTrace('duckduckgo.apiParseError', { query: safeKeyword, apiUrl: candidate });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!parsed) return [];
|
|
128
|
+
if (apiUrl) {
|
|
129
|
+
imageTrace('duckduckgo.apiUsed', { query: safeKeyword, apiUrl });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
imageTrace('duckduckgo.apiResult', {
|
|
133
|
+
query: safeKeyword,
|
|
134
|
+
resultCount: Array.isArray(parsed?.results) ? parsed.results.length : 0,
|
|
135
|
+
});
|
|
136
|
+
const results = Array.isArray(parsed.results) ? parsed.results : [];
|
|
137
|
+
|
|
138
|
+
const images = [];
|
|
139
|
+
for (const item of results) {
|
|
140
|
+
if (typeof item !== 'object' || !item) continue;
|
|
141
|
+
const candidates = [
|
|
142
|
+
item.image,
|
|
143
|
+
item.thumbnail,
|
|
144
|
+
item.image_thumb,
|
|
145
|
+
item.url,
|
|
146
|
+
item.original,
|
|
147
|
+
];
|
|
148
|
+
for (const candidate of candidates) {
|
|
149
|
+
const candidateUrl = normalizeAbsoluteUrl(candidate);
|
|
150
|
+
if (candidateUrl && !/favicon|logo|sprite|pixel/i.test(candidateUrl)) {
|
|
151
|
+
images.push(candidateUrl);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return images;
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const buildKeywordImageCandidates = async (keyword = '') => {
|
|
164
|
+
const cleaned = String(keyword || '').trim().toLowerCase();
|
|
165
|
+
const compacted = cleaned
|
|
166
|
+
.replace(/[^a-z0-9가-힣\s]/g, ' ')
|
|
167
|
+
.replace(/\s+/g, ' ')
|
|
168
|
+
.trim();
|
|
169
|
+
const safeKeyword = compacted;
|
|
170
|
+
if (!safeKeyword) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
imageTrace('buildKeywordImageCandidates.start', { safeKeyword });
|
|
174
|
+
|
|
175
|
+
const duckduckgoQueries = [
|
|
176
|
+
safeKeyword,
|
|
177
|
+
`${safeKeyword} 이미지`,
|
|
178
|
+
`${safeKeyword} 뉴스`,
|
|
179
|
+
];
|
|
180
|
+
const searchCandidates = [];
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
|
|
183
|
+
const collectIfImage = (imageUrl) => {
|
|
184
|
+
const resolved = normalizeAbsoluteUrl(imageUrl);
|
|
185
|
+
if (resolved && !seen.has(resolved)) {
|
|
186
|
+
seen.add(resolved);
|
|
187
|
+
searchCandidates.push(resolved);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
for (const query of duckduckgoQueries) {
|
|
192
|
+
if (searchCandidates.length >= 6) {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
imageTrace('buildKeywordImageCandidates.ddgQuery', { query, currentCount: searchCandidates.length });
|
|
196
|
+
const duckImages = await fetchDuckDuckGoImageResults(query);
|
|
197
|
+
imageTrace('buildKeywordImageCandidates.ddgResult', { query, count: duckImages.length });
|
|
198
|
+
for (const duckImage of duckImages.slice(0, 6)) {
|
|
199
|
+
if (searchCandidates.length >= 6) break;
|
|
200
|
+
collectIfImage(duckImage);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const fallbackQueries = [
|
|
205
|
+
safeKeyword,
|
|
206
|
+
`${safeKeyword} 이미지`,
|
|
207
|
+
`${safeKeyword} news`,
|
|
208
|
+
'뉴스',
|
|
209
|
+
'세계 뉴스',
|
|
210
|
+
];
|
|
211
|
+
for (const query of fallbackQueries) {
|
|
212
|
+
if (searchCandidates.length >= 6) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
imageTrace('buildKeywordImageCandidates.fallbackQuery', { query, currentCount: searchCandidates.length });
|
|
216
|
+
const wikiImages = await buildWikimediaImageCandidates(query);
|
|
217
|
+
imageTrace('buildKeywordImageCandidates.wikimediaResult', { query, count: wikiImages.length });
|
|
218
|
+
for (const candidate of wikiImages) {
|
|
219
|
+
if (searchCandidates.length >= 6) {
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
collectIfImage(candidate);
|
|
223
|
+
}
|
|
224
|
+
for (const candidate of buildLoremFlickrImageCandidates(query)) {
|
|
225
|
+
imageTrace('buildKeywordImageCandidates.loremflickrCandidate', { query, candidate });
|
|
226
|
+
if (searchCandidates.length >= 6) {
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
collectIfImage(candidate);
|
|
230
|
+
}
|
|
231
|
+
for (const candidate of buildPicsumImageCandidates(query)) {
|
|
232
|
+
imageTrace('buildKeywordImageCandidates.picsumCandidate', { query, candidate });
|
|
233
|
+
if (searchCandidates.length >= 6) {
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
collectIfImage(candidate);
|
|
237
|
+
}
|
|
238
|
+
for (const candidate of buildPlaceholderImageCandidates()) {
|
|
239
|
+
imageTrace('buildKeywordImageCandidates.placeholderCandidate', { candidate });
|
|
240
|
+
if (searchCandidates.length >= 6) {
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
collectIfImage(candidate);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return searchCandidates.slice(0, 6);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const buildFallbackImageSources = async (keyword = '') => {
|
|
251
|
+
const trimmedKeyword = String(keyword || '').trim();
|
|
252
|
+
if (!trimmedKeyword) {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
if (trimmedKeyword.startsWith('image-')) {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
return buildKeywordImageCandidates(trimmedKeyword);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
sanitizeImageQueryForProvider,
|
|
263
|
+
buildLoremFlickrImageCandidates,
|
|
264
|
+
buildPicsumImageCandidates,
|
|
265
|
+
buildPlaceholderImageCandidates,
|
|
266
|
+
buildWikimediaImageCandidates,
|
|
267
|
+
fetchDuckDuckGoImageResults,
|
|
268
|
+
buildKeywordImageCandidates,
|
|
269
|
+
buildFallbackImageSources,
|
|
270
|
+
};
|