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.
@@ -1,3141 +0,0 @@
1
- const { chromium } = require('playwright');
2
- const { execSync, execFileSync } = require('child_process');
3
- const fs = require('fs');
4
- const os = require('os');
5
- const crypto = require('crypto');
6
- const readline = require('readline');
7
- const path = require('path');
8
- const { saveProviderMeta, clearProviderMeta, getProviderMeta } = require('../storage/sessionStore');
9
- const createTistoryApiClient = require('../services/tistoryApiClient');
10
- const IMAGE_TRACE_ENABLED = process.env.VIRUAGENT_IMAGE_TRACE === '1';
11
-
12
- const imageTrace = (message, data) => {
13
- if (!IMAGE_TRACE_ENABLED) {
14
- return;
15
- }
16
- if (data === undefined) {
17
- console.log(`[이미지 추적] ${message}`);
18
- return;
19
- }
20
- console.log(`[이미지 추적] ${message}`, data);
21
- };
22
-
23
- const LOGIN_OTP_SELECTORS = [
24
- 'input[name*="otp"]',
25
- 'input[placeholder*="인증"]',
26
- 'input[autocomplete="one-time-code"]',
27
- 'input[name*="code"]',
28
- ];
29
-
30
- const KAKAO_TRIGGER_SELECTORS = [
31
- 'a.link_kakao_id',
32
- 'a:has-text("카카오계정으로 로그인")',
33
- ];
34
-
35
- const KAKAO_LOGIN_SELECTORS = {
36
- username: ['input[name="loginId"]', '#loginId--1', 'input[placeholder*="카카오메일"]'],
37
- password: ['input[name="password"]', '#password--2', 'input[type="password"]'],
38
- submit: ['button[type="submit"]', 'button:has-text("로그인")', '.btn_g.highlight.submit'],
39
- rememberLogin: ['#saveSignedIn--4', 'input[name="saveSignedIn"]'],
40
- };
41
-
42
- const KAKAO_2FA_SELECTORS = {
43
- start: ['#tmsTwoStepVerification', '#emailTwoStepVerification'],
44
- emailModeButton: ['button:has-text("이메일로 인증하기")', '.link_certify'],
45
- codeInput: ['input[name="email_passcode"]', '#passcode--6', 'input[placeholder*="인증번호"]'],
46
- confirm: ['button:has-text("확인")', 'button.btn_g.submit', 'button[type="submit"]'],
47
- rememberDevice: ['#isRememberBrowser--5', 'input[name="isRememberBrowser"]'],
48
- };
49
-
50
- const KAKAO_ACCOUNT_CONFIRM_SELECTORS = {
51
- textMarker: [
52
- 'text=해당 카카오 계정으로',
53
- 'text=티스토리\n해당 카카오 계정으로',
54
- 'text=해당 카카오계정으로 로그인',
55
- ],
56
- continue: [
57
- 'button:has-text("계속하기")',
58
- 'a:has-text("계속하기")',
59
- 'button:has-text("다음")',
60
- ],
61
- otherAccount: [
62
- 'button:has-text("다른 카카오계정으로 로그인")',
63
- 'a:has-text("다른 카카오계정으로 로그인")',
64
- ],
65
- };
66
-
67
- const MAX_IMAGE_UPLOAD_COUNT = 1;
68
- const readCredentialsFromEnv = () => {
69
- const username = process.env.TISTORY_USERNAME || process.env.TISTORY_USER || process.env.TISTORY_ID;
70
- const password = process.env.TISTORY_PASSWORD || process.env.TISTORY_PW;
71
- return {
72
- username: typeof username === 'string' && username.trim() ? username.trim() : null,
73
- password: typeof password === 'string' && password.trim() ? password.trim() : null,
74
- };
75
- };
76
-
77
- const mapVisibility = (visibility) => {
78
- const normalized = String(visibility || 'public').toLowerCase();
79
- if (Number.isFinite(Number(visibility)) && [0, 15, 20].includes(Number(visibility))) {
80
- return Number(visibility);
81
- }
82
- if (normalized === 'private') return 0;
83
- if (normalized === 'protected') return 15;
84
- return 20;
85
- };
86
-
87
- const normalizeTagList = (value = '') => {
88
- const source = Array.isArray(value)
89
- ? value
90
- : String(value || '').replace(/\r?\n/g, ',').split(',');
91
-
92
- return source
93
- .map((tag) => String(tag || '').trim())
94
- .filter(Boolean)
95
- .map((tag) => tag.replace(/["']/g, '').trim())
96
- .filter(Boolean)
97
- .slice(0, 10)
98
- .join(',');
99
- };
100
-
101
- const parseSessionError = (error) => {
102
- const message = String(error?.message || '').toLowerCase();
103
- return [
104
- '세션이 만료',
105
- '세션에 유효한 쿠키',
106
- '세션 파일이 없습니다',
107
- '블로그 정보 조회 실패: 401',
108
- '블로그 정보 조회 실패: 403',
109
- '세션이 만료되었습니다',
110
- '다시 로그인',
111
- ].some((token) => message.includes(token.toLowerCase()));
112
- };
113
-
114
- const buildLoginErrorMessage = (error) => String(error?.message || '세션 검증에 실패했습니다.');
115
-
116
- const promptCategorySelection = async (categories = []) => {
117
- if (!process.stdin || !process.stdin.isTTY) {
118
- return null;
119
- }
120
- if (!Array.isArray(categories) || categories.length === 0) {
121
- return null;
122
- }
123
-
124
- const candidates = categories.map((category, index) => `${index + 1}. ${category.name} (${category.id})`);
125
- const lines = [
126
- '발행할 카테고리를 선택해 주세요.',
127
- ...candidates,
128
- `입력: 번호(1-${categories.length}) 또는 카테고리 ID (엔터 입력 시 건너뛰기)`,
129
- ];
130
- const prompt = `${lines.join('\n')}\n> `;
131
-
132
- const parseSelection = (input) => {
133
- const normalized = String(input || '').trim();
134
- if (!normalized) {
135
- return null;
136
- }
137
-
138
- const numeric = Number(normalized);
139
- if (Number.isInteger(numeric) && numeric > 0) {
140
- if (numeric <= categories.length) {
141
- return Number(categories[numeric - 1].id);
142
- }
143
- const matchedById = categories.find((item) => Number(item.id) === numeric);
144
- if (matchedById) {
145
- return Number(matchedById.id);
146
- }
147
- }
148
-
149
- return null;
150
- };
151
-
152
- return new Promise((resolve) => {
153
- const rl = readline.createInterface({
154
- input: process.stdin,
155
- output: process.stdout,
156
- });
157
-
158
- const ask = (retryCount = 0) => {
159
- rl.question(prompt, (input) => {
160
- const selectedId = parseSelection(input);
161
- if (selectedId) {
162
- rl.close();
163
- resolve(selectedId);
164
- return;
165
- }
166
-
167
- if (retryCount >= 2) {
168
- rl.close();
169
- resolve(null);
170
- return;
171
- }
172
-
173
- console.log('잘못된 입력입니다. 번호 또는 카테고리 ID를 다시 입력해 주세요.');
174
- ask(retryCount + 1);
175
- });
176
- };
177
-
178
- ask(0);
179
- });
180
- };
181
-
182
- const isPublishLimitError = (error) => {
183
- const message = String(error?.message || '');
184
- return /발행 실패:\s*403/.test(message) || /\b403\b/.test(message);
185
- };
186
-
187
- const isProvidedCategory = (value) => {
188
- return value !== undefined && value !== null && String(value).trim() !== '';
189
- };
190
-
191
- const buildCategoryList = (rawCategories) => {
192
- const entries = Object.entries(rawCategories || {});
193
- const categories = entries.map(([name, id]) => ({
194
- name,
195
- id: Number(id),
196
- }));
197
- return categories.sort((a, b) => a.id - b.id);
198
- };
199
-
200
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
201
-
202
- const pickValue = async (page, selectors) => {
203
- for (const selector of selectors) {
204
- const element = await page.$(selector);
205
- if (element) {
206
- return selector;
207
- }
208
- }
209
- return null;
210
- };
211
-
212
- const fillBySelector = async (page, selectors, value) => {
213
- const selector = await pickValue(page, selectors);
214
- if (!selector) {
215
- return false;
216
- }
217
- await page.locator(selector).fill(value);
218
- return true;
219
- };
220
-
221
- const clickSubmit = async (page, selectors) => {
222
- const selector = await pickValue(page, selectors);
223
- if (!selector) {
224
- return false;
225
- }
226
- await page.locator(selector).click({ timeout: 5000 });
227
- return true;
228
- };
229
-
230
- const checkBySelector = async (page, selectors) => {
231
- const selector = await pickValue(page, selectors);
232
- if (!selector) {
233
- return false;
234
- }
235
- const locator = page.locator(selector);
236
- const isChecked = await locator.isChecked().catch(() => false);
237
- if (!isChecked) {
238
- await locator.check({ force: true }).catch(() => {});
239
- }
240
- return true;
241
- };
242
-
243
- const hasElement = async (page, selectors) => {
244
- for (const selector of selectors) {
245
- const locator = page.locator(selector);
246
- const count = await locator.count();
247
- if (count > 0) {
248
- return true;
249
- }
250
- }
251
- return false;
252
- };
253
-
254
- const hasKakaoAccountConfirmScreen = async (page) => {
255
- const url = page.url();
256
- const isKakaoDomain = url.includes('accounts.kakao.com') || url.includes('kauth.kakao.com');
257
- if (!isKakaoDomain) {
258
- return false;
259
- }
260
-
261
- return await hasElement(page, KAKAO_ACCOUNT_CONFIRM_SELECTORS.textMarker);
262
- };
263
-
264
- const clickKakaoAccountContinue = async (page) => {
265
- if (!(await hasKakaoAccountConfirmScreen(page))) {
266
- return false;
267
- }
268
-
269
- const continueSelector = await pickValue(page, KAKAO_ACCOUNT_CONFIRM_SELECTORS.continue);
270
- if (!continueSelector) {
271
- return false;
272
- }
273
-
274
- await page.locator(continueSelector).click({ timeout: 5000 });
275
- await page.waitForLoadState('domcontentloaded').catch(() => {});
276
- await page.waitForTimeout(800);
277
- return true;
278
- };
279
-
280
- const IMAGE_PLACEHOLDER_REGEX = /<!--\s*IMAGE:\s*([^>]*?)\s*-->/g;
281
-
282
- const escapeRegExp = (value = '') => {
283
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
284
- };
285
-
286
- const sanitizeKeywordForFilename = (value = '') => {
287
- return String(value)
288
- .trim()
289
- .toLowerCase()
290
- .replace(/[^a-z0-9]+/gi, '-')
291
- .replace(/^-+|-+$/g, '')
292
- .replace(/-{2,}/g, '-')
293
- .slice(0, 50) || 'image';
294
- };
295
-
296
- const normalizeTempDir = () => {
297
- const tmpDir = path.join(os.tmpdir(), 'viruagent-cli-images');
298
- fs.mkdirSync(tmpDir, { recursive: true });
299
- return tmpDir;
300
- };
301
-
302
- const buildImageFileName = (keyword, ext = 'jpg') => {
303
- const base = sanitizeKeywordForFilename(keyword || 'image');
304
- const random = crypto.randomBytes(4).toString('hex');
305
- return `${base}-${random}.${ext}`;
306
- };
307
-
308
- const buildTistoryImageTag = (uploadedImage, keyword) => {
309
- const alt = String(keyword || '').replace(/"/g, '&quot;');
310
- const normalizedKage = normalizeUploadedImageThumbnail(uploadedImage);
311
- if (normalizedKage) {
312
- return `<p data-ke-size="size16">[##_Image|${normalizedKage}|CDM|1.3|{"originWidth":0,"originHeight":0,"style":"alignCenter"}_##]</p>`;
313
- }
314
- if (uploadedImage?.uploadedKage) {
315
- return `<p data-ke-size="size16">[##_Image|${uploadedImage.uploadedKage}|CDM|1.3|{"originWidth":0,"originHeight":0,"style":"alignCenter"}_##]</p>`;
316
- }
317
- if (uploadedImage?.uploadedUrl) {
318
- return `<p data-ke-size="size16"><img src="${uploadedImage.uploadedUrl}" alt="${alt}" /></p>`;
319
- }
320
-
321
- return `<p data-ke-size="size16"><img src="${uploadedImage.uploadedUrl}" alt="${alt}" /></p>`;
322
- };
323
-
324
- const normalizeKageFromUrl = (value) => {
325
- const trimmed = String(value || '').trim();
326
- if (!trimmed) return null;
327
-
328
- if (trimmed.startsWith('kage@')) {
329
- return trimmed.replace(/["'`> )\]]+$/u, '');
330
- }
331
-
332
- try {
333
- const parsed = new URL(trimmed);
334
- const path = parsed.pathname || '';
335
- const dnaIndex = path.indexOf('/dna/');
336
- if (dnaIndex >= 0) {
337
- const keyPath = path.slice(dnaIndex + '/dna/'.length).replace(/^\/+/, '');
338
- if (keyPath) {
339
- return `kage@${keyPath}`;
340
- }
341
- }
342
- } catch {
343
- // URL 파싱이 실패하면 기존 정규식 경로로 폴백
344
- }
345
-
346
- const directKageMatch = trimmed.match(/kage@([^|\s\]>"']+)/u);
347
- if (directKageMatch?.[1]) {
348
- return `kage@${directKageMatch[1]}`;
349
- }
350
-
351
- const dnaMatch = trimmed.match(/\/dna\/([^?#\s]+)/u);
352
- if (dnaMatch?.[1]) {
353
- return `kage@${dnaMatch[1].replace(/["'`> )\]]+$/u, '')}`;
354
- }
355
-
356
- if (/^[A-Za-z0-9_-]{10,}$/u.test(trimmed)) {
357
- return `kage@${trimmed}`;
358
- }
359
-
360
- const rawPathMatch = trimmed.match(/([^/?#\s]+\.[A-Za-z0-9]+)$/u);
361
- if (rawPathMatch?.[0] && !trimmed.includes('://') && trimmed.includes('/')) {
362
- return `kage@${trimmed}`;
363
- }
364
-
365
- if (!trimmed.includes('://') && !trimmed.includes(' ')) {
366
- if (trimmed.startsWith('kage@') || trimmed.includes('/')) {
367
- return `kage@${trimmed}`;
368
- }
369
- }
370
-
371
- return null;
372
- };
373
-
374
- const normalizeThumbnailForPublish = (value) => {
375
- const normalized = normalizeKageFromUrl(value);
376
- if (!normalized) {
377
- return normalizeImageUrlForThumbnail(value);
378
- }
379
-
380
- const body = normalized.replace(/^kage@/i, '').split(/[?#]/)[0];
381
- const pathPart = body?.trim();
382
- if (!pathPart) return null;
383
- const hasImageFile = /\/[^/]+\.[A-Za-z0-9]+$/u.test(pathPart);
384
- if (hasImageFile) {
385
- return `kage@${pathPart}`;
386
- }
387
- const suffix = pathPart.endsWith('/') ? 'img.jpg' : '/img.jpg';
388
- return `kage@${pathPart}${suffix}`;
389
- };
390
-
391
- const normalizeImageUrlForThumbnail = (value) => {
392
- const trimmed = String(value || '').trim();
393
- if (!trimmed) return null;
394
- if (!/^https?:\/\//i.test(trimmed)) {
395
- return null;
396
- }
397
- if (trimmed.includes('data:image')) {
398
- return null;
399
- }
400
- if (trimmed.includes(' ') || trimmed.length < 10) {
401
- return null;
402
- }
403
- const imageExtensionMatch = trimmed.match(/\.(?:jpg|jpeg|png|gif|webp|bmp|avif|svg)(?:$|\?|#)/i);
404
- return imageExtensionMatch ? trimmed : null;
405
- };
406
-
407
- const extractKageFromCandidate = (value) => {
408
- const normalized = normalizeThumbnailForPublish(value);
409
- if (normalized) {
410
- return normalized;
411
- }
412
-
413
- if (typeof value !== 'string') {
414
- return null;
415
- }
416
-
417
- const trimmed = value.trim();
418
- if (!trimmed) return null;
419
-
420
- const imageTagMatch = trimmed.match(/\[##_Image\|([^|]+)\|/);
421
- if (imageTagMatch?.[1]) {
422
- return normalizeKageFromUrl(imageTagMatch[1]);
423
- }
424
-
425
- if (!trimmed.includes('://') && trimmed.includes('|')) {
426
- const match = trimmed.match(/kage@[^\s|]+/);
427
- if (match?.[0]) {
428
- return match[0];
429
- }
430
- }
431
-
432
- return null;
433
- };
434
-
435
- const normalizeUploadedImageThumbnail = (uploadedImage) => {
436
- const candidates = [
437
- uploadedImage?.uploadedKage,
438
- uploadedImage?.raw?.kage,
439
- uploadedImage?.raw?.uploadedKage,
440
- uploadedImage?.uploadedKey,
441
- uploadedImage?.raw?.key,
442
- uploadedImage?.raw?.attachmentKey,
443
- uploadedImage?.raw?.imageKey,
444
- uploadedImage?.raw?.id,
445
- uploadedImage?.raw?.url,
446
- uploadedImage?.raw?.attachmentUrl,
447
- uploadedImage?.raw?.thumbnail,
448
- uploadedImage?.url,
449
- uploadedImage?.uploadedUrl,
450
- ];
451
-
452
- for (const candidate of candidates) {
453
- const normalized = extractKageFromCandidate(candidate);
454
- if (normalized) {
455
- const final = normalizeThumbnailForPublish(normalized);
456
- if (final) {
457
- return final;
458
- }
459
- }
460
- }
461
-
462
- return null;
463
- };
464
-
465
- const dedupeTextValues = (values = []) => {
466
- const seen = new Set();
467
- return values
468
- .filter(Boolean)
469
- .map((value) => String(value || '').trim())
470
- .filter(Boolean)
471
- .filter((value) => {
472
- if (seen.has(value)) {
473
- return false;
474
- }
475
- seen.add(value);
476
- return true;
477
- });
478
- };
479
-
480
- const dedupeImageSources = (sources = []) => {
481
- const seen = new Set();
482
- return sources
483
- .filter(Boolean)
484
- .map((source) => String(source || '').trim())
485
- .filter(Boolean)
486
- .filter((source) => {
487
- if (seen.has(source)) {
488
- return false;
489
- }
490
- seen.add(source);
491
- return true;
492
- });
493
- };
494
-
495
- const buildFallbackImageSources = async (keyword = '') => {
496
- const trimmedKeyword = String(keyword || '').trim();
497
- if (!trimmedKeyword) {
498
- return [];
499
- }
500
- if (trimmedKeyword.startsWith('image-')) {
501
- return [];
502
- }
503
- return buildKeywordImageCandidates(trimmedKeyword);
504
- };
505
-
506
- const sanitizeImageQueryForProvider = (value = '') => {
507
- return String(value || '')
508
- .trim()
509
- .replace(/[^a-z0-9가-힣\s]/gi, ' ')
510
- .replace(/\s+/g, ' ')
511
- .trim();
512
- };
513
-
514
- const buildLoremFlickrImageCandidates = (keyword = '') => {
515
- const safeKeyword = sanitizeImageQueryForProvider(keyword);
516
- if (!safeKeyword) {
517
- return [];
518
- }
519
- const encoded = encodeURIComponent(safeKeyword.replace(/\s+/g, ','));
520
- return [
521
- `https://loremflickr.com/1200/800/${encoded}`,
522
- `https://loremflickr.com/g/1200/800/${encoded}`,
523
- ];
524
- };
525
-
526
- const buildPicsumImageCandidates = (keyword = '') => {
527
- const safeKeyword = sanitizeImageQueryForProvider(keyword);
528
- const hash = safeKeyword
529
- ? crypto.createHash('md5').update(safeKeyword).digest('hex').slice(0, 10)
530
- : 'default';
531
- return [
532
- `https://picsum.photos/seed/${hash}/1200/800`,
533
- `https://picsum.photos/1200/800`,
534
- ];
535
- };
536
-
537
- const buildPlaceholderImageCandidates = () => {
538
- return [
539
- 'https://placehold.co/1200x800.png',
540
- 'https://via.placeholder.com/1200x800.jpg',
541
- 'https://dummyimage.com/1200x800/000/fff.png&text=thumbnail',
542
- ];
543
- };
544
-
545
- const buildWikimediaImageCandidates = async (keyword = '') => {
546
- const safeKeyword = sanitizeImageQueryForProvider(keyword);
547
- if (!safeKeyword) {
548
- return [];
549
- }
550
- try {
551
- const query = encodeURIComponent(`${safeKeyword} file`);
552
- 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=*`;
553
- imageTrace('wikimedia.request', { keyword: safeKeyword, apiUrl });
554
- const raw = await fetchText(apiUrl);
555
- const parsed = JSON.parse(raw || '{}');
556
- const pages = parsed?.query?.pages || {};
557
- const candidates = [];
558
- for (const page of Object.values(pages)) {
559
- const imageInfo = Array.isArray(page?.imageinfo) ? page.imageinfo : [];
560
- if (imageInfo.length === 0) {
561
- continue;
562
- }
563
- const first = imageInfo[0];
564
- if (first?.thumburl) {
565
- candidates.push(first.thumburl);
566
- } else if (first?.url) {
567
- candidates.push(first.url);
568
- }
569
- }
570
- imageTrace('wikimedia.response', { keyword: safeKeyword, count: candidates.length });
571
- return candidates;
572
- } catch {
573
- imageTrace('wikimedia.error', { keyword: safeKeyword });
574
- return [];
575
- }
576
- };
577
-
578
- const extractThumbnailFromContent = (content = '') => {
579
- const match = String(content).match(/\[##_Image\|([^|]+)\|/);
580
- if (!match?.[1]) {
581
- const imgMatch = String(content).match(/<img[^>]+src=["']([^"']+)["'][^>]*>/i);
582
- if (!imgMatch?.[1]) {
583
- return null;
584
- }
585
- return normalizeImageUrlForThumbnail(imgMatch[1]);
586
- }
587
- return extractKageFromCandidate(match[1]);
588
- };
589
-
590
- const resolveMandatoryThumbnail = async ({
591
- rawThumbnail,
592
- content,
593
- uploadedImages = [],
594
- relatedImageKeywords = [],
595
- title = '',
596
- }) => {
597
- const directThumbnail = normalizeThumbnailForPublish(rawThumbnail);
598
- if (directThumbnail) {
599
- return directThumbnail;
600
- }
601
-
602
- const uploadedThumbnail = dedupeTextValues(
603
- uploadedImages.flatMap((image) => [
604
- normalizeUploadedImageThumbnail(image),
605
- normalizeImageUrlForThumbnail(image?.uploadedUrl),
606
- ]),
607
- ).find(Boolean);
608
- if (uploadedThumbnail) {
609
- return normalizeThumbnailForPublish(uploadedThumbnail);
610
- }
611
-
612
- const contentThumbnail = extractThumbnailFromContent(content);
613
- if (contentThumbnail) {
614
- return normalizeThumbnailForPublish(contentThumbnail);
615
- }
616
-
617
- const normalizedKeywords = dedupeTextValues([
618
- ...(Array.isArray(relatedImageKeywords)
619
- ? relatedImageKeywords.map((item) => String(item || '').trim())
620
- : String(relatedImageKeywords || '')
621
- .split(',')
622
- .map((item) => item.trim())),
623
- String(title || '').trim(),
624
- '뉴스 이미지',
625
- '뉴스',
626
- 'thumbnail',
627
- ]);
628
-
629
- for (const keyword of normalizedKeywords) {
630
- const candidates = await buildKeywordImageCandidates(keyword);
631
- const thumbnail = candidates
632
- .map((candidate) => normalizeImageUrlForThumbnail(candidate))
633
- .find(Boolean);
634
- if (thumbnail) {
635
- return normalizeThumbnailForPublish(thumbnail);
636
- }
637
- }
638
-
639
- return null;
640
- };
641
-
642
- const guessExtensionFromContentType = (contentType = '') => {
643
- const normalized = contentType.toLowerCase();
644
- if (normalized.includes('png')) return 'png';
645
- if (normalized.includes('webp')) return 'webp';
646
- if (normalized.includes('gif')) return 'gif';
647
- if (normalized.includes('bmp')) return 'bmp';
648
- if (normalized.includes('jpeg') || normalized.includes('jpg')) return 'jpg';
649
- return 'jpg';
650
- };
651
-
652
- const isImageContentType = (contentType = '') => {
653
- const normalized = contentType.toLowerCase();
654
- return normalized.startsWith('image/') || normalized.includes('application/octet-stream') || normalized.includes('binary/octet-stream');
655
- };
656
-
657
- const guessExtensionFromUrl = (rawUrl) => {
658
- try {
659
- const parsed = new URL(rawUrl);
660
- const match = parsed.pathname.match(/\.([a-zA-Z0-9]+)(?:$|\?)/);
661
- if (!match) return null;
662
- const ext = match[1].toLowerCase();
663
- if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif', 'ico'].includes(ext)) {
664
- return ext === 'jpeg' ? 'jpg' : ext;
665
- }
666
- return null;
667
- } catch {
668
- return null;
669
- }
670
- };
671
-
672
- const getImageSignatureExtension = (buffer) => {
673
- if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
674
- const magic4 = buffer.slice(0, 4).toString('hex');
675
- const magic2 = buffer.slice(0, 2).toString('hex');
676
- const magic8 = buffer.slice(8, 12).toString('hex');
677
- if (magic2 === 'ffd8') return 'jpg';
678
- if (magic4 === '89504e47') return 'png';
679
- if (magic4 === '47494638') return 'gif';
680
- if (magic4 === '52494646' && magic8 === '57454250') return 'webp';
681
- if (magic2 === '424d') return 'bmp';
682
- return null;
683
- };
684
-
685
- const resolveLocalImagePath = (value) => {
686
- if (typeof value !== 'string') {
687
- return null;
688
- }
689
-
690
- const trimmed = value.trim();
691
- if (!trimmed) return null;
692
-
693
- if (trimmed.startsWith('file://')) {
694
- try {
695
- const filePath = decodeURIComponent(new URL(trimmed).pathname);
696
- if (fs.existsSync(filePath)) {
697
- const stat = fs.statSync(filePath);
698
- if (stat.isFile()) return filePath;
699
- }
700
- } catch {}
701
- return null;
702
- }
703
-
704
- const expanded = trimmed.startsWith('~')
705
- ? path.join(os.homedir(), trimmed.slice(1))
706
- : trimmed;
707
- const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(process.cwd(), expanded);
708
- if (!fs.existsSync(candidate)) return null;
709
-
710
- try {
711
- const stat = fs.statSync(candidate);
712
- return stat.isFile() ? candidate : null;
713
- } catch {
714
- return null;
715
- }
716
- };
717
-
718
- const normalizeImageInput = (value) => {
719
- if (typeof value !== 'string') {
720
- return null;
721
- }
722
- const text = value.trim();
723
- if (!text) {
724
- return null;
725
- }
726
- const localPath = resolveLocalImagePath(text);
727
- if (localPath) {
728
- return localPath;
729
- }
730
-
731
- try {
732
- const parsed = new URL(text);
733
- if (!['http:', 'https:'].includes(parsed.protocol)) {
734
- return null;
735
- }
736
- return parsed.toString();
737
- } catch (error) {
738
- return null;
739
- }
740
- };
741
-
742
- const normalizeImageInputs = (inputs) => {
743
- if (typeof inputs === 'string') {
744
- return inputs.split(',').map((item) => normalizeImageInput(item)).filter(Boolean);
745
- }
746
-
747
- if (!Array.isArray(inputs)) {
748
- return [];
749
- }
750
-
751
- return inputs.map(normalizeImageInput).filter(Boolean);
752
- };
753
-
754
- const fetchText = async (url, retryCount = 0) => {
755
- if (!url) {
756
- throw new Error('텍스트 URL이 없습니다.');
757
- }
758
-
759
- const headers = {
760
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
761
- 'Accept': 'text/html,application/xhtml+xml',
762
- };
763
- const controller = new AbortController();
764
- const timeout = setTimeout(() => controller.abort(), 20000);
765
-
766
- try {
767
- imageTrace('fetchText', { url, retryCount });
768
- const response = await fetch(url, {
769
- method: 'GET',
770
- redirect: 'follow',
771
- headers,
772
- signal: controller.signal,
773
- });
774
-
775
- if (!response.ok) {
776
- throw new Error(`텍스트 요청 실패: ${response.status} ${response.statusText}, url=${url}`);
777
- }
778
-
779
- return response.text();
780
- } catch (error) {
781
- if (retryCount < 1) {
782
- await sleep(700);
783
- return fetchText(url, retryCount + 1);
784
- }
785
- throw new Error(`웹 텍스트 다운로드 실패: ${error.message}`);
786
- } finally {
787
- clearTimeout(timeout);
788
- }
789
- };
790
-
791
- const fetchTextWithHeaders = async (url, headers = {}, retryCount = 0) => {
792
- const merged = {
793
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
794
- ...headers,
795
- };
796
- const controller = new AbortController();
797
- const timeout = setTimeout(() => controller.abort(), 20000);
798
- try {
799
- const response = await fetch(url, {
800
- method: 'GET',
801
- redirect: 'follow',
802
- headers: merged,
803
- signal: controller.signal,
804
- });
805
- if (!response.ok) {
806
- throw new Error(`텍스트 요청 실패: ${response.status} ${response.statusText}, url=${url}`);
807
- }
808
- return response.text();
809
- } catch (error) {
810
- if (retryCount < 1) {
811
- await sleep(700);
812
- return fetchTextWithHeaders(url, headers, retryCount + 1);
813
- }
814
- throw new Error(`웹 텍스트 다운로드 실패: ${error.message}`);
815
- } finally {
816
- clearTimeout(timeout);
817
- }
818
- };
819
-
820
- const normalizeAbsoluteUrl = (value = '', base = '') => {
821
- const trimmed = String(value || '').trim();
822
- if (!trimmed) return null;
823
- try {
824
- const url = base ? new URL(trimmed, base) : new URL(trimmed);
825
- if (!['http:', 'https:'].includes(url.protocol)) {
826
- return null;
827
- }
828
- return url.toString();
829
- } catch {
830
- return null;
831
- }
832
- };
833
-
834
- const extractArticleUrlsFromContent = (content = '') => {
835
- const matches = Array.from(String(content).matchAll(/<a\s+[^>]*href=(['"])(.*?)\1/gi));
836
- const urls = matches
837
- .map((match) => match[2])
838
- .filter((href) => /^https?:\/\//i.test(href))
839
- .map((href) => href.trim())
840
- .filter(Boolean);
841
- return Array.from(new Set(urls));
842
- };
843
-
844
- const extractDuckDuckGoRedirectTarget = (value = '') => {
845
- const urlText = String(value || '').trim();
846
- if (!urlText) return null;
847
-
848
- try {
849
- const parsed = new URL(urlText);
850
- if (parsed.hostname.includes('duckduckgo.com') && parsed.pathname === '/l/') {
851
- const encoded = parsed.searchParams.get('uddg');
852
- if (encoded) {
853
- try {
854
- return decodeURIComponent(encoded);
855
- } catch {
856
- return encoded;
857
- }
858
- }
859
- }
860
-
861
- if (parsed.hostname === 'duckduckgo.com' && parsed.pathname === '/y.js') {
862
- const articleLike = parsed.searchParams.get('u3') || parsed.searchParams.get('url');
863
- if (articleLike) {
864
- try {
865
- return decodeURIComponent(articleLike);
866
- } catch {
867
- return articleLike;
868
- }
869
- }
870
- }
871
- } catch {
872
- return null;
873
- }
874
-
875
- return null;
876
- };
877
-
878
- const extractImageFromHtml = (html = '', base = '') => {
879
- const normalizedHtml = String(html || '');
880
- const metaCandidates = [
881
- /<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["'][^>]*>/i,
882
- /<meta[^>]*name=["']twitter:image["'][^>]*content=["']([^"']+)["'][^>]*>/i,
883
- /<meta[^>]*name=["']og:image["'][^>]*content=["']([^"']+)["'][^>]*>/i,
884
- /<meta[^>]*itemprop=["']image["'][^>]*content=["']([^"']+)["'][^>]*>/i,
885
- /<link[^>]*rel=["']image_src["'][^>]*href=["']([^"']+)["'][^>]*>/i,
886
- ];
887
-
888
- for (const pattern of metaCandidates) {
889
- const match = normalizedHtml.match(pattern);
890
- if (match?.[1]) {
891
- const url = normalizeAbsoluteUrl(match[1], base);
892
- if (url && !/favicon/i.test(url) && !/logo/i.test(url)) {
893
- return url;
894
- }
895
- }
896
- }
897
-
898
- const imageMatch = normalizedHtml.match(/<img[^>]+src=["']([^"']+)["'][^>]*>/i);
899
- if (imageMatch?.[1]) {
900
- const src = normalizeAbsoluteUrl(imageMatch[1], base);
901
- if (src && !/logo|favicon|avatar|pixel|spacer/i.test(src)) {
902
- return src;
903
- }
904
- }
905
- return null;
906
- };
907
-
908
- const resolveArticleImageByUrl = async (articleUrl) => {
909
- try {
910
- const html = await fetchText(articleUrl);
911
- const imageUrl = extractImageFromHtml(html, articleUrl);
912
- if (imageUrl) {
913
- return imageUrl;
914
- }
915
- } catch {
916
- // fallback below
917
- }
918
-
919
- try {
920
- const normalizedArticleUrl = String(articleUrl).trim();
921
- if (!normalizedArticleUrl) return null;
922
- const normalizedForJina = normalizedArticleUrl.startsWith('https://')
923
- ? normalizedArticleUrl.slice(8)
924
- : normalizedArticleUrl.startsWith('http://')
925
- ? normalizedArticleUrl.slice(7)
926
- : normalizedArticleUrl;
927
- const jinaUrl = `https://r.jina.ai/http://${normalizedForJina}`;
928
- const jinaHtml = await fetchText(jinaUrl);
929
- return extractImageFromHtml(jinaHtml, articleUrl);
930
- } catch {
931
- return null;
932
- }
933
- };
934
-
935
- const extractSearchUrlsFromText = (markdown = '') => {
936
- const matched = [];
937
- const pattern = /https?:\/\/duckduckgo\.com\/l\/\?uddg=([^)\s"']+)(?:&[^)\s"']*)?/g;
938
- let m = pattern.exec(markdown);
939
- while (m) {
940
- const decoded = extractDuckDuckGoRedirectTarget(`https://duckduckgo.com/l/?uddg=${m[1]}`);
941
- if (decoded && /^https?:\/\/.+/i.test(decoded)) {
942
- matched.push(decoded);
943
- }
944
- m = pattern.exec(markdown);
945
- }
946
-
947
- if (matched.length === 0) {
948
- const directLinks = String(markdown).match(/https?:\/\/(?:www\.)?[^\\s\)\]\[]+/g) || [];
949
- directLinks.forEach((link) => {
950
- if (link.length > 12) {
951
- matched.push(link);
952
- }
953
- });
954
- }
955
-
956
- return Array.from(new Set(matched));
957
- };
958
-
959
- const extractDuckDuckGoVqd = (html = '') => {
960
- const raw = String(html || '');
961
- const patterns = [
962
- /vqd='([^']+)'/i,
963
- /vqd="([^"]+)"/i,
964
- /["']vqd["']\s*:\s*["']([^"']+)["']/i,
965
- /vqd=([^&"'\\s>]+)/i,
966
- ];
967
-
968
- for (const pattern of patterns) {
969
- const matched = raw.match(pattern);
970
- if (matched?.[1] && matched[1].trim()) {
971
- return matched[1].trim();
972
- }
973
- }
974
-
975
- return null;
976
- };
977
-
978
- const fetchDuckDuckGoImageResults = async (query = '') => {
979
- try {
980
- const safeKeyword = String(query || '').trim();
981
- if (!safeKeyword) return [];
982
- const searchUrl = `https://duckduckgo.com/?ia=images&origin=funnel_home_google&t=h_&q=${encodeURIComponent(safeKeyword)}&chip-select=search&iax=images`;
983
- imageTrace('duckduckgo.searchPage', { query: safeKeyword, searchUrl });
984
- const searchText = await fetchTextWithHeaders(searchUrl, {
985
- Accept: 'text/html,application/xhtml+xml',
986
- Referer: 'https://duckduckgo.com/',
987
- });
988
- const vqd = extractDuckDuckGoVqd(searchText);
989
- if (!vqd) return [];
990
-
991
- const apiCandidates = [
992
- `https://duckduckgo.com/i.js?l=wt-wt&o=json&q=${encodeURIComponent(safeKeyword)}&vqd=${encodeURIComponent(vqd)}&ia=images&iax=images`,
993
- `https://duckduckgo.com/i.js?l=wt-wt&o=json&q=${encodeURIComponent(safeKeyword)}&ia=images&iax=images&vqd=${encodeURIComponent(vqd)}&s=0`,
994
- `https://duckduckgo.com/i.js?o=json&q=${encodeURIComponent(safeKeyword)}&ia=images&iax=images&vqd=${encodeURIComponent(vqd)}&p=1`,
995
- `https://duckduckgo.com/i.js?l=en-gb&o=json&q=${encodeURIComponent(safeKeyword)}&ia=images&iax=images&vqd=${encodeURIComponent(vqd)}`,
996
- ];
997
-
998
- const jsonHeaders = {
999
- Accept: 'application/json, text/javascript, */*; q=0.01',
1000
- 'Referer': `https://duckduckgo.com/?q=${encodeURIComponent(safeKeyword)}&ia=images&iax=images`,
1001
- 'Origin': 'https://duckduckgo.com',
1002
- };
1003
-
1004
- let parsed = null;
1005
- let apiUrl = null;
1006
- for (const candidate of apiCandidates) {
1007
- try {
1008
- imageTrace('duckduckgo.apiUrl', { query: safeKeyword, apiUrl: candidate });
1009
- const apiText = await fetchTextWithHeaders(candidate, jsonHeaders);
1010
- const safeText = String(apiText || '').trim();
1011
- if (!safeText) {
1012
- continue;
1013
- }
1014
- if (!safeText.startsWith('{') && !safeText.startsWith('[')) {
1015
- imageTrace('duckduckgo.apiParseSkipped', { query: safeKeyword, apiUrl: candidate, reason: 'nonJsonStart' });
1016
- continue;
1017
- }
1018
- parsed = JSON.parse(safeText);
1019
- if (Array.isArray(parsed.results) && parsed.results.length > 0) {
1020
- apiUrl = candidate;
1021
- break;
1022
- }
1023
- } catch {
1024
- imageTrace('duckduckgo.apiParseError', { query: safeKeyword, apiUrl: candidate });
1025
- }
1026
- }
1027
-
1028
- if (!parsed) return [];
1029
- if (apiUrl) {
1030
- imageTrace('duckduckgo.apiUsed', { query: safeKeyword, apiUrl });
1031
- }
1032
-
1033
- imageTrace('duckduckgo.apiResult', {
1034
- query: safeKeyword,
1035
- resultCount: Array.isArray(parsed?.results) ? parsed.results.length : 0,
1036
- });
1037
- const results = Array.isArray(parsed.results) ? parsed.results : [];
1038
-
1039
- const images = [];
1040
- for (const item of results) {
1041
- if (typeof item !== 'object' || !item) continue;
1042
- const candidates = [
1043
- item.image,
1044
- item.thumbnail,
1045
- item.image_thumb,
1046
- item.url,
1047
- item.original,
1048
- ];
1049
- for (const candidate of candidates) {
1050
- const candidateUrl = normalizeAbsoluteUrl(candidate);
1051
- if (candidateUrl && !/favicon|logo|sprite|pixel/i.test(candidateUrl)) {
1052
- images.push(candidateUrl);
1053
- break;
1054
- }
1055
- }
1056
- }
1057
-
1058
- return images;
1059
- } catch {
1060
- return [];
1061
- }
1062
- };
1063
-
1064
- const buildKeywordImageCandidates = async (keyword = '') => {
1065
- const cleaned = String(keyword || '').trim().toLowerCase();
1066
- const compacted = cleaned
1067
- .replace(/[^a-z0-9가-힣\s]/g, ' ')
1068
- .replace(/\s+/g, ' ')
1069
- .trim();
1070
- const safeKeyword = compacted;
1071
- if (!safeKeyword) {
1072
- return [];
1073
- }
1074
- imageTrace('buildKeywordImageCandidates.start', { safeKeyword });
1075
-
1076
- const duckduckgoQueries = [
1077
- safeKeyword,
1078
- `${safeKeyword} 이미지`,
1079
- `${safeKeyword} 뉴스`,
1080
- ];
1081
- const searchCandidates = [];
1082
- const seen = new Set();
1083
-
1084
- const collectIfImage = (imageUrl) => {
1085
- const resolved = normalizeAbsoluteUrl(imageUrl);
1086
- if (resolved && !seen.has(resolved)) {
1087
- seen.add(resolved);
1088
- searchCandidates.push(resolved);
1089
- }
1090
- };
1091
-
1092
- for (const query of duckduckgoQueries) {
1093
- if (searchCandidates.length >= 6) {
1094
- break;
1095
- }
1096
- imageTrace('buildKeywordImageCandidates.ddgQuery', { query, currentCount: searchCandidates.length });
1097
- const duckImages = await fetchDuckDuckGoImageResults(query);
1098
- imageTrace('buildKeywordImageCandidates.ddgResult', { query, count: duckImages.length });
1099
- for (const duckImage of duckImages.slice(0, 6)) {
1100
- if (searchCandidates.length >= 6) break;
1101
- collectIfImage(duckImage);
1102
- }
1103
- }
1104
-
1105
- const fallbackQueries = [
1106
- safeKeyword,
1107
- `${safeKeyword} 이미지`,
1108
- `${safeKeyword} news`,
1109
- '뉴스',
1110
- '세계 뉴스',
1111
- ];
1112
- for (const query of fallbackQueries) {
1113
- if (searchCandidates.length >= 6) {
1114
- break;
1115
- }
1116
- imageTrace('buildKeywordImageCandidates.fallbackQuery', { query, currentCount: searchCandidates.length });
1117
- const wikiImages = await buildWikimediaImageCandidates(query);
1118
- imageTrace('buildKeywordImageCandidates.wikimediaResult', { query, count: wikiImages.length });
1119
- for (const candidate of wikiImages) {
1120
- if (searchCandidates.length >= 6) {
1121
- break;
1122
- }
1123
- collectIfImage(candidate);
1124
- }
1125
- for (const candidate of buildLoremFlickrImageCandidates(query)) {
1126
- imageTrace('buildKeywordImageCandidates.loremflickrCandidate', { query, candidate });
1127
- if (searchCandidates.length >= 6) {
1128
- break;
1129
- }
1130
- collectIfImage(candidate);
1131
- }
1132
- for (const candidate of buildPicsumImageCandidates(query)) {
1133
- imageTrace('buildKeywordImageCandidates.picsumCandidate', { query, candidate });
1134
- if (searchCandidates.length >= 6) {
1135
- break;
1136
- }
1137
- collectIfImage(candidate);
1138
- }
1139
- for (const candidate of buildPlaceholderImageCandidates()) {
1140
- imageTrace('buildKeywordImageCandidates.placeholderCandidate', { candidate });
1141
- if (searchCandidates.length >= 6) {
1142
- break;
1143
- }
1144
- collectIfImage(candidate);
1145
- }
1146
- }
1147
-
1148
- return searchCandidates.slice(0, 6);
1149
- };
1150
-
1151
- const extractImagePlaceholders = (content = '') => {
1152
- const matches = Array.from(String(content).matchAll(IMAGE_PLACEHOLDER_REGEX));
1153
- return matches.map((match) => ({
1154
- raw: match[0],
1155
- keyword: String(match[1] || '').trim(),
1156
- }));
1157
- };
1158
-
1159
- const fetchImageBuffer = async (url, retryCount = 0) => {
1160
- if (!url) {
1161
- throw new Error('이미지 URL이 없습니다.');
1162
- }
1163
-
1164
- const localPath = resolveLocalImagePath(url);
1165
- if (localPath && !/https?:/.test(url)) {
1166
- const buffer = await fs.promises.readFile(localPath);
1167
- if (!buffer || buffer.length === 0) {
1168
- throw new Error(`이미지 파일이 비어 있습니다: ${localPath}`);
1169
- }
1170
-
1171
- const extensionFromSignature = getImageSignatureExtension(buffer);
1172
- const extensionFromUrl = guessExtensionFromUrl(localPath);
1173
- return {
1174
- buffer,
1175
- ext: extensionFromSignature || extensionFromUrl || 'jpg',
1176
- finalUrl: localPath,
1177
- isLocal: true,
1178
- };
1179
- }
1180
-
1181
- const headers = {
1182
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
1183
- 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
1184
- 'Cache-Control': 'no-cache',
1185
- };
1186
- const controller = new AbortController();
1187
- const timeout = setTimeout(() => controller.abort(), 20000);
1188
-
1189
- try {
1190
- const response = await fetch(url, {
1191
- redirect: 'follow',
1192
- signal: controller.signal,
1193
- headers,
1194
- });
1195
-
1196
- if (!response.ok) {
1197
- throw new Error(`이미지 다운로드 실패: ${response.status} ${response.statusText} (${url})`);
1198
- }
1199
-
1200
- const contentType = response.headers.get('content-type') || '';
1201
- const normalizedContentType = contentType.toLowerCase();
1202
- const finalUrl = response.url || url;
1203
- const looksLikeHtml = normalizedContentType.includes('text/html') || normalizedContentType.includes('application/xhtml+xml');
1204
- if (looksLikeHtml) {
1205
- const html = await response.text();
1206
- return {
1207
- html,
1208
- ext: 'jpg',
1209
- finalUrl,
1210
- isHtml: true,
1211
- };
1212
- }
1213
-
1214
- const arrayBuffer = await response.arrayBuffer();
1215
- const buffer = Buffer.from(arrayBuffer);
1216
- const extensionFromUrl = guessExtensionFromUrl(finalUrl);
1217
- const extensionFromSignature = getImageSignatureExtension(buffer);
1218
- const isImage = isImageContentType(contentType)
1219
- || extensionFromUrl
1220
- || extensionFromSignature;
1221
-
1222
- if (!isImage) {
1223
- throw new Error(`이미지 콘텐츠가 아닙니다: ${contentType || '(미확인)'}, url=${finalUrl}`);
1224
- }
1225
-
1226
- return {
1227
- buffer,
1228
- ext: extensionFromSignature || guessExtensionFromContentType(contentType) || extensionFromUrl || 'jpg',
1229
- finalUrl,
1230
- };
1231
- } catch (error) {
1232
- if (retryCount < 1) {
1233
- await new Promise((resolve) => setTimeout(resolve, 800));
1234
- return fetchImageBuffer(url, retryCount + 1);
1235
- }
1236
- throw new Error(`이미지 다운로드 실패: ${error.message}`);
1237
- } finally {
1238
- clearTimeout(timeout);
1239
- }
1240
- };
1241
-
1242
- const uploadImageFromRemote = async (api, remoteUrl, fallbackName = 'image', depth = 0) => {
1243
- const downloaded = await fetchImageBuffer(remoteUrl);
1244
-
1245
- if (downloaded?.isHtml && downloaded?.html) {
1246
- const extractedImageUrl = extractImageFromHtml(downloaded.html, downloaded.finalUrl || remoteUrl);
1247
- if (!extractedImageUrl) {
1248
- throw new Error('이미지 페이지에서 유효한 대표 이미지를 찾지 못했습니다.');
1249
- }
1250
- if (depth >= 1 || extractedImageUrl === remoteUrl) {
1251
- throw new Error('이미지 페이지에서 추출된 URL이 유효하지 않아 업로드를 중단했습니다.');
1252
- }
1253
- return uploadImageFromRemote(api, extractedImageUrl, fallbackName, depth + 1);
1254
- }
1255
- const tmpDir = normalizeTempDir();
1256
- const filename = buildImageFileName(fallbackName, downloaded.ext);
1257
- const filePath = path.join(tmpDir, filename);
1258
-
1259
- await fs.promises.writeFile(filePath, downloaded.buffer);
1260
- let uploaded;
1261
- try {
1262
- uploaded = await api.uploadImage(downloaded.buffer, filename);
1263
- } finally {
1264
- await fs.promises.unlink(filePath).catch(() => {});
1265
- }
1266
- const uploadedKage = normalizeUploadedImageThumbnail(uploaded) || (uploaded?.key ? `kage@${uploaded.key}` : null);
1267
-
1268
- if (!uploaded || !(uploaded.url || uploaded.key)) {
1269
- throw new Error('이미지 업로드 응답이 비정상적입니다.');
1270
- }
1271
-
1272
- return {
1273
- sourceUrl: downloaded.finalUrl,
1274
- uploadedUrl: uploaded.url,
1275
- uploadedKey: uploaded.key || uploaded.url,
1276
- uploadedKage,
1277
- raw: uploaded,
1278
- };
1279
- };
1280
-
1281
- const replaceImagePlaceholdersWithUploaded = async (
1282
- api,
1283
- content,
1284
- autoUploadImages,
1285
- relatedImageKeywords = [],
1286
- imageUrls = [],
1287
- _imageCountLimit = 1,
1288
- _minimumImageCount = 1
1289
- ) => {
1290
- const originalContent = content || '';
1291
- if (!autoUploadImages) {
1292
- return {
1293
- content: originalContent,
1294
- uploaded: [],
1295
- uploadedCount: 0,
1296
- status: 'skipped',
1297
- };
1298
- }
1299
-
1300
- let updatedContent = originalContent;
1301
- const uploadedImages = [];
1302
- const uploadErrors = [];
1303
- const matches = extractImagePlaceholders(updatedContent);
1304
- const collectedImageUrls = normalizeImageInputs(imageUrls);
1305
- const hasPlaceholders = matches.length > 0;
1306
-
1307
- const normalizedKeywords = Array.isArray(relatedImageKeywords)
1308
- ? relatedImageKeywords.map((item) => String(item || '').trim()).filter(Boolean)
1309
- : typeof relatedImageKeywords === 'string'
1310
- ? relatedImageKeywords.split(',').map((item) => item.trim()).filter(Boolean)
1311
- : [];
1312
- const safeImageUploadLimit = MAX_IMAGE_UPLOAD_COUNT;
1313
- const safeMinimumImageCount = MAX_IMAGE_UPLOAD_COUNT;
1314
- const targetImageCount = MAX_IMAGE_UPLOAD_COUNT;
1315
-
1316
- const uploadTargets = hasPlaceholders
1317
- ? await Promise.all(matches.map(async (match, index) => {
1318
- const keyword = match.keyword || normalizedKeywords[index] || '';
1319
- const hasKeywordSource = Boolean(keyword);
1320
- const primarySources = hasKeywordSource
1321
- ? await buildKeywordImageCandidates(keyword)
1322
- : [];
1323
- const keywordSources = [...primarySources].filter(Boolean);
1324
- const finalSources = keywordSources.length > 0
1325
- ? keywordSources
1326
- : [];
1327
- return {
1328
- placeholder: match,
1329
- sources: [
1330
- ...(collectedImageUrls[index] ? [collectedImageUrls[index]] : []),
1331
- ...finalSources,
1332
- ],
1333
- keyword: keyword || `image-${index + 1}`,
1334
- };
1335
- }))
1336
- : collectedImageUrls.slice(0, targetImageCount).map((imageUrl, index) => ({
1337
- placeholder: null,
1338
- sources: [imageUrl],
1339
- keyword: normalizedKeywords[index] || `image-${index + 1}`,
1340
- }));
1341
-
1342
- const missingTargets = Math.max(0, targetImageCount - uploadTargets.length);
1343
- const fallbackBaseKeywords = normalizedKeywords.length > 0 ? normalizedKeywords : [];
1344
- const fallbackTargets = missingTargets > 0
1345
- ? await Promise.all(Array.from({ length: missingTargets }).map(async (_, index) => {
1346
- const keyword = fallbackBaseKeywords[index] || fallbackBaseKeywords[fallbackBaseKeywords.length - 1];
1347
- const sources = await buildKeywordImageCandidates(keyword);
1348
- return {
1349
- placeholder: null,
1350
- sources,
1351
- keyword: keyword || `image-${uploadTargets.length + index + 1}`,
1352
- };
1353
- }))
1354
- : [];
1355
-
1356
- const finalUploadTargets = [...uploadTargets, ...fallbackTargets];
1357
- const limitedUploadTargets = finalUploadTargets.slice(0, targetImageCount);
1358
- const requestedImageCount = targetImageCount;
1359
- const resolvedRequestedKeywords = dedupeTextValues(
1360
- hasPlaceholders
1361
- ? [
1362
- ...matches.map((match) => match.keyword).filter(Boolean),
1363
- ...finalUploadTargets.map((target) => target.keyword).filter(Boolean),
1364
- ...normalizedKeywords,
1365
- ]
1366
- : normalizedKeywords
1367
- );
1368
-
1369
- const requestedKeywords = resolvedRequestedKeywords.length > 0
1370
- ? resolvedRequestedKeywords
1371
- : normalizedKeywords;
1372
-
1373
- const hasUsableSource = limitedUploadTargets.some((target) => Array.isArray(target.sources) && target.sources.length > 0);
1374
- if (!hasUsableSource) {
1375
- return {
1376
- content: originalContent,
1377
- uploaded: [],
1378
- uploadedCount: 0,
1379
- status: 'need_image_urls',
1380
- message: '자동 업로드할 이미지 후보 키워드가 없습니다. imageUrls 또는 relatedImageKeywords/플레이스홀더 키워드를 제공해 주세요.',
1381
- requestedKeywords,
1382
- requestedCount: requestedImageCount,
1383
- providedImageUrls: collectedImageUrls.length,
1384
- };
1385
- }
1386
-
1387
- for (let i = 0; i < limitedUploadTargets.length; i += 1) {
1388
- const target = limitedUploadTargets[i];
1389
- const uniqueSources = dedupeImageSources(target.sources);
1390
- let uploadedImage = null;
1391
- let lastMessage = '';
1392
- let success = false;
1393
-
1394
- if (uniqueSources.length === 0) {
1395
- uploadErrors.push({
1396
- index: i,
1397
- sourceUrl: null,
1398
- keyword: target.keyword,
1399
- message: '이미지 소스가 없습니다.',
1400
- });
1401
- continue;
1402
- }
1403
-
1404
- for (let sourceIndex = 0; sourceIndex < uniqueSources.length; sourceIndex += 1) {
1405
- const sourceUrl = uniqueSources[sourceIndex];
1406
- if (IMAGE_TRACE_ENABLED) {
1407
- imageTrace('uploadAttempt', {
1408
- index: i,
1409
- sourceIndex,
1410
- sourceUrl,
1411
- host: (() => {
1412
- try {
1413
- return new URL(sourceUrl).hostname;
1414
- } catch {
1415
- return 'invalid-url';
1416
- }
1417
- })(),
1418
- });
1419
- }
1420
- try {
1421
- uploadedImage = await uploadImageFromRemote(api, sourceUrl, target.keyword);
1422
- success = true;
1423
- break;
1424
- } catch (error) {
1425
- lastMessage = error.message;
1426
- console.log('이미지 처리 실패:', sourceUrl, error.message);
1427
- }
1428
- }
1429
-
1430
- if (!success) {
1431
- const fallbackSources = dedupeImageSources([
1432
- ...uniqueSources,
1433
- ...(await buildFallbackImageSources(target.keyword)),
1434
- ]);
1435
-
1436
- for (let sourceIndex = 0; sourceIndex < fallbackSources.length; sourceIndex += 1) {
1437
- const sourceUrl = fallbackSources[sourceIndex];
1438
- if (uniqueSources.includes(sourceUrl)) {
1439
- continue;
1440
- }
1441
- if (IMAGE_TRACE_ENABLED) {
1442
- imageTrace('uploadAttempt.fallback', {
1443
- index: i,
1444
- sourceIndex,
1445
- sourceUrl,
1446
- host: (() => {
1447
- try {
1448
- return new URL(sourceUrl).hostname;
1449
- } catch {
1450
- return 'invalid-url';
1451
- }
1452
- })(),
1453
- });
1454
- }
1455
- try {
1456
- uploadedImage = await uploadImageFromRemote(api, sourceUrl, target.keyword);
1457
- success = true;
1458
- break;
1459
- } catch (error) {
1460
- lastMessage = error.message;
1461
- console.log('이미지 처리 실패(보정 소스):', sourceUrl, error.message);
1462
- }
1463
- }
1464
- }
1465
-
1466
- if (!success) {
1467
- uploadErrors.push({
1468
- index: i,
1469
- sourceUrl: uniqueSources[0],
1470
- keyword: target.keyword,
1471
- message: `이미지 업로드 실패(대체 이미지 재시도 포함): ${lastMessage}`,
1472
- });
1473
- continue;
1474
- }
1475
-
1476
- const tag = buildTistoryImageTag(uploadedImage, target.keyword);
1477
- if (target.placeholder && target.placeholder.raw) {
1478
- const replaced = new RegExp(escapeRegExp(target.placeholder.raw), 'g');
1479
- updatedContent = updatedContent.replace(replaced, tag);
1480
- } else {
1481
- updatedContent = `${tag}\n${updatedContent}`;
1482
- }
1483
-
1484
- uploadedImages.push(uploadedImage);
1485
- }
1486
-
1487
- if (hasPlaceholders && uploadedImages.length === 0) {
1488
- return {
1489
- content: originalContent,
1490
- uploaded: [],
1491
- uploadedCount: 0,
1492
- status: 'image_upload_failed',
1493
- message: '이미지 업로드에 실패했습니다. 수집한 이미지 URL을 확인해 다시 호출해 주세요.',
1494
- errors: uploadErrors,
1495
- requestedKeywords,
1496
- requestedCount: requestedImageCount,
1497
- providedImageUrls: collectedImageUrls.length,
1498
- };
1499
- }
1500
-
1501
- if (uploadErrors.length > 0) {
1502
- if (uploadedImages.length < safeMinimumImageCount) {
1503
- return {
1504
- content: updatedContent,
1505
- uploaded: uploadedImages,
1506
- uploadedCount: uploadedImages.length,
1507
- status: 'insufficient_images',
1508
- message: `최소 이미지 업로드 장수를 충족하지 못했습니다. (요청: ${safeMinimumImageCount} / 실제: ${uploadedImages.length})`,
1509
- errors: uploadErrors,
1510
- requestedKeywords,
1511
- requestedCount: requestedImageCount,
1512
- uploadedPlaceholders: uploadedImages.length,
1513
- providedImageUrls: collectedImageUrls.length,
1514
- missingImageCount: Math.max(0, safeMinimumImageCount - uploadedImages.length),
1515
- imageLimit: safeImageUploadLimit,
1516
- };
1517
- }
1518
-
1519
- return {
1520
- content: updatedContent,
1521
- uploaded: uploadedImages,
1522
- uploadedCount: uploadedImages.length,
1523
- status: 'image_upload_partial',
1524
- message: '일부 이미지 업로드가 실패했습니다.',
1525
- errors: uploadErrors,
1526
- requestedCount: requestedImageCount,
1527
- uploadedPlaceholders: uploadedImages.length,
1528
- providedImageUrls: collectedImageUrls.length,
1529
- };
1530
- }
1531
-
1532
- if (safeMinimumImageCount > 0 && uploadedImages.length < safeMinimumImageCount) {
1533
- return {
1534
- content: updatedContent,
1535
- uploaded: uploadedImages,
1536
- uploadedCount: uploadedImages.length,
1537
- status: 'insufficient_images',
1538
- message: `최소 이미지 업로드 장수를 충족하지 못했습니다. (요청: ${safeMinimumImageCount} / 실제: ${uploadedImages.length})`,
1539
- errors: uploadErrors,
1540
- requestedKeywords,
1541
- requestedCount: requestedImageCount,
1542
- uploadedPlaceholders: uploadedImages.length,
1543
- providedImageUrls: collectedImageUrls.length,
1544
- missingImageCount: Math.max(0, safeMinimumImageCount - uploadedImages.length),
1545
- imageLimit: safeImageUploadLimit,
1546
- };
1547
- }
1548
-
1549
- return {
1550
- content: updatedContent,
1551
- uploaded: uploadedImages,
1552
- uploadedCount: uploadedImages.length,
1553
- status: 'ok',
1554
- };
1555
- };
1556
-
1557
- const enrichContentWithUploadedImages = async ({
1558
- api,
1559
- rawContent,
1560
- autoUploadImages,
1561
- relatedImageKeywords = [],
1562
- imageUrls = [],
1563
- _imageUploadLimit = 1,
1564
- _minimumImageCount = 1,
1565
- }) => {
1566
- const safeImageUploadLimit = MAX_IMAGE_UPLOAD_COUNT;
1567
- const safeMinimumImageCount = MAX_IMAGE_UPLOAD_COUNT;
1568
-
1569
- const shouldAutoUpload = autoUploadImages !== false;
1570
- const enrichedImages = await replaceImagePlaceholdersWithUploaded(
1571
- api,
1572
- rawContent,
1573
- shouldAutoUpload,
1574
- relatedImageKeywords,
1575
- imageUrls,
1576
- safeImageUploadLimit,
1577
- safeMinimumImageCount
1578
- );
1579
-
1580
- if (enrichedImages.status === 'need_image_urls') {
1581
- return {
1582
- status: 'need_image_urls',
1583
- message: enrichedImages.message,
1584
- requestedKeywords: enrichedImages.requestedKeywords,
1585
- requestedCount: enrichedImages.requestedCount,
1586
- providedImageUrls: enrichedImages.providedImageUrls,
1587
- content: enrichedImages.content,
1588
- images: enrichedImages.uploaded || [],
1589
- imageCount: enrichedImages.uploadedCount,
1590
- uploadedCount: enrichedImages.uploadedCount,
1591
- uploadErrors: enrichedImages.errors || [],
1592
- };
1593
- }
1594
-
1595
- if (enrichedImages.status === 'insufficient_images') {
1596
- return {
1597
- status: 'insufficient_images',
1598
- message: enrichedImages.message,
1599
- imageCount: enrichedImages.uploadedCount,
1600
- requestedCount: enrichedImages.requestedCount,
1601
- uploadedCount: enrichedImages.uploadedCount,
1602
- images: enrichedImages.uploaded || [],
1603
- content: enrichedImages.content,
1604
- uploadErrors: enrichedImages.errors || [],
1605
- providedImageUrls: enrichedImages.providedImageUrls,
1606
- requestedKeywords: enrichedImages.requestedKeywords || [],
1607
- missingImageCount: enrichedImages.missingImageCount || 0,
1608
- imageLimit: enrichedImages.imageLimit || safeImageUploadLimit,
1609
- minimumImageCount: safeMinimumImageCount,
1610
- };
1611
- }
1612
-
1613
- if (enrichedImages.status === 'image_upload_failed' || enrichedImages.status === 'image_upload_partial') {
1614
- return {
1615
- status: enrichedImages.status,
1616
- message: enrichedImages.message,
1617
- imageCount: enrichedImages.uploadedCount,
1618
- requestedCount: enrichedImages.requestedCount,
1619
- uploadedCount: enrichedImages.uploadedCount,
1620
- images: enrichedImages.uploaded || [],
1621
- content: enrichedImages.content,
1622
- uploadErrors: enrichedImages.errors || [],
1623
- providedImageUrls: enrichedImages.providedImageUrls,
1624
- };
1625
- }
1626
-
1627
- return {
1628
- status: 'ok',
1629
- content: enrichedImages.content,
1630
- images: enrichedImages.uploaded || [],
1631
- imageCount: enrichedImages.uploadedCount,
1632
- uploadedCount: enrichedImages.uploadedCount,
1633
- };
1634
- };
1635
-
1636
- const isLoggedInByCookies = async (context) => {
1637
- const cookies = await context.cookies('https://www.tistory.com');
1638
- return cookies.some((cookie) => {
1639
- const name = cookie.name.toLowerCase();
1640
- return name.includes('tistory') || name.includes('access') || name.includes('login');
1641
- });
1642
- };
1643
-
1644
- const waitForLoginFinish = async (page, context, timeoutMs = 45000) => {
1645
- const deadline = Date.now() + timeoutMs;
1646
- while (Date.now() < deadline) {
1647
- if (await isLoggedInByCookies(context)) {
1648
- return true;
1649
- }
1650
-
1651
- if (await clickKakaoAccountContinue(page)) {
1652
- continue;
1653
- }
1654
-
1655
- const url = page.url();
1656
- if (!url.includes('/auth/login') && !url.includes('accounts.kakao.com/login') && !url.includes('kauth.kakao.com')) {
1657
- return true;
1658
- }
1659
-
1660
- await sleep(1000);
1661
- }
1662
- return false;
1663
- };
1664
-
1665
- const withProviderSession = async (fn) => {
1666
- const credentials = readCredentialsFromEnv();
1667
- const hasCredentials = Boolean(credentials.username && credentials.password);
1668
-
1669
- try {
1670
- const result = await fn();
1671
- saveProviderMeta('tistory', {
1672
- loggedIn: true,
1673
- lastValidatedAt: new Date().toISOString(),
1674
- });
1675
- return result;
1676
- } catch (error) {
1677
- if (!parseSessionError(error) || !hasCredentials) {
1678
- throw error;
1679
- }
1680
-
1681
- try {
1682
- const loginResult = await askForAuthentication({
1683
- headless: false,
1684
- manual: false,
1685
- username: credentials.username,
1686
- password: credentials.password,
1687
- });
1688
-
1689
- saveProviderMeta('tistory', {
1690
- loggedIn: loginResult.loggedIn,
1691
- blogName: loginResult.blogName,
1692
- blogUrl: loginResult.blogUrl,
1693
- sessionPath: loginResult.sessionPath,
1694
- lastRefreshedAt: new Date().toISOString(),
1695
- lastError: null,
1696
- });
1697
-
1698
- if (!loginResult.loggedIn) {
1699
- throw new Error(loginResult.message || '세션 갱신 후 로그인 상태가 확인되지 않았습니다.');
1700
- }
1701
-
1702
- return fn();
1703
- } catch (reloginError) {
1704
- saveProviderMeta('tistory', {
1705
- loggedIn: false,
1706
- lastError: buildLoginErrorMessage(reloginError),
1707
- lastValidatedAt: new Date().toISOString(),
1708
- });
1709
- throw reloginError;
1710
- }
1711
- }
1712
- };
1713
-
1714
- const persistTistorySession = async (context, targetSessionPath) => {
1715
- const cookies = await context.cookies('https://www.tistory.com');
1716
- const sanitized = cookies.map((cookie) => ({
1717
- ...cookie,
1718
- expires: Number(cookie.expires || -1),
1719
- size: undefined,
1720
- partitionKey: undefined,
1721
- sourcePort: undefined,
1722
- sourceScheme: undefined,
1723
- }));
1724
-
1725
- const payload = {
1726
- cookies: sanitized,
1727
- updatedAt: new Date().toISOString(),
1728
- };
1729
- await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
1730
- await fs.promises.writeFile(
1731
- targetSessionPath,
1732
- JSON.stringify(payload, null, 2),
1733
- 'utf-8'
1734
- );
1735
- };
1736
-
1737
- const decryptChromeCookieMac = (encryptedValue, derivedKey) => {
1738
- if (!encryptedValue || encryptedValue.length < 4) return '';
1739
- const prefix = encryptedValue.slice(0, 3).toString('ascii');
1740
- if (prefix !== 'v10') return encryptedValue.toString('utf-8');
1741
-
1742
- const encrypted = encryptedValue.slice(3);
1743
- const iv = Buffer.alloc(16, 0x20);
1744
- const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
1745
- decipher.setAutoPadding(true);
1746
- try {
1747
- const dec = Buffer.concat([decipher.update(encrypted), decipher.final()]);
1748
- // CBC 첫 블록은 IV 불일치로 깨짐 → 끝에서부터 printable ASCII 범위 추출
1749
- let start = dec.length;
1750
- for (let i = dec.length - 1; i >= 0; i--) {
1751
- if (dec[i] >= 0x20 && dec[i] <= 0x7e) { start = i; }
1752
- else { break; }
1753
- }
1754
- return start < dec.length ? dec.slice(start).toString('utf-8') : '';
1755
- } catch {
1756
- return '';
1757
- }
1758
- };
1759
-
1760
- const getWindowsChromeMasterKey = (chromeRoot) => {
1761
- const localStatePath = path.join(chromeRoot, 'Local State');
1762
- if (!fs.existsSync(localStatePath)) {
1763
- throw new Error('Chrome Local State 파일을 찾을 수 없습니다.');
1764
- }
1765
- const localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
1766
- const encryptedKeyB64 = localState.os_crypt && localState.os_crypt.encrypted_key;
1767
- if (!encryptedKeyB64) {
1768
- throw new Error('Chrome Local State에서 암호화 키를 찾을 수 없습니다.');
1769
- }
1770
- const encryptedKeyWithPrefix = Buffer.from(encryptedKeyB64, 'base64');
1771
- // 앞 5바이트 "DPAPI" 접두사 제거
1772
- const encryptedKey = encryptedKeyWithPrefix.slice(5);
1773
- const encHex = encryptedKey.toString('hex');
1774
-
1775
- // PowerShell DPAPI로 복호화
1776
- const psScript = `
1777
- Add-Type -AssemblyName System.Security
1778
- $encBytes = [byte[]]::new(${encryptedKey.length})
1779
- $hex = '${encHex}'
1780
- for ($i = 0; $i -lt $encBytes.Length; $i++) {
1781
- $encBytes[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16)
1782
- }
1783
- $decBytes = [System.Security.Cryptography.ProtectedData]::Unprotect($encBytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
1784
- $decHex = -join ($decBytes | ForEach-Object { $_.ToString('x2') })
1785
- Write-Output $decHex
1786
- `.trim().replace(/\n/g, '; ');
1787
-
1788
- try {
1789
- const decHex = execSync(
1790
- `powershell -NoProfile -Command "${psScript}"`,
1791
- { encoding: 'utf-8', timeout: 10000 }
1792
- ).trim();
1793
- return Buffer.from(decHex, 'hex');
1794
- } catch {
1795
- throw new Error('Chrome 암호화 키를 DPAPI로 복호화할 수 없습니다.');
1796
- }
1797
- };
1798
-
1799
- const decryptChromeCookieWindows = (encryptedValue, masterKey) => {
1800
- if (!encryptedValue || encryptedValue.length < 4) return '';
1801
- const prefix = encryptedValue.slice(0, 3).toString('ascii');
1802
- if (prefix !== 'v10' && prefix !== 'v20') return encryptedValue.toString('utf-8');
1803
-
1804
- // AES-256-GCM: nonce(12바이트) + ciphertext + authTag(16바이트)
1805
- const nonce = encryptedValue.slice(3, 3 + 12);
1806
- const authTag = encryptedValue.slice(encryptedValue.length - 16);
1807
- const ciphertext = encryptedValue.slice(3 + 12, encryptedValue.length - 16);
1808
-
1809
- try {
1810
- const decipher = crypto.createDecipheriv('aes-256-gcm', masterKey, nonce);
1811
- decipher.setAuthTag(authTag);
1812
- const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1813
- return dec.toString('utf-8');
1814
- } catch {
1815
- return '';
1816
- }
1817
- };
1818
-
1819
- const decryptChromeCookie = (encryptedValue, key) => {
1820
- if (process.platform === 'win32') {
1821
- return decryptChromeCookieWindows(encryptedValue, key);
1822
- }
1823
- return decryptChromeCookieMac(encryptedValue, key);
1824
- };
1825
-
1826
- const copyFileViaVSS = (srcPath, destPath) => {
1827
- const scriptPath = path.join(__dirname, '..', 'scripts', 'vss-copy.ps1');
1828
- if (!fs.existsSync(scriptPath)) return false;
1829
- try {
1830
- const result = execSync(
1831
- 'powershell -NoProfile -ExecutionPolicy Bypass -File "' + scriptPath + '" -SourcePath "' + srcPath + '" -DestPath "' + destPath + '"',
1832
- { encoding: 'utf-8', timeout: 30000 }
1833
- ).trim();
1834
- return result.includes('OK');
1835
- } catch {
1836
- return false;
1837
- }
1838
- };
1839
-
1840
- const isChromeRunning = () => {
1841
- try {
1842
- if (process.platform === 'win32') {
1843
- const result = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', { encoding: 'utf-8', timeout: 5000 });
1844
- return result.includes('chrome.exe');
1845
- }
1846
- const result = execSync('pgrep -x "Google Chrome" 2>/dev/null || pgrep -x chrome 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
1847
- return result.trim().length > 0;
1848
- } catch {
1849
- return false;
1850
- }
1851
- };
1852
-
1853
- const extractChromeCookies = (cookiesDb, derivedKey, domainPattern) => {
1854
- const tempDb = path.join(os.tmpdir(), `viruagent-cookies-${Date.now()}.db`);
1855
-
1856
- // SQLite 온라인 백업 API 사용 (Chrome이 실행 중이어도 동작)
1857
- // execFileSync로 쉘을 거치지 않아 Windows 경로 공백/이스케이핑 문제 없음
1858
- const backupCmd = process.platform === 'win32'
1859
- ? `.backup "${tempDb}"`
1860
- : `.backup '${tempDb.replace(/'/g, "''")}'`;
1861
- try {
1862
- execFileSync('sqlite3', [cookiesDb, backupCmd], { stdio: 'ignore', timeout: 10000 });
1863
- } catch {
1864
- // sqlite3 백업 실패 시 파일 복사 → VSS 순으로 폴백
1865
- let copied = false;
1866
- try {
1867
- fs.copyFileSync(cookiesDb, tempDb);
1868
- copied = true;
1869
- } catch {}
1870
- if (!copied && process.platform === 'win32') {
1871
- // Windows: VSS(Volume Shadow Copy)로 잠긴 파일 복사
1872
- copied = copyFileViaVSS(cookiesDb, tempDb);
1873
- }
1874
- if (!copied) {
1875
- throw new Error('Chrome 쿠키 DB 복사에 실패했습니다. Chrome이 실행 중이면 종료 후 다시 시도해 주세요.');
1876
- }
1877
- }
1878
-
1879
- // 백업 후 남은 WAL/SHM 파일 제거 (깨끗한 DB 보장)
1880
- for (const suffix of ['-wal', '-shm', '-journal']) {
1881
- try { fs.unlinkSync(tempDb + suffix); } catch {}
1882
- }
1883
-
1884
- try {
1885
- const query = `SELECT host_key, name, value, hex(encrypted_value), path, expires_utc, is_secure, is_httponly, samesite FROM cookies WHERE host_key LIKE '${domainPattern}'`;
1886
- const rows = execFileSync('sqlite3', ['-separator', '||', tempDb, query], {
1887
- encoding: 'utf-8',
1888
- timeout: 5000,
1889
- }).trim();
1890
- if (!rows) return [];
1891
-
1892
- const chromeEpochOffset = 11644473600;
1893
- const sameSiteMap = { '-1': 'None', '0': 'None', '1': 'Lax', '2': 'Strict' };
1894
- return rows.split('\n').map(row => {
1895
- const [domain, name, plainValue, encHex, cookiePath, expiresUtc, isSecure, isHttpOnly, sameSite] = row.split('||');
1896
- let value = plainValue || '';
1897
- if (!value && encHex) {
1898
- value = decryptChromeCookie(Buffer.from(encHex, 'hex'), derivedKey);
1899
- }
1900
- if (value && !/^[\x20-\x7E]*$/.test(value)) value = '';
1901
- const expires = expiresUtc === '0' ? -1 : Math.floor(Number(expiresUtc) / 1000000) - chromeEpochOffset;
1902
- return { name, value, domain, path: cookiePath || '/', expires, httpOnly: isHttpOnly === '1', secure: isSecure === '1', sameSite: sameSiteMap[sameSite] || 'None' };
1903
- }).filter(c => c.value);
1904
- } finally {
1905
- try { fs.unlinkSync(tempDb); } catch {}
1906
- }
1907
- };
1908
-
1909
- const findWindowsChromePath = () => {
1910
- const candidates = [
1911
- path.join(process.env['PROGRAMFILES(X86)'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
1912
- path.join(process.env['PROGRAMFILES'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
1913
- path.join(process.env['LOCALAPPDATA'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
1914
- ];
1915
- return candidates.find(p => fs.existsSync(p)) || null;
1916
- };
1917
-
1918
- const generateSelfSignedCert = (domain) => {
1919
- const tempDir = path.join(os.tmpdir(), `viruagent-cert-${Date.now()}`);
1920
- fs.mkdirSync(tempDir, { recursive: true });
1921
- const keyPath = path.join(tempDir, 'key.pem');
1922
- const certPath = path.join(tempDir, 'cert.pem');
1923
-
1924
- // openssl (Git for Windows에 포함)
1925
- const opensslPaths = [
1926
- 'openssl',
1927
- 'C:/Program Files/Git/usr/bin/openssl.exe',
1928
- 'C:/Program Files (x86)/Git/usr/bin/openssl.exe',
1929
- ];
1930
- let generated = false;
1931
- for (const openssl of opensslPaths) {
1932
- try {
1933
- execSync(
1934
- `"${openssl}" req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 1 -subj "/CN=${domain}"`,
1935
- { timeout: 10000, stdio: 'pipe' }
1936
- );
1937
- generated = true;
1938
- break;
1939
- } catch {}
1940
- }
1941
- if (!generated) {
1942
- try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
1943
- return null;
1944
- }
1945
- return { keyPath, certPath, tempDir };
1946
- };
1947
-
1948
- const CDP_DEBUG_PORT = 9222;
1949
-
1950
- const tryConnectCDP = async (port) => {
1951
- const http = require('http');
1952
- return new Promise((resolve) => {
1953
- http.get(`http://127.0.0.1:${port}/json/version`, { timeout: 2000 }, (res) => {
1954
- let data = '';
1955
- res.on('data', c => data += c);
1956
- res.on('end', () => {
1957
- try {
1958
- const info = JSON.parse(data);
1959
- resolve(info.webSocketDebuggerUrl || null);
1960
- } catch { resolve(null); }
1961
- });
1962
- }).on('error', () => resolve(null));
1963
- });
1964
- };
1965
-
1966
- const findChromeDebugPort = async () => {
1967
- // 1. 고정 포트 9222 시도
1968
- const ws = await tryConnectCDP(CDP_DEBUG_PORT);
1969
- if (ws) return { port: CDP_DEBUG_PORT, wsUrl: ws };
1970
-
1971
- // 2. DevToolsActivePort 파일 확인
1972
- const dtpPath = path.join(
1973
- process.env.LOCALAPPDATA || '',
1974
- 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'
1975
- );
1976
- try {
1977
- const content = fs.readFileSync(dtpPath, 'utf-8').trim();
1978
- const port = parseInt(content.split('\n')[0], 10);
1979
- if (port > 0) {
1980
- const ws2 = await tryConnectCDP(port);
1981
- if (ws2) return { port, wsUrl: ws2 };
1982
- }
1983
- } catch {}
1984
-
1985
- return null;
1986
- };
1987
-
1988
- const enableChromeDebugPort = () => {
1989
- // Chrome 바로가기에 --remote-debugging-port 추가 (한 번만 실행)
1990
- if (process.platform !== 'win32') return false;
1991
-
1992
- const flag = `--remote-debugging-port=${CDP_DEBUG_PORT}`;
1993
- const shortcutPaths = [];
1994
-
1995
- // 바탕화면, 시작 메뉴, 작업표시줄 바로가기 검색
1996
- const locations = [
1997
- path.join(os.homedir(), 'Desktop'),
1998
- path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
1999
- path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Internet Explorer', 'Quick Launch', 'User Pinned', 'TaskBar'),
2000
- 'C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs',
2001
- ];
2002
- for (const loc of locations) {
2003
- try {
2004
- const files = fs.readdirSync(loc);
2005
- for (const f of files) {
2006
- if (/chrome/i.test(f) && f.endsWith('.lnk')) {
2007
- shortcutPaths.push(path.join(loc, f));
2008
- }
2009
- }
2010
- } catch {}
2011
- }
2012
- // Google Chrome 폴더 내부도 탐색
2013
- for (const loc of locations) {
2014
- try {
2015
- const chromeDir = path.join(loc, 'Google Chrome');
2016
- if (fs.existsSync(chromeDir)) {
2017
- const files = fs.readdirSync(chromeDir);
2018
- for (const f of files) {
2019
- if (/chrome/i.test(f) && f.endsWith('.lnk')) {
2020
- shortcutPaths.push(path.join(chromeDir, f));
2021
- }
2022
- }
2023
- }
2024
- } catch {}
2025
- }
2026
-
2027
- let modified = 0;
2028
- for (const lnkPath of shortcutPaths) {
2029
- try {
2030
- const psScript = `
2031
- $shell = New-Object -ComObject WScript.Shell
2032
- $sc = $shell.CreateShortcut('${lnkPath.replace(/'/g, "''")}')
2033
- if ($sc.Arguments -notmatch 'remote-debugging-port') {
2034
- $sc.Arguments = ($sc.Arguments + ' ${flag}').Trim()
2035
- $sc.Save()
2036
- Write-Output 'MODIFIED'
2037
- } else {
2038
- Write-Output 'ALREADY'
2039
- }`;
2040
- const result = execSync(`powershell -Command "${psScript.replace(/"/g, '\\"')}"`, {
2041
- timeout: 5000,
2042
- encoding: 'utf-8',
2043
- }).trim();
2044
- if (result === 'MODIFIED') modified++;
2045
- } catch {}
2046
- }
2047
- return modified > 0;
2048
- };
2049
-
2050
- const extractCookiesFromCDP = async (port, targetSessionPath) => {
2051
- const http = require('http');
2052
- const WebSocket = require('ws');
2053
-
2054
- // 1. 브라우저 레벨 CDP에 연결하여 tistory 탭 생성/탐색
2055
- const browserWsUrl = await tryConnectCDP(port);
2056
- if (!browserWsUrl) throw new Error('Chrome CDP 연결 실패');
2057
-
2058
- // 2. 기존 tistory 탭 찾거나 새로 생성
2059
- const targetsJson = await new Promise((resolve, reject) => {
2060
- http.get(`http://127.0.0.1:${port}/json/list`, { timeout: 3000 }, (res) => {
2061
- let data = '';
2062
- res.on('data', c => data += c);
2063
- res.on('end', () => resolve(data));
2064
- }).on('error', reject);
2065
- });
2066
- const targets = JSON.parse(targetsJson);
2067
- let pageTarget = targets.find(t => t.type === 'page' && t.url && t.url.includes('tistory'));
2068
-
2069
- if (!pageTarget) {
2070
- // tistory 탭이 없으면 브라우저 CDP로 새 탭 생성
2071
- const bws = new WebSocket(browserWsUrl);
2072
- const newTargetId = await new Promise((resolve, reject) => {
2073
- const timeout = setTimeout(() => reject(new Error('탭 생성 시간 초과')), 10000);
2074
- bws.on('open', () => {
2075
- bws.send(JSON.stringify({ id: 1, method: 'Target.createTarget', params: { url: 'https://www.tistory.com/' } }));
2076
- });
2077
- bws.on('message', (msg) => {
2078
- const resp = JSON.parse(msg.toString());
2079
- if (resp.id === 1) {
2080
- clearTimeout(timeout);
2081
- resolve(resp.result?.targetId);
2082
- bws.close();
2083
- }
2084
- });
2085
- bws.on('error', (e) => { clearTimeout(timeout); reject(e); });
2086
- });
2087
- // 새 탭의 WebSocket URL 조회
2088
- await new Promise(r => setTimeout(r, 3000));
2089
- const newTargetsJson = await new Promise((resolve, reject) => {
2090
- http.get(`http://127.0.0.1:${port}/json/list`, { timeout: 3000 }, (res) => {
2091
- let data = '';
2092
- res.on('data', c => data += c);
2093
- res.on('end', () => resolve(data));
2094
- }).on('error', reject);
2095
- });
2096
- const newTargets = JSON.parse(newTargetsJson);
2097
- pageTarget = newTargets.find(t => t.id === newTargetId) || newTargets.find(t => t.type === 'page' && t.url && t.url.includes('tistory'));
2098
- }
2099
-
2100
- if (!pageTarget || !pageTarget.webSocketDebuggerUrl) {
2101
- throw new Error('tistory 페이지 타겟을 찾을 수 없습니다.');
2102
- }
2103
-
2104
- // 3. 페이지 레벨 CDP에서 Network.enable → Network.getAllCookies
2105
- const ws = new WebSocket(pageTarget.webSocketDebuggerUrl);
2106
- const cookies = await new Promise((resolve, reject) => {
2107
- const timeout = setTimeout(() => reject(new Error('CDP 쿠키 추출 시간 초과')), 15000);
2108
- let msgId = 1;
2109
- ws.on('open', () => {
2110
- ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
2111
- });
2112
- ws.on('message', (msg) => {
2113
- const resp = JSON.parse(msg.toString());
2114
- if (resp.id === 1) {
2115
- // Network enabled → getAllCookies
2116
- ws.send(JSON.stringify({ id: msgId++, method: 'Network.getAllCookies' }));
2117
- }
2118
- if (resp.id === 2) {
2119
- clearTimeout(timeout);
2120
- resolve(resp.result?.cookies || []);
2121
- ws.close();
2122
- }
2123
- });
2124
- ws.on('error', (e) => { clearTimeout(timeout); reject(e); });
2125
- });
2126
-
2127
- const tistoryCookies = cookies.filter(c => String(c.domain).includes('tistory'));
2128
- const tssession = tistoryCookies.find(c => c.name === 'TSSESSION');
2129
- if (!tssession || !tssession.value) {
2130
- throw new Error('Chrome에 티스토리 로그인 세션이 없습니다. Chrome에서 먼저 티스토리에 로그인해 주세요.');
2131
- }
2132
-
2133
- const payload = {
2134
- cookies: tistoryCookies.map(c => ({
2135
- name: c.name, value: c.value, domain: c.domain,
2136
- path: c.path || '/', expires: c.expires > 0 ? c.expires : -1,
2137
- httpOnly: !!c.httpOnly, secure: !!c.secure,
2138
- sameSite: c.sameSite || 'None',
2139
- })),
2140
- updatedAt: new Date().toISOString(),
2141
- };
2142
- await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
2143
- await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
2144
- return { cookieCount: tistoryCookies.length };
2145
- };
2146
-
2147
- const getOrCreateJunctionPath = (chromeRoot) => {
2148
- // Chrome 145+: 기본 user-data-dir에서는 --remote-debugging-port가 작동하지 않음
2149
- // Junction point로 같은 디렉토리를 다른 경로로 가리켜서 우회
2150
- if (process.platform !== 'win32') return chromeRoot;
2151
-
2152
- const junctionPath = path.join(path.dirname(chromeRoot), 'ChromeDebug');
2153
- if (!fs.existsSync(junctionPath)) {
2154
- try {
2155
- execSync(`cmd /c "mklink /J "${junctionPath}" "${chromeRoot}""`, {
2156
- timeout: 5000, stdio: 'pipe',
2157
- });
2158
- } catch {
2159
- // Junction 생성 실패 시 원본 경로 사용 (디버그 포트 작동 안 할 수 있음)
2160
- return chromeRoot;
2161
- }
2162
- }
2163
- return junctionPath;
2164
- };
2165
-
2166
- const extractCookiesViaCDP = async (targetSessionPath, chromeRoot, profileName) => {
2167
- // Chrome 실행 중: CDP(Chrome DevTools Protocol)로 쿠키 추출
2168
- // 1단계: 이미 디버그 포트가 열려있으면 바로 연결 (크롬 종료 없음)
2169
- // 2단계: 없으면 한 번만 재시작 + 바로가기 수정 (이후 재시작 불필요)
2170
- const { spawn } = require('child_process');
2171
-
2172
- // 1. 이미 디버그 포트가 열려있는지 확인
2173
- const existing = await findChromeDebugPort();
2174
- if (existing) {
2175
- console.log(`[chrome-cdp] 기존 Chrome 디버그 포트(${existing.port}) 감지 — 크롬 종료 없이 쿠키 추출`);
2176
- return await extractCookiesFromCDP(existing.port, targetSessionPath);
2177
- }
2178
-
2179
- // 2. 디버그 포트 없음 → Chrome 바로가기에 디버그 포트 추가 (이후 재시작 불필요)
2180
- console.log('[chrome-cdp] Chrome 디버그 포트 미감지 — 바로가기에 --remote-debugging-port 추가 중...');
2181
- const shortcutModified = enableChromeDebugPort();
2182
- if (shortcutModified) {
2183
- console.log('[chrome-cdp] Chrome 바로가기 수정 완료 — 다음부터는 크롬 종료 없이 쿠키 추출 가능');
2184
- }
2185
-
2186
- // 3. Chrome을 graceful하게 종료하고 디버그 포트로 재시작 (최초 1회만)
2187
- const chromePath = findWindowsChromePath();
2188
- if (!chromePath) throw new Error('Chrome 실행 파일을 찾을 수 없습니다.');
2189
-
2190
- console.log('[chrome-cdp] Chrome을 디버그 포트와 함께 재시작합니다 (탭 자동 복원)...');
2191
- try {
2192
- if (process.platform === 'win32') {
2193
- execSync('cmd /c "taskkill /IM chrome.exe"', { stdio: 'ignore', timeout: 10000 });
2194
- }
2195
- } catch {}
2196
- await new Promise(r => setTimeout(r, 2000));
2197
- if (isChromeRunning()) {
2198
- try { execSync('cmd /c "taskkill /F /IM chrome.exe"', { stdio: 'ignore', timeout: 5000 }); } catch {}
2199
- await new Promise(r => setTimeout(r, 1000));
2200
- }
2201
-
2202
- // 4. Junction 경로로 디버그 포트 + 세션 복원 재시작
2203
- // Chrome 145+는 기본 user-data-dir에서 디버그 포트를 거부하므로 junction으로 우회
2204
- const junctionRoot = getOrCreateJunctionPath(chromeRoot);
2205
- const chromeProc = spawn(chromePath, [
2206
- `--remote-debugging-port=${CDP_DEBUG_PORT}`,
2207
- '--remote-allow-origins=*',
2208
- '--restore-last-session',
2209
- `--user-data-dir=${junctionRoot}`,
2210
- `--profile-directory=${profileName}`,
2211
- ], { detached: true, stdio: 'ignore' });
2212
- chromeProc.unref();
2213
-
2214
- // 5. CDP 연결 대기
2215
- let connected = null;
2216
- const maxWait = 15000;
2217
- const start = Date.now();
2218
- while (Date.now() - start < maxWait) {
2219
- await new Promise(r => setTimeout(r, 500));
2220
- connected = await findChromeDebugPort();
2221
- if (connected) break;
2222
- }
2223
- if (!connected) throw new Error('Chrome 디버그 포트 연결 시간 초과');
2224
-
2225
- // 6. 쿠키 추출 (Chrome은 계속 실행 상태 유지 — 종료하지 않음)
2226
- return await extractCookiesFromCDP(connected.port, targetSessionPath);
2227
- };
2228
-
2229
- const importSessionViaChromeDirectLaunch = async (targetSessionPath, chromeRoot, profileName) => {
2230
- // Windows Chrome 145+: v20 App Bound Encryption으로 외부에서 쿠키 복호화 불가
2231
- // Chrome 실행 중이면 CDP 방식으로 추출 (잠시 재시작, 탭 자동 복원)
2232
- if (isChromeRunning()) {
2233
- return await extractCookiesViaCDP(targetSessionPath, chromeRoot, profileName);
2234
- }
2235
-
2236
- const chromePath = findWindowsChromePath();
2237
- if (!chromePath) {
2238
- throw new Error('Chrome 실행 파일을 찾을 수 없습니다.');
2239
- }
2240
-
2241
- // 1. 자체 서명 인증서 생성 (openssl 필요)
2242
- const cert = generateSelfSignedCert('www.tistory.com');
2243
- if (!cert) {
2244
- throw new Error(
2245
- 'openssl을 찾을 수 없습니다. Git for Windows를 설치하면 openssl이 포함됩니다.'
2246
- );
2247
- }
2248
-
2249
- const https = require('https');
2250
- const { spawn } = require('child_process');
2251
-
2252
- // 2. HTTPS 서버 시작 (포트 443)
2253
- const server = https.createServer({
2254
- key: fs.readFileSync(cert.keyPath),
2255
- cert: fs.readFileSync(cert.certPath),
2256
- });
2257
-
2258
- try {
2259
- await new Promise((resolve, reject) => {
2260
- server.on('error', reject);
2261
- server.listen(443, '127.0.0.1', resolve);
2262
- });
2263
- } catch (e) {
2264
- try { fs.rmSync(cert.tempDir, { recursive: true, force: true }); } catch {}
2265
- throw new Error(`포트 443 바인딩 실패: ${e.message}. 관리자 권한으로 실행해 주세요.`);
2266
- }
2267
-
2268
- // 3. 쿠키 수신 Promise
2269
- let chromeProc = null;
2270
- const cookiePromise = new Promise((resolve, reject) => {
2271
- const timeout = setTimeout(() => {
2272
- reject(new Error('Chrome 쿠키 추출 시간 초과 (15초)'));
2273
- }, 15000);
2274
-
2275
- server.on('request', (req, res) => {
2276
- res.writeHead(200, { 'Content-Type': 'text/html' });
2277
- res.end('<html><body>Session captured. You can close this window.</body></html>');
2278
-
2279
- if (req.url === '/' || req.url === '') {
2280
- clearTimeout(timeout);
2281
- const cookieHeader = req.headers.cookie || '';
2282
- resolve(cookieHeader);
2283
- }
2284
- });
2285
- });
2286
-
2287
- // 4. Chrome 실행 (Chrome이 꺼진 상태에서만 실행됨 - DNS 리다이렉션, 인증서 오류 무시)
2288
- chromeProc = spawn(chromePath, [
2289
- '--no-first-run',
2290
- '--no-default-browser-check',
2291
- `--profile-directory=${profileName}`,
2292
- '--host-resolver-rules=MAP www.tistory.com 127.0.0.1',
2293
- '--ignore-certificate-errors',
2294
- 'https://www.tistory.com/',
2295
- ], { detached: true, stdio: 'ignore' });
2296
- chromeProc.unref();
2297
-
2298
- try {
2299
- const cookieHeader = await cookiePromise;
2300
-
2301
- // Cookie 헤더 파싱
2302
- const cookies = cookieHeader.split(';')
2303
- .map(c => c.trim())
2304
- .filter(Boolean)
2305
- .map(c => {
2306
- const eqIdx = c.indexOf('=');
2307
- if (eqIdx < 0) return null;
2308
- return { name: c.slice(0, eqIdx).trim(), value: c.slice(eqIdx + 1).trim() };
2309
- })
2310
- .filter(Boolean);
2311
-
2312
- const tssession = cookies.find(c => c.name === 'TSSESSION');
2313
- if (!tssession || !tssession.value) {
2314
- throw new Error(
2315
- 'Chrome에 티스토리 로그인 세션이 없습니다. Chrome에서 먼저 티스토리에 로그인해 주세요.'
2316
- );
2317
- }
2318
-
2319
- // Cookie 헤더에는 domain/path/expires 정보가 없으므로 기본값 설정
2320
- const payload = {
2321
- cookies: cookies.map(c => ({
2322
- name: c.name,
2323
- value: c.value,
2324
- domain: '.tistory.com',
2325
- path: '/',
2326
- expires: -1,
2327
- httpOnly: false,
2328
- secure: true,
2329
- sameSite: 'None',
2330
- })),
2331
- updatedAt: new Date().toISOString(),
2332
- };
2333
-
2334
- await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
2335
- await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
2336
-
2337
- return { cookieCount: cookies.length };
2338
- } finally {
2339
- server.close();
2340
- if (chromeProc) {
2341
- try { execSync(`taskkill /F /PID ${chromeProc.pid} /T`, { stdio: 'ignore', timeout: 5000 }); } catch {}
2342
- }
2343
- try { fs.rmSync(cert.tempDir, { recursive: true, force: true }); } catch {}
2344
- }
2345
- };
2346
-
2347
- const importSessionFromChrome = async (targetSessionPath, profileName = 'Default') => {
2348
- let chromeRoot;
2349
- if (process.platform === 'win32') {
2350
- chromeRoot = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Google', 'Chrome', 'User Data');
2351
- } else {
2352
- chromeRoot = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
2353
- }
2354
- if (!fs.existsSync(chromeRoot)) {
2355
- throw new Error('Chrome이 설치되어 있지 않습니다.');
2356
- }
2357
-
2358
- const profileDir = path.join(chromeRoot, profileName);
2359
- // Windows 최신 Chrome은 Network/Cookies, 이전 버전은 Cookies
2360
- let cookiesDb = path.join(profileDir, 'Network', 'Cookies');
2361
- if (!fs.existsSync(cookiesDb)) {
2362
- cookiesDb = path.join(profileDir, 'Cookies');
2363
- }
2364
- if (!fs.existsSync(cookiesDb)) {
2365
- throw new Error(`Chrome 프로필 "${profileName}"에 쿠키 DB가 없습니다.`);
2366
- }
2367
-
2368
- let derivedKey;
2369
- if (process.platform === 'win32') {
2370
- // Windows: Local State → DPAPI로 마스터 키 복호화
2371
- derivedKey = getWindowsChromeMasterKey(chromeRoot);
2372
- } else {
2373
- // macOS: Keychain에서 Chrome 암호화 키 추출
2374
- let keychainPassword;
2375
- try {
2376
- keychainPassword = execSync(
2377
- 'security find-generic-password -s "Chrome Safe Storage" -w',
2378
- { encoding: 'utf-8', timeout: 5000 }
2379
- ).trim();
2380
- } catch {
2381
- throw new Error('Chrome Safe Storage 키를 Keychain에서 읽을 수 없습니다. macOS 권한을 확인해 주세요.');
2382
- }
2383
- derivedKey = crypto.pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
2384
- }
2385
-
2386
- // Chrome에서 tistory + kakao 쿠키 복호화 추출
2387
- const tistoryCookies = extractChromeCookies(cookiesDb, derivedKey, '%tistory.com');
2388
- const kakaoCookies = extractChromeCookies(cookiesDb, derivedKey, '%kakao.com');
2389
-
2390
- // 이미 TSSESSION 있으면 바로 저장
2391
- const existingSession = tistoryCookies.some(c => c.name === 'TSSESSION' && c.value);
2392
- if (existingSession) {
2393
- const payload = { cookies: tistoryCookies, updatedAt: new Date().toISOString() };
2394
- await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
2395
- await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
2396
- return { cookieCount: tistoryCookies.length };
2397
- }
2398
-
2399
- // 3) 카카오 세션 쿠키가 있으면 Playwright에 주입 후 자동 로그인
2400
- const hasKakaoSession = kakaoCookies.some(c => c.domain.includes('kakao.com') && (c.name === '_kawlt' || c.name === '_kawltea' || c.name === '_karmt'));
2401
- if (!hasKakaoSession) {
2402
- // Windows v20 App Bound Encryption: DPAPI만으로 복호화 불가
2403
- // Playwright persistent context (pipe 모드)로 Chrome 기본 프로필에서 직접 추출
2404
- if (process.platform === 'win32') {
2405
- return await importSessionViaChromeDirectLaunch(targetSessionPath, chromeRoot, profileName);
2406
- }
2407
- throw new Error('Chrome에 카카오 로그인 세션이 없습니다. Chrome에서 먼저 카카오 계정에 로그인해 주세요.');
2408
- }
2409
-
2410
- const browser = await chromium.launch({ headless: true });
2411
- const context = await browser.newContext();
2412
- try {
2413
- // Playwright 형식으로 변환하여 쿠키 주입
2414
- const allCookies = [...tistoryCookies, ...kakaoCookies].map(c => ({
2415
- ...c,
2416
- domain: c.domain.startsWith('.') ? c.domain : c.domain,
2417
- expires: c.expires > 0 ? c.expires : undefined,
2418
- }));
2419
- await context.addCookies(allCookies);
2420
-
2421
- const page = await context.newPage();
2422
- await page.goto('https://www.tistory.com/auth/login', { waitUntil: 'domcontentloaded', timeout: 10000 });
2423
- await page.waitForTimeout(1000);
2424
-
2425
- // 카카오 로그인 버튼 클릭
2426
- const kakaoBtn = await pickValue(page, KAKAO_TRIGGER_SELECTORS);
2427
- if (kakaoBtn) {
2428
- await page.locator(kakaoBtn).click({ timeout: 5000 }).catch(() => {});
2429
- await page.waitForLoadState('domcontentloaded').catch(() => {});
2430
- }
2431
-
2432
- // 카카오 계정 확인 → 계속하기 클릭
2433
- await page.waitForTimeout(2000);
2434
- const confirmBtn = await pickValue(page, [
2435
- ...KAKAO_ACCOUNT_CONFIRM_SELECTORS.continue,
2436
- 'button[type="submit"]',
2437
- ]);
2438
- if (confirmBtn) {
2439
- await page.locator(confirmBtn).click({ timeout: 3000 }).catch(() => {});
2440
- }
2441
-
2442
- // TSSESSION 대기 (최대 15초)
2443
- let hasSession = false;
2444
- const maxWait = 15000;
2445
- const startTime = Date.now();
2446
- while (Date.now() - startTime < maxWait) {
2447
- await page.waitForTimeout(1000);
2448
- const cookies = await context.cookies('https://www.tistory.com');
2449
- hasSession = cookies.some(c => c.name === 'TSSESSION' && c.value);
2450
- if (hasSession) break;
2451
- }
2452
-
2453
- if (!hasSession) {
2454
- throw new Error('Chrome 카카오 세션으로 티스토리 자동 로그인에 실패했습니다.');
2455
- }
2456
-
2457
- await persistTistorySession(context, targetSessionPath);
2458
- const finalCookies = await context.cookies('https://www.tistory.com');
2459
- return { cookieCount: finalCookies.filter(c => String(c.domain).includes('tistory')).length };
2460
- } finally {
2461
- await context.close().catch(() => {});
2462
- await browser.close().catch(() => {});
2463
- }
2464
- };
2465
-
2466
- const createTistoryProvider = ({ sessionPath }) => {
2467
- const tistoryApi = createTistoryApiClient({ sessionPath });
2468
-
2469
- const pending2faResult = (mode = 'kakao') => ({
2470
- provider: 'tistory',
2471
- status: 'pending_2fa',
2472
- loggedIn: false,
2473
- message: mode === 'otp'
2474
- ? '2차 인증이 필요합니다. otp 코드를 twoFactorCode로 전달해 주세요.'
2475
- : '카카오 2차 인증이 필요합니다. 앱에서 인증 후 다시 실행하면 됩니다.',
2476
- });
2477
-
2478
- const askForAuthentication = async ({
2479
- headless = false,
2480
- manual = false,
2481
- username,
2482
- password,
2483
- twoFactorCode,
2484
- } = {}) => {
2485
- fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
2486
-
2487
- const resolvedUsername = username || readCredentialsFromEnv().username;
2488
- const resolvedPassword = password || readCredentialsFromEnv().password;
2489
- const shouldAutoFill = !manual;
2490
-
2491
- if (!manual && (!resolvedUsername || !resolvedPassword)) {
2492
- throw new Error('티스토리 로그인 요청에 id/pw가 없습니다. id/pw를 먼저 전달하거나 TISTORY_USERNAME/TISTORY_PASSWORD를 설정해 주세요.');
2493
- }
2494
-
2495
- const browser = await chromium.launch({
2496
- headless: manual ? false : headless,
2497
- });
2498
- const context = await browser.newContext();
2499
-
2500
- const page = context.pages()[0] || (await context.newPage());
2501
-
2502
- try {
2503
- await page.goto('https://www.tistory.com/auth/login', {
2504
- waitUntil: 'domcontentloaded',
2505
- });
2506
-
2507
- const loginId = resolvedUsername;
2508
- const loginPw = resolvedPassword;
2509
-
2510
- const kakaoLoginSelector = await pickValue(page, KAKAO_TRIGGER_SELECTORS);
2511
- if (!kakaoLoginSelector) {
2512
- throw new Error('카카오 로그인 버튼을 찾지 못했습니다. 로그인 화면 UI가 변경되었는지 확인해 주세요.');
2513
- }
2514
-
2515
- await page.locator(kakaoLoginSelector).click({ timeout: 5000 }).catch(() => {});
2516
- await page.waitForLoadState('domcontentloaded').catch(() => {});
2517
- await page.waitForTimeout(800);
2518
-
2519
- let finalLoginStatus = false;
2520
- let pendingTwoFactorAction = false;
2521
-
2522
- if (manual) {
2523
- console.log('');
2524
- console.log('==============================');
2525
- console.log('수동 로그인 모드로 전환합니다.');
2526
- console.log('브라우저에서 직접 ID/PW/2차 인증을 완료한 뒤, 로그인 완료 상태를 기다립니다.');
2527
- console.log('로그인 완료 또는 2차 인증은 최대 5분 내에 처리해 주세요.');
2528
- console.log('==============================');
2529
- finalLoginStatus = await waitForLoginFinish(page, context, 300000);
2530
- } else if (shouldAutoFill) {
2531
- const usernameFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.username, loginId);
2532
- const passwordFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.password, loginPw);
2533
- if (!usernameFilled || !passwordFilled) {
2534
- throw new Error('카카오 로그인 폼 입력 필드를 찾지 못했습니다. 티스토리 로그인 화면 변경 시도를 확인해 주세요.');
2535
- }
2536
-
2537
- await checkBySelector(page, KAKAO_LOGIN_SELECTORS.rememberLogin);
2538
- const kakaoSubmitted = await clickSubmit(page, KAKAO_LOGIN_SELECTORS.submit);
2539
- if (!kakaoSubmitted) {
2540
- await page.keyboard.press('Enter');
2541
- }
2542
-
2543
- finalLoginStatus = await waitForLoginFinish(page, context);
2544
-
2545
- if (!finalLoginStatus && await hasElement(page, LOGIN_OTP_SELECTORS)) {
2546
- if (!twoFactorCode) {
2547
- return pending2faResult('otp');
2548
- }
2549
- const otpFilled = await fillBySelector(page, LOGIN_OTP_SELECTORS, twoFactorCode);
2550
- if (!otpFilled) {
2551
- throw new Error('OTP 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
2552
- }
2553
- await page.keyboard.press('Enter');
2554
- finalLoginStatus = await waitForLoginFinish(page, context, 45000);
2555
- } else if (!finalLoginStatus && (await hasElement(page, KAKAO_2FA_SELECTORS.start) || page.url().includes('tmsTwoStepVerification') || page.url().includes('emailTwoStepVerification'))) {
2556
- await checkBySelector(page, KAKAO_2FA_SELECTORS.rememberDevice);
2557
- const isEmailModeAvailable = await hasElement(page, KAKAO_2FA_SELECTORS.emailModeButton);
2558
- const hasEmailCodeInput = await hasElement(page, KAKAO_2FA_SELECTORS.codeInput);
2559
-
2560
- if (hasEmailCodeInput && twoFactorCode) {
2561
- const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
2562
- if (!codeFilled) {
2563
- throw new Error('2차 인증 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
2564
- }
2565
- const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
2566
- if (!confirmed) {
2567
- await page.keyboard.press('Enter');
2568
- }
2569
- finalLoginStatus = await waitForLoginFinish(page, context, 45000);
2570
- } else if (!twoFactorCode) {
2571
- pendingTwoFactorAction = true;
2572
- } else if (isEmailModeAvailable) {
2573
- await clickSubmit(page, KAKAO_2FA_SELECTORS.emailModeButton).catch(() => {});
2574
- await page.waitForLoadState('domcontentloaded').catch(() => {});
2575
- await page.waitForTimeout(800);
2576
-
2577
- const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
2578
- if (!codeFilled) {
2579
- throw new Error('카카오 이메일 인증 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
2580
- }
2581
-
2582
- const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
2583
- if (!confirmed) {
2584
- await page.keyboard.press('Enter');
2585
- }
2586
- finalLoginStatus = await waitForLoginFinish(page, context, 45000);
2587
- } else {
2588
- return pending2faResult('kakao');
2589
- }
2590
- }
2591
- }
2592
-
2593
- if (!finalLoginStatus) {
2594
- if (pendingTwoFactorAction) {
2595
- return pending2faResult('kakao');
2596
- }
2597
- throw new Error('로그인에 실패했습니다. 아이디/비밀번호가 정확한지 확인하고, 없으면 환경변수 TISTORY_USERNAME/TISTORY_PASSWORD를 다시 설정해 주세요.');
2598
- }
2599
-
2600
- await context.storageState({ path: sessionPath });
2601
- await persistTistorySession(context, sessionPath);
2602
-
2603
- tistoryApi.resetState();
2604
- const blogName = await tistoryApi.initBlog();
2605
- return {
2606
- provider: 'tistory',
2607
- loggedIn: true,
2608
- blogName,
2609
- blogUrl: `https://${blogName}.tistory.com`,
2610
- sessionPath,
2611
- };
2612
- } finally {
2613
- if (browser) {
2614
- await browser.close().catch(() => {});
2615
- }
2616
- }
2617
- };
2618
-
2619
- return {
2620
- id: 'tistory',
2621
- name: 'Tistory',
2622
-
2623
- async authStatus() {
2624
- return withProviderSession(async () => {
2625
- try {
2626
- const blogName = await tistoryApi.initBlog();
2627
- return {
2628
- provider: 'tistory',
2629
- loggedIn: true,
2630
- blogName,
2631
- blogUrl: `https://${blogName}.tistory.com`,
2632
- sessionPath,
2633
- metadata: getProviderMeta('tistory') || {},
2634
- };
2635
- } catch (error) {
2636
- return {
2637
- provider: 'tistory',
2638
- loggedIn: false,
2639
- sessionPath,
2640
- error: error.message,
2641
- metadata: getProviderMeta('tistory') || {},
2642
- };
2643
- }
2644
- });
2645
- },
2646
-
2647
- async login({
2648
- headless = false,
2649
- manual = false,
2650
- username,
2651
- password,
2652
- twoFactorCode,
2653
- fromChrome,
2654
- profile,
2655
- } = {}) {
2656
- if (fromChrome) {
2657
- await importSessionFromChrome(sessionPath, profile || 'Default');
2658
- tistoryApi.resetState();
2659
- const blogName = await tistoryApi.initBlog();
2660
- const result = {
2661
- provider: 'tistory',
2662
- loggedIn: true,
2663
- blogName,
2664
- blogUrl: `https://${blogName}.tistory.com`,
2665
- sessionPath,
2666
- source: 'chrome-import',
2667
- };
2668
- saveProviderMeta('tistory', { loggedIn: true, blogName, blogUrl: result.blogUrl, sessionPath });
2669
- return result;
2670
- }
2671
-
2672
- const creds = readCredentialsFromEnv();
2673
- const resolved = {
2674
- headless,
2675
- manual,
2676
- username: username || creds.username,
2677
- password: password || creds.password,
2678
- twoFactorCode,
2679
- };
2680
-
2681
- if (!resolved.manual && (!resolved.username || !resolved.password)) {
2682
- throw new Error('티스토리 자동 로그인을 진행하려면 username/password가 필요합니다. 요청 값으로 전달하거나, 환경변수 TISTORY_USERNAME / TISTORY_PASSWORD를 설정해 주세요.');
2683
- }
2684
-
2685
- const result = await askForAuthentication(resolved);
2686
- saveProviderMeta('tistory', {
2687
- loggedIn: result.loggedIn,
2688
- blogName: result.blogName,
2689
- blogUrl: result.blogUrl,
2690
- sessionPath: result.sessionPath,
2691
- });
2692
- return result;
2693
- },
2694
-
2695
- async publish(payload) {
2696
- return withProviderSession(async () => {
2697
- const title = payload.title || '제목 없음';
2698
- const rawContent = payload.content || '';
2699
- const visibility = mapVisibility(payload.visibility);
2700
- const tag = normalizeTagList(payload.tags);
2701
- const rawThumbnail = payload.thumbnail || null;
2702
- const relatedImageKeywords = payload.relatedImageKeywords || [];
2703
- const imageUrls = payload.imageUrls || [];
2704
- const autoUploadImages = payload.autoUploadImages !== false;
2705
- const safeImageUploadLimit = MAX_IMAGE_UPLOAD_COUNT;
2706
- const safeMinimumImageCount = MAX_IMAGE_UPLOAD_COUNT;
2707
-
2708
- if (autoUploadImages) {
2709
- await tistoryApi.initBlog();
2710
- }
2711
-
2712
- const enrichedImages = await enrichContentWithUploadedImages({
2713
- api: tistoryApi,
2714
- rawContent,
2715
- autoUploadImages,
2716
- relatedImageKeywords,
2717
- imageUrls,
2718
- imageUploadLimit: safeImageUploadLimit,
2719
- minimumImageCount: safeMinimumImageCount,
2720
- });
2721
- if (enrichedImages.status === 'need_image_urls') {
2722
- return {
2723
- mode: 'publish',
2724
- status: 'need_image_urls',
2725
- loggedIn: true,
2726
- provider: 'tistory',
2727
- title,
2728
- visibility,
2729
- tags: tag,
2730
- message: enrichedImages.message,
2731
- requestedKeywords: enrichedImages.requestedKeywords,
2732
- requestedCount: enrichedImages.requestedCount,
2733
- providedImageUrls: enrichedImages.providedImageUrls,
2734
- };
2735
- }
2736
-
2737
- if (enrichedImages.status === 'insufficient_images') {
2738
- return {
2739
- mode: 'publish',
2740
- status: 'insufficient_images',
2741
- loggedIn: true,
2742
- provider: 'tistory',
2743
- title,
2744
- visibility,
2745
- tags: tag,
2746
- message: enrichedImages.message,
2747
- imageCount: enrichedImages.uploadedCount,
2748
- requestedCount: enrichedImages.requestedCount,
2749
- uploadedCount: enrichedImages.uploadedCount,
2750
- uploadErrors: enrichedImages.uploadErrors || [],
2751
- providedImageUrls: enrichedImages.providedImageUrls,
2752
- missingImageCount: enrichedImages.missingImageCount || 0,
2753
- imageLimit: enrichedImages.imageLimit || safeImageUploadLimit,
2754
- minimumImageCount: safeMinimumImageCount,
2755
- };
2756
- }
2757
-
2758
- if (enrichedImages.status === 'image_upload_failed' || enrichedImages.status === 'image_upload_partial') {
2759
- return {
2760
- mode: 'publish',
2761
- status: enrichedImages.status,
2762
- loggedIn: true,
2763
- provider: 'tistory',
2764
- title,
2765
- visibility,
2766
- tags: tag,
2767
- thumbnail: normalizeThumbnailForPublish(payload.thumbnail) || null,
2768
- message: enrichedImages.message,
2769
- imageCount: enrichedImages.uploadedCount,
2770
- requestedCount: enrichedImages.requestedCount,
2771
- uploadedCount: enrichedImages.uploadedCount,
2772
- uploadErrors: enrichedImages.uploadErrors || [],
2773
- providedImageUrls: enrichedImages.providedImageUrls,
2774
- };
2775
- }
2776
- const content = enrichedImages.content;
2777
- const uploadedImages = enrichedImages?.images || enrichedImages?.uploaded || [];
2778
- const finalThumbnail = await resolveMandatoryThumbnail({
2779
- rawThumbnail,
2780
- content,
2781
- uploadedImages,
2782
- relatedImageKeywords,
2783
- title,
2784
- });
2785
-
2786
- await tistoryApi.initBlog();
2787
- const rawCategories = await tistoryApi.getCategories();
2788
- const categories = buildCategoryList(rawCategories);
2789
-
2790
- if (!isProvidedCategory(payload.category)) {
2791
- if (categories.length === 0) {
2792
- return {
2793
- provider: 'tistory',
2794
- mode: 'publish',
2795
- status: 'need_category',
2796
- loggedIn: true,
2797
- title,
2798
- visibility,
2799
- tags: tag,
2800
- message: '발행을 위해 카테고리가 필요합니다. categories를 확인하고 category를 지정해 주세요.',
2801
- categories,
2802
- };
2803
- }
2804
-
2805
- if (categories.length === 1) {
2806
- payload = { ...payload, category: categories[0].id };
2807
- } else {
2808
- if (!process.stdin || !process.stdin.isTTY) {
2809
- const sampleCategory = categories.slice(0, 5).map((item) => `${item.id}: ${item.name}`).join(', ');
2810
- const sampleText = sampleCategory.length > 0 ? ` 예: ${sampleCategory}` : '';
2811
- return {
2812
- provider: 'tistory',
2813
- mode: 'publish',
2814
- status: 'need_category',
2815
- loggedIn: true,
2816
- title,
2817
- visibility,
2818
- tags: tag,
2819
- message: `카테고리가 지정되지 않았습니다. 비대화형 환경에서는 --category 옵션이 필수입니다. 사용법: --category <카테고리ID>.${sampleText}`,
2820
- categories,
2821
- };
2822
- }
2823
-
2824
- const selectedCategoryId = await promptCategorySelection(categories);
2825
- if (!selectedCategoryId) {
2826
- return {
2827
- provider: 'tistory',
2828
- mode: 'publish',
2829
- status: 'need_category',
2830
- loggedIn: true,
2831
- title,
2832
- visibility,
2833
- tags: tag,
2834
- message: '카테고리가 지정되지 않았습니다. 카테고리를 입력해 발행을 진행해 주세요.',
2835
- categories,
2836
- };
2837
- }
2838
-
2839
- payload = { ...payload, category: selectedCategoryId };
2840
- }
2841
- }
2842
-
2843
- const category = Number(payload.category);
2844
- if (!Number.isInteger(category) || Number.isNaN(category)) {
2845
- return {
2846
- provider: 'tistory',
2847
- mode: 'publish',
2848
- status: 'invalid_category',
2849
- loggedIn: true,
2850
- title,
2851
- visibility,
2852
- tags: tag,
2853
- message: '유효한 category를 숫자로 지정해 주세요.',
2854
- categories,
2855
- };
2856
- }
2857
-
2858
- const validCategoryIds = categories.map((item) => item.id);
2859
- if (!validCategoryIds.includes(category) && categories.length > 0) {
2860
- return {
2861
- provider: 'tistory',
2862
- mode: 'publish',
2863
- status: 'invalid_category',
2864
- loggedIn: true,
2865
- title,
2866
- visibility,
2867
- tags: tag,
2868
- message: '존재하지 않는 category입니다. categories를 확인해 주세요.',
2869
- categories,
2870
- };
2871
- }
2872
-
2873
- try {
2874
- const result = await tistoryApi.publishPost({
2875
- title,
2876
- content,
2877
- visibility,
2878
- category,
2879
- tag,
2880
- thumbnail: finalThumbnail,
2881
- });
2882
-
2883
- return {
2884
- provider: 'tistory',
2885
- mode: 'publish',
2886
- title,
2887
- category,
2888
- visibility,
2889
- tags: tag,
2890
- thumbnail: finalThumbnail,
2891
- images: enrichedImages.images,
2892
- imageCount: enrichedImages.uploadedCount,
2893
- minimumImageCount: safeMinimumImageCount,
2894
- url: result.entryUrl || null,
2895
- raw: result,
2896
- };
2897
- } catch (error) {
2898
- if (!isPublishLimitError(error)) {
2899
- throw error;
2900
- }
2901
-
2902
- try {
2903
- const fallbackPublishResult = await tistoryApi.publishPost({
2904
- title,
2905
- content,
2906
- visibility: 0,
2907
- category,
2908
- tag,
2909
- thumbnail: finalThumbnail,
2910
- });
2911
-
2912
- return {
2913
- provider: 'tistory',
2914
- mode: 'publish',
2915
- status: 'publish_fallback_to_private',
2916
- title,
2917
- category,
2918
- visibility: 0,
2919
- tags: tag,
2920
- thumbnail: finalThumbnail,
2921
- images: enrichedImages.images,
2922
- imageCount: enrichedImages.uploadedCount,
2923
- minimumImageCount: safeMinimumImageCount,
2924
- url: fallbackPublishResult.entryUrl || null,
2925
- raw: fallbackPublishResult,
2926
- message: '발행 제한(403)으로 인해 비공개로 발행했습니다.',
2927
- fallbackThumbnail: finalThumbnail,
2928
- };
2929
- } catch (fallbackError) {
2930
- if (!isPublishLimitError(fallbackError)) {
2931
- throw fallbackError;
2932
- }
2933
-
2934
- return {
2935
- provider: 'tistory',
2936
- mode: 'publish',
2937
- status: 'publish_fallback_to_private_failed',
2938
- title,
2939
- category,
2940
- visibility: 0,
2941
- tags: tag,
2942
- thumbnail: finalThumbnail,
2943
- images: enrichedImages.images,
2944
- imageCount: enrichedImages.uploadedCount,
2945
- minimumImageCount: safeMinimumImageCount,
2946
- message: '발행 제한(403)으로 인해 공개/비공개 모두 실패했습니다.',
2947
- raw: {
2948
- success: false,
2949
- error: fallbackError.message,
2950
- },
2951
- };
2952
- }
2953
- }
2954
- });
2955
- },
2956
-
2957
- async saveDraft(payload) {
2958
- return withProviderSession(async () => {
2959
- const title = payload.title || '임시저장';
2960
- const rawContent = payload.content || '';
2961
- const rawThumbnail = payload.thumbnail || null;
2962
- const tag = normalizeTagList(payload.tags);
2963
- const relatedImageKeywords = payload.relatedImageKeywords || [];
2964
- const imageUrls = payload.imageUrls || [];
2965
- const autoUploadImages = payload.autoUploadImages !== false;
2966
- const safeImageUploadCount = MAX_IMAGE_UPLOAD_COUNT;
2967
- const safeMinimumImageCount = MAX_IMAGE_UPLOAD_COUNT;
2968
-
2969
- if (autoUploadImages) {
2970
- await tistoryApi.initBlog();
2971
- }
2972
-
2973
- const enrichedImages = await enrichContentWithUploadedImages({
2974
- api: tistoryApi,
2975
- rawContent,
2976
- autoUploadImages,
2977
- relatedImageKeywords,
2978
- imageUrls,
2979
- imageUploadLimit: safeImageUploadCount,
2980
- minimumImageCount: safeMinimumImageCount,
2981
- });
2982
- if (enrichedImages.status === 'need_image_urls') {
2983
- return {
2984
- mode: 'draft',
2985
- status: 'need_image_urls',
2986
- loggedIn: true,
2987
- provider: 'tistory',
2988
- title,
2989
- message: enrichedImages.message,
2990
- requestedKeywords: enrichedImages.requestedKeywords,
2991
- requestedCount: enrichedImages.requestedCount,
2992
- providedImageUrls: enrichedImages.providedImageUrls,
2993
- imageCount: enrichedImages.imageCount,
2994
- minimumImageCount: safeMinimumImageCount,
2995
- images: enrichedImages.uploaded || [],
2996
- uploadedCount: enrichedImages.uploadedCount,
2997
- };
2998
- }
2999
-
3000
- if (enrichedImages.status === 'insufficient_images') {
3001
- return {
3002
- mode: 'draft',
3003
- status: 'insufficient_images',
3004
- loggedIn: true,
3005
- provider: 'tistory',
3006
- title,
3007
- message: enrichedImages.message,
3008
- imageCount: enrichedImages.imageCount,
3009
- requestedCount: enrichedImages.requestedCount,
3010
- uploadedCount: enrichedImages.uploadedCount,
3011
- uploadErrors: enrichedImages.uploadErrors,
3012
- providedImageUrls: enrichedImages.providedImageUrls,
3013
- minimumImageCount: safeMinimumImageCount,
3014
- imageLimit: enrichedImages.imageLimit || safeImageUploadCount,
3015
- images: enrichedImages.uploaded || [],
3016
- };
3017
- }
3018
-
3019
- if (enrichedImages.status === 'image_upload_failed' || enrichedImages.status === 'image_upload_partial') {
3020
- return {
3021
- mode: 'draft',
3022
- status: enrichedImages.status,
3023
- loggedIn: true,
3024
- provider: 'tistory',
3025
- title,
3026
- message: enrichedImages.message,
3027
- imageCount: enrichedImages.imageCount,
3028
- requestedCount: enrichedImages.requestedCount,
3029
- uploadedCount: enrichedImages.uploadedCount,
3030
- uploadErrors: enrichedImages.uploadErrors || [],
3031
- providedImageUrls: enrichedImages.providedImageUrls,
3032
- images: enrichedImages.uploaded || [],
3033
- };
3034
- }
3035
-
3036
- const content = enrichedImages.content;
3037
- const thumbnail = await resolveMandatoryThumbnail({
3038
- rawThumbnail,
3039
- content,
3040
- uploadedImages: enrichedImages?.uploaded || [],
3041
- relatedImageKeywords,
3042
- title,
3043
- });
3044
-
3045
- await tistoryApi.initBlog();
3046
- const result = await tistoryApi.saveDraft({ title, content });
3047
- return {
3048
- provider: 'tistory',
3049
- mode: 'draft',
3050
- title,
3051
- status: 'ok',
3052
- category: Number(payload.category) || 0,
3053
- tags: tag,
3054
- sequence: result.draft?.sequence || null,
3055
- thumbnail,
3056
- minimumImageCount: safeMinimumImageCount,
3057
- imageCount: enrichedImages.imageCount,
3058
- images: enrichedImages.uploaded || [],
3059
- uploadErrors: enrichedImages.uploadErrors || null,
3060
- draftContent: content,
3061
- raw: result,
3062
- };
3063
- });
3064
- },
3065
-
3066
- async listCategories() {
3067
- return withProviderSession(async () => {
3068
- await tistoryApi.initBlog();
3069
- const categories = await tistoryApi.getCategories();
3070
- return {
3071
- provider: 'tistory',
3072
- categories: Object.entries(categories).map(([name, id]) => ({
3073
- name,
3074
- id: Number(id),
3075
- })),
3076
- };
3077
- });
3078
- },
3079
-
3080
- async listPosts({ limit = 20 } = {}) {
3081
- return withProviderSession(async () => {
3082
- await tistoryApi.initBlog();
3083
- const result = await tistoryApi.getPosts();
3084
- const items = Array.isArray(result?.items) ? result.items : [];
3085
- return {
3086
- provider: 'tistory',
3087
- totalCount: result.totalCount || items.length,
3088
- posts: items.slice(0, Math.max(1, Number(limit) || 20)),
3089
- };
3090
- });
3091
- },
3092
-
3093
- async getPost({ postId, includeDraft = false } = {}) {
3094
- return withProviderSession(async () => {
3095
- const resolvedPostId = String(postId || '').trim();
3096
- if (!resolvedPostId) {
3097
- return {
3098
- provider: 'tistory',
3099
- mode: 'post',
3100
- status: 'invalid_post_id',
3101
- message: 'postId가 필요합니다.',
3102
- };
3103
- }
3104
-
3105
- await tistoryApi.initBlog();
3106
- const post = await tistoryApi.getPost({
3107
- postId: resolvedPostId,
3108
- includeDraft: Boolean(includeDraft),
3109
- });
3110
- if (!post) {
3111
- return {
3112
- provider: 'tistory',
3113
- mode: 'post',
3114
- status: 'not_found',
3115
- postId: resolvedPostId,
3116
- includeDraft: Boolean(includeDraft),
3117
- message: '해당 postId의 글을 찾을 수 없습니다.',
3118
- };
3119
- }
3120
- return {
3121
- provider: 'tistory',
3122
- mode: 'post',
3123
- postId: resolvedPostId,
3124
- post,
3125
- includeDraft: Boolean(includeDraft),
3126
- };
3127
- });
3128
- },
3129
-
3130
- async logout() {
3131
- clearProviderMeta('tistory');
3132
- return {
3133
- provider: 'tistory',
3134
- loggedOut: true,
3135
- sessionPath,
3136
- };
3137
- },
3138
- };
3139
- };
3140
-
3141
- module.exports = createTistoryProvider;