viruagent-cli 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/va-insta/SKILL.md +2 -1
- package/skills/va-insta-publish/SKILL.md +157 -0
- package/skills/va-naver-publish/SKILL.md +272 -32
- package/skills/va-shared/SKILL.md +53 -15
- package/skills/va-tistory-publish/SKILL.md +379 -33
- package/src/providers/chromeManager.js +186 -0
- package/src/providers/insta/index.js +6 -6
- package/src/providers/insta/session.js +4 -7
- package/src/providers/naver/auth.js +37 -23
- package/src/providers/naver/index.js +6 -10
- package/src/providers/naver/session.js +22 -28
- package/src/providers/tistory/auth.js +129 -105
- package/src/providers/tistory/index.js +16 -7
- package/src/providers/tistory/session.js +66 -24
- package/src/runner.js +2 -1
- package/src/services/providerManager.js +7 -5
- package/src/storage/sessionStore.js +18 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: va-tistory-publish
|
|
3
|
-
version:
|
|
4
|
-
description: "Tistory: 블로그 글 발행
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Tistory: 블로그 글 발행 — 5종 포맷 템플릿 (story/howto/list/review/qa)"
|
|
5
5
|
metadata:
|
|
6
6
|
category: "command"
|
|
7
7
|
provider: "tistory"
|
|
@@ -17,13 +17,13 @@ metadata:
|
|
|
17
17
|
```bash
|
|
18
18
|
npx viruagent-cli publish \
|
|
19
19
|
--provider tistory \
|
|
20
|
-
--title "
|
|
20
|
+
--title "제목 (25~35자, 핵심 키워드 앞배치)" \
|
|
21
21
|
--content "<h2>...</h2><p>...</p>" \
|
|
22
22
|
--category <id> \
|
|
23
23
|
--tags "tag1,tag2,tag3,tag4,tag5" \
|
|
24
24
|
--visibility public \
|
|
25
|
-
--related-image-keywords "keyword1
|
|
26
|
-
--image-upload-limit
|
|
25
|
+
--related-image-keywords "keyword1 keyword2 keyword3" \
|
|
26
|
+
--image-upload-limit 3 \
|
|
27
27
|
--minimum-image-count 1
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -38,46 +38,392 @@ npx viruagent-cli publish \
|
|
|
38
38
|
| `--tags` | 쉼표 구분 태그 (5개) | - |
|
|
39
39
|
| `--visibility` | public / private | public |
|
|
40
40
|
| `--related-image-keywords` | 이미지 검색 키워드 (영어) | - |
|
|
41
|
-
| `--image-upload-limit` | 최대 이미지 수 |
|
|
41
|
+
| `--image-upload-limit` | 최대 이미지 수 | 3 |
|
|
42
42
|
| `--minimum-image-count` | 최소 이미지 수 | 1 |
|
|
43
43
|
| `--dry-run` | 파라미터만 검증 | false |
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 티스토리 SEO 핵심 원칙 (구글 기준)
|
|
48
|
+
|
|
49
|
+
- **제목**: 25~35자, 핵심 키워드를 앞쪽에 배치. 숫자/연도 포함 시 CTR +23%
|
|
50
|
+
- **글자 수**: 정보성 글 3,000~5,000자, 경쟁 키워드 5,000자 이상
|
|
51
|
+
- **H태그 계층**: 블로그 타이틀이 h1 → 본문은 `h2` → `h3` → `h4` 순서. **본문에 h1 사용 금지**
|
|
52
|
+
- **소제목 간격**: 600~1,000자(약 300~500단어)마다 h3 1개
|
|
53
|
+
- **목차**: 3개 이상 섹션이면 앵커 링크 목차 삽입 (Featured Snippet 진입 확률 상승)
|
|
54
|
+
- **이미지 alt 태그**: 반드시 `핵심키워드 + 설명` 형식으로 작성
|
|
55
|
+
- **내부 링크**: 실제 발행된 URL이 제공된 경우에만 삽입. URL 없으면 섹션 전체 생략 (가짜 링크 절대 금지)
|
|
56
|
+
- **E-E-A-T**: 직접 경험 기반 문장, 데이터/출처 인용, 작성자 관점 명시
|
|
57
|
+
|
|
58
|
+
### Tistory HTML 규칙
|
|
59
|
+
- `data-ke-*` 속성 사용 — Tistory 에디터가 자동 처리하므로 일반 HTML로 작성
|
|
60
|
+
- 이미지 삽입 후 alt 속성 추가 필수
|
|
61
|
+
- `<blockquote>` 사용 가능 (티스토리가 스타일 자동 적용)
|
|
62
|
+
|
|
63
|
+
### 피해야 할 패턴
|
|
64
|
+
- 본문에 `<h1>` 직접 사용 (블로그 타이틀과 중복)
|
|
65
|
+
- `<h2>` 건너뛰고 `<h3>` 바로 사용
|
|
66
|
+
- 균일한 3단락 공식 반복 — AI 탐지 위험
|
|
67
|
+
- 이미지 alt 태그 비워두기 (이미지 SEO 가치 전체 손실)
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 서론 후킹 패턴 3종 (첫 3문장이 체류시간 결정)
|
|
72
|
+
|
|
73
|
+
### 패턴 A — 공감형 (범용, 정보성 글)
|
|
74
|
+
```html
|
|
75
|
+
<p>[독자가 이미 겪고 있는 상황을 1문장으로 정확히 표현]</p>
|
|
76
|
+
<p>그런데 사실 그 이유는 생각보다 단순합니다. [반전 핵심 한 줄]</p>
|
|
77
|
+
<p>이 글에서는 [구체적 약속 — 읽으면 얻는 것]을 다룹니다.</p>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 패턴 B — 손실 회피형 (경쟁 키워드, 클릭 유지)
|
|
81
|
+
```html
|
|
82
|
+
<blockquote>[모르면 손해인 핵심 한 줄. 구체적 수치 포함]</blockquote>
|
|
83
|
+
<p>실제로 [통계나 사례 — 신뢰 부여]. 대부분은 이 차이를 모르고 넘어갑니다.</p>
|
|
84
|
+
<p>지금부터 [해결책 예고]를 정리해 드립니다.</p>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 패턴 C — 질문형 (전문성 어필, 심화 콘텐츠)
|
|
88
|
+
```html
|
|
89
|
+
<p>[독자가 검색창에 치는 질문을 그대로 첫 문장으로]</p>
|
|
90
|
+
<p>[데이터나 통계로 문제의 크기를 보여줌]. 저도 처음엔 이 답을 찾는 데 [시간]이 걸렸습니다.</p>
|
|
91
|
+
<p>지금부터 [핵심 내용 예고]합니다.</p>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 목차 블록 (3개 이상 섹션이면 항상 삽입)
|
|
97
|
+
|
|
98
|
+
```html
|
|
99
|
+
<h2>목차</h2>
|
|
100
|
+
<ul>
|
|
101
|
+
<li><a href="#section1">[섹션 1 제목]</a></li>
|
|
102
|
+
<li><a href="#section2">[섹션 2 제목]</a></li>
|
|
103
|
+
<li><a href="#section3">[섹션 3 제목]</a></li>
|
|
104
|
+
<li><a href="#section4">[섹션 4 제목]</a></li>
|
|
105
|
+
</ul>
|
|
106
|
+
<p> </p>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 포맷 5종 템플릿
|
|
112
|
+
|
|
113
|
+
글 성격에 맞는 포맷을 선택하세요. **기본 추천: story형** (체류시간 최고, AI 탐지 회피 최적).
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### 1. story형 — 스토리텔링 (체류시간 최고, AI 탐지 낮음)
|
|
118
|
+
경험 후기, 사례 공유, 인사이트 전달에 적합.
|
|
119
|
+
|
|
120
|
+
```html
|
|
121
|
+
<!-- 후킹: 패턴 A 또는 C -->
|
|
122
|
+
<blockquote>[임팩트 한 줄 — 독자의 상황을 대변하는 문장]</blockquote>
|
|
123
|
+
<p> </p>
|
|
124
|
+
|
|
125
|
+
<p>[독자 공감 + 내 경험으로 연결 — 3~5문장. "저도 그랬습니다" 식으로 시작]</p>
|
|
126
|
+
<p>[이 글에서 다룰 내용 예고 — 구체적으로]</p>
|
|
127
|
+
<p> </p>
|
|
128
|
+
|
|
129
|
+
<!-- 목차 삽입 -->
|
|
130
|
+
<h2>목차</h2>
|
|
131
|
+
<ul>
|
|
132
|
+
<li><a href="#s1">[섹션 1]</a></li>
|
|
133
|
+
<li><a href="#s2">[섹션 2]</a></li>
|
|
134
|
+
<li><a href="#s3">[섹션 3]</a></li>
|
|
135
|
+
</ul>
|
|
136
|
+
<p> </p>
|
|
137
|
+
|
|
138
|
+
<h2 id="s1">[섹션 1 — 문제 상황 묘사]</h2>
|
|
139
|
+
<h3>[세부 관점 1]</h3>
|
|
140
|
+
<p>[직접 겪은 구체적 상황 — 날짜, 수치, 감정 포함. 3~5문장]</p>
|
|
141
|
+
<p>[그 상황에서 발견한 인사이트 — 독자가 몰랐던 관점]</p>
|
|
142
|
+
<p> </p>
|
|
143
|
+
|
|
144
|
+
<h2 id="s2">[섹션 2 — 전환점 또는 발견]</h2>
|
|
145
|
+
<h3>[세부 관점 1]</h3>
|
|
146
|
+
<p>[문제를 어떻게 바라보게 됐는지 — BAB 구조의 After 단계]</p>
|
|
147
|
+
<p>[구체적 수치나 결과 언급]</p>
|
|
148
|
+
<p> </p>
|
|
149
|
+
<h3>[세부 관점 2]</h3>
|
|
150
|
+
<p>[추가 설명 — 짧은 문장. 긴 문장과 섞어서]</p>
|
|
151
|
+
<p> </p>
|
|
152
|
+
|
|
153
|
+
<h2 id="s3">[섹션 3 — 실전 적용법]</h2>
|
|
154
|
+
<h3>[방법 1]</h3>
|
|
155
|
+
<p>[독자가 바로 쓸 수 있는 방법 — 단계보다 맥락 중심으로]</p>
|
|
156
|
+
<p>[예상 실패 포인트와 극복법 — 경험 기반으로]</p>
|
|
157
|
+
<p> </p>
|
|
158
|
+
<h3>[방법 2]</h3>
|
|
159
|
+
<p>[동일 구조]</p>
|
|
160
|
+
<p> </p>
|
|
161
|
+
|
|
162
|
+
<h2>[섹션 4 — 의외의 발견 또는 여담]</h2>
|
|
163
|
+
<p>[예상 못한 결과나 부가적 인사이트 — 이 섹션이 인간미를 만듦]</p>
|
|
164
|
+
<p> </p>
|
|
165
|
+
|
|
166
|
+
<!-- 내부 링크: 실제 발행된 URL만 사용. 모르면 섹션 전체 생략 -->
|
|
167
|
+
|
|
168
|
+
<p>[핵심 요약 1~2문장] [독자에게 구체적 행동 제안] [공유/댓글 유도]</p>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
### 2. howto형 — 튜토리얼 (정보 쿼리 1위, 체류시간 높음)
|
|
174
|
+
설치법, 사용법, 단계별 가이드에 적합.
|
|
175
|
+
|
|
176
|
+
```html
|
|
177
|
+
<!-- 후킹: 패턴 B -->
|
|
178
|
+
<blockquote>[이 글 하나로 [X]할 수 있습니다 — 구체적 약속]</blockquote>
|
|
179
|
+
<p> </p>
|
|
180
|
+
|
|
181
|
+
<p>[왜 이 방법이 필요한지 — 기존 방식의 문제점. 2~3문장]</p>
|
|
182
|
+
<p>[이 글을 끝까지 읽으면 얻는 것]</p>
|
|
183
|
+
<p> </p>
|
|
184
|
+
|
|
185
|
+
<!-- 목차 -->
|
|
186
|
+
<h2>목차</h2>
|
|
187
|
+
<ul>
|
|
188
|
+
<li><a href="#prep">준비물</a></li>
|
|
189
|
+
<li><a href="#step1">1단계 — [단계명]</a></li>
|
|
190
|
+
<li><a href="#step2">2단계 — [단계명]</a></li>
|
|
191
|
+
<li><a href="#step3">3단계 — [단계명]</a></li>
|
|
192
|
+
<li><a href="#faq">자주 묻는 질문</a></li>
|
|
193
|
+
</ul>
|
|
194
|
+
<p> </p>
|
|
195
|
+
|
|
196
|
+
<h2 id="prep">시작 전 준비물</h2>
|
|
197
|
+
<p>[왜 필요한지 이유 포함]</p>
|
|
198
|
+
<ul>
|
|
199
|
+
<li>[준비물 1] — [이유]</li>
|
|
200
|
+
<li>[준비물 2] — [이유]</li>
|
|
201
|
+
</ul>
|
|
202
|
+
<p> </p>
|
|
203
|
+
|
|
204
|
+
<h2 id="step1">1단계 — [단계명]</h2>
|
|
205
|
+
<h3>무엇을 하는 단계인가</h3>
|
|
206
|
+
<p>[맥락 제공 — 왜 이 단계가 필요한지]</p>
|
|
207
|
+
<h3>실행 방법</h3>
|
|
208
|
+
<p>[구체적 방법 — 이미지 직후 텍스트 설명]</p>
|
|
209
|
+
<p>[이 단계에서 흔히 하는 실수와 예방법]</p>
|
|
210
|
+
<p> </p>
|
|
211
|
+
|
|
212
|
+
<h2 id="step2">2단계 — [단계명]</h2>
|
|
213
|
+
<h3>무엇을 하는 단계인가</h3>
|
|
214
|
+
<p>[맥락 제공]</p>
|
|
215
|
+
<h3>실행 방법</h3>
|
|
216
|
+
<p>[방법 + 주의사항]</p>
|
|
217
|
+
<p> </p>
|
|
218
|
+
|
|
219
|
+
<h2 id="step3">3단계 — [단계명]</h2>
|
|
220
|
+
<p>[동일 구조]</p>
|
|
221
|
+
<p> </p>
|
|
222
|
+
|
|
223
|
+
<h2 id="faq">자주 묻는 질문</h2>
|
|
224
|
+
<h3>[가장 흔한 질문]</h3>
|
|
225
|
+
<p>[구체적 답변]</p>
|
|
226
|
+
<h3>[두 번째 질문]</h3>
|
|
227
|
+
<p>[구체적 답변]</p>
|
|
228
|
+
<p> </p>
|
|
229
|
+
|
|
230
|
+
<!-- 내부 링크: 실제 발행된 URL만 사용. 모르면 섹션 전체 생략 -->
|
|
231
|
+
|
|
232
|
+
<p>[완료 축하 + 다음 단계 제안]</p>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
### 3. list형 — 리스트 정리 (CTR 높음, 초기 트래픽 확보)
|
|
238
|
+
"베스트 N", "추천 N선", 비교 정리글에 적합.
|
|
239
|
+
|
|
240
|
+
```html
|
|
241
|
+
<!-- 후킹: 패턴 B -->
|
|
242
|
+
<blockquote>[N가지 중 실제로 쓸 만한 건 [X]개입니다 — 기대 역전]</blockquote>
|
|
243
|
+
<p> </p>
|
|
244
|
+
|
|
245
|
+
<p>[선별 기준 명시 — "제가 직접 [기간] 동안 사용/테스트한 기준"]</p>
|
|
246
|
+
<p>[이 글에서 다루는 범위]</p>
|
|
247
|
+
<p> </p>
|
|
248
|
+
|
|
249
|
+
<!-- 목차 -->
|
|
250
|
+
<h2>목차</h2>
|
|
251
|
+
<ul>
|
|
252
|
+
<li><a href="#item1">1. [항목명]</a></li>
|
|
253
|
+
<li><a href="#item2">2. [항목명]</a></li>
|
|
254
|
+
<li><a href="#compare">한눈에 비교</a></li>
|
|
255
|
+
<li><a href="#conclusion">상황별 추천</a></li>
|
|
256
|
+
</ul>
|
|
257
|
+
<p> </p>
|
|
258
|
+
|
|
259
|
+
<h2 id="item1">1. [항목명] — [핵심 특징 한 줄]</h2>
|
|
260
|
+
<h3>왜 1위인가</h3>
|
|
261
|
+
<p>[단순 설명이 아닌 직접 경험 기반 이유]</p>
|
|
262
|
+
<h3>장단점</h3>
|
|
263
|
+
<p>[장점 — 구체적 수치나 사례]</p>
|
|
264
|
+
<p>[단점 솔직하게 1~2줄 — 없으면 신뢰도 하락]</p>
|
|
265
|
+
<p> </p>
|
|
266
|
+
|
|
267
|
+
<h2 id="item2">2. [항목명] — [핵심 특징 한 줄]</h2>
|
|
268
|
+
<h3>특징과 실사용 경험</h3>
|
|
269
|
+
<p>[앞 항목과 차별점 명시]</p>
|
|
270
|
+
<p> </p>
|
|
271
|
+
|
|
272
|
+
<!-- 3~N개 동일 패턴 -->
|
|
273
|
+
|
|
274
|
+
<h2 id="compare">한눈에 비교</h2>
|
|
275
|
+
<p>[표 또는 항목별 비교 요약]</p>
|
|
276
|
+
<p> </p>
|
|
277
|
+
|
|
278
|
+
<h2 id="conclusion">결론 — 상황별 추천</h2>
|
|
279
|
+
<h3>[상황 A]에는 [항목 X]</h3>
|
|
280
|
+
<p>[이유]</p>
|
|
281
|
+
<h3>[상황 B]에는 [항목 Y]</h3>
|
|
282
|
+
<p>[이유]</p>
|
|
283
|
+
<p> </p>
|
|
284
|
+
|
|
285
|
+
<!-- 내부 링크: 실제 발행된 URL만 사용. 모르면 섹션 전체 생략 -->
|
|
286
|
+
|
|
287
|
+
<p>[내 개인 픽 + 이유] [댓글 유도 — "여러분이 써본 것 중 좋았던 건?"]</p>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
### 4. review형 — 분석/리뷰 (전문성 최고, AI 탐지 낮음)
|
|
293
|
+
도구 분석, 서비스 리뷰, 책/영상 리뷰에 적합.
|
|
294
|
+
|
|
295
|
+
```html
|
|
296
|
+
<!-- 후킹: 패턴 C -->
|
|
297
|
+
<blockquote>결론부터 말하면: [한 줄 평가 — 긍정/부정 명확하게]</blockquote>
|
|
298
|
+
<p> </p>
|
|
299
|
+
|
|
300
|
+
<p>[어떤 계기로 사용/분석하게 됐는지 — 배경 스토리. 2~3문장]</p>
|
|
301
|
+
<p>[리뷰 관점/기준 명시 — "저는 [직군/상황]의 입장에서 평가했습니다"]</p>
|
|
302
|
+
<p> </p>
|
|
303
|
+
|
|
304
|
+
<!-- 목차 -->
|
|
305
|
+
<h2>목차</h2>
|
|
306
|
+
<ul>
|
|
307
|
+
<li><a href="#summary">한줄 요약</a></li>
|
|
308
|
+
<li><a href="#feature1">[분석 1]</a></li>
|
|
309
|
+
<li><a href="#feature2">[분석 2]</a></li>
|
|
310
|
+
<li><a href="#vs">경쟁 제품과 비교</a></li>
|
|
311
|
+
<li><a href="#recommend">이런 분께 추천</a></li>
|
|
312
|
+
</ul>
|
|
313
|
+
<p> </p>
|
|
314
|
+
|
|
315
|
+
<h2 id="summary">한줄 요약</h2>
|
|
316
|
+
<p>[장점 2~3가지 + 단점 1~2가지 솔직하게]</p>
|
|
317
|
+
<p> </p>
|
|
318
|
+
|
|
319
|
+
<h2 id="feature1">[분석 섹션 1 — 가장 중요한 특징]</h2>
|
|
320
|
+
<h3>[세부 항목 1]</h3>
|
|
321
|
+
<p>[특징 설명 → 실제 사용 경험 → 내 평가 순서]</p>
|
|
322
|
+
<p>[수치나 비교 대상 포함]</p>
|
|
323
|
+
<h3>[세부 항목 2]</h3>
|
|
324
|
+
<p>[동일 구조]</p>
|
|
325
|
+
<p> </p>
|
|
326
|
+
|
|
327
|
+
<h2 id="feature2">[분석 섹션 2]</h2>
|
|
328
|
+
<p>[동일 구조]</p>
|
|
329
|
+
<p> </p>
|
|
330
|
+
|
|
331
|
+
<h2 id="vs">경쟁 제품/서비스와 비교</h2>
|
|
332
|
+
<h3>[어떤 상황에서 이게 나은가]</h3>
|
|
333
|
+
<p>[이유]</p>
|
|
334
|
+
<h3>[어떤 상황에서 다른 게 나은가]</h3>
|
|
335
|
+
<p>[솔직하게]</p>
|
|
336
|
+
<p> </p>
|
|
337
|
+
|
|
338
|
+
<h2 id="recommend">이런 분께 추천합니다</h2>
|
|
339
|
+
<h3>추천하는 경우</h3>
|
|
340
|
+
<p>[타겟 명확히]</p>
|
|
341
|
+
<h3>추천하지 않는 경우</h3>
|
|
342
|
+
<p>[맞지 않는 상황도 언급 — 신뢰도 상승]</p>
|
|
343
|
+
<p> </p>
|
|
344
|
+
|
|
345
|
+
<!-- 내부 링크: 실제 발행된 URL만 사용. 모르면 섹션 전체 생략 -->
|
|
346
|
+
|
|
347
|
+
<p>[최종 점수나 별점 + 총평 1~2문장]</p>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
### 5. qa형 — Q&A (Featured Snippet 최적, 롱테일 키워드)
|
|
353
|
+
개념 설명, FAQ, "~가 뭔가요?" 쿼리에 적합.
|
|
46
354
|
|
|
47
355
|
```html
|
|
48
|
-
<!--
|
|
49
|
-
<
|
|
50
|
-
<p
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
<p data-ke-size="size16"> </p>
|
|
56
|
-
|
|
57
|
-
<!-- 3. 본문 (3~4 섹션) -->
|
|
58
|
-
<h2>[섹션 제목]</h2>
|
|
59
|
-
<p data-ke-size="size18">[3~5문장, 근거 포함]</p>
|
|
60
|
-
<p data-ke-size="size18">[분석과 시사점]</p>
|
|
61
|
-
<p data-ke-size="size16"> </p>
|
|
62
|
-
|
|
63
|
-
<!-- 4. 핵심 정리 -->
|
|
64
|
-
<h2>핵심 정리</h2>
|
|
356
|
+
<!-- 후킹: 패턴 C -->
|
|
357
|
+
<p>[가장 많이 검색되는 질문을 첫 문장으로 — 검색창 그대로]</p>
|
|
358
|
+
<p>[이 질문이 중요한 이유 + 이 글에서 다룰 범위]</p>
|
|
359
|
+
<p> </p>
|
|
360
|
+
|
|
361
|
+
<!-- 목차 -->
|
|
362
|
+
<h2>목차</h2>
|
|
65
363
|
<ul>
|
|
66
|
-
<li>[
|
|
67
|
-
<li>[
|
|
68
|
-
<li>[
|
|
364
|
+
<li><a href="#q1">[질문 1]</a></li>
|
|
365
|
+
<li><a href="#q2">[질문 2]</a></li>
|
|
366
|
+
<li><a href="#q3">[질문 3]</a></li>
|
|
367
|
+
<li><a href="#q4">[질문 4 — 심화]</a></li>
|
|
368
|
+
<li><a href="#summary">핵심 정리</a></li>
|
|
69
369
|
</ul>
|
|
70
|
-
<p
|
|
370
|
+
<p> </p>
|
|
71
371
|
|
|
72
|
-
|
|
73
|
-
<p
|
|
372
|
+
<h2 id="q1">[핵심 질문 1]</h2>
|
|
373
|
+
<p>[직접 답변 — 첫 문장에 핵심 답 먼저. 구글 스니펫 추출 고려]</p>
|
|
374
|
+
<h3>더 자세히</h3>
|
|
375
|
+
<p>[부연 설명 — 왜 그런지, 어떤 경우에 해당하는지]</p>
|
|
376
|
+
<p>[실제 사례나 예시]</p>
|
|
377
|
+
<p> </p>
|
|
378
|
+
|
|
379
|
+
<h2 id="q2">[핵심 질문 2]</h2>
|
|
380
|
+
<p>[답 먼저, 설명 나중]</p>
|
|
381
|
+
<h3>예시</h3>
|
|
382
|
+
<p>[구체적 예시]</p>
|
|
383
|
+
<p> </p>
|
|
384
|
+
|
|
385
|
+
<h2 id="q3">[핵심 질문 3]</h2>
|
|
386
|
+
<p>[동일 구조]</p>
|
|
387
|
+
<p> </p>
|
|
388
|
+
|
|
389
|
+
<h2 id="q4">[심화 질문 — 독자가 생각 못 했던 질문]</h2>
|
|
390
|
+
<p>[이 섹션이 전문성 어필 포인트]</p>
|
|
391
|
+
<p> </p>
|
|
392
|
+
|
|
393
|
+
<h2 id="summary">핵심 정리</h2>
|
|
394
|
+
<ul>
|
|
395
|
+
<li>[Q1 핵심 답 — 한 줄]</li>
|
|
396
|
+
<li>[Q2 핵심 답 — 한 줄]</li>
|
|
397
|
+
<li>[Q3 핵심 답 — 한 줄]</li>
|
|
398
|
+
<li>[Q4 핵심 답 — 한 줄]</li>
|
|
399
|
+
</ul>
|
|
400
|
+
<p> </p>
|
|
401
|
+
|
|
402
|
+
<!-- 내부 링크: 실제 발행된 URL만 사용. 모르면 섹션 전체 생략 -->
|
|
403
|
+
|
|
404
|
+
<p>[추가 궁금한 점은 댓글로 — 독자 참여 유도]</p>
|
|
74
405
|
```
|
|
75
406
|
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## 포맷 선택 가이드
|
|
410
|
+
|
|
411
|
+
| 글 주제 | 추천 포맷 |
|
|
412
|
+
|---------|---------|
|
|
413
|
+
| 도구/서비스 소개, 경험 공유 | **story** |
|
|
414
|
+
| 설치법, 사용법, 단계별 방법 | **howto** |
|
|
415
|
+
| 추천 목록, 비교 정리 | **list** |
|
|
416
|
+
| 심층 분석, 솔직 리뷰 | **review** |
|
|
417
|
+
| 개념 설명, 자주 묻는 질문 | **qa** |
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
76
421
|
## 이미지 규칙
|
|
77
422
|
|
|
78
|
-
- `--related-image-keywords`에 영어 키워드 2~3개
|
|
79
|
-
- `--image-upload-limit
|
|
80
|
-
-
|
|
423
|
+
- `--related-image-keywords`에 영어 키워드 2~3개
|
|
424
|
+
- `--image-upload-limit 3`, `--minimum-image-count 1` 기본값
|
|
425
|
+
- 이미지는 설명 단락 **위**에 배치 — "시각 → 텍스트" 순서
|
|
426
|
+
- 이미지 alt 태그: `핵심키워드 + 설명` 형식 필수
|
|
81
427
|
|
|
82
428
|
## 발행 후 검증
|
|
83
429
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const { exec } = require('child_process');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { chromium } = require('playwright');
|
|
7
|
+
|
|
8
|
+
const CHROME_PROFILE_DIR = path.join(os.homedir(), '.viruagent-cli', 'chrome-profile');
|
|
9
|
+
const CDP_PORT = 9224;
|
|
10
|
+
|
|
11
|
+
// openchrome uses port 9222 and keeps an always-on Chrome with existing sessions.
|
|
12
|
+
// If installed, login requires no 2FA and reuses your browser's saved accounts.
|
|
13
|
+
// Install: npx openchrome-mcp setup
|
|
14
|
+
const OPENCHROME_PORT = 9222;
|
|
15
|
+
const OPENCHROME_PROFILE = path.join(os.homedir(), '.openchrome', 'profile');
|
|
16
|
+
|
|
17
|
+
const CHROME_PATHS = [
|
|
18
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
20
|
+
'/usr/bin/google-chrome',
|
|
21
|
+
'/usr/bin/google-chrome-stable',
|
|
22
|
+
'/usr/bin/chromium-browser',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function findChrome() {
|
|
26
|
+
for (const p of CHROME_PATHS) {
|
|
27
|
+
if (fs.existsSync(p)) return p;
|
|
28
|
+
}
|
|
29
|
+
throw new Error('Google Chrome not found. Please install Chrome and try again.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isCDPAvailable(port) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const req = http.get(`http://localhost:${port}/json/version`, (res) => {
|
|
35
|
+
resolve(res.statusCode === 200);
|
|
36
|
+
});
|
|
37
|
+
req.on('error', () => resolve(false));
|
|
38
|
+
req.setTimeout(1500, () => { req.destroy(); resolve(false); });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isOpenChromeInstalled() {
|
|
43
|
+
return fs.existsSync(OPENCHROME_PROFILE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the best available Chrome CDP port.
|
|
48
|
+
*
|
|
49
|
+
* Priority:
|
|
50
|
+
* 1. openchrome (port 9222) — already running with saved sessions, no 2FA needed.
|
|
51
|
+
* 2. openchrome installed → start it via `npx openchrome-mcp serve`.
|
|
52
|
+
* 3. Fallback: launch our own Chrome with a dedicated profile (first-time login requires 2FA).
|
|
53
|
+
*
|
|
54
|
+
* TIP: Install openchrome for a seamless login experience (no 2FA, sessions persist).
|
|
55
|
+
* → npx openchrome-mcp setup
|
|
56
|
+
*/
|
|
57
|
+
async function resolveChromeCDP(ownProfileDir = CHROME_PROFILE_DIR) {
|
|
58
|
+
// 1. openchrome already running
|
|
59
|
+
if (await isCDPAvailable(OPENCHROME_PORT)) {
|
|
60
|
+
return { port: OPENCHROME_PORT, source: 'openchrome' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. openchrome installed but Chrome not running → launch Chrome with openchrome profile
|
|
64
|
+
if (isOpenChromeInstalled()) {
|
|
65
|
+
console.error('[viruagent] Launching Chrome with openchrome profile...');
|
|
66
|
+
const chrome = findChrome();
|
|
67
|
+
// Use same flags as openchrome to ensure stability
|
|
68
|
+
const child = exec(`"${chrome}" \
|
|
69
|
+
--remote-debugging-port=${OPENCHROME_PORT} \
|
|
70
|
+
--user-data-dir="${OPENCHROME_PROFILE}" \
|
|
71
|
+
--no-first-run \
|
|
72
|
+
--no-default-browser-check \
|
|
73
|
+
--no-restore-last-session \
|
|
74
|
+
--start-maximized \
|
|
75
|
+
--disable-backgrounding-occluded-windows \
|
|
76
|
+
--disable-blink-features=AutomationControlled \
|
|
77
|
+
--disable-background-networking \
|
|
78
|
+
--disable-sync \
|
|
79
|
+
--disable-translate \
|
|
80
|
+
about:blank`);
|
|
81
|
+
child.unref();
|
|
82
|
+
for (let i = 0; i < 20; i++) {
|
|
83
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
84
|
+
if (await isCDPAvailable(OPENCHROME_PORT)) {
|
|
85
|
+
console.error('[viruagent] openchrome Chrome ready.');
|
|
86
|
+
return { port: OPENCHROME_PORT, source: 'openchrome' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// failed — fall through to own Chrome
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Fallback: our own Chrome with dedicated profile
|
|
93
|
+
// Kill any stale Chrome on CDP_PORT first to ensure correct profile is used
|
|
94
|
+
const chrome = findChrome();
|
|
95
|
+
fs.mkdirSync(ownProfileDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
exec(`"${chrome}" \
|
|
98
|
+
--remote-debugging-port=${CDP_PORT} \
|
|
99
|
+
--user-data-dir="${ownProfileDir}" \
|
|
100
|
+
--no-first-run \
|
|
101
|
+
--no-default-browser-check \
|
|
102
|
+
--disable-sync \
|
|
103
|
+
--disable-background-networking`);
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < 20; i++) {
|
|
106
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
107
|
+
if (await isCDPAvailable(CDP_PORT)) {
|
|
108
|
+
return { port: CDP_PORT, source: 'own' };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new Error('Chrome failed to start. Please check your Chrome installation.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function launchChrome(ownProfileDir = CHROME_PROFILE_DIR) {
|
|
116
|
+
const { port, source } = await resolveChromeCDP(ownProfileDir);
|
|
117
|
+
|
|
118
|
+
if (source === 'own') {
|
|
119
|
+
console.error(
|
|
120
|
+
'\n[TIP] Install openchrome for seamless logins (no 2FA, sessions always persist):\n' +
|
|
121
|
+
' npx openchrome-mcp setup\n'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return port;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function connectChrome(port) {
|
|
129
|
+
// Retry a few times — Chrome may need a moment after launch before contexts are ready
|
|
130
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
131
|
+
try {
|
|
132
|
+
const browser = await chromium.connectOverCDP(`http://localhost:${port}`);
|
|
133
|
+
const context = browser.contexts()[0];
|
|
134
|
+
if (!context) {
|
|
135
|
+
await browser.close().catch(() => {});
|
|
136
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
140
|
+
return { browser, context, page };
|
|
141
|
+
} catch {
|
|
142
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error('Failed to connect to Chrome. Please try again.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extract ALL cookies including httpOnly via CDP Network.getAllCookies.
|
|
150
|
+
* Unlike context.cookies(), this bypasses the JS sandbox restriction.
|
|
151
|
+
*/
|
|
152
|
+
async function extractAllCookies(context, page) {
|
|
153
|
+
const cdp = await context.newCDPSession(page);
|
|
154
|
+
const { cookies } = await cdp.send('Network.getAllCookies');
|
|
155
|
+
return cookies;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function filterCookies(cookies, domains) {
|
|
159
|
+
return cookies.filter((c) => domains.some((d) => c.domain.includes(d)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function cookiesToSessionFormat(cookies) {
|
|
163
|
+
return cookies.map((c) => ({
|
|
164
|
+
name: c.name,
|
|
165
|
+
value: c.value,
|
|
166
|
+
domain: c.domain,
|
|
167
|
+
path: c.path || '/',
|
|
168
|
+
expires: typeof c.expires === 'number' ? c.expires : -1,
|
|
169
|
+
httpOnly: c.httpOnly || false,
|
|
170
|
+
secure: c.secure || false,
|
|
171
|
+
sameSite: c.sameSite || 'Lax',
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
CHROME_PROFILE_DIR,
|
|
177
|
+
CDP_PORT,
|
|
178
|
+
OPENCHROME_PORT,
|
|
179
|
+
launchChrome,
|
|
180
|
+
connectChrome,
|
|
181
|
+
isCDPAvailable,
|
|
182
|
+
isOpenChromeInstalled,
|
|
183
|
+
extractAllCookies,
|
|
184
|
+
filterCookies,
|
|
185
|
+
cookiesToSessionFormat,
|
|
186
|
+
};
|
|
@@ -5,12 +5,12 @@ const { readInstaCredentials } = require('./utils');
|
|
|
5
5
|
const { createInstaWithProviderSession } = require('./session');
|
|
6
6
|
const { createAskForAuthentication } = require('./auth');
|
|
7
7
|
|
|
8
|
-
const createInstaProvider = ({ sessionPath }) => {
|
|
8
|
+
const createInstaProvider = ({ sessionPath, account }) => {
|
|
9
9
|
const instaApi = createInstaApiClient({ sessionPath });
|
|
10
10
|
|
|
11
11
|
const askForAuthentication = createAskForAuthentication({ sessionPath });
|
|
12
12
|
|
|
13
|
-
const withProviderSession = createInstaWithProviderSession(askForAuthentication);
|
|
13
|
+
const withProviderSession = createInstaWithProviderSession(askForAuthentication, account);
|
|
14
14
|
const smart = createSmartComment(instaApi);
|
|
15
15
|
|
|
16
16
|
return {
|
|
@@ -29,7 +29,7 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
29
29
|
userId,
|
|
30
30
|
hasSession: Boolean(sessionid?.value),
|
|
31
31
|
sessionPath,
|
|
32
|
-
metadata: getProviderMeta('insta') || {},
|
|
32
|
+
metadata: getProviderMeta('insta', account) || {},
|
|
33
33
|
};
|
|
34
34
|
} catch (error) {
|
|
35
35
|
return {
|
|
@@ -37,7 +37,7 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
37
37
|
loggedIn: false,
|
|
38
38
|
sessionPath,
|
|
39
39
|
error: error.message,
|
|
40
|
-
metadata: getProviderMeta('insta') || {},
|
|
40
|
+
metadata: getProviderMeta('insta', account) || {},
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
});
|
|
@@ -65,7 +65,7 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
65
65
|
userId: result.userId,
|
|
66
66
|
username: result.username,
|
|
67
67
|
sessionPath: result.sessionPath,
|
|
68
|
-
});
|
|
68
|
+
}, account);
|
|
69
69
|
|
|
70
70
|
return result;
|
|
71
71
|
},
|
|
@@ -509,7 +509,7 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
509
509
|
},
|
|
510
510
|
|
|
511
511
|
async logout() {
|
|
512
|
-
clearProviderMeta('insta');
|
|
512
|
+
clearProviderMeta('insta', account);
|
|
513
513
|
return {
|
|
514
514
|
provider: 'insta',
|
|
515
515
|
loggedOut: true,
|