viruagent-cli 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +21 -5
- package/README.md +21 -5
- package/package.json +1 -1
- package/skills/viruagent.md +98 -9
- package/src/providers/naver/auth.js +180 -0
- package/src/providers/naver/chromeImport.js +195 -0
- package/src/providers/naver/editorConvert.js +198 -0
- package/src/providers/naver/imageUpload.js +111 -0
- package/src/providers/naver/index.js +253 -0
- package/src/providers/naver/selectors.js +23 -0
- package/src/providers/naver/session.js +119 -0
- package/src/providers/naver/utils.js +56 -0
- package/src/providers/tistory/chromeImport.js +86 -41
- package/src/services/naverApiClient.js +493 -0
- package/src/services/providerManager.js +1 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
const seId = () => `SE-${crypto.randomUUID()}`;
|
|
4
|
+
|
|
5
|
+
const createTextComponent = (text, { fontSize = 'fs16', bold = 'false', align = 'left', lineHeight = '1.8', ctype = 'text' } = {}) => ({
|
|
6
|
+
id: seId(),
|
|
7
|
+
layout: 'default',
|
|
8
|
+
value: [{
|
|
9
|
+
id: seId(),
|
|
10
|
+
nodes: [{
|
|
11
|
+
id: seId(),
|
|
12
|
+
value: text,
|
|
13
|
+
style: {
|
|
14
|
+
fontColor: '#333333',
|
|
15
|
+
fontSizeCode: fontSize,
|
|
16
|
+
bold,
|
|
17
|
+
'@ctype': 'nodeStyle',
|
|
18
|
+
},
|
|
19
|
+
'@ctype': 'textNode',
|
|
20
|
+
}],
|
|
21
|
+
style: {
|
|
22
|
+
align,
|
|
23
|
+
lineHeight,
|
|
24
|
+
'@ctype': 'paragraphStyle',
|
|
25
|
+
},
|
|
26
|
+
'@ctype': 'paragraph',
|
|
27
|
+
}],
|
|
28
|
+
'@ctype': ctype,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const createImageComponent = (imgData) => ({
|
|
32
|
+
id: seId(),
|
|
33
|
+
layout: 'default',
|
|
34
|
+
align: 'center',
|
|
35
|
+
src: `https://blogfiles.pstatic.net/${imgData.url}?type=w1`,
|
|
36
|
+
internalResource: 'true',
|
|
37
|
+
represent: imgData.represent || 'false',
|
|
38
|
+
path: imgData.url,
|
|
39
|
+
domain: 'https://blogfiles.pstatic.net',
|
|
40
|
+
fileSize: imgData.fileSize,
|
|
41
|
+
width: imgData.width,
|
|
42
|
+
widthPercentage: 0,
|
|
43
|
+
height: imgData.height,
|
|
44
|
+
originalWidth: imgData.width,
|
|
45
|
+
originalHeight: imgData.height,
|
|
46
|
+
fileName: imgData.fileName,
|
|
47
|
+
caption: null,
|
|
48
|
+
format: 'normal',
|
|
49
|
+
displayFormat: 'normal',
|
|
50
|
+
imageLoaded: 'true',
|
|
51
|
+
contentMode: 'normal',
|
|
52
|
+
origin: {
|
|
53
|
+
srcFrom: 'local',
|
|
54
|
+
'@ctype': 'imageOrigin',
|
|
55
|
+
},
|
|
56
|
+
ai: 'false',
|
|
57
|
+
'@ctype': 'image',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const stripHtmlTags = (html) => html.replace(/<[^>]*>/g, '');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* HTML을 네이버 에디터 컴포넌트 배열로 변환한다.
|
|
64
|
+
* Primary: 네이버 API (upconvert.editor.naver.com)
|
|
65
|
+
* Fallback: 커스텀 파싱
|
|
66
|
+
*/
|
|
67
|
+
const convertHtmlToEditorComponents = async (naverApi, html, imageComponents = []) => {
|
|
68
|
+
// 1. 네이버 API 변환 시도
|
|
69
|
+
const apiComponents = await naverApi.convertHtmlToComponents(html);
|
|
70
|
+
if (Array.isArray(apiComponents) && apiComponents.length > 0) {
|
|
71
|
+
// 이미지를 글 맨 위에 배치 (티스토리 스타일)
|
|
72
|
+
return [...imageComponents, ...apiComponents];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Fallback: 커스텀 파싱 (이미지를 글 맨 위에 배치)
|
|
76
|
+
const textComponents = parseHtmlToComponents(html, []);
|
|
77
|
+
return [...imageComponents, ...textComponents];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* HTML을 수동으로 파싱하여 네이버 에디터 컴포넌트로 변환한다.
|
|
82
|
+
* Python의 process_html_to_components() 포팅
|
|
83
|
+
*/
|
|
84
|
+
const parseHtmlToComponents = (html, imageComponents = []) => {
|
|
85
|
+
// heading(h1-h6) 또는 strong 태그 기준으로 분할
|
|
86
|
+
const segments = html.split(/(<h[1-6][^>]*>.*?<\/h[1-6]>|<strong>.*?<\/strong>)/is);
|
|
87
|
+
const components = [];
|
|
88
|
+
const images = [...imageComponents];
|
|
89
|
+
let firstHeadingSeen = false;
|
|
90
|
+
|
|
91
|
+
for (const segment of segments) {
|
|
92
|
+
const trimmed = segment.trim();
|
|
93
|
+
if (!trimmed) continue;
|
|
94
|
+
|
|
95
|
+
const isHeading = /^<h[1-6]/i.test(trimmed);
|
|
96
|
+
const isStrong = /<strong>/i.test(trimmed);
|
|
97
|
+
const isBoldSection = isHeading || isStrong;
|
|
98
|
+
|
|
99
|
+
// heading 태그 자체는 건너뛰기 (Python 코드의 continue와 동일)
|
|
100
|
+
if (/^<h[1-6][^>]*>.*<\/h[1-6]>$/is.test(trimmed)) {
|
|
101
|
+
const text = stripHtmlTags(trimmed);
|
|
102
|
+
if (!text.trim()) continue;
|
|
103
|
+
|
|
104
|
+
if (!firstHeadingSeen) {
|
|
105
|
+
firstHeadingSeen = true;
|
|
106
|
+
components.push(createTextComponent(text, {
|
|
107
|
+
fontSize: 'fs38',
|
|
108
|
+
bold: 'true',
|
|
109
|
+
align: 'center',
|
|
110
|
+
lineHeight: '2.1',
|
|
111
|
+
ctype: 'text',
|
|
112
|
+
}));
|
|
113
|
+
} else {
|
|
114
|
+
// 이미지 삽입
|
|
115
|
+
if (images.length > 0) {
|
|
116
|
+
components.push(images.shift());
|
|
117
|
+
}
|
|
118
|
+
components.push(createTextComponent(text, {
|
|
119
|
+
fontSize: 'fs24',
|
|
120
|
+
bold: 'true',
|
|
121
|
+
align: 'center',
|
|
122
|
+
ctype: 'quotation',
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 일반 텍스트 세그먼트
|
|
129
|
+
const text = stripHtmlTags(trimmed);
|
|
130
|
+
if (!text.trim()) continue;
|
|
131
|
+
|
|
132
|
+
if (isBoldSection && !firstHeadingSeen) {
|
|
133
|
+
firstHeadingSeen = true;
|
|
134
|
+
components.push(createTextComponent(text, {
|
|
135
|
+
fontSize: 'fs24',
|
|
136
|
+
bold: 'true',
|
|
137
|
+
lineHeight: '2.1',
|
|
138
|
+
}));
|
|
139
|
+
} else if (isBoldSection) {
|
|
140
|
+
if (images.length > 0) {
|
|
141
|
+
components.push(images.shift());
|
|
142
|
+
}
|
|
143
|
+
components.push(createTextComponent(text, {
|
|
144
|
+
fontSize: 'fs24',
|
|
145
|
+
bold: 'true',
|
|
146
|
+
ctype: 'quotation',
|
|
147
|
+
}));
|
|
148
|
+
} else {
|
|
149
|
+
// 일반 단락: <p> 또는 <br> 기준으로 분할
|
|
150
|
+
const paragraphs = text.split(/\n+/).filter((p) => p.trim());
|
|
151
|
+
for (const para of paragraphs) {
|
|
152
|
+
components.push(createTextComponent(para.trim()));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 남은 이미지 append
|
|
158
|
+
for (const img of images) {
|
|
159
|
+
components.push(img);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return components;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* API 반환 컴포넌트 사이에 이미지를 삽입한다.
|
|
167
|
+
*/
|
|
168
|
+
const intersperse = (components, imageComponents) => {
|
|
169
|
+
if (!imageComponents.length) return components;
|
|
170
|
+
|
|
171
|
+
const result = [];
|
|
172
|
+
const images = [...imageComponents];
|
|
173
|
+
let headingCount = 0;
|
|
174
|
+
|
|
175
|
+
for (const comp of components) {
|
|
176
|
+
const isQuotation = comp['@ctype'] === 'quotation';
|
|
177
|
+
if (isQuotation && headingCount > 0 && images.length > 0) {
|
|
178
|
+
result.push(images.shift());
|
|
179
|
+
}
|
|
180
|
+
if (isQuotation) headingCount++;
|
|
181
|
+
result.push(comp);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 남은 이미지 append
|
|
185
|
+
for (const img of images) {
|
|
186
|
+
result.push(img);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return result;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
convertHtmlToEditorComponents,
|
|
194
|
+
parseHtmlToComponents,
|
|
195
|
+
createTextComponent,
|
|
196
|
+
createImageComponent,
|
|
197
|
+
seId,
|
|
198
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { createImageComponent } = require('./editorConvert');
|
|
4
|
+
const { buildKeywordImageCandidates } = require('../tistory/imageSources');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 이미지 URL 또는 로컬 경로에서 이미지 버퍼를 가져온다.
|
|
8
|
+
*/
|
|
9
|
+
const fetchImageBuffer = async (source) => {
|
|
10
|
+
if (fs.existsSync(source)) {
|
|
11
|
+
return {
|
|
12
|
+
buffer: fs.readFileSync(source),
|
|
13
|
+
filename: path.basename(source),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(source, {
|
|
21
|
+
redirect: 'follow',
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
headers: {
|
|
24
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
|
25
|
+
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`이미지 다운로드 실패: ${response.status} — ${source}`);
|
|
30
|
+
}
|
|
31
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
32
|
+
const urlPath = new URL(response.url || source).pathname;
|
|
33
|
+
const filename = path.basename(urlPath) || 'image.jpg';
|
|
34
|
+
return {
|
|
35
|
+
buffer: Buffer.from(arrayBuffer),
|
|
36
|
+
filename,
|
|
37
|
+
};
|
|
38
|
+
} finally {
|
|
39
|
+
clearTimeout(timeout);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 이미지 소스들을 네이버에 업로드하고 에디터 컴포넌트 배열로 반환한다.
|
|
45
|
+
*/
|
|
46
|
+
const uploadAndCreateImageComponents = async (naverApi, imageSources, token) => {
|
|
47
|
+
const components = [];
|
|
48
|
+
const errors = [];
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < imageSources.length; i++) {
|
|
51
|
+
const source = imageSources[i];
|
|
52
|
+
try {
|
|
53
|
+
const { buffer, filename } = await fetchImageBuffer(source);
|
|
54
|
+
const imgData = await naverApi.uploadImage(buffer, filename, token);
|
|
55
|
+
if (imgData) {
|
|
56
|
+
imgData.represent = i === 0 ? 'true' : 'false';
|
|
57
|
+
components.push(createImageComponent(imgData));
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
errors.push({ source, error: error.message });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { components, errors };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* relatedImageKeywords에서 이미지를 검색하고, imageUrls와 합쳐서 업로드한다.
|
|
69
|
+
* 티스토리 imageSources.js의 buildKeywordImageCandidates를 재사용한다.
|
|
70
|
+
*/
|
|
71
|
+
const collectAndUploadImages = async (naverApi, {
|
|
72
|
+
imageUrls = [],
|
|
73
|
+
relatedImageKeywords = [],
|
|
74
|
+
token,
|
|
75
|
+
imageUploadLimit = 2,
|
|
76
|
+
}) => {
|
|
77
|
+
const collectedUrls = [...imageUrls];
|
|
78
|
+
|
|
79
|
+
// 키워드에서 이미지 URL 검색
|
|
80
|
+
const normalizedKeywords = Array.isArray(relatedImageKeywords)
|
|
81
|
+
? relatedImageKeywords.map((k) => String(k || '').trim()).filter(Boolean)
|
|
82
|
+
: String(relatedImageKeywords || '').split(',').map((k) => k.trim()).filter(Boolean);
|
|
83
|
+
|
|
84
|
+
for (const keyword of normalizedKeywords) {
|
|
85
|
+
if (collectedUrls.length >= imageUploadLimit) break;
|
|
86
|
+
try {
|
|
87
|
+
const candidates = await buildKeywordImageCandidates(keyword);
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (collectedUrls.length >= imageUploadLimit) break;
|
|
90
|
+
if (!collectedUrls.includes(candidate)) {
|
|
91
|
+
collectedUrls.push(candidate);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// 검색 실패 시 무시
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const limitedUrls = collectedUrls.slice(0, imageUploadLimit);
|
|
100
|
+
if (limitedUrls.length === 0) {
|
|
101
|
+
return { components: [], errors: [] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return uploadAndCreateImageComponents(naverApi, limitedUrls, token);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
fetchImageBuffer,
|
|
109
|
+
uploadAndCreateImageComponents,
|
|
110
|
+
collectAndUploadImages,
|
|
111
|
+
};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
const { saveProviderMeta, clearProviderMeta, getProviderMeta } = require('../../storage/sessionStore');
|
|
2
|
+
const createNaverApiClient = require('../../services/naverApiClient');
|
|
3
|
+
const {
|
|
4
|
+
readNaverCredentials,
|
|
5
|
+
normalizeNaverTagList,
|
|
6
|
+
mapNaverVisibility,
|
|
7
|
+
} = require('./utils');
|
|
8
|
+
const { convertHtmlToEditorComponents } = require('./editorConvert');
|
|
9
|
+
const { collectAndUploadImages } = require('./imageUpload');
|
|
10
|
+
const { createNaverWithProviderSession } = require('./session');
|
|
11
|
+
const { importSessionFromChrome } = require('./chromeImport');
|
|
12
|
+
const { createAskForAuthentication } = require('./auth');
|
|
13
|
+
|
|
14
|
+
const createNaverProvider = ({ sessionPath }) => {
|
|
15
|
+
const naverApi = createNaverApiClient({ sessionPath });
|
|
16
|
+
|
|
17
|
+
const askForAuthentication = createAskForAuthentication({
|
|
18
|
+
sessionPath,
|
|
19
|
+
naverApi,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const withProviderSession = createNaverWithProviderSession(askForAuthentication);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
id: 'naver',
|
|
26
|
+
name: 'Naver',
|
|
27
|
+
|
|
28
|
+
async authStatus() {
|
|
29
|
+
return withProviderSession(async () => {
|
|
30
|
+
try {
|
|
31
|
+
const blogId = await naverApi.initBlog();
|
|
32
|
+
return {
|
|
33
|
+
provider: 'naver',
|
|
34
|
+
loggedIn: true,
|
|
35
|
+
blogId,
|
|
36
|
+
blogUrl: `https://blog.naver.com/${blogId}`,
|
|
37
|
+
sessionPath,
|
|
38
|
+
metadata: getProviderMeta('naver') || {},
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return {
|
|
42
|
+
provider: 'naver',
|
|
43
|
+
loggedIn: false,
|
|
44
|
+
sessionPath,
|
|
45
|
+
error: error.message,
|
|
46
|
+
metadata: getProviderMeta('naver') || {},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async login({
|
|
53
|
+
headless = false,
|
|
54
|
+
manual = false,
|
|
55
|
+
username,
|
|
56
|
+
password,
|
|
57
|
+
fromChrome,
|
|
58
|
+
profile,
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (fromChrome) {
|
|
61
|
+
await importSessionFromChrome(sessionPath, profile || 'Default');
|
|
62
|
+
naverApi.resetState();
|
|
63
|
+
const blogId = await naverApi.initBlog();
|
|
64
|
+
const result = {
|
|
65
|
+
provider: 'naver',
|
|
66
|
+
loggedIn: true,
|
|
67
|
+
blogId,
|
|
68
|
+
blogUrl: `https://blog.naver.com/${blogId}`,
|
|
69
|
+
sessionPath,
|
|
70
|
+
source: 'chrome-import',
|
|
71
|
+
};
|
|
72
|
+
saveProviderMeta('naver', { loggedIn: true, blogId, blogUrl: result.blogUrl, sessionPath });
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const creds = readNaverCredentials();
|
|
77
|
+
const resolved = {
|
|
78
|
+
headless,
|
|
79
|
+
manual,
|
|
80
|
+
username: username || creds.username,
|
|
81
|
+
password: password || creds.password,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (!resolved.manual && (!resolved.username || !resolved.password)) {
|
|
85
|
+
throw new Error('네이버 자동 로그인을 진행하려면 username/password가 필요합니다. 환경변수 NAVER_USERNAME / NAVER_PASSWORD를 설정하거나 --manual 모드를 사용해 주세요.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = await askForAuthentication(resolved);
|
|
89
|
+
saveProviderMeta('naver', {
|
|
90
|
+
loggedIn: result.loggedIn,
|
|
91
|
+
blogId: result.blogId,
|
|
92
|
+
blogUrl: result.blogUrl,
|
|
93
|
+
sessionPath: result.sessionPath,
|
|
94
|
+
});
|
|
95
|
+
return result;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async publish(payload) {
|
|
99
|
+
return withProviderSession(async () => {
|
|
100
|
+
const title = payload.title || '제목 없음';
|
|
101
|
+
const rawContent = payload.content || '';
|
|
102
|
+
const openType = mapNaverVisibility(payload.visibility);
|
|
103
|
+
const tags = normalizeNaverTagList(payload.tags);
|
|
104
|
+
const imageUrls = payload.imageUrls || [];
|
|
105
|
+
const relatedImageKeywords = payload.relatedImageKeywords || [];
|
|
106
|
+
const autoUploadImages = payload.autoUploadImages !== false;
|
|
107
|
+
const imageUploadLimit = Number(payload.imageUploadLimit) || 2;
|
|
108
|
+
|
|
109
|
+
await naverApi.initBlog();
|
|
110
|
+
const rawCategories = await naverApi.getCategories();
|
|
111
|
+
const categories = Object.entries(rawCategories).map(([name, id]) => ({ name, id: Number(id) })).sort((a, b) => a.id - b.id);
|
|
112
|
+
|
|
113
|
+
// 카테고리 결정
|
|
114
|
+
let categoryNo = payload.category;
|
|
115
|
+
if (categoryNo === undefined || categoryNo === null || String(categoryNo).trim() === '') {
|
|
116
|
+
if (categories.length === 0) {
|
|
117
|
+
categoryNo = '0';
|
|
118
|
+
} else if (categories.length === 1) {
|
|
119
|
+
categoryNo = String(categories[0].id);
|
|
120
|
+
} else {
|
|
121
|
+
return {
|
|
122
|
+
provider: 'naver',
|
|
123
|
+
mode: 'publish',
|
|
124
|
+
status: 'need_category',
|
|
125
|
+
loggedIn: true,
|
|
126
|
+
title,
|
|
127
|
+
openType,
|
|
128
|
+
tags,
|
|
129
|
+
message: '발행을 위해 카테고리가 필요합니다. categories를 확인하고 category를 지정해 주세요.',
|
|
130
|
+
categories,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
categoryNo = String(categoryNo);
|
|
135
|
+
|
|
136
|
+
// 이미지 업로드 (imageUrls + relatedImageKeywords 기반 자동 검색)
|
|
137
|
+
let imageComponents = [];
|
|
138
|
+
const hasImageSources = imageUrls.length > 0 || relatedImageKeywords.length > 0;
|
|
139
|
+
if (autoUploadImages && hasImageSources) {
|
|
140
|
+
const token = await naverApi.getToken(categoryNo);
|
|
141
|
+
const uploadResult = await collectAndUploadImages(naverApi, {
|
|
142
|
+
imageUrls,
|
|
143
|
+
relatedImageKeywords,
|
|
144
|
+
token,
|
|
145
|
+
imageUploadLimit,
|
|
146
|
+
});
|
|
147
|
+
imageComponents = uploadResult.components;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// HTML → 에디터 컴포넌트 변환
|
|
151
|
+
const contentComponents = await convertHtmlToEditorComponents(naverApi, rawContent, imageComponents);
|
|
152
|
+
|
|
153
|
+
const result = await naverApi.publishPost({
|
|
154
|
+
title,
|
|
155
|
+
content: contentComponents,
|
|
156
|
+
categoryNo,
|
|
157
|
+
tags,
|
|
158
|
+
openType,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
provider: 'naver',
|
|
163
|
+
mode: 'publish',
|
|
164
|
+
title,
|
|
165
|
+
category: categoryNo,
|
|
166
|
+
openType,
|
|
167
|
+
tags,
|
|
168
|
+
imageCount: imageComponents.length,
|
|
169
|
+
url: result.entryUrl || null,
|
|
170
|
+
raw: result,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async saveDraft(payload) {
|
|
176
|
+
// 네이버는 임시저장 API가 없으므로 비공개(openType: 0)로 발행
|
|
177
|
+
return this.publish({
|
|
178
|
+
...payload,
|
|
179
|
+
visibility: 'private',
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async listCategories() {
|
|
184
|
+
return withProviderSession(async () => {
|
|
185
|
+
await naverApi.initBlog();
|
|
186
|
+
const rawCategories = await naverApi.getCategories();
|
|
187
|
+
return {
|
|
188
|
+
provider: 'naver',
|
|
189
|
+
categories: Object.entries(rawCategories).map(([name, id]) => ({
|
|
190
|
+
name,
|
|
191
|
+
id: Number(id),
|
|
192
|
+
})),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async listPosts({ limit = 20 } = {}) {
|
|
198
|
+
return withProviderSession(async () => {
|
|
199
|
+
await naverApi.initBlog();
|
|
200
|
+
const result = await naverApi.getPosts({ countPerPage: Math.max(1, Number(limit) || 20) });
|
|
201
|
+
const items = Array.isArray(result?.items) ? result.items : [];
|
|
202
|
+
return {
|
|
203
|
+
provider: 'naver',
|
|
204
|
+
totalCount: result.totalCount || items.length,
|
|
205
|
+
posts: items.slice(0, Math.max(1, Number(limit) || 20)),
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async getPost({ postId } = {}) {
|
|
211
|
+
return withProviderSession(async () => {
|
|
212
|
+
const resolvedPostId = String(postId || '').trim();
|
|
213
|
+
if (!resolvedPostId) {
|
|
214
|
+
return {
|
|
215
|
+
provider: 'naver',
|
|
216
|
+
mode: 'post',
|
|
217
|
+
status: 'invalid_post_id',
|
|
218
|
+
message: 'postId가 필요합니다.',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await naverApi.initBlog();
|
|
223
|
+
const post = await naverApi.getPost({ postId: resolvedPostId });
|
|
224
|
+
if (!post) {
|
|
225
|
+
return {
|
|
226
|
+
provider: 'naver',
|
|
227
|
+
mode: 'post',
|
|
228
|
+
status: 'not_found',
|
|
229
|
+
postId: resolvedPostId,
|
|
230
|
+
message: '해당 postId의 글을 찾을 수 없습니다.',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
provider: 'naver',
|
|
235
|
+
mode: 'post',
|
|
236
|
+
postId: resolvedPostId,
|
|
237
|
+
post,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
async logout() {
|
|
243
|
+
clearProviderMeta('naver');
|
|
244
|
+
return {
|
|
245
|
+
provider: 'naver',
|
|
246
|
+
loggedOut: true,
|
|
247
|
+
sessionPath,
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
module.exports = createNaverProvider;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const NAVER_LOGIN_SELECTORS = {
|
|
2
|
+
username: '#id',
|
|
3
|
+
password: '#pw',
|
|
4
|
+
submit: '#log\\.login',
|
|
5
|
+
keepLogin: '.keep_check',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const NAVER_LOGIN_ERROR_PATTERNS = {
|
|
9
|
+
wrongPassword: '비밀번호가 잘못',
|
|
10
|
+
accountProtected: '회원님의 아이디를 보호',
|
|
11
|
+
phoneNumberMismatch: '등록된 정보와 일치하지',
|
|
12
|
+
regionBlocked: '허용하지 않은 지역에서',
|
|
13
|
+
captcha: ['자동입력 방지 문자', '자동입력 방지문자', 'captcha'],
|
|
14
|
+
usageRestricted: '비정상적인 활동이 감지되어',
|
|
15
|
+
twoFactor: '2단계 인증 알림',
|
|
16
|
+
operationViolation: '운영원칙 위반',
|
|
17
|
+
newDevice: '새로운 기기(브라우저)에서 로그인되었습니다.',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
NAVER_LOGIN_SELECTORS,
|
|
22
|
+
NAVER_LOGIN_ERROR_PATTERNS,
|
|
23
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { saveProviderMeta } = require('../../storage/sessionStore');
|
|
4
|
+
const { readNaverCredentials, parseNaverSessionError, buildLoginErrorMessage } = require('./utils');
|
|
5
|
+
|
|
6
|
+
const NAVER_COOKIE_DOMAINS = ['https://www.naver.com', 'https://nid.naver.com', 'https://blog.naver.com'];
|
|
7
|
+
|
|
8
|
+
const isLoggedInByCookies = async (context) => {
|
|
9
|
+
for (const domain of NAVER_COOKIE_DOMAINS) {
|
|
10
|
+
const cookies = await context.cookies(domain);
|
|
11
|
+
const hasAuth = cookies.some((c) => c.name === 'NID_AUT' || c.name === 'NID_SES');
|
|
12
|
+
if (hasAuth) return true;
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const persistNaverSession = async (context, targetSessionPath) => {
|
|
18
|
+
const allCookies = [];
|
|
19
|
+
for (const domain of NAVER_COOKIE_DOMAINS) {
|
|
20
|
+
const cookies = await context.cookies(domain);
|
|
21
|
+
allCookies.push(...cookies);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 중복 제거 (name+domain 기준)
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const unique = allCookies.filter((c) => {
|
|
27
|
+
const key = `${c.name}@${c.domain}`;
|
|
28
|
+
if (seen.has(key)) return false;
|
|
29
|
+
seen.add(key);
|
|
30
|
+
return true;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const sanitized = unique.map((cookie) => ({
|
|
34
|
+
...cookie,
|
|
35
|
+
expires: Number(cookie.expires || -1),
|
|
36
|
+
size: undefined,
|
|
37
|
+
partitionKey: undefined,
|
|
38
|
+
sourcePort: undefined,
|
|
39
|
+
sourceScheme: undefined,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const payload = {
|
|
43
|
+
cookies: sanitized,
|
|
44
|
+
updatedAt: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
|
|
47
|
+
await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const validateNaverSession = async (sessionPath) => {
|
|
51
|
+
if (!fs.existsSync(sessionPath)) return false;
|
|
52
|
+
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cookies = Array.isArray(raw?.cookies) ? raw.cookies : [];
|
|
61
|
+
return cookies.some((c) => c.name === 'NID_AUT' && c.value) &&
|
|
62
|
+
cookies.some((c) => c.name === 'NID_SES' && c.value);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const createNaverWithProviderSession = (askForAuthentication) => async (fn) => {
|
|
66
|
+
const credentials = readNaverCredentials();
|
|
67
|
+
const hasCredentials = Boolean(credentials.username && credentials.password);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = await fn();
|
|
71
|
+
saveProviderMeta('naver', {
|
|
72
|
+
loggedIn: true,
|
|
73
|
+
lastValidatedAt: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
return result;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!parseNaverSessionError(error) || !hasCredentials) {
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const loginResult = await askForAuthentication({
|
|
83
|
+
headless: false,
|
|
84
|
+
manual: false,
|
|
85
|
+
username: credentials.username,
|
|
86
|
+
password: credentials.password,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
saveProviderMeta('naver', {
|
|
90
|
+
loggedIn: loginResult.loggedIn,
|
|
91
|
+
blogId: loginResult.blogId,
|
|
92
|
+
blogUrl: loginResult.blogUrl,
|
|
93
|
+
sessionPath: loginResult.sessionPath,
|
|
94
|
+
lastRefreshedAt: new Date().toISOString(),
|
|
95
|
+
lastError: null,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!loginResult.loggedIn) {
|
|
99
|
+
throw new Error(loginResult.message || '세션 갱신 후 로그인 상태가 확인되지 않았습니다.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return fn();
|
|
103
|
+
} catch (reloginError) {
|
|
104
|
+
saveProviderMeta('naver', {
|
|
105
|
+
loggedIn: false,
|
|
106
|
+
lastError: buildLoginErrorMessage(reloginError),
|
|
107
|
+
lastValidatedAt: new Date().toISOString(),
|
|
108
|
+
});
|
|
109
|
+
throw reloginError;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
isLoggedInByCookies,
|
|
116
|
+
persistNaverSession,
|
|
117
|
+
validateNaverSession,
|
|
118
|
+
createNaverWithProviderSession,
|
|
119
|
+
};
|