viruagent-cli 0.3.3 → 0.3.5
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 +100 -6
- 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/auth.js +167 -0
- package/src/providers/tistory/browserHelpers.js +91 -0
- package/src/providers/tistory/chromeImport.js +790 -0
- package/src/providers/tistory/fetchLayer.js +237 -0
- package/src/providers/tistory/imageEnrichment.js +574 -0
- package/src/providers/tistory/imageNormalization.js +301 -0
- package/src/providers/tistory/imageSources.js +270 -0
- package/src/providers/tistory/index.js +561 -0
- package/src/providers/tistory/selectors.js +51 -0
- package/src/providers/tistory/session.js +117 -0
- package/src/providers/tistory/utils.js +235 -0
- package/src/services/naverApiClient.js +493 -0
- package/src/services/providerManager.js +2 -2
- package/src/providers/tistoryProvider.js +0 -3141
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const readNaverCredentials = () => {
|
|
2
|
+
const username = process.env.NAVER_USERNAME || process.env.NAVER_USER || process.env.NAVER_ID;
|
|
3
|
+
const password = process.env.NAVER_PASSWORD || process.env.NAVER_PW;
|
|
4
|
+
return {
|
|
5
|
+
username: typeof username === 'string' && username.trim() ? username.trim() : null,
|
|
6
|
+
password: typeof password === 'string' && password.trim() ? password.trim() : null,
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const parseNaverSessionError = (error) => {
|
|
11
|
+
const message = String(error?.message || '').toLowerCase();
|
|
12
|
+
return [
|
|
13
|
+
'세션 파일이 없습니다',
|
|
14
|
+
'세션에 유효한 쿠키',
|
|
15
|
+
'세션이 만료',
|
|
16
|
+
'로그인이 필요합니다',
|
|
17
|
+
'blogid를 찾을 수 없습니다',
|
|
18
|
+
'블로그 정보 조회 실패',
|
|
19
|
+
'다시 로그인',
|
|
20
|
+
'401',
|
|
21
|
+
'403',
|
|
22
|
+
].some((token) => message.includes(token.toLowerCase()));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const buildLoginErrorMessage = (error) => String(error?.message || '세션 검증에 실패했습니다.');
|
|
26
|
+
|
|
27
|
+
const normalizeNaverTagList = (value = '') => {
|
|
28
|
+
const source = Array.isArray(value)
|
|
29
|
+
? value
|
|
30
|
+
: String(value || '').replace(/\r?\n/g, ',').split(',');
|
|
31
|
+
return source
|
|
32
|
+
.map((tag) => String(tag || '').trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((tag) => tag.replace(/["']/g, '').trim())
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.slice(0, 10)
|
|
37
|
+
.join(',');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const mapNaverVisibility = (visibility) => {
|
|
41
|
+
const normalized = String(visibility || 'public').toLowerCase();
|
|
42
|
+
if (normalized === 'private') return 0;
|
|
43
|
+
if (normalized === 'protected' || normalized === 'mutual') return 1;
|
|
44
|
+
return 2; // public (openType: 2)
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
readNaverCredentials,
|
|
51
|
+
parseNaverSessionError,
|
|
52
|
+
buildLoginErrorMessage,
|
|
53
|
+
normalizeNaverTagList,
|
|
54
|
+
mapNaverVisibility,
|
|
55
|
+
sleep,
|
|
56
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { chromium } = require('playwright');
|
|
4
|
+
const { readCredentialsFromEnv } = require('./utils');
|
|
5
|
+
const {
|
|
6
|
+
pickValue,
|
|
7
|
+
fillBySelector,
|
|
8
|
+
clickSubmit,
|
|
9
|
+
checkBySelector,
|
|
10
|
+
hasElement,
|
|
11
|
+
} = require('./browserHelpers');
|
|
12
|
+
const { waitForLoginFinish, persistTistorySession } = require('./session');
|
|
13
|
+
const {
|
|
14
|
+
LOGIN_OTP_SELECTORS,
|
|
15
|
+
KAKAO_TRIGGER_SELECTORS,
|
|
16
|
+
KAKAO_LOGIN_SELECTORS,
|
|
17
|
+
KAKAO_2FA_SELECTORS,
|
|
18
|
+
} = require('./selectors');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* askForAuthentication 팩토리.
|
|
22
|
+
* sessionPath, tistoryApi, pending2faResult를 외부에서 주입받는다.
|
|
23
|
+
*/
|
|
24
|
+
const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult }) => async ({
|
|
25
|
+
headless = false,
|
|
26
|
+
manual = false,
|
|
27
|
+
username,
|
|
28
|
+
password,
|
|
29
|
+
twoFactorCode,
|
|
30
|
+
} = {}) => {
|
|
31
|
+
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
|
|
32
|
+
|
|
33
|
+
const resolvedUsername = username || readCredentialsFromEnv().username;
|
|
34
|
+
const resolvedPassword = password || readCredentialsFromEnv().password;
|
|
35
|
+
const shouldAutoFill = !manual;
|
|
36
|
+
|
|
37
|
+
if (!manual && (!resolvedUsername || !resolvedPassword)) {
|
|
38
|
+
throw new Error('티스토리 로그인 요청에 id/pw가 없습니다. id/pw를 먼저 전달하거나 TISTORY_USERNAME/TISTORY_PASSWORD를 설정해 주세요.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const browser = await chromium.launch({
|
|
42
|
+
headless: manual ? false : headless,
|
|
43
|
+
});
|
|
44
|
+
const context = await browser.newContext();
|
|
45
|
+
|
|
46
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await page.goto('https://www.tistory.com/auth/login', {
|
|
50
|
+
waitUntil: 'domcontentloaded',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const loginId = resolvedUsername;
|
|
54
|
+
const loginPw = resolvedPassword;
|
|
55
|
+
|
|
56
|
+
const kakaoLoginSelector = await pickValue(page, KAKAO_TRIGGER_SELECTORS);
|
|
57
|
+
if (!kakaoLoginSelector) {
|
|
58
|
+
throw new Error('카카오 로그인 버튼을 찾지 못했습니다. 로그인 화면 UI가 변경되었는지 확인해 주세요.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await page.locator(kakaoLoginSelector).click({ timeout: 5000 }).catch(() => {});
|
|
62
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
63
|
+
await page.waitForTimeout(800);
|
|
64
|
+
|
|
65
|
+
let finalLoginStatus = false;
|
|
66
|
+
let pendingTwoFactorAction = false;
|
|
67
|
+
|
|
68
|
+
if (manual) {
|
|
69
|
+
console.log('');
|
|
70
|
+
console.log('==============================');
|
|
71
|
+
console.log('수동 로그인 모드로 전환합니다.');
|
|
72
|
+
console.log('브라우저에서 직접 ID/PW/2차 인증을 완료한 뒤, 로그인 완료 상태를 기다립니다.');
|
|
73
|
+
console.log('로그인 완료 또는 2차 인증은 최대 5분 내에 처리해 주세요.');
|
|
74
|
+
console.log('==============================');
|
|
75
|
+
finalLoginStatus = await waitForLoginFinish(page, context, 300000);
|
|
76
|
+
} else if (shouldAutoFill) {
|
|
77
|
+
const usernameFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.username, loginId);
|
|
78
|
+
const passwordFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.password, loginPw);
|
|
79
|
+
if (!usernameFilled || !passwordFilled) {
|
|
80
|
+
throw new Error('카카오 로그인 폼 입력 필드를 찾지 못했습니다. 티스토리 로그인 화면 변경 시도를 확인해 주세요.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await checkBySelector(page, KAKAO_LOGIN_SELECTORS.rememberLogin);
|
|
84
|
+
const kakaoSubmitted = await clickSubmit(page, KAKAO_LOGIN_SELECTORS.submit);
|
|
85
|
+
if (!kakaoSubmitted) {
|
|
86
|
+
await page.keyboard.press('Enter');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
finalLoginStatus = await waitForLoginFinish(page, context);
|
|
90
|
+
|
|
91
|
+
if (!finalLoginStatus && await hasElement(page, LOGIN_OTP_SELECTORS)) {
|
|
92
|
+
if (!twoFactorCode) {
|
|
93
|
+
return pending2faResult('otp');
|
|
94
|
+
}
|
|
95
|
+
const otpFilled = await fillBySelector(page, LOGIN_OTP_SELECTORS, twoFactorCode);
|
|
96
|
+
if (!otpFilled) {
|
|
97
|
+
throw new Error('OTP 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
|
|
98
|
+
}
|
|
99
|
+
await page.keyboard.press('Enter');
|
|
100
|
+
finalLoginStatus = await waitForLoginFinish(page, context, 45000);
|
|
101
|
+
} else if (!finalLoginStatus && (await hasElement(page, KAKAO_2FA_SELECTORS.start) || page.url().includes('tmsTwoStepVerification') || page.url().includes('emailTwoStepVerification'))) {
|
|
102
|
+
await checkBySelector(page, KAKAO_2FA_SELECTORS.rememberDevice);
|
|
103
|
+
const isEmailModeAvailable = await hasElement(page, KAKAO_2FA_SELECTORS.emailModeButton);
|
|
104
|
+
const hasEmailCodeInput = await hasElement(page, KAKAO_2FA_SELECTORS.codeInput);
|
|
105
|
+
|
|
106
|
+
if (hasEmailCodeInput && twoFactorCode) {
|
|
107
|
+
const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
|
|
108
|
+
if (!codeFilled) {
|
|
109
|
+
throw new Error('2차 인증 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
|
|
110
|
+
}
|
|
111
|
+
const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
|
|
112
|
+
if (!confirmed) {
|
|
113
|
+
await page.keyboard.press('Enter');
|
|
114
|
+
}
|
|
115
|
+
finalLoginStatus = await waitForLoginFinish(page, context, 45000);
|
|
116
|
+
} else if (!twoFactorCode) {
|
|
117
|
+
pendingTwoFactorAction = true;
|
|
118
|
+
} else if (isEmailModeAvailable) {
|
|
119
|
+
await clickSubmit(page, KAKAO_2FA_SELECTORS.emailModeButton).catch(() => {});
|
|
120
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
121
|
+
await page.waitForTimeout(800);
|
|
122
|
+
|
|
123
|
+
const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
|
|
124
|
+
if (!codeFilled) {
|
|
125
|
+
throw new Error('카카오 이메일 인증 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
|
|
129
|
+
if (!confirmed) {
|
|
130
|
+
await page.keyboard.press('Enter');
|
|
131
|
+
}
|
|
132
|
+
finalLoginStatus = await waitForLoginFinish(page, context, 45000);
|
|
133
|
+
} else {
|
|
134
|
+
return pending2faResult('kakao');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!finalLoginStatus) {
|
|
140
|
+
if (pendingTwoFactorAction) {
|
|
141
|
+
return pending2faResult('kakao');
|
|
142
|
+
}
|
|
143
|
+
throw new Error('로그인에 실패했습니다. 아이디/비밀번호가 정확한지 확인하고, 없으면 환경변수 TISTORY_USERNAME/TISTORY_PASSWORD를 다시 설정해 주세요.');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await context.storageState({ path: sessionPath });
|
|
147
|
+
await persistTistorySession(context, sessionPath);
|
|
148
|
+
|
|
149
|
+
tistoryApi.resetState();
|
|
150
|
+
const blogName = await tistoryApi.initBlog();
|
|
151
|
+
return {
|
|
152
|
+
provider: 'tistory',
|
|
153
|
+
loggedIn: true,
|
|
154
|
+
blogName,
|
|
155
|
+
blogUrl: `https://${blogName}.tistory.com`,
|
|
156
|
+
sessionPath,
|
|
157
|
+
};
|
|
158
|
+
} finally {
|
|
159
|
+
if (browser) {
|
|
160
|
+
await browser.close().catch(() => {});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
createAskForAuthentication,
|
|
167
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const {
|
|
2
|
+
KAKAO_ACCOUNT_CONFIRM_SELECTORS,
|
|
3
|
+
} = require('./selectors');
|
|
4
|
+
|
|
5
|
+
const pickValue = async (page, selectors) => {
|
|
6
|
+
for (const selector of selectors) {
|
|
7
|
+
const element = await page.$(selector);
|
|
8
|
+
if (element) {
|
|
9
|
+
return selector;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const fillBySelector = async (page, selectors, value) => {
|
|
16
|
+
const selector = await pickValue(page, selectors);
|
|
17
|
+
if (!selector) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
await page.locator(selector).fill(value);
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const clickSubmit = async (page, selectors) => {
|
|
25
|
+
const selector = await pickValue(page, selectors);
|
|
26
|
+
if (!selector) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
await page.locator(selector).click({ timeout: 5000 });
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const checkBySelector = async (page, selectors) => {
|
|
34
|
+
const selector = await pickValue(page, selectors);
|
|
35
|
+
if (!selector) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const locator = page.locator(selector);
|
|
39
|
+
const isChecked = await locator.isChecked().catch(() => false);
|
|
40
|
+
if (!isChecked) {
|
|
41
|
+
await locator.check({ force: true }).catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const hasElement = async (page, selectors) => {
|
|
47
|
+
for (const selector of selectors) {
|
|
48
|
+
const locator = page.locator(selector);
|
|
49
|
+
const count = await locator.count();
|
|
50
|
+
if (count > 0) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const hasKakaoAccountConfirmScreen = async (page) => {
|
|
58
|
+
const url = page.url();
|
|
59
|
+
const isKakaoDomain = url.includes('accounts.kakao.com') || url.includes('kauth.kakao.com');
|
|
60
|
+
if (!isKakaoDomain) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return await hasElement(page, KAKAO_ACCOUNT_CONFIRM_SELECTORS.textMarker);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const clickKakaoAccountContinue = async (page) => {
|
|
68
|
+
if (!(await hasKakaoAccountConfirmScreen(page))) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const continueSelector = await pickValue(page, KAKAO_ACCOUNT_CONFIRM_SELECTORS.continue);
|
|
73
|
+
if (!continueSelector) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await page.locator(continueSelector).click({ timeout: 5000 });
|
|
78
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
79
|
+
await page.waitForTimeout(800);
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
pickValue,
|
|
85
|
+
fillBySelector,
|
|
86
|
+
clickSubmit,
|
|
87
|
+
checkBySelector,
|
|
88
|
+
hasElement,
|
|
89
|
+
hasKakaoAccountConfirmScreen,
|
|
90
|
+
clickKakaoAccountContinue,
|
|
91
|
+
};
|