viruagent-cli 0.3.3 → 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 -3141
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const {
|
|
4
|
+
IMAGE_TRACE_ENABLED,
|
|
5
|
+
imageTrace,
|
|
6
|
+
MAX_IMAGE_UPLOAD_COUNT,
|
|
7
|
+
IMAGE_PLACEHOLDER_REGEX,
|
|
8
|
+
escapeRegExp,
|
|
9
|
+
normalizeTempDir,
|
|
10
|
+
buildImageFileName,
|
|
11
|
+
dedupeTextValues,
|
|
12
|
+
dedupeImageSources,
|
|
13
|
+
} = require('./utils');
|
|
14
|
+
const {
|
|
15
|
+
normalizeUploadedImageThumbnail,
|
|
16
|
+
normalizeImageUrlForThumbnail,
|
|
17
|
+
normalizeThumbnailForPublish,
|
|
18
|
+
buildTistoryImageTag,
|
|
19
|
+
normalizeImageInputs,
|
|
20
|
+
guessExtensionFromContentType,
|
|
21
|
+
isImageContentType,
|
|
22
|
+
guessExtensionFromUrl,
|
|
23
|
+
getImageSignatureExtension,
|
|
24
|
+
resolveLocalImagePath,
|
|
25
|
+
extractThumbnailFromContent,
|
|
26
|
+
} = require('./imageNormalization');
|
|
27
|
+
const { extractImageFromHtml } = require('./fetchLayer');
|
|
28
|
+
const { buildKeywordImageCandidates, buildFallbackImageSources } = require('./imageSources');
|
|
29
|
+
|
|
30
|
+
const extractImagePlaceholders = (content = '') => {
|
|
31
|
+
const matches = Array.from(String(content).matchAll(IMAGE_PLACEHOLDER_REGEX));
|
|
32
|
+
return matches.map((match) => ({
|
|
33
|
+
raw: match[0],
|
|
34
|
+
keyword: String(match[1] || '').trim(),
|
|
35
|
+
}));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const fetchImageBuffer = async (url, retryCount = 0) => {
|
|
39
|
+
if (!url) {
|
|
40
|
+
throw new Error('이미지 URL이 없습니다.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const localPath = resolveLocalImagePath(url);
|
|
44
|
+
if (localPath && !/https?:/.test(url)) {
|
|
45
|
+
const buffer = await fs.promises.readFile(localPath);
|
|
46
|
+
if (!buffer || buffer.length === 0) {
|
|
47
|
+
throw new Error(`이미지 파일이 비어 있습니다: ${localPath}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const extensionFromSignature = getImageSignatureExtension(buffer);
|
|
51
|
+
const extensionFromUrl = guessExtensionFromUrl(localPath);
|
|
52
|
+
return {
|
|
53
|
+
buffer,
|
|
54
|
+
ext: extensionFromSignature || extensionFromUrl || 'jpg',
|
|
55
|
+
finalUrl: localPath,
|
|
56
|
+
isLocal: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const headers = {
|
|
61
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
|
62
|
+
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
|
63
|
+
'Cache-Control': 'no-cache',
|
|
64
|
+
};
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(url, {
|
|
70
|
+
redirect: 'follow',
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
headers,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`이미지 다운로드 실패: ${response.status} ${response.statusText} (${url})`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const contentType = response.headers.get('content-type') || '';
|
|
80
|
+
const normalizedContentType = contentType.toLowerCase();
|
|
81
|
+
const finalUrl = response.url || url;
|
|
82
|
+
const looksLikeHtml = normalizedContentType.includes('text/html') || normalizedContentType.includes('application/xhtml+xml');
|
|
83
|
+
if (looksLikeHtml) {
|
|
84
|
+
const html = await response.text();
|
|
85
|
+
return {
|
|
86
|
+
html,
|
|
87
|
+
ext: 'jpg',
|
|
88
|
+
finalUrl,
|
|
89
|
+
isHtml: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
94
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
95
|
+
const extensionFromUrl = guessExtensionFromUrl(finalUrl);
|
|
96
|
+
const extensionFromSignature = getImageSignatureExtension(buffer);
|
|
97
|
+
const isImage = isImageContentType(contentType)
|
|
98
|
+
|| extensionFromUrl
|
|
99
|
+
|| extensionFromSignature;
|
|
100
|
+
|
|
101
|
+
if (!isImage) {
|
|
102
|
+
throw new Error(`이미지 콘텐츠가 아닙니다: ${contentType || '(미확인)'}, url=${finalUrl}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
buffer,
|
|
107
|
+
ext: extensionFromSignature || guessExtensionFromContentType(contentType) || extensionFromUrl || 'jpg',
|
|
108
|
+
finalUrl,
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (retryCount < 1) {
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
113
|
+
return fetchImageBuffer(url, retryCount + 1);
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`이미지 다운로드 실패: ${error.message}`);
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const uploadImageFromRemote = async (api, remoteUrl, fallbackName = 'image', depth = 0) => {
|
|
122
|
+
const downloaded = await fetchImageBuffer(remoteUrl);
|
|
123
|
+
|
|
124
|
+
if (downloaded?.isHtml && downloaded?.html) {
|
|
125
|
+
const extractedImageUrl = extractImageFromHtml(downloaded.html, downloaded.finalUrl || remoteUrl);
|
|
126
|
+
if (!extractedImageUrl) {
|
|
127
|
+
throw new Error('이미지 페이지에서 유효한 대표 이미지를 찾지 못했습니다.');
|
|
128
|
+
}
|
|
129
|
+
if (depth >= 1 || extractedImageUrl === remoteUrl) {
|
|
130
|
+
throw new Error('이미지 페이지에서 추출된 URL이 유효하지 않아 업로드를 중단했습니다.');
|
|
131
|
+
}
|
|
132
|
+
return uploadImageFromRemote(api, extractedImageUrl, fallbackName, depth + 1);
|
|
133
|
+
}
|
|
134
|
+
const tmpDir = normalizeTempDir();
|
|
135
|
+
const filename = buildImageFileName(fallbackName, downloaded.ext);
|
|
136
|
+
const filePath = path.join(tmpDir, filename);
|
|
137
|
+
|
|
138
|
+
await fs.promises.writeFile(filePath, downloaded.buffer);
|
|
139
|
+
let uploaded;
|
|
140
|
+
try {
|
|
141
|
+
uploaded = await api.uploadImage(downloaded.buffer, filename);
|
|
142
|
+
} finally {
|
|
143
|
+
await fs.promises.unlink(filePath).catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
const uploadedKage = normalizeUploadedImageThumbnail(uploaded) || (uploaded?.key ? `kage@${uploaded.key}` : null);
|
|
146
|
+
|
|
147
|
+
if (!uploaded || !(uploaded.url || uploaded.key)) {
|
|
148
|
+
throw new Error('이미지 업로드 응답이 비정상적입니다.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
sourceUrl: downloaded.finalUrl,
|
|
153
|
+
uploadedUrl: uploaded.url,
|
|
154
|
+
uploadedKey: uploaded.key || uploaded.url,
|
|
155
|
+
uploadedKage,
|
|
156
|
+
raw: uploaded,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const replaceImagePlaceholdersWithUploaded = async (
|
|
161
|
+
api,
|
|
162
|
+
content,
|
|
163
|
+
autoUploadImages,
|
|
164
|
+
relatedImageKeywords = [],
|
|
165
|
+
imageUrls = [],
|
|
166
|
+
_imageCountLimit = 1,
|
|
167
|
+
_minimumImageCount = 1
|
|
168
|
+
) => {
|
|
169
|
+
const originalContent = content || '';
|
|
170
|
+
if (!autoUploadImages) {
|
|
171
|
+
return {
|
|
172
|
+
content: originalContent,
|
|
173
|
+
uploaded: [],
|
|
174
|
+
uploadedCount: 0,
|
|
175
|
+
status: 'skipped',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let updatedContent = originalContent;
|
|
180
|
+
const uploadedImages = [];
|
|
181
|
+
const uploadErrors = [];
|
|
182
|
+
const matches = extractImagePlaceholders(updatedContent);
|
|
183
|
+
const collectedImageUrls = normalizeImageInputs(imageUrls);
|
|
184
|
+
const hasPlaceholders = matches.length > 0;
|
|
185
|
+
|
|
186
|
+
const normalizedKeywords = Array.isArray(relatedImageKeywords)
|
|
187
|
+
? relatedImageKeywords.map((item) => String(item || '').trim()).filter(Boolean)
|
|
188
|
+
: typeof relatedImageKeywords === 'string'
|
|
189
|
+
? relatedImageKeywords.split(',').map((item) => item.trim()).filter(Boolean)
|
|
190
|
+
: [];
|
|
191
|
+
const safeImageUploadLimit = MAX_IMAGE_UPLOAD_COUNT;
|
|
192
|
+
const safeMinimumImageCount = MAX_IMAGE_UPLOAD_COUNT;
|
|
193
|
+
const targetImageCount = MAX_IMAGE_UPLOAD_COUNT;
|
|
194
|
+
|
|
195
|
+
const uploadTargets = hasPlaceholders
|
|
196
|
+
? await Promise.all(matches.map(async (match, index) => {
|
|
197
|
+
const keyword = match.keyword || normalizedKeywords[index] || '';
|
|
198
|
+
const hasKeywordSource = Boolean(keyword);
|
|
199
|
+
const primarySources = hasKeywordSource
|
|
200
|
+
? await buildKeywordImageCandidates(keyword)
|
|
201
|
+
: [];
|
|
202
|
+
const keywordSources = [...primarySources].filter(Boolean);
|
|
203
|
+
const finalSources = keywordSources.length > 0
|
|
204
|
+
? keywordSources
|
|
205
|
+
: [];
|
|
206
|
+
return {
|
|
207
|
+
placeholder: match,
|
|
208
|
+
sources: [
|
|
209
|
+
...(collectedImageUrls[index] ? [collectedImageUrls[index]] : []),
|
|
210
|
+
...finalSources,
|
|
211
|
+
],
|
|
212
|
+
keyword: keyword || `image-${index + 1}`,
|
|
213
|
+
};
|
|
214
|
+
}))
|
|
215
|
+
: collectedImageUrls.slice(0, targetImageCount).map((imageUrl, index) => ({
|
|
216
|
+
placeholder: null,
|
|
217
|
+
sources: [imageUrl],
|
|
218
|
+
keyword: normalizedKeywords[index] || `image-${index + 1}`,
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
const missingTargets = Math.max(0, targetImageCount - uploadTargets.length);
|
|
222
|
+
const fallbackBaseKeywords = normalizedKeywords.length > 0 ? normalizedKeywords : [];
|
|
223
|
+
const fallbackTargets = missingTargets > 0
|
|
224
|
+
? await Promise.all(Array.from({ length: missingTargets }).map(async (_, index) => {
|
|
225
|
+
const keyword = fallbackBaseKeywords[index] || fallbackBaseKeywords[fallbackBaseKeywords.length - 1];
|
|
226
|
+
const sources = await buildKeywordImageCandidates(keyword);
|
|
227
|
+
return {
|
|
228
|
+
placeholder: null,
|
|
229
|
+
sources,
|
|
230
|
+
keyword: keyword || `image-${uploadTargets.length + index + 1}`,
|
|
231
|
+
};
|
|
232
|
+
}))
|
|
233
|
+
: [];
|
|
234
|
+
|
|
235
|
+
const finalUploadTargets = [...uploadTargets, ...fallbackTargets];
|
|
236
|
+
const limitedUploadTargets = finalUploadTargets.slice(0, targetImageCount);
|
|
237
|
+
const requestedImageCount = targetImageCount;
|
|
238
|
+
const resolvedRequestedKeywords = dedupeTextValues(
|
|
239
|
+
hasPlaceholders
|
|
240
|
+
? [
|
|
241
|
+
...matches.map((match) => match.keyword).filter(Boolean),
|
|
242
|
+
...finalUploadTargets.map((target) => target.keyword).filter(Boolean),
|
|
243
|
+
...normalizedKeywords,
|
|
244
|
+
]
|
|
245
|
+
: normalizedKeywords
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const requestedKeywords = resolvedRequestedKeywords.length > 0
|
|
249
|
+
? resolvedRequestedKeywords
|
|
250
|
+
: normalizedKeywords;
|
|
251
|
+
|
|
252
|
+
const hasUsableSource = limitedUploadTargets.some((target) => Array.isArray(target.sources) && target.sources.length > 0);
|
|
253
|
+
if (!hasUsableSource) {
|
|
254
|
+
return {
|
|
255
|
+
content: originalContent,
|
|
256
|
+
uploaded: [],
|
|
257
|
+
uploadedCount: 0,
|
|
258
|
+
status: 'need_image_urls',
|
|
259
|
+
message: '자동 업로드할 이미지 후보 키워드가 없습니다. imageUrls 또는 relatedImageKeywords/플레이스홀더 키워드를 제공해 주세요.',
|
|
260
|
+
requestedKeywords,
|
|
261
|
+
requestedCount: requestedImageCount,
|
|
262
|
+
providedImageUrls: collectedImageUrls.length,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (let i = 0; i < limitedUploadTargets.length; i += 1) {
|
|
267
|
+
const target = limitedUploadTargets[i];
|
|
268
|
+
const uniqueSources = dedupeImageSources(target.sources);
|
|
269
|
+
let uploadedImage = null;
|
|
270
|
+
let lastMessage = '';
|
|
271
|
+
let success = false;
|
|
272
|
+
|
|
273
|
+
if (uniqueSources.length === 0) {
|
|
274
|
+
uploadErrors.push({
|
|
275
|
+
index: i,
|
|
276
|
+
sourceUrl: null,
|
|
277
|
+
keyword: target.keyword,
|
|
278
|
+
message: '이미지 소스가 없습니다.',
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (let sourceIndex = 0; sourceIndex < uniqueSources.length; sourceIndex += 1) {
|
|
284
|
+
const sourceUrl = uniqueSources[sourceIndex];
|
|
285
|
+
if (IMAGE_TRACE_ENABLED) {
|
|
286
|
+
imageTrace('uploadAttempt', {
|
|
287
|
+
index: i,
|
|
288
|
+
sourceIndex,
|
|
289
|
+
sourceUrl,
|
|
290
|
+
host: (() => {
|
|
291
|
+
try {
|
|
292
|
+
return new URL(sourceUrl).hostname;
|
|
293
|
+
} catch {
|
|
294
|
+
return 'invalid-url';
|
|
295
|
+
}
|
|
296
|
+
})(),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
uploadedImage = await uploadImageFromRemote(api, sourceUrl, target.keyword);
|
|
301
|
+
success = true;
|
|
302
|
+
break;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
lastMessage = error.message;
|
|
305
|
+
console.log('이미지 처리 실패:', sourceUrl, error.message);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!success) {
|
|
310
|
+
const fallbackSources = dedupeImageSources([
|
|
311
|
+
...uniqueSources,
|
|
312
|
+
...(await buildFallbackImageSources(target.keyword)),
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
for (let sourceIndex = 0; sourceIndex < fallbackSources.length; sourceIndex += 1) {
|
|
316
|
+
const sourceUrl = fallbackSources[sourceIndex];
|
|
317
|
+
if (uniqueSources.includes(sourceUrl)) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (IMAGE_TRACE_ENABLED) {
|
|
321
|
+
imageTrace('uploadAttempt.fallback', {
|
|
322
|
+
index: i,
|
|
323
|
+
sourceIndex,
|
|
324
|
+
sourceUrl,
|
|
325
|
+
host: (() => {
|
|
326
|
+
try {
|
|
327
|
+
return new URL(sourceUrl).hostname;
|
|
328
|
+
} catch {
|
|
329
|
+
return 'invalid-url';
|
|
330
|
+
}
|
|
331
|
+
})(),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
uploadedImage = await uploadImageFromRemote(api, sourceUrl, target.keyword);
|
|
336
|
+
success = true;
|
|
337
|
+
break;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
lastMessage = error.message;
|
|
340
|
+
console.log('이미지 처리 실패(보정 소스):', sourceUrl, error.message);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!success) {
|
|
346
|
+
uploadErrors.push({
|
|
347
|
+
index: i,
|
|
348
|
+
sourceUrl: uniqueSources[0],
|
|
349
|
+
keyword: target.keyword,
|
|
350
|
+
message: `이미지 업로드 실패(대체 이미지 재시도 포함): ${lastMessage}`,
|
|
351
|
+
});
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const tag = buildTistoryImageTag(uploadedImage, target.keyword);
|
|
356
|
+
if (target.placeholder && target.placeholder.raw) {
|
|
357
|
+
const replaced = new RegExp(escapeRegExp(target.placeholder.raw), 'g');
|
|
358
|
+
updatedContent = updatedContent.replace(replaced, tag);
|
|
359
|
+
} else {
|
|
360
|
+
updatedContent = `${tag}\n${updatedContent}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
uploadedImages.push(uploadedImage);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (hasPlaceholders && uploadedImages.length === 0) {
|
|
367
|
+
return {
|
|
368
|
+
content: originalContent,
|
|
369
|
+
uploaded: [],
|
|
370
|
+
uploadedCount: 0,
|
|
371
|
+
status: 'image_upload_failed',
|
|
372
|
+
message: '이미지 업로드에 실패했습니다. 수집한 이미지 URL을 확인해 다시 호출해 주세요.',
|
|
373
|
+
errors: uploadErrors,
|
|
374
|
+
requestedKeywords,
|
|
375
|
+
requestedCount: requestedImageCount,
|
|
376
|
+
providedImageUrls: collectedImageUrls.length,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (uploadErrors.length > 0) {
|
|
381
|
+
if (uploadedImages.length < safeMinimumImageCount) {
|
|
382
|
+
return {
|
|
383
|
+
content: updatedContent,
|
|
384
|
+
uploaded: uploadedImages,
|
|
385
|
+
uploadedCount: uploadedImages.length,
|
|
386
|
+
status: 'insufficient_images',
|
|
387
|
+
message: `최소 이미지 업로드 장수를 충족하지 못했습니다. (요청: ${safeMinimumImageCount} / 실제: ${uploadedImages.length})`,
|
|
388
|
+
errors: uploadErrors,
|
|
389
|
+
requestedKeywords,
|
|
390
|
+
requestedCount: requestedImageCount,
|
|
391
|
+
uploadedPlaceholders: uploadedImages.length,
|
|
392
|
+
providedImageUrls: collectedImageUrls.length,
|
|
393
|
+
missingImageCount: Math.max(0, safeMinimumImageCount - uploadedImages.length),
|
|
394
|
+
imageLimit: safeImageUploadLimit,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
content: updatedContent,
|
|
400
|
+
uploaded: uploadedImages,
|
|
401
|
+
uploadedCount: uploadedImages.length,
|
|
402
|
+
status: 'image_upload_partial',
|
|
403
|
+
message: '일부 이미지 업로드가 실패했습니다.',
|
|
404
|
+
errors: uploadErrors,
|
|
405
|
+
requestedCount: requestedImageCount,
|
|
406
|
+
uploadedPlaceholders: uploadedImages.length,
|
|
407
|
+
providedImageUrls: collectedImageUrls.length,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (safeMinimumImageCount > 0 && uploadedImages.length < safeMinimumImageCount) {
|
|
412
|
+
return {
|
|
413
|
+
content: updatedContent,
|
|
414
|
+
uploaded: uploadedImages,
|
|
415
|
+
uploadedCount: uploadedImages.length,
|
|
416
|
+
status: 'insufficient_images',
|
|
417
|
+
message: `최소 이미지 업로드 장수를 충족하지 못했습니다. (요청: ${safeMinimumImageCount} / 실제: ${uploadedImages.length})`,
|
|
418
|
+
errors: uploadErrors,
|
|
419
|
+
requestedKeywords,
|
|
420
|
+
requestedCount: requestedImageCount,
|
|
421
|
+
uploadedPlaceholders: uploadedImages.length,
|
|
422
|
+
providedImageUrls: collectedImageUrls.length,
|
|
423
|
+
missingImageCount: Math.max(0, safeMinimumImageCount - uploadedImages.length),
|
|
424
|
+
imageLimit: safeImageUploadLimit,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
content: updatedContent,
|
|
430
|
+
uploaded: uploadedImages,
|
|
431
|
+
uploadedCount: uploadedImages.length,
|
|
432
|
+
status: 'ok',
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const enrichContentWithUploadedImages = async ({
|
|
437
|
+
api,
|
|
438
|
+
rawContent,
|
|
439
|
+
autoUploadImages,
|
|
440
|
+
relatedImageKeywords = [],
|
|
441
|
+
imageUrls = [],
|
|
442
|
+
_imageUploadLimit = 1,
|
|
443
|
+
_minimumImageCount = 1,
|
|
444
|
+
}) => {
|
|
445
|
+
const safeImageUploadLimit = MAX_IMAGE_UPLOAD_COUNT;
|
|
446
|
+
const safeMinimumImageCount = MAX_IMAGE_UPLOAD_COUNT;
|
|
447
|
+
|
|
448
|
+
const shouldAutoUpload = autoUploadImages !== false;
|
|
449
|
+
const enrichedImages = await replaceImagePlaceholdersWithUploaded(
|
|
450
|
+
api,
|
|
451
|
+
rawContent,
|
|
452
|
+
shouldAutoUpload,
|
|
453
|
+
relatedImageKeywords,
|
|
454
|
+
imageUrls,
|
|
455
|
+
safeImageUploadLimit,
|
|
456
|
+
safeMinimumImageCount
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
if (enrichedImages.status === 'need_image_urls') {
|
|
460
|
+
return {
|
|
461
|
+
status: 'need_image_urls',
|
|
462
|
+
message: enrichedImages.message,
|
|
463
|
+
requestedKeywords: enrichedImages.requestedKeywords,
|
|
464
|
+
requestedCount: enrichedImages.requestedCount,
|
|
465
|
+
providedImageUrls: enrichedImages.providedImageUrls,
|
|
466
|
+
content: enrichedImages.content,
|
|
467
|
+
images: enrichedImages.uploaded || [],
|
|
468
|
+
imageCount: enrichedImages.uploadedCount,
|
|
469
|
+
uploadedCount: enrichedImages.uploadedCount,
|
|
470
|
+
uploadErrors: enrichedImages.errors || [],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (enrichedImages.status === 'insufficient_images') {
|
|
475
|
+
return {
|
|
476
|
+
status: 'insufficient_images',
|
|
477
|
+
message: enrichedImages.message,
|
|
478
|
+
imageCount: enrichedImages.uploadedCount,
|
|
479
|
+
requestedCount: enrichedImages.requestedCount,
|
|
480
|
+
uploadedCount: enrichedImages.uploadedCount,
|
|
481
|
+
images: enrichedImages.uploaded || [],
|
|
482
|
+
content: enrichedImages.content,
|
|
483
|
+
uploadErrors: enrichedImages.errors || [],
|
|
484
|
+
providedImageUrls: enrichedImages.providedImageUrls,
|
|
485
|
+
requestedKeywords: enrichedImages.requestedKeywords || [],
|
|
486
|
+
missingImageCount: enrichedImages.missingImageCount || 0,
|
|
487
|
+
imageLimit: enrichedImages.imageLimit || safeImageUploadLimit,
|
|
488
|
+
minimumImageCount: safeMinimumImageCount,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (enrichedImages.status === 'image_upload_failed' || enrichedImages.status === 'image_upload_partial') {
|
|
493
|
+
return {
|
|
494
|
+
status: enrichedImages.status,
|
|
495
|
+
message: enrichedImages.message,
|
|
496
|
+
imageCount: enrichedImages.uploadedCount,
|
|
497
|
+
requestedCount: enrichedImages.requestedCount,
|
|
498
|
+
uploadedCount: enrichedImages.uploadedCount,
|
|
499
|
+
images: enrichedImages.uploaded || [],
|
|
500
|
+
content: enrichedImages.content,
|
|
501
|
+
uploadErrors: enrichedImages.errors || [],
|
|
502
|
+
providedImageUrls: enrichedImages.providedImageUrls,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
status: 'ok',
|
|
508
|
+
content: enrichedImages.content,
|
|
509
|
+
images: enrichedImages.uploaded || [],
|
|
510
|
+
imageCount: enrichedImages.uploadedCount,
|
|
511
|
+
uploadedCount: enrichedImages.uploadedCount,
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const resolveMandatoryThumbnail = async ({
|
|
516
|
+
rawThumbnail,
|
|
517
|
+
content,
|
|
518
|
+
uploadedImages = [],
|
|
519
|
+
relatedImageKeywords = [],
|
|
520
|
+
title = '',
|
|
521
|
+
}) => {
|
|
522
|
+
const directThumbnail = normalizeThumbnailForPublish(rawThumbnail);
|
|
523
|
+
if (directThumbnail) {
|
|
524
|
+
return directThumbnail;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const uploadedThumbnail = dedupeTextValues(
|
|
528
|
+
uploadedImages.flatMap((image) => [
|
|
529
|
+
normalizeUploadedImageThumbnail(image),
|
|
530
|
+
normalizeImageUrlForThumbnail(image?.uploadedUrl),
|
|
531
|
+
]),
|
|
532
|
+
).find(Boolean);
|
|
533
|
+
if (uploadedThumbnail) {
|
|
534
|
+
return normalizeThumbnailForPublish(uploadedThumbnail);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const contentThumbnail = extractThumbnailFromContent(content);
|
|
538
|
+
if (contentThumbnail) {
|
|
539
|
+
return normalizeThumbnailForPublish(contentThumbnail);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const normalizedKeywords = dedupeTextValues([
|
|
543
|
+
...(Array.isArray(relatedImageKeywords)
|
|
544
|
+
? relatedImageKeywords.map((item) => String(item || '').trim())
|
|
545
|
+
: String(relatedImageKeywords || '')
|
|
546
|
+
.split(',')
|
|
547
|
+
.map((item) => item.trim())),
|
|
548
|
+
String(title || '').trim(),
|
|
549
|
+
'뉴스 이미지',
|
|
550
|
+
'뉴스',
|
|
551
|
+
'thumbnail',
|
|
552
|
+
]);
|
|
553
|
+
|
|
554
|
+
for (const keyword of normalizedKeywords) {
|
|
555
|
+
const candidates = await buildKeywordImageCandidates(keyword);
|
|
556
|
+
const thumbnail = candidates
|
|
557
|
+
.map((candidate) => normalizeImageUrlForThumbnail(candidate))
|
|
558
|
+
.find(Boolean);
|
|
559
|
+
if (thumbnail) {
|
|
560
|
+
return normalizeThumbnailForPublish(thumbnail);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return null;
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
module.exports = {
|
|
568
|
+
extractImagePlaceholders,
|
|
569
|
+
fetchImageBuffer,
|
|
570
|
+
uploadImageFromRemote,
|
|
571
|
+
replaceImagePlaceholdersWithUploaded,
|
|
572
|
+
enrichContentWithUploadedImages,
|
|
573
|
+
resolveMandatoryThumbnail,
|
|
574
|
+
};
|