viruagent-cli 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +12 -0
- package/README.md +12 -0
- package/bin/index.js +1 -1
- package/package.json +1 -1
- package/skills/va-shared/SKILL.md +5 -0
- package/skills/va-threads/SKILL.md +67 -0
- package/skills/va-threads-publish/SKILL.md +76 -0
- package/src/providers/threads/apiClient.js +487 -0
- package/src/providers/threads/auth.js +142 -0
- package/src/providers/threads/index.js +248 -0
- package/src/providers/threads/session.js +109 -0
- package/src/providers/threads/utils.js +33 -0
- package/src/runner.js +1 -1
- package/src/services/providerManager.js +4 -2
package/README.ko.md
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
| **Naver Cafe** | 카페 가입 (모바일 5회 캡차 면제), 글쓰기, 게시판 조회, 이미지 업로드 (슬라이드/콜라주) | [가이드](docs/ko/guide-naver-cafe.md) |
|
|
32
32
|
| **Instagram** | 좋아요, 댓글, 팔로우, 포스팅, 프로필, 피드 | [가이드](docs/ko/guide-instagram.md) |
|
|
33
33
|
| **X (Twitter)** | 트윗, 좋아요, 리트윗, 팔로우, 검색, 타임라인, 미디어 업로드 | [가이드](docs/ko/guide-x.md) |
|
|
34
|
+
| **Threads** | 글쓰기, 답글, 좋아요, 팔로우, 이미지 업로드, 검색, 피드 | [가이드](docs/ko/guide-threads.md) |
|
|
34
35
|
|
|
35
36
|
## 동작 방식
|
|
36
37
|
|
|
@@ -119,6 +120,13 @@ npx viruagent-cli login --provider x --auth-token <토큰> --ct0 <ct0>
|
|
|
119
120
|
>
|
|
120
121
|
> 전체 API 레퍼런스, GraphQL 동기화, rate limit 규칙은 [X 가이드](docs/ko/guide-x.md)를 참고하세요.
|
|
121
122
|
|
|
123
|
+
### Threads
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npx viruagent-cli login --provider threads --username <인스타 ID> --password <비밀번호>
|
|
127
|
+
```
|
|
128
|
+
> Instagram 계정으로 로그인합니다. 쓰레드는 별도 계정이 없습니다.
|
|
129
|
+
|
|
122
130
|
## 사용법
|
|
123
131
|
|
|
124
132
|
| 이렇게 말하면 | 에이전트가 알아서 |
|
|
@@ -137,6 +145,8 @@ npx viruagent-cli login --provider x --auth-token <토큰> --ct0 <ct0>
|
|
|
137
145
|
| "내 X 타임라인 보여줘" | getFeed → 최신 트윗 표시 |
|
|
138
146
|
| "이 네이버 카페 가입해줘" | cafe-id → cafe-join (모바일 5회 캡차 면제) |
|
|
139
147
|
| "네이버 카페에 글 써줘" | cafe-list → cafe-write |
|
|
148
|
+
| "쓰레드에 글 올려줘" | login → publish (텍스트 또는 이미지) |
|
|
149
|
+
| "이 쓰레드에 답글 달아줘" | comment (rate limit 자동 적용) |
|
|
140
150
|
|
|
141
151
|
## 플랫폼별 가이드
|
|
142
152
|
|
|
@@ -145,6 +155,7 @@ npx viruagent-cli login --provider x --auth-token <토큰> --ct0 <ct0>
|
|
|
145
155
|
- **[Naver Cafe 가이드](docs/ko/guide-naver-cafe.md)** — 카페 가입 (모바일 5회 캡차 면제), 글쓰기, 슬라이드/콜라주
|
|
146
156
|
- **[Instagram 가이드](docs/ko/guide-instagram.md)** — 18개 API 메서드, rate limit 규칙, AI 댓글
|
|
147
157
|
- **[X (Twitter) 가이드](docs/ko/guide-x.md)** — GraphQL API, queryId 동적 동기화, rate limit 규칙
|
|
158
|
+
- **[Threads 가이드](docs/ko/guide-threads.md)** — Barcelona API, IGT:2 토큰 인증, rate limit 규칙
|
|
148
159
|
|
|
149
160
|
## 지원 환경
|
|
150
161
|
|
|
@@ -167,6 +178,7 @@ npx viruagent-cli login --provider x --auth-token <토큰> --ct0 <ct0>
|
|
|
167
178
|
| 이미지 검색 | DuckDuckGo, Wikimedia Commons |
|
|
168
179
|
| 네이버 에디터 | SE Editor 컴포넌트 모델 + RabbitWrite API |
|
|
169
180
|
| 네이버 카페 API | 순수 HTTP (가입, 글쓰기, 게시판 조회, 사용자 캡차 입력) |
|
|
181
|
+
| Threads API | Barcelona (Instagram Private API), IGT:2 토큰 인증 |
|
|
170
182
|
| 출력 형식 | JSON envelope (`{ ok, data }` / `{ ok, error, hint }`) |
|
|
171
183
|
|
|
172
184
|
## Contributing
|
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@ Designed not for humans, but for **AI agents**.
|
|
|
32
32
|
| **Instagram** | Like, Comment, Follow, Post, Profile, Feed, Rate Limit | [Guide](docs/en/guide-instagram.md) |
|
|
33
33
|
| **X (Twitter)** | Tweet, Like, Retweet, Follow, Search, Timeline, Media Upload | [Guide](docs/en/guide-x.md) |
|
|
34
34
|
| **Reddit** | Post, Comment, Upvote, Search, Subscribe, Subreddit | [Guide](docs/en/guide-reddit.md) |
|
|
35
|
+
| **Threads** | Post, Reply, Like, Follow, Image Upload, Search, Feed | [Guide](docs/en/guide-threads.md) |
|
|
35
36
|
|
|
36
37
|
## How It Works
|
|
37
38
|
|
|
@@ -120,6 +121,13 @@ npx viruagent-cli login --provider x --auth-token <token> --ct0 <ct0>
|
|
|
120
121
|
>
|
|
121
122
|
> See the [X Guide](docs/en/guide-x.md) for full API reference, GraphQL sync, and rate limit rules.
|
|
122
123
|
|
|
124
|
+
### Threads
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx viruagent-cli login --provider threads --username <instagram_id> --password <password>
|
|
128
|
+
```
|
|
129
|
+
> Threads uses your Instagram account. No separate login needed.
|
|
130
|
+
|
|
123
131
|
## Usage
|
|
124
132
|
|
|
125
133
|
| Say this | Agent handles |
|
|
@@ -138,6 +146,8 @@ npx viruagent-cli login --provider x --auth-token <token> --ct0 <ct0>
|
|
|
138
146
|
| "Show my X timeline" | getFeed → show latest tweets |
|
|
139
147
|
| "Join this Naver cafe" | cafe-id → cafe-join (captcha-free for 5 joins) |
|
|
140
148
|
| "Write a post on Naver cafe" | cafe-list → cafe-write |
|
|
149
|
+
| "Post on Threads" | login → publish (text or image) |
|
|
150
|
+
| "Reply to a thread" | comment (with rate limit) |
|
|
141
151
|
|
|
142
152
|
## Platform Guides
|
|
143
153
|
|
|
@@ -146,6 +156,7 @@ npx viruagent-cli login --provider x --auth-token <token> --ct0 <ct0>
|
|
|
146
156
|
- **[Naver Cafe Guide](docs/en/guide-naver-cafe.md)** — Cafe join (captcha-free for 5 joins), write post, slide/collage images
|
|
147
157
|
- **[Instagram Guide](docs/en/guide-instagram.md)** — 18 API methods, rate limits, AI commenting
|
|
148
158
|
- **[X (Twitter) Guide](docs/en/guide-x.md)** — GraphQL API, dynamic queryId sync, rate limits
|
|
159
|
+
- **[Threads Guide](docs/en/guide-threads.md)** — Barcelona API, IGT:2 token auth, rate limits
|
|
149
160
|
|
|
150
161
|
## Supported Environments
|
|
151
162
|
|
|
@@ -168,6 +179,7 @@ npx viruagent-cli login --provider x --auth-token <token> --ct0 <ct0>
|
|
|
168
179
|
| Image Search | DuckDuckGo, Wikimedia Commons |
|
|
169
180
|
| Naver Editor | SE Editor component model + RabbitWrite API |
|
|
170
181
|
| Naver Cafe API | Pure HTTP (join, write, board list, manual captcha) |
|
|
182
|
+
| Threads API | Barcelona (Instagram Private API), IGT:2 token auth |
|
|
171
183
|
| Output Format | JSON envelope (`{ ok, data }` / `{ ok, error, hint }`) |
|
|
172
184
|
|
|
173
185
|
## Contributing
|
package/bin/index.js
CHANGED
|
@@ -18,7 +18,7 @@ program
|
|
|
18
18
|
|
|
19
19
|
// Global options
|
|
20
20
|
const addProviderOption = (cmd) =>
|
|
21
|
-
cmd.option('--provider <name>', 'Provider name (tistory, naver, insta, x, reddit)', 'tistory');
|
|
21
|
+
cmd.option('--provider <name>', 'Provider name (tistory, naver, insta, x, reddit, threads)', 'tistory');
|
|
22
22
|
|
|
23
23
|
const addDryRunOption = (cmd) =>
|
|
24
24
|
cmd.option('--dry-run', 'Validate params without executing', false);
|
package/package.json
CHANGED
|
@@ -13,6 +13,8 @@ triggers:
|
|
|
13
13
|
- 좋아요
|
|
14
14
|
- 댓글
|
|
15
15
|
- 팔로우
|
|
16
|
+
- 쓰레드
|
|
17
|
+
- threads
|
|
16
18
|
metadata:
|
|
17
19
|
category: "router"
|
|
18
20
|
requires:
|
|
@@ -32,6 +34,7 @@ viruagent-cli를 사용하는 블로그/SNS 자동화 에이전트입니다.
|
|
|
32
34
|
| 네이버, naver | `va-naver/SKILL.md` |
|
|
33
35
|
| 카페, cafe, 카페 가입, 카페 글쓰기 | `va-naver-cafe-join/SKILL.md` 또는 `va-naver-cafe-write/SKILL.md` |
|
|
34
36
|
| 인스타, instagram, 좋아요, 댓글, 팔로우 | `va-insta/SKILL.md` |
|
|
37
|
+
| 쓰레드, threads | `va-threads/SKILL.md` |
|
|
35
38
|
| 블로그 써줘 (플랫폼 미지정) | 사용자에게 플랫폼 질문 |
|
|
36
39
|
| 블로거 역할 | `persona-blogger/SKILL.md` |
|
|
37
40
|
| 인플루언서 관리 | `persona-influencer-manager/SKILL.md` |
|
|
@@ -75,6 +78,8 @@ SKILLS_DIR: <viruagent-cli 설치 경로>/skills/
|
|
|
75
78
|
| va-insta-follow | `va-insta-follow/SKILL.md` | 팔로우/언팔 |
|
|
76
79
|
| va-insta-dm | `va-insta-dm/SKILL.md` | DM |
|
|
77
80
|
| va-insta-feed | `va-insta-feed/SKILL.md` | 피드/프로필/분석 |
|
|
81
|
+
| va-threads | `va-threads/SKILL.md` | Threads 개요 + 레이트리밋 |
|
|
82
|
+
| va-threads-publish | `va-threads-publish/SKILL.md` | Threads 글쓰기 |
|
|
78
83
|
|
|
79
84
|
### 페르소나 스킬
|
|
80
85
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: va-threads
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "Threads 자동화: 명령 개요 및 레이트리밋 규칙"
|
|
5
|
+
metadata:
|
|
6
|
+
category: "overview"
|
|
7
|
+
provider: "threads"
|
|
8
|
+
requires:
|
|
9
|
+
bins: ["viruagent-cli"]
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# va-threads — Threads 자동화 개요
|
|
13
|
+
|
|
14
|
+
Threads 자동화를 위한 viruagent-cli 가이드. 항상 `--provider threads` 사용.
|
|
15
|
+
|
|
16
|
+
## 명령 목록
|
|
17
|
+
|
|
18
|
+
| 명령 | 스킬 | 설명 |
|
|
19
|
+
|------|------|------|
|
|
20
|
+
| login | va-threads | 로그인 (Instagram 계정) |
|
|
21
|
+
| publish | va-threads-publish | 글쓰기 (텍스트/이미지) |
|
|
22
|
+
| like | va-threads | 좋아요 |
|
|
23
|
+
| comment | va-threads | 답글 |
|
|
24
|
+
| follow | va-threads | 팔로우 |
|
|
25
|
+
| search | va-threads | 검색 |
|
|
26
|
+
| get-profile | va-threads | 프로필 조회 |
|
|
27
|
+
| get-feed | va-threads | 피드 조회 |
|
|
28
|
+
|
|
29
|
+
## Rate Limit Safety (신규 계정 기준)
|
|
30
|
+
|
|
31
|
+
| 액션 | 딜레이 | 시간당 | 일일 |
|
|
32
|
+
|------|--------|--------|------|
|
|
33
|
+
| Post | 2~5min | 5 | 25 |
|
|
34
|
+
| Like | 20~40s | 15 | 500 |
|
|
35
|
+
| Reply | 5~7min | 5 | 100 |
|
|
36
|
+
| Follow | 1~2min | 15 | 250 |
|
|
37
|
+
|
|
38
|
+
모든 딜레이는 랜덤화되어 자동 적용. 카운터는 세션별 userId로 영속화.
|
|
39
|
+
|
|
40
|
+
## 레이트리밋 확인
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx viruagent-cli rate-limit-status --provider threads
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
대량 작업 전 반드시 확인.
|
|
47
|
+
|
|
48
|
+
## 중요 사항
|
|
49
|
+
|
|
50
|
+
- Instagram 계정을 공유하므로 Instagram rate limit에 영향을 줄 수 있음
|
|
51
|
+
- Barcelona User-Agent + Bloks API 사용
|
|
52
|
+
- IGT:2 토큰이 주요 인증 수단
|
|
53
|
+
- 세션 + 카운터: `~/.viruagent-cli/sessions/threads-session.json`
|
|
54
|
+
|
|
55
|
+
## 환경변수
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
THREADS_USERNAME=
|
|
59
|
+
THREADS_PASSWORD=
|
|
60
|
+
# 또는
|
|
61
|
+
INSTA_USERNAME=
|
|
62
|
+
INSTA_PASSWORD=
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## See Also
|
|
66
|
+
|
|
67
|
+
va-shared, va-threads-publish
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: va-threads-publish
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "Threads 글쓰기: 텍스트/이미지 포스팅"
|
|
5
|
+
metadata:
|
|
6
|
+
category: "publish"
|
|
7
|
+
provider: "threads"
|
|
8
|
+
requires:
|
|
9
|
+
bins: ["viruagent-cli"]
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# va-threads-publish — Threads 글쓰기
|
|
13
|
+
|
|
14
|
+
Threads에 텍스트/이미지를 발행하는 가이드.
|
|
15
|
+
|
|
16
|
+
## 명령어
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 텍스트만
|
|
20
|
+
npx viruagent-cli publish --provider threads \
|
|
21
|
+
--content "<텍스트>"
|
|
22
|
+
|
|
23
|
+
# 이미지 첨부
|
|
24
|
+
npx viruagent-cli publish --provider threads \
|
|
25
|
+
--content "<텍스트>" \
|
|
26
|
+
--image-urls "<이미지URL>"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 글쓰기 규칙 (Threads 최적화)
|
|
30
|
+
|
|
31
|
+
### 분량
|
|
32
|
+
- **최적 길이**: 100~300자 (짧고 임팩트 있게)
|
|
33
|
+
- Threads는 짧은 텍스트 중심 — 블로그처럼 길게 쓰지 않음
|
|
34
|
+
|
|
35
|
+
### 첫 줄이 전부다
|
|
36
|
+
- **숫자**: "3가지만 알면 됩니다"
|
|
37
|
+
- **반전**: "잘못 알고 있었습니다"
|
|
38
|
+
- **질문**: "이거 해보셨나요?"
|
|
39
|
+
- **공감**: "저만 이런 거 아니죠?"
|
|
40
|
+
|
|
41
|
+
### 해시태그
|
|
42
|
+
- Threads는 해시태그 효과가 Instagram보다 약함
|
|
43
|
+
- 최대 5개, 본문 하단에 배치
|
|
44
|
+
- 검색 기능이 제한적이므로 키워드를 본문에 자연스럽게 녹이기
|
|
45
|
+
|
|
46
|
+
### 이미지
|
|
47
|
+
- 이미지 1장 첨부 가능
|
|
48
|
+
- `--image-urls`에 직접 URL 지정
|
|
49
|
+
- 고해상도 (1080x1080 이상) 권장
|
|
50
|
+
|
|
51
|
+
## 발행 예시
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# 짧은 의견
|
|
55
|
+
npx viruagent-cli publish --provider threads \
|
|
56
|
+
--content "AI가 코드 리뷰해주는 시대.
|
|
57
|
+
|
|
58
|
+
그런데 아직도 혼자 다 보고 있다면
|
|
59
|
+
도구를 바꿀 때입니다."
|
|
60
|
+
|
|
61
|
+
# 이미지 포함
|
|
62
|
+
npx viruagent-cli publish --provider threads \
|
|
63
|
+
--content "오늘의 작업 환경. 카페에서 코딩하는 게 제일 잘 됨." \
|
|
64
|
+
--image-urls "https://example.com/workspace.jpg"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 주의사항
|
|
68
|
+
|
|
69
|
+
- 글 최대 500자 (초과 시 잘림)
|
|
70
|
+
- 발행 간격: 최소 2~5분 (rate limit)
|
|
71
|
+
- 로그인 안 된 경우: `login --provider threads` 먼저 실행
|
|
72
|
+
- Instagram 계정 공유이므로 Instagram challenge 발생 가능
|
|
73
|
+
|
|
74
|
+
## See Also
|
|
75
|
+
|
|
76
|
+
va-threads, va-shared
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { loadThreadsSession, loadRateLimits, saveRateLimits } = require('./session');
|
|
3
|
+
const { THREADS_APP_ID, THREADS_USER_AGENT, BLOKS_VERSION, BASE_URL } = require('./auth');
|
|
4
|
+
|
|
5
|
+
const randomDelay = (minSec, maxSec) => {
|
|
6
|
+
const ms = (minSec + Math.random() * (maxSec - minSec)) * 1000;
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// ──────────────────────────────────────────────────────────────
|
|
11
|
+
// Threads Safe Action Rules (conservative, Instagram-based)
|
|
12
|
+
// ──────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const DELAY = {
|
|
15
|
+
publish: [120, 300], // 2~5min
|
|
16
|
+
like: [20, 40], // 20~40s
|
|
17
|
+
comment: [120, 300], // 2~5min
|
|
18
|
+
follow: [60, 120], // 1~2min
|
|
19
|
+
unfollow: [60, 120], // 1~2min
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const HOURLY_LIMIT = {
|
|
23
|
+
publish: 10,
|
|
24
|
+
like: 15,
|
|
25
|
+
comment: 5,
|
|
26
|
+
follow: 15,
|
|
27
|
+
unfollow: 10,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DAILY_LIMIT = {
|
|
31
|
+
publish: 50,
|
|
32
|
+
like: 500,
|
|
33
|
+
comment: 100,
|
|
34
|
+
follow: 250,
|
|
35
|
+
unfollow: 200,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let lastActionTime = 0;
|
|
39
|
+
|
|
40
|
+
const createThreadsApiClient = ({ sessionPath }) => {
|
|
41
|
+
let cachedSession = null;
|
|
42
|
+
let countersCache = null;
|
|
43
|
+
|
|
44
|
+
// ── Session helpers ──
|
|
45
|
+
|
|
46
|
+
const getSession = () => {
|
|
47
|
+
if (cachedSession) return cachedSession;
|
|
48
|
+
const session = loadThreadsSession(sessionPath);
|
|
49
|
+
if (!session) {
|
|
50
|
+
throw new Error('No session file found. Please log in first.');
|
|
51
|
+
}
|
|
52
|
+
if (!session.token) {
|
|
53
|
+
throw new Error('No valid token in session. Please log in again.');
|
|
54
|
+
}
|
|
55
|
+
cachedSession = session;
|
|
56
|
+
return session;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const getToken = () => getSession().token;
|
|
60
|
+
const getUserIdFromSession = () => getSession().userId;
|
|
61
|
+
const getDeviceId = () => getSession().deviceId;
|
|
62
|
+
|
|
63
|
+
// ── Rate Limit counters ──
|
|
64
|
+
|
|
65
|
+
const loadCounters = () => {
|
|
66
|
+
if (countersCache) return countersCache;
|
|
67
|
+
try {
|
|
68
|
+
const userId = getUserIdFromSession();
|
|
69
|
+
if (!userId) { countersCache = {}; return countersCache; }
|
|
70
|
+
const saved = loadRateLimits(sessionPath, userId);
|
|
71
|
+
countersCache = saved || {};
|
|
72
|
+
} catch {
|
|
73
|
+
countersCache = {};
|
|
74
|
+
}
|
|
75
|
+
return countersCache;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const persistCounters = () => {
|
|
79
|
+
try {
|
|
80
|
+
const userId = getUserIdFromSession();
|
|
81
|
+
if (!userId || !countersCache) return;
|
|
82
|
+
saveRateLimits(sessionPath, userId, countersCache);
|
|
83
|
+
} catch {
|
|
84
|
+
// Save failure does not affect operation
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const getCounter = (type) => {
|
|
89
|
+
const counters = loadCounters();
|
|
90
|
+
if (!counters[type]) {
|
|
91
|
+
counters[type] = { hourly: 0, daily: 0, hourStart: Date.now(), dayStart: Date.now() };
|
|
92
|
+
}
|
|
93
|
+
const c = counters[type];
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (now - c.hourStart > 3600000) { c.hourly = 0; c.hourStart = now; }
|
|
96
|
+
if (now - c.dayStart > 86400000) { c.daily = 0; c.dayStart = now; }
|
|
97
|
+
return c;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const checkLimit = (type) => {
|
|
101
|
+
const c = getCounter(type);
|
|
102
|
+
const hourlyMax = HOURLY_LIMIT[type];
|
|
103
|
+
const dailyMax = DAILY_LIMIT[type];
|
|
104
|
+
if (hourlyMax && c.hourly >= hourlyMax) {
|
|
105
|
+
const waitMin = Math.ceil((3600000 - (Date.now() - c.hourStart)) / 60000);
|
|
106
|
+
throw new Error(`hourly_limit: ${type} exceeded hourly limit of ${hourlyMax}. Retry in ${waitMin} minutes.`);
|
|
107
|
+
}
|
|
108
|
+
if (dailyMax && c.daily >= dailyMax) {
|
|
109
|
+
throw new Error(`daily_limit: ${type} exceeded daily limit of ${dailyMax}. Try again tomorrow.`);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const incrementCounter = (type) => {
|
|
114
|
+
const c = getCounter(type);
|
|
115
|
+
c.hourly++;
|
|
116
|
+
c.daily++;
|
|
117
|
+
persistCounters();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const withDelay = async (type, fn) => {
|
|
121
|
+
checkLimit(type);
|
|
122
|
+
|
|
123
|
+
const [min, max] = DELAY[type] || [20, 40];
|
|
124
|
+
const elapsed = (Date.now() - lastActionTime) / 1000;
|
|
125
|
+
if (lastActionTime > 0 && elapsed < min) {
|
|
126
|
+
await randomDelay(min - elapsed, max - elapsed);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const result = await fn();
|
|
130
|
+
lastActionTime = Date.now();
|
|
131
|
+
incrementCounter(type);
|
|
132
|
+
return result;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ── HTTP request helper ──
|
|
136
|
+
|
|
137
|
+
const getHeaders = () => ({
|
|
138
|
+
'User-Agent': `${THREADS_USER_AGENT} (30/11; 420dpi; 1080x2400; samsung; SM-A325F; a32; exynos850)`,
|
|
139
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
140
|
+
'Authorization': `Bearer IGT:2:${getToken()}`,
|
|
141
|
+
'X-IG-App-ID': THREADS_APP_ID,
|
|
142
|
+
'X-Bloks-Version-Id': BLOKS_VERSION,
|
|
143
|
+
'Sec-Fetch-Site': 'same-origin',
|
|
144
|
+
'Sec-Fetch-Mode': 'cors',
|
|
145
|
+
'Sec-Fetch-Dest': 'empty',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const request = async (url, options = {}) => {
|
|
149
|
+
const headers = { ...getHeaders(), ...options.headers };
|
|
150
|
+
|
|
151
|
+
const res = await fetch(url, {
|
|
152
|
+
...options,
|
|
153
|
+
headers,
|
|
154
|
+
redirect: options.followRedirect ? 'follow' : 'manual',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (res.status === 302 || res.status === 301) {
|
|
158
|
+
const location = res.headers.get('location') || '';
|
|
159
|
+
if (location.includes('/accounts/login') || location.includes('login')) {
|
|
160
|
+
throw new Error('Session expired. Please log in again.');
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`Redirect occurred: ${res.status} -> ${location}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (res.status === 401 || res.status === 403) {
|
|
166
|
+
throw new Error(`Authentication error (${res.status}). Please log in again.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!res.ok && !options.allowError) {
|
|
170
|
+
throw new Error(`Threads API error: ${res.status} ${res.statusText}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return res;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// ── API methods ──
|
|
177
|
+
|
|
178
|
+
const getUserId = async (username) => {
|
|
179
|
+
const res = await request(
|
|
180
|
+
`${BASE_URL}/api/v1/users/search/?q=${encodeURIComponent(username)}`,
|
|
181
|
+
);
|
|
182
|
+
const data = await res.json();
|
|
183
|
+
const user = data?.users?.find(
|
|
184
|
+
(u) => u.username?.toLowerCase() === username.toLowerCase(),
|
|
185
|
+
);
|
|
186
|
+
if (!user) throw new Error(`User not found: ${username}`);
|
|
187
|
+
return user.pk_id || user.pk || String(user.pk);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const getUserProfile = async (userId) => {
|
|
191
|
+
const res = await request(`${BASE_URL}/api/v1/users/${userId}/info/`);
|
|
192
|
+
const data = await res.json();
|
|
193
|
+
const user = data?.user;
|
|
194
|
+
if (!user) throw new Error(`Profile not found for userId: ${userId}`);
|
|
195
|
+
return {
|
|
196
|
+
id: user.pk || userId,
|
|
197
|
+
username: user.username,
|
|
198
|
+
fullName: user.full_name,
|
|
199
|
+
biography: user.biography,
|
|
200
|
+
followerCount: user.follower_count || 0,
|
|
201
|
+
followingCount: user.following_count || 0,
|
|
202
|
+
isPrivate: user.is_private,
|
|
203
|
+
isVerified: user.is_verified,
|
|
204
|
+
profilePicUrl: user.hd_profile_pic_url_info?.url || user.profile_pic_url,
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const getTimeline = async () => {
|
|
209
|
+
const res = await request(`${BASE_URL}/api/v1/feed/text_post_app_timeline/`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
body: '',
|
|
212
|
+
});
|
|
213
|
+
const data = await res.json();
|
|
214
|
+
const items = data?.items || [];
|
|
215
|
+
return items
|
|
216
|
+
.filter((item) => item.post || item.thread_items)
|
|
217
|
+
.slice(0, 20)
|
|
218
|
+
.map((item) => {
|
|
219
|
+
const threadItems = item.thread_items || [item];
|
|
220
|
+
const first = threadItems[0]?.post || threadItems[0];
|
|
221
|
+
return {
|
|
222
|
+
id: first?.pk || first?.id,
|
|
223
|
+
code: first?.code,
|
|
224
|
+
username: first?.user?.username,
|
|
225
|
+
caption: first?.caption?.text || '',
|
|
226
|
+
likeCount: first?.like_count || 0,
|
|
227
|
+
replyCount: first?.text_post_app_info?.direct_reply_count || 0,
|
|
228
|
+
timestamp: first?.taken_at,
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const getUserThreads = async (userId, limit = 20) => {
|
|
234
|
+
const res = await request(`${BASE_URL}/api/v1/text_feed/${userId}/profile/`);
|
|
235
|
+
const data = await res.json();
|
|
236
|
+
const threads = data?.threads || [];
|
|
237
|
+
return threads.slice(0, limit).map((thread) => {
|
|
238
|
+
const items = thread.thread_items || [];
|
|
239
|
+
const first = items[0]?.post;
|
|
240
|
+
return {
|
|
241
|
+
id: first?.pk || first?.id,
|
|
242
|
+
code: first?.code,
|
|
243
|
+
caption: first?.caption?.text || '',
|
|
244
|
+
likeCount: first?.like_count || 0,
|
|
245
|
+
replyCount: first?.text_post_app_info?.direct_reply_count || 0,
|
|
246
|
+
timestamp: first?.taken_at,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const getThreadReplies = async (postId) => {
|
|
252
|
+
const res = await request(`${BASE_URL}/api/v1/text_feed/${postId}/replies/`);
|
|
253
|
+
const data = await res.json();
|
|
254
|
+
const items = data?.thread_items || [];
|
|
255
|
+
return items.map((item) => {
|
|
256
|
+
const post = item.post;
|
|
257
|
+
return {
|
|
258
|
+
id: post?.pk || post?.id,
|
|
259
|
+
username: post?.user?.username,
|
|
260
|
+
text: post?.caption?.text || '',
|
|
261
|
+
likeCount: post?.like_count || 0,
|
|
262
|
+
timestamp: post?.taken_at,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const publishTextThread = (text, replyToId) => withDelay('publish', async () => {
|
|
268
|
+
const userId = getUserIdFromSession();
|
|
269
|
+
const uploadId = Date.now().toString();
|
|
270
|
+
const deviceId = getDeviceId();
|
|
271
|
+
|
|
272
|
+
const payload = {
|
|
273
|
+
publish_mode: 'text_post',
|
|
274
|
+
text_post_app_info: JSON.stringify({ reply_control: 0 }),
|
|
275
|
+
timezone_offset: '32400',
|
|
276
|
+
source_type: '4',
|
|
277
|
+
caption: text,
|
|
278
|
+
upload_id: uploadId,
|
|
279
|
+
device_id: deviceId,
|
|
280
|
+
_uid: userId,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
if (replyToId) {
|
|
284
|
+
payload.text_post_app_info = JSON.stringify({
|
|
285
|
+
reply_control: 0,
|
|
286
|
+
reply_id: replyToId,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const body = `signed_body=SIGNATURE.${encodeURIComponent(JSON.stringify(payload))}`;
|
|
291
|
+
|
|
292
|
+
const res = await request(`${BASE_URL}/api/v1/media/configure_text_only_post/`, {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
body,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const data = await res.json();
|
|
298
|
+
if (data.status !== 'ok') {
|
|
299
|
+
throw new Error(`Thread publish failed: ${data.message || JSON.stringify(data)}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
id: data.media?.pk || data.media?.id,
|
|
304
|
+
code: data.media?.code,
|
|
305
|
+
caption: data.media?.caption?.text || text,
|
|
306
|
+
status: data.status,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const uploadImage = async (imageBuffer) => {
|
|
311
|
+
const uploadId = Date.now().toString();
|
|
312
|
+
const uploadName = `${uploadId}_0_${Math.floor(Math.random() * 9000000000 + 1000000000)}`;
|
|
313
|
+
|
|
314
|
+
const res = await request(
|
|
315
|
+
`https://www.instagram.com/rupload_igphoto/${uploadName}`,
|
|
316
|
+
{
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: {
|
|
319
|
+
'Content-Type': 'image/jpeg',
|
|
320
|
+
'X-Entity-Name': uploadName,
|
|
321
|
+
'X-Entity-Length': imageBuffer.length.toString(),
|
|
322
|
+
'X-Instagram-Rupload-Params': JSON.stringify({
|
|
323
|
+
media_type: 1,
|
|
324
|
+
upload_id: uploadId,
|
|
325
|
+
upload_media_height: 1080,
|
|
326
|
+
upload_media_width: 1080,
|
|
327
|
+
}),
|
|
328
|
+
Offset: '0',
|
|
329
|
+
},
|
|
330
|
+
body: imageBuffer,
|
|
331
|
+
followRedirect: true,
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
const data = await res.json();
|
|
335
|
+
if (data.status !== 'ok') {
|
|
336
|
+
throw new Error(`Image upload failed: ${data.message || 'unknown'}`);
|
|
337
|
+
}
|
|
338
|
+
return uploadId;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const publishImageThread = (uploadId, text) => withDelay('publish', async () => {
|
|
342
|
+
const userId = getUserIdFromSession();
|
|
343
|
+
const deviceId = getDeviceId();
|
|
344
|
+
|
|
345
|
+
const payload = {
|
|
346
|
+
publish_mode: 'text_post',
|
|
347
|
+
text_post_app_info: JSON.stringify({ reply_control: 0 }),
|
|
348
|
+
timezone_offset: '32400',
|
|
349
|
+
source_type: '4',
|
|
350
|
+
caption: text || '',
|
|
351
|
+
upload_id: uploadId,
|
|
352
|
+
device_id: deviceId,
|
|
353
|
+
_uid: userId,
|
|
354
|
+
scene_capture_type: '',
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const body = `signed_body=SIGNATURE.${encodeURIComponent(JSON.stringify(payload))}`;
|
|
358
|
+
|
|
359
|
+
// Single image uses configure_text_only_post (same as text, with upload_id)
|
|
360
|
+
const res = await request(`${BASE_URL}/api/v1/media/configure_text_only_post/`, {
|
|
361
|
+
method: 'POST',
|
|
362
|
+
body,
|
|
363
|
+
allowError: true,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const data = await res.json();
|
|
367
|
+
if (data.status !== 'ok') {
|
|
368
|
+
throw new Error(`Image thread publish failed: ${data.message || JSON.stringify(data)}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
id: data.media?.pk || data.media?.id,
|
|
373
|
+
code: data.media?.code,
|
|
374
|
+
caption: data.media?.caption?.text || text,
|
|
375
|
+
status: data.status,
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const likeThread = (postId) => withDelay('like', async () => {
|
|
380
|
+
const userId = getUserIdFromSession();
|
|
381
|
+
const res = await request(
|
|
382
|
+
`${BASE_URL}/api/v1/media/${postId}_${userId}/like/`,
|
|
383
|
+
{ method: 'POST', body: '', allowError: true },
|
|
384
|
+
);
|
|
385
|
+
const data = await res.json();
|
|
386
|
+
return { status: data.status || 'ok' };
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const unlikeThread = (postId) => withDelay('like', async () => {
|
|
390
|
+
const userId = getUserIdFromSession();
|
|
391
|
+
const res = await request(
|
|
392
|
+
`${BASE_URL}/api/v1/media/${postId}_${userId}/unlike/`,
|
|
393
|
+
{ method: 'POST', body: '', allowError: true },
|
|
394
|
+
);
|
|
395
|
+
const data = await res.json();
|
|
396
|
+
return { status: data.status || 'ok' };
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const followUser = (userId) => withDelay('follow', async () => {
|
|
400
|
+
const res = await request(
|
|
401
|
+
`${BASE_URL}/api/v1/friendships/create/${userId}/`,
|
|
402
|
+
{ method: 'POST', body: '', allowError: true },
|
|
403
|
+
);
|
|
404
|
+
const data = await res.json();
|
|
405
|
+
return {
|
|
406
|
+
status: data.status || (data.friendship_status ? 'ok' : 'fail'),
|
|
407
|
+
following: data.friendship_status?.following || false,
|
|
408
|
+
outgoingRequest: data.friendship_status?.outgoing_request || false,
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const unfollowUser = (userId) => withDelay('unfollow', async () => {
|
|
413
|
+
const res = await request(
|
|
414
|
+
`${BASE_URL}/api/v1/friendships/destroy/${userId}/`,
|
|
415
|
+
{ method: 'POST', body: '', allowError: true },
|
|
416
|
+
);
|
|
417
|
+
const data = await res.json();
|
|
418
|
+
return {
|
|
419
|
+
status: data.status || (data.friendship_status ? 'ok' : 'fail'),
|
|
420
|
+
following: data.friendship_status?.following || false,
|
|
421
|
+
};
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const searchUsers = async (query, limit = 20) => {
|
|
425
|
+
const res = await request(
|
|
426
|
+
`${BASE_URL}/api/v1/users/search/?q=${encodeURIComponent(query)}`,
|
|
427
|
+
);
|
|
428
|
+
const data = await res.json();
|
|
429
|
+
const users = data?.users || [];
|
|
430
|
+
return users.slice(0, limit).map((u) => ({
|
|
431
|
+
id: u.pk || u.pk_id,
|
|
432
|
+
username: u.username,
|
|
433
|
+
fullName: u.full_name,
|
|
434
|
+
isVerified: u.is_verified,
|
|
435
|
+
profilePicUrl: u.profile_pic_url,
|
|
436
|
+
}));
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const deleteThread = async (postId) => {
|
|
440
|
+
const res = await request(
|
|
441
|
+
`${BASE_URL}/api/v1/media/${postId}/delete/?media_type=TEXT_POST`,
|
|
442
|
+
{ method: 'POST' },
|
|
443
|
+
);
|
|
444
|
+
return res.json();
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const resetState = () => {
|
|
448
|
+
cachedSession = null;
|
|
449
|
+
countersCache = null;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
getSession,
|
|
454
|
+
getToken,
|
|
455
|
+
getUserIdFromSession,
|
|
456
|
+
request,
|
|
457
|
+
getUserId,
|
|
458
|
+
getUserProfile,
|
|
459
|
+
getTimeline,
|
|
460
|
+
getUserThreads,
|
|
461
|
+
getThreadReplies,
|
|
462
|
+
publishTextThread,
|
|
463
|
+
uploadImage,
|
|
464
|
+
publishImageThread,
|
|
465
|
+
likeThread,
|
|
466
|
+
unlikeThread,
|
|
467
|
+
followUser,
|
|
468
|
+
unfollowUser,
|
|
469
|
+
searchUsers,
|
|
470
|
+
deleteThread,
|
|
471
|
+
resetState,
|
|
472
|
+
getRateLimitStatus: () => {
|
|
473
|
+
const status = {};
|
|
474
|
+
for (const type of Object.keys(HOURLY_LIMIT)) {
|
|
475
|
+
const c = getCounter(type);
|
|
476
|
+
status[type] = {
|
|
477
|
+
hourly: `${c.hourly}/${HOURLY_LIMIT[type]}`,
|
|
478
|
+
daily: `${c.daily}/${DAILY_LIMIT[type]}`,
|
|
479
|
+
delay: `${DELAY[type]?.[0]}~${DELAY[type]?.[1]}s`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return status;
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
module.exports = createThreadsApiClient;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { readThreadsCredentials } = require('./utils');
|
|
5
|
+
const { saveThreadsSession } = require('./session');
|
|
6
|
+
|
|
7
|
+
const THREADS_APP_ID = '238260118697367';
|
|
8
|
+
const THREADS_USER_AGENT = 'Barcelona 289.0.0.77.109 Android';
|
|
9
|
+
const BLOKS_VERSION = '00ba6fa565c3c707243ad976fa30a071a625f2a3d158d9412091176fe35027d8';
|
|
10
|
+
const BASE_URL = 'https://i.instagram.com';
|
|
11
|
+
|
|
12
|
+
const generateDeviceId = () => `android-${crypto.randomBytes(8).toString('hex')}`;
|
|
13
|
+
|
|
14
|
+
const createAskForAuthentication = ({ sessionPath }) => async ({
|
|
15
|
+
username,
|
|
16
|
+
password,
|
|
17
|
+
} = {}) => {
|
|
18
|
+
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
|
|
19
|
+
|
|
20
|
+
const resolvedUsername = username || readThreadsCredentials().username;
|
|
21
|
+
const resolvedPassword = password || readThreadsCredentials().password;
|
|
22
|
+
|
|
23
|
+
if (!resolvedUsername || !resolvedPassword) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'Threads login requires username/password. ' +
|
|
26
|
+
'Please set the THREADS_USERNAME / THREADS_PASSWORD (or INSTA_USERNAME / INSTA_PASSWORD) environment variables.',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const deviceId = generateDeviceId();
|
|
31
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
32
|
+
|
|
33
|
+
const clientInputParams = JSON.stringify({
|
|
34
|
+
password: `#PWD_INSTAGRAM:0:${timestamp}:${resolvedPassword}`,
|
|
35
|
+
contact_point: resolvedUsername,
|
|
36
|
+
device_id: deviceId,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const serverParams = JSON.stringify({
|
|
40
|
+
credential_type: 'password',
|
|
41
|
+
device_id: deviceId,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const body = new URLSearchParams({
|
|
45
|
+
params: JSON.stringify({
|
|
46
|
+
client_input_params: JSON.parse(clientInputParams),
|
|
47
|
+
server_params: JSON.parse(serverParams),
|
|
48
|
+
}),
|
|
49
|
+
bk_client_context: JSON.stringify({ bloks_version: BLOKS_VERSION, styles_id: 'instagram' }),
|
|
50
|
+
bloks_versioning_id: BLOKS_VERSION,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const res = await fetch(
|
|
54
|
+
`${BASE_URL}/api/v1/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/`,
|
|
55
|
+
{
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
'User-Agent': THREADS_USER_AGENT,
|
|
59
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
60
|
+
'X-Bloks-Version-Id': BLOKS_VERSION,
|
|
61
|
+
'X-IG-App-ID': THREADS_APP_ID,
|
|
62
|
+
'Sec-Fetch-Site': 'same-origin',
|
|
63
|
+
'Sec-Fetch-Mode': 'cors',
|
|
64
|
+
'Sec-Fetch-Dest': 'empty',
|
|
65
|
+
},
|
|
66
|
+
body: body.toString(),
|
|
67
|
+
redirect: 'manual',
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const responseText = await res.text();
|
|
72
|
+
|
|
73
|
+
// Extract Bearer token — check header first, then Bloks response body
|
|
74
|
+
let token = null;
|
|
75
|
+
const authHeader = res.headers.get('ig-set-authorization');
|
|
76
|
+
if (authHeader) {
|
|
77
|
+
const match = authHeader.match(/Bearer IGT:2:(.+)/);
|
|
78
|
+
if (match) token = match[1];
|
|
79
|
+
}
|
|
80
|
+
if (!token) {
|
|
81
|
+
// Token is embedded in Bloks response body (escaped JSON)
|
|
82
|
+
const bodyMatch = responseText.match(/Bearer IGT:2:([a-zA-Z0-9_=+/]+)/);
|
|
83
|
+
if (bodyMatch) token = bodyMatch[1];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Extract userId from token (base64 decode) or response body
|
|
87
|
+
let userId = null;
|
|
88
|
+
if (token) {
|
|
89
|
+
try {
|
|
90
|
+
const decoded = JSON.parse(Buffer.from(token, 'base64').toString());
|
|
91
|
+
userId = decoded.ds_user_id || null;
|
|
92
|
+
} catch { /* ignore decode error */ }
|
|
93
|
+
}
|
|
94
|
+
if (!userId) {
|
|
95
|
+
const userIdMatch = responseText.match(/"pk_string":"(\d+)"/);
|
|
96
|
+
if (userIdMatch) {
|
|
97
|
+
userId = userIdMatch[1];
|
|
98
|
+
} else {
|
|
99
|
+
const altMatch = responseText.match(/"user_id":(\d+)/);
|
|
100
|
+
if (altMatch) userId = altMatch[1];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check for errors
|
|
105
|
+
if (!token) {
|
|
106
|
+
if (responseText.includes('checkpoint_required') || responseText.includes('challenge_required')) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
'challenge_required: Identity verification required. Please complete it in the Threads app or Instagram.',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (responseText.includes('two_factor_required')) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'Two-factor authentication (2FA) is required. Please complete verification in the app first.',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (responseText.includes('invalid_user') || responseText.includes('invalid_password')) {
|
|
117
|
+
throw new Error('Threads login failed: Invalid username or password.');
|
|
118
|
+
}
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Threads login failed: Could not extract authorization token. Response status: ${res.status}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Save session
|
|
125
|
+
saveThreadsSession(sessionPath, { token, userId, deviceId });
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
provider: 'threads',
|
|
129
|
+
loggedIn: true,
|
|
130
|
+
userId,
|
|
131
|
+
username: resolvedUsername,
|
|
132
|
+
sessionPath,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
createAskForAuthentication,
|
|
138
|
+
THREADS_APP_ID,
|
|
139
|
+
THREADS_USER_AGENT,
|
|
140
|
+
BLOKS_VERSION,
|
|
141
|
+
BASE_URL,
|
|
142
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { saveProviderMeta, clearProviderMeta, getProviderMeta } = require('../../storage/sessionStore');
|
|
3
|
+
const createThreadsApiClient = require('./apiClient');
|
|
4
|
+
const { readThreadsCredentials } = require('./utils');
|
|
5
|
+
const { createThreadsWithProviderSession } = require('./session');
|
|
6
|
+
const { createAskForAuthentication } = require('./auth');
|
|
7
|
+
|
|
8
|
+
const createThreadsProvider = ({ sessionPath, account }) => {
|
|
9
|
+
const api = createThreadsApiClient({ sessionPath });
|
|
10
|
+
|
|
11
|
+
const askForAuthentication = createAskForAuthentication({ sessionPath });
|
|
12
|
+
|
|
13
|
+
const withProviderSession = createThreadsWithProviderSession(askForAuthentication, account);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
id: 'threads',
|
|
17
|
+
name: 'Threads',
|
|
18
|
+
|
|
19
|
+
async authStatus() {
|
|
20
|
+
return withProviderSession(async () => {
|
|
21
|
+
try {
|
|
22
|
+
const session = api.getSession();
|
|
23
|
+
return {
|
|
24
|
+
provider: 'threads',
|
|
25
|
+
loggedIn: true,
|
|
26
|
+
userId: session.userId,
|
|
27
|
+
hasSession: Boolean(session.token),
|
|
28
|
+
sessionPath,
|
|
29
|
+
metadata: getProviderMeta('threads', account) || {},
|
|
30
|
+
};
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return {
|
|
33
|
+
provider: 'threads',
|
|
34
|
+
loggedIn: false,
|
|
35
|
+
sessionPath,
|
|
36
|
+
error: error.message,
|
|
37
|
+
metadata: getProviderMeta('threads', account) || {},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async login({ username, password } = {}) {
|
|
44
|
+
const creds = readThreadsCredentials();
|
|
45
|
+
const resolved = {
|
|
46
|
+
username: username || creds.username,
|
|
47
|
+
password: password || creds.password,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (!resolved.username || !resolved.password) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Threads login requires username/password. ' +
|
|
53
|
+
'Please set the THREADS_USERNAME / THREADS_PASSWORD (or INSTA_USERNAME / INSTA_PASSWORD) environment variables.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = await askForAuthentication(resolved);
|
|
58
|
+
api.resetState();
|
|
59
|
+
|
|
60
|
+
saveProviderMeta('threads', {
|
|
61
|
+
loggedIn: result.loggedIn,
|
|
62
|
+
userId: result.userId,
|
|
63
|
+
username: result.username,
|
|
64
|
+
sessionPath: result.sessionPath,
|
|
65
|
+
}, account);
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async publish({ content, imageUrls, imageUrl, imagePath, replyTo, caption } = {}) {
|
|
71
|
+
return withProviderSession(async () => {
|
|
72
|
+
const text = content || caption || '';
|
|
73
|
+
|
|
74
|
+
// Image thread
|
|
75
|
+
let resolvedImageUrl = imageUrl;
|
|
76
|
+
if (!resolvedImageUrl && !imagePath && imageUrls?.length > 0) {
|
|
77
|
+
resolvedImageUrl = imageUrls[0];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (resolvedImageUrl || imagePath) {
|
|
81
|
+
let imageBuffer;
|
|
82
|
+
if (imagePath) {
|
|
83
|
+
imageBuffer = fs.readFileSync(imagePath);
|
|
84
|
+
} else {
|
|
85
|
+
const res = await fetch(resolvedImageUrl);
|
|
86
|
+
if (!res.ok) throw new Error(`Image download failed: ${res.status}`);
|
|
87
|
+
imageBuffer = Buffer.from(await res.arrayBuffer());
|
|
88
|
+
}
|
|
89
|
+
const uploadId = await api.uploadImage(imageBuffer);
|
|
90
|
+
const result = await api.publishImageThread(uploadId, text);
|
|
91
|
+
return { provider: 'threads', mode: 'publish', ...result };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Text-only thread
|
|
95
|
+
if (!text) {
|
|
96
|
+
throw new Error('content is required for text threads.');
|
|
97
|
+
}
|
|
98
|
+
const result = await api.publishTextThread(text, replyTo);
|
|
99
|
+
return { provider: 'threads', mode: 'publish', ...result };
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async comment({ postId, text } = {}) {
|
|
104
|
+
return withProviderSession(async () => {
|
|
105
|
+
if (!postId) throw new Error('postId is required.');
|
|
106
|
+
if (!text) throw new Error('text is required.');
|
|
107
|
+
const result = await api.publishTextThread(text, postId);
|
|
108
|
+
return {
|
|
109
|
+
provider: 'threads',
|
|
110
|
+
mode: 'comment',
|
|
111
|
+
replyTo: postId,
|
|
112
|
+
...result,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async like({ postId } = {}) {
|
|
118
|
+
return withProviderSession(async () => {
|
|
119
|
+
if (!postId) throw new Error('postId is required.');
|
|
120
|
+
const result = await api.likeThread(postId);
|
|
121
|
+
return { provider: 'threads', mode: 'like', postId, status: result.status };
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async unlike({ postId } = {}) {
|
|
126
|
+
return withProviderSession(async () => {
|
|
127
|
+
if (!postId) throw new Error('postId is required.');
|
|
128
|
+
const result = await api.unlikeThread(postId);
|
|
129
|
+
return { provider: 'threads', mode: 'unlike', postId, status: result.status };
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async follow({ username } = {}) {
|
|
134
|
+
return withProviderSession(async () => {
|
|
135
|
+
if (!username) throw new Error('username is required.');
|
|
136
|
+
const userId = await api.getUserId(username);
|
|
137
|
+
const result = await api.followUser(userId);
|
|
138
|
+
return {
|
|
139
|
+
provider: 'threads',
|
|
140
|
+
mode: 'follow',
|
|
141
|
+
username,
|
|
142
|
+
userId,
|
|
143
|
+
following: result.following,
|
|
144
|
+
outgoingRequest: result.outgoingRequest,
|
|
145
|
+
status: result.status,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async unfollow({ username } = {}) {
|
|
151
|
+
return withProviderSession(async () => {
|
|
152
|
+
if (!username) throw new Error('username is required.');
|
|
153
|
+
const userId = await api.getUserId(username);
|
|
154
|
+
const result = await api.unfollowUser(userId);
|
|
155
|
+
return {
|
|
156
|
+
provider: 'threads',
|
|
157
|
+
mode: 'unfollow',
|
|
158
|
+
username,
|
|
159
|
+
userId,
|
|
160
|
+
following: result.following,
|
|
161
|
+
status: result.status,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async getProfile({ username } = {}) {
|
|
167
|
+
return withProviderSession(async () => {
|
|
168
|
+
if (!username) throw new Error('username is required.');
|
|
169
|
+
const userId = await api.getUserId(username);
|
|
170
|
+
const profile = await api.getUserProfile(userId);
|
|
171
|
+
return { provider: 'threads', mode: 'profile', ...profile };
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async getFeed() {
|
|
176
|
+
return withProviderSession(async () => {
|
|
177
|
+
const items = await api.getTimeline();
|
|
178
|
+
return {
|
|
179
|
+
provider: 'threads',
|
|
180
|
+
mode: 'feed',
|
|
181
|
+
count: items.length,
|
|
182
|
+
items,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async listPosts({ username, limit = 20 } = {}) {
|
|
188
|
+
return withProviderSession(async () => {
|
|
189
|
+
if (!username) throw new Error('username is required.');
|
|
190
|
+
const userId = await api.getUserId(username);
|
|
191
|
+
const posts = await api.getUserThreads(userId, limit);
|
|
192
|
+
return {
|
|
193
|
+
provider: 'threads',
|
|
194
|
+
mode: 'posts',
|
|
195
|
+
username,
|
|
196
|
+
totalCount: posts.length,
|
|
197
|
+
posts,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
async search({ query, limit = 20 } = {}) {
|
|
203
|
+
return withProviderSession(async () => {
|
|
204
|
+
if (!query) throw new Error('query is required.');
|
|
205
|
+
const users = await api.searchUsers(query, limit);
|
|
206
|
+
return {
|
|
207
|
+
provider: 'threads',
|
|
208
|
+
mode: 'search',
|
|
209
|
+
query,
|
|
210
|
+
totalCount: users.length,
|
|
211
|
+
users,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async deletePost({ postId } = {}) {
|
|
217
|
+
return withProviderSession(async () => {
|
|
218
|
+
if (!postId) throw new Error('postId is required.');
|
|
219
|
+
const result = await api.deleteThread(postId);
|
|
220
|
+
return {
|
|
221
|
+
provider: 'threads',
|
|
222
|
+
mode: 'delete',
|
|
223
|
+
postId,
|
|
224
|
+
status: result.status,
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
rateLimitStatus() {
|
|
230
|
+
return {
|
|
231
|
+
provider: 'threads',
|
|
232
|
+
mode: 'rateLimitStatus',
|
|
233
|
+
...api.getRateLimitStatus(),
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async logout() {
|
|
238
|
+
clearProviderMeta('threads', account);
|
|
239
|
+
return {
|
|
240
|
+
provider: 'threads',
|
|
241
|
+
loggedOut: true,
|
|
242
|
+
sessionPath,
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
module.exports = createThreadsProvider;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { saveProviderMeta } = require('../../storage/sessionStore');
|
|
4
|
+
const { readThreadsCredentials, parseThreadsSessionError, buildLoginErrorMessage } = require('./utils');
|
|
5
|
+
|
|
6
|
+
const readSessionFile = (sessionPath) => {
|
|
7
|
+
if (!fs.existsSync(sessionPath)) return null;
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const writeSessionFile = (sessionPath, data) => {
|
|
16
|
+
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
|
|
17
|
+
fs.writeFileSync(sessionPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const saveThreadsSession = (sessionPath, { token, userId, deviceId }) => {
|
|
21
|
+
const existing = readSessionFile(sessionPath) || {};
|
|
22
|
+
writeSessionFile(sessionPath, {
|
|
23
|
+
...existing,
|
|
24
|
+
token,
|
|
25
|
+
userId,
|
|
26
|
+
deviceId,
|
|
27
|
+
updatedAt: new Date().toISOString(),
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const loadThreadsSession = (sessionPath) => {
|
|
32
|
+
const raw = readSessionFile(sessionPath);
|
|
33
|
+
if (!raw?.token) return null;
|
|
34
|
+
return { token: raw.token, userId: raw.userId, deviceId: raw.deviceId };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const validateThreadsSession = (sessionPath) => {
|
|
38
|
+
const session = loadThreadsSession(sessionPath);
|
|
39
|
+
return Boolean(session?.token && session?.userId);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ── Rate Limit persistence (per userId) ──
|
|
43
|
+
|
|
44
|
+
const loadRateLimits = (sessionPath, userId) => {
|
|
45
|
+
const raw = readSessionFile(sessionPath);
|
|
46
|
+
return raw?.rateLimits?.[userId] || null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const saveRateLimits = (sessionPath, userId, counters) => {
|
|
50
|
+
const raw = readSessionFile(sessionPath) || {};
|
|
51
|
+
if (!raw.rateLimits) raw.rateLimits = {};
|
|
52
|
+
raw.rateLimits[userId] = {
|
|
53
|
+
...counters,
|
|
54
|
+
savedAt: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
writeSessionFile(sessionPath, raw);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const createThreadsWithProviderSession = (askForAuthentication, account) => async (fn) => {
|
|
60
|
+
const credentials = readThreadsCredentials();
|
|
61
|
+
const hasCredentials = Boolean(credentials.username && credentials.password);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const result = await fn();
|
|
65
|
+
saveProviderMeta('threads', { loggedIn: true, lastValidatedAt: new Date().toISOString() }, account);
|
|
66
|
+
return result;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (!parseThreadsSessionError(error) || !hasCredentials) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const loginResult = await askForAuthentication({
|
|
74
|
+
username: credentials.username,
|
|
75
|
+
password: credentials.password,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
saveProviderMeta('threads', {
|
|
79
|
+
loggedIn: loginResult.loggedIn,
|
|
80
|
+
userId: loginResult.userId,
|
|
81
|
+
sessionPath: loginResult.sessionPath,
|
|
82
|
+
lastRefreshedAt: new Date().toISOString(),
|
|
83
|
+
lastError: null,
|
|
84
|
+
}, account);
|
|
85
|
+
|
|
86
|
+
if (!loginResult.loggedIn) {
|
|
87
|
+
throw new Error(loginResult.message || 'Login status could not be confirmed after session refresh.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return fn();
|
|
91
|
+
} catch (reloginError) {
|
|
92
|
+
saveProviderMeta('threads', {
|
|
93
|
+
loggedIn: false,
|
|
94
|
+
lastError: buildLoginErrorMessage(reloginError),
|
|
95
|
+
lastValidatedAt: new Date().toISOString(),
|
|
96
|
+
}, account);
|
|
97
|
+
throw reloginError;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
saveThreadsSession,
|
|
104
|
+
loadThreadsSession,
|
|
105
|
+
validateThreadsSession,
|
|
106
|
+
loadRateLimits,
|
|
107
|
+
saveRateLimits,
|
|
108
|
+
createThreadsWithProviderSession,
|
|
109
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const readThreadsCredentials = () => {
|
|
2
|
+
const username = process.env.THREADS_USERNAME || process.env.INSTA_USERNAME || process.env.INSTAGRAM_USERNAME;
|
|
3
|
+
const password = process.env.THREADS_PASSWORD || process.env.INSTA_PASSWORD || process.env.INSTAGRAM_PASSWORD;
|
|
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 parseThreadsSessionError = (error) => {
|
|
11
|
+
const message = String(error?.message || '').toLowerCase();
|
|
12
|
+
return [
|
|
13
|
+
'no session file found',
|
|
14
|
+
'no valid token in session',
|
|
15
|
+
'session expired',
|
|
16
|
+
'login required',
|
|
17
|
+
'login_required',
|
|
18
|
+
'checkpoint_required',
|
|
19
|
+
'401',
|
|
20
|
+
'403',
|
|
21
|
+
].some((token) => message.includes(token.toLowerCase()));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const buildLoginErrorMessage = (error) => String(error?.message || 'Session validation failed.');
|
|
25
|
+
|
|
26
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
readThreadsCredentials,
|
|
30
|
+
parseThreadsSessionError,
|
|
31
|
+
buildLoginErrorMessage,
|
|
32
|
+
sleep,
|
|
33
|
+
};
|
package/src/runner.js
CHANGED
|
@@ -132,7 +132,7 @@ const runCommand = async (command, opts = {}) => {
|
|
|
132
132
|
|
|
133
133
|
case 'publish': {
|
|
134
134
|
const content = readContent(opts);
|
|
135
|
-
if (!content && providerName !== 'insta' && providerName !== 'x' && providerName !== 'reddit') {
|
|
135
|
+
if (!content && providerName !== 'insta' && providerName !== 'x' && providerName !== 'reddit' && providerName !== 'threads') {
|
|
136
136
|
throw createError('MISSING_CONTENT', 'publish requires --content or --content-file', 'viruagent-cli publish --spec');
|
|
137
137
|
}
|
|
138
138
|
return withProvider(() =>
|
|
@@ -5,6 +5,7 @@ const createNaverProvider = require('../providers/naver');
|
|
|
5
5
|
const createInstaProvider = require('../providers/insta');
|
|
6
6
|
const createXProvider = require('../providers/x');
|
|
7
7
|
const createRedditProvider = require('../providers/reddit');
|
|
8
|
+
const createThreadsProvider = require('../providers/threads');
|
|
8
9
|
|
|
9
10
|
const providerFactory = {
|
|
10
11
|
tistory: createTistoryProvider,
|
|
@@ -12,9 +13,10 @@ const providerFactory = {
|
|
|
12
13
|
insta: createInstaProvider,
|
|
13
14
|
x: createXProvider,
|
|
14
15
|
reddit: createRedditProvider,
|
|
16
|
+
threads: createThreadsProvider,
|
|
15
17
|
};
|
|
16
18
|
|
|
17
|
-
const providers = ['tistory', 'naver', 'insta', 'x', 'reddit'];
|
|
19
|
+
const providers = ['tistory', 'naver', 'insta', 'x', 'reddit', 'threads'];
|
|
18
20
|
|
|
19
21
|
const createProviderManager = () => {
|
|
20
22
|
const cache = new Map();
|
|
@@ -40,7 +42,7 @@ const createProviderManager = () => {
|
|
|
40
42
|
return cache.get(cacheKey);
|
|
41
43
|
};
|
|
42
44
|
|
|
43
|
-
const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram', x: 'X (Twitter)', reddit: 'Reddit' };
|
|
45
|
+
const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram', x: 'X (Twitter)', reddit: 'Reddit', threads: 'Threads' };
|
|
44
46
|
const getAvailableProviders = () => providers.map((provider) => ({
|
|
45
47
|
id: provider,
|
|
46
48
|
name: providerNames[provider] || provider,
|