viruagent 1.0.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,151 @@
1
+ const path = require('path');
2
+ require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') });
3
+ const { createLogger } = require('./logger');
4
+ const log = createLogger('unsplash');
5
+
6
+ const UNSPLASH_ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY;
7
+
8
+ /**
9
+ * Unsplash에서 키워드로 이미지 검색
10
+ * @param {string} keyword - 검색 키워드 (영문)
11
+ * @returns {Promise<Object|null>} { url, alt, credit, link } 또는 null
12
+ */
13
+ const searchImage = async (keyword) => {
14
+ if (!UNSPLASH_ACCESS_KEY) return null;
15
+
16
+ try {
17
+ const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(keyword)}&per_page=1&orientation=landscape`;
18
+ const res = await fetch(url, {
19
+ headers: { Authorization: `Client-ID ${UNSPLASH_ACCESS_KEY}` },
20
+ });
21
+
22
+ if (!res.ok) {
23
+ log.error('Unsplash 검색 실패', { keyword, status: res.status });
24
+ return null;
25
+ }
26
+
27
+ const data = await res.json();
28
+ if (!data.results || data.results.length === 0) {
29
+ log.warn('Unsplash 검색 결과 없음', { keyword });
30
+ return null;
31
+ }
32
+
33
+ const photo = data.results[0];
34
+ return {
35
+ url: photo.urls.regular,
36
+ alt: photo.alt_description || keyword,
37
+ credit: photo.user.name,
38
+ link: photo.user.links.html,
39
+ };
40
+ } catch (e) {
41
+ log.error('Unsplash 검색 예외', { keyword, error: e.message });
42
+ return null;
43
+ }
44
+ };
45
+
46
+ /**
47
+ * 이미지 URL에서 Buffer로 다운로드
48
+ * @param {string} imageUrl
49
+ * @returns {Promise<Buffer|null>}
50
+ */
51
+ const downloadImage = async (imageUrl) => {
52
+ try {
53
+ const res = await fetch(imageUrl);
54
+ if (!res.ok) {
55
+ log.error('이미지 다운로드 실패', { imageUrl, status: res.status });
56
+ return null;
57
+ }
58
+ const buf = Buffer.from(await res.arrayBuffer());
59
+ log.info('이미지 다운로드 완료', { size: buf.length });
60
+ return buf;
61
+ } catch (e) {
62
+ log.error('이미지 다운로드 예외', { imageUrl, error: e.message });
63
+ return null;
64
+ }
65
+ };
66
+
67
+ /**
68
+ * HTML 본문의 <!-- IMAGE: keyword --> 플레이스홀더를 티스토리 업로드 이미지로 치환
69
+ * @param {string} html - HTML 본문
70
+ * @param {Object} [options]
71
+ * @param {Function} [options.uploadFn] - 티스토리 uploadImage 함수 (없으면 외부 img 태그 사용)
72
+ * @returns {Promise<{html: string, thumbnailUrl: string|null, thumbnailKage: string|null}>}
73
+ */
74
+ const replaceImagePlaceholders = async (html, options = {}) => {
75
+ let thumbnailUrl = null;
76
+ let thumbnailKage = null;
77
+
78
+ if (!UNSPLASH_ACCESS_KEY) {
79
+ log.warn('UNSPLASH_ACCESS_KEY 미설정 — 이미지 처리 건너뜀');
80
+ return { html, thumbnailUrl, thumbnailKage };
81
+ }
82
+
83
+ const pattern = /<!-- IMAGE: (.+?) -->/g;
84
+ const matches = [...html.matchAll(pattern)];
85
+ log.info(`이미지 플레이스홀더 ${matches.length}개 발견`);
86
+ if (matches.length === 0) return { html, thumbnailUrl, thumbnailKage };
87
+
88
+ const { uploadFn } = options;
89
+ log.info(`업로드 함수: ${uploadFn ? '있음' : '없음 (폴백 모드)'}`);
90
+
91
+ let result = html;
92
+ for (const match of matches) {
93
+ const keyword = match[1].trim();
94
+ log.info(`이미지 처리 시작`, { keyword });
95
+
96
+ const image = await searchImage(keyword);
97
+ if (!image) {
98
+ log.warn(`이미지 검색 실패 — 플레이스홀더 제거`, { keyword });
99
+ result = result.replace(match[0], '');
100
+ continue;
101
+ }
102
+ log.info(`Unsplash 이미지 찾음`, { keyword, url: image.url.substring(0, 80) });
103
+
104
+ // 티스토리 업로드 시도
105
+ if (uploadFn) {
106
+ try {
107
+ const buffer = await downloadImage(image.url);
108
+ if (!buffer) {
109
+ log.error('이미지 버퍼 null', { keyword });
110
+ } else {
111
+ log.info('티스토리 업로드 시도', { keyword, bufferSize: buffer.length });
112
+ const uploadResult = await uploadFn(buffer, `${keyword.replace(/\s+/g, '_')}.jpg`);
113
+ log.info('업로드 결과', { keyword, uploadResult });
114
+
115
+ if (uploadResult?.url) {
116
+ const dnaMatch = uploadResult.url.match(/\/dna\/(.+)/);
117
+ if (dnaMatch) {
118
+ const kagePath = `kage@${dnaMatch[1]}`;
119
+ const tistoryImg = `<p>[##_Image|${kagePath}|CDM|1.3|{"originWidth":0,"originHeight":0,"style":"alignCenter"}_##]</p>`;
120
+ result = result.replace(match[0], tistoryImg);
121
+ if (!thumbnailUrl) {
122
+ thumbnailUrl = uploadResult.url;
123
+ thumbnailKage = kagePath;
124
+ }
125
+ log.info('티스토리 치환자 삽입 완료', { keyword, kagePath });
126
+ continue;
127
+ }
128
+ // dna 패턴 없으면 img 태그 폴백
129
+ log.warn('dna 패턴 미발견 — img 태그 폴백', { keyword, url: uploadResult.url });
130
+ const tistoryImg = `<p data-ke-size="size16"><img src="${uploadResult.url}" alt="${image.alt}" /></p>`;
131
+ result = result.replace(match[0], tistoryImg);
132
+ if (!thumbnailUrl) thumbnailUrl = uploadResult.url;
133
+ continue;
134
+ }
135
+ }
136
+ } catch (e) {
137
+ log.error('업로드 실패 — 외부 이미지 폴백', { keyword, error: e.message, stack: e.stack });
138
+ }
139
+ }
140
+
141
+ // 폴백: 외부 이미지 태그
142
+ log.info('외부 이미지 태그 사용', { keyword });
143
+ const imgTag = `<p data-ke-size="size16"><img src="${image.url}" alt="${image.alt}" /></p>`;
144
+ result = result.replace(match[0], imgTag);
145
+ }
146
+
147
+ log.info('이미지 처리 완료', { thumbnailUrl: thumbnailUrl?.substring(0, 80), thumbnailKage });
148
+ return { html: result, thumbnailUrl, thumbnailKage };
149
+ };
150
+
151
+ module.exports = { searchImage, downloadImage, replaceImagePlaceholders };