claude-world-studio 1.0.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/.env.example +30 -0
- package/.mcp.json +51 -0
- package/README.md +224 -0
- package/client/App.tsx +446 -0
- package/client/components/ChatWindow.tsx +790 -0
- package/client/components/FileExplorer.tsx +218 -0
- package/client/components/FilePreviewModal.tsx +179 -0
- package/client/components/PublishDialog.tsx +307 -0
- package/client/components/SettingsPage.tsx +452 -0
- package/client/components/Sidebar.tsx +198 -0
- package/client/components/ToolUseBlock.tsx +140 -0
- package/client/index.html +12 -0
- package/client/index.tsx +10 -0
- package/client/styles/globals.css +48 -0
- package/demo/01-welcome.png +0 -0
- package/demo/02-pipeline-cards.png +0 -0
- package/demo/03-custom-topic-fill.png +0 -0
- package/demo/04-topic-typed.png +0 -0
- package/demo/05-loading-state.png +0 -0
- package/demo/06-tool-calls.png +0 -0
- package/demo/07-history-rich.png +0 -0
- package/demo/09-en-cards.png +0 -0
- package/demo/10-ja-cards.png +0 -0
- package/demo/capture-remaining.mjs +73 -0
- package/demo/capture.mjs +110 -0
- package/demo/demo-walkthrough-2.webm +0 -0
- package/demo/demo-walkthrough.webm +0 -0
- package/package.json +48 -0
- package/postcss.config.js +6 -0
- package/scripts/threads_api.py +536 -0
- package/server/ai-client.ts +356 -0
- package/server/db.ts +299 -0
- package/server/mcp-config.ts +85 -0
- package/server/routes/accounts.ts +88 -0
- package/server/routes/files.ts +175 -0
- package/server/routes/publish.ts +77 -0
- package/server/routes/sessions.ts +59 -0
- package/server/routes/settings.ts +220 -0
- package/server/server.ts +261 -0
- package/server/services/social-publisher.ts +74 -0
- package/server/services/studio-mcp.ts +107 -0
- package/server/session.ts +167 -0
- package/server/types.ts +86 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Threads API Client — Full lifecycle: search, analyze, generate, publish.
|
|
4
|
+
Based on Meta's official Threads API (graph.threads.net/v1.0).
|
|
5
|
+
|
|
6
|
+
Tokens are auto-loaded from ../.env (relative to this script).
|
|
7
|
+
Use --account to select which account (cw or lf). --token overrides env.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python threads_api.py me # uses THREADS_TOKEN_CW from .env
|
|
11
|
+
python threads_api.py me --account lf # uses THREADS_TOKEN_LF from .env
|
|
12
|
+
python threads_api.py search --query "AI" --limit 20
|
|
13
|
+
python threads_api.py publish --text "Your post content" --account lf
|
|
14
|
+
python threads_api.py batch-publish --file posts.json --delay 1800
|
|
15
|
+
python threads_api.py schedule --file posts.json --times "21:00,12:00,07:30"
|
|
16
|
+
python threads_api.py me --token TOKEN # explicit token overrides .env
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import os
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
from urllib.request import Request, urlopen
|
|
26
|
+
from urllib.parse import urlencode, quote
|
|
27
|
+
from urllib.error import HTTPError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_env():
|
|
31
|
+
"""Load .env file from the skill root (one level up from scripts/)."""
|
|
32
|
+
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env")
|
|
33
|
+
env_path = os.path.normpath(env_path)
|
|
34
|
+
if not os.path.isfile(env_path):
|
|
35
|
+
return
|
|
36
|
+
with open(env_path, "r", encoding="utf-8") as f:
|
|
37
|
+
for line in f:
|
|
38
|
+
line = line.strip()
|
|
39
|
+
if not line or line.startswith("#"):
|
|
40
|
+
continue
|
|
41
|
+
if "=" not in line:
|
|
42
|
+
continue
|
|
43
|
+
key, value = line.split("=", 1)
|
|
44
|
+
key, value = key.strip(), value.strip()
|
|
45
|
+
if key and key not in os.environ:
|
|
46
|
+
os.environ[key] = value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
load_env()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_token(args):
|
|
53
|
+
"""Resolve the API token from --token flag or environment variables."""
|
|
54
|
+
if args.token:
|
|
55
|
+
return args.token
|
|
56
|
+
account = getattr(args, "account", "cw")
|
|
57
|
+
if account == "lf":
|
|
58
|
+
token = os.environ.get("THREADS_TOKEN_LF")
|
|
59
|
+
else:
|
|
60
|
+
token = os.environ.get("THREADS_TOKEN_CW")
|
|
61
|
+
if not token:
|
|
62
|
+
# Fallback to generic THREADS_ACCESS_TOKEN
|
|
63
|
+
token = os.environ.get("THREADS_ACCESS_TOKEN")
|
|
64
|
+
if not token:
|
|
65
|
+
print("Error: No token provided. Use --token or set THREADS_TOKEN_CW / THREADS_TOKEN_LF in .env", file=sys.stderr)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
return token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
API_BASE = "https://graph.threads.net/v1.0"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ThreadsAPI:
|
|
74
|
+
def __init__(self, access_token: str):
|
|
75
|
+
self.token = access_token
|
|
76
|
+
self.user_id = None
|
|
77
|
+
self.username = None
|
|
78
|
+
self.rate_limit_remaining = None
|
|
79
|
+
|
|
80
|
+
def _request(self, method: str, endpoint: str, params: dict = None, data: dict = None) -> dict:
|
|
81
|
+
"""Make an API request to Threads."""
|
|
82
|
+
url = f"{API_BASE}{endpoint}"
|
|
83
|
+
|
|
84
|
+
if params is None:
|
|
85
|
+
params = {}
|
|
86
|
+
params["access_token"] = self.token
|
|
87
|
+
|
|
88
|
+
if method == "GET":
|
|
89
|
+
url += "?" + urlencode(params)
|
|
90
|
+
req = Request(url, method="GET")
|
|
91
|
+
else:
|
|
92
|
+
url += "?" + urlencode({"access_token": self.token})
|
|
93
|
+
body = urlencode({k: v for k, v in (data or params).items() if k != "access_token"})
|
|
94
|
+
req = Request(url, data=body.encode(), method="POST")
|
|
95
|
+
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
with urlopen(req, timeout=30) as resp:
|
|
99
|
+
return json.loads(resp.read().decode())
|
|
100
|
+
except HTTPError as e:
|
|
101
|
+
error_body = e.read().decode()
|
|
102
|
+
print(f"API Error {e.code}: {error_body}", file=sys.stderr)
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
# ── Identity ──
|
|
106
|
+
|
|
107
|
+
def get_me(self) -> dict:
|
|
108
|
+
"""Get current user profile."""
|
|
109
|
+
data = self._request("GET", "/me", {
|
|
110
|
+
"fields": "id,username,name,threads_profile_picture_url,threads_biography"
|
|
111
|
+
})
|
|
112
|
+
self.user_id = data.get("id")
|
|
113
|
+
self.username = data.get("username", data.get("name", "unknown"))
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
# ── Search ──
|
|
117
|
+
|
|
118
|
+
def search(self, query: str, limit: int = 20) -> list:
|
|
119
|
+
"""
|
|
120
|
+
Search Threads for posts matching a keyword.
|
|
121
|
+
Returns posts sorted by heat score.
|
|
122
|
+
Rate limit: 500 queries per rolling 7 days.
|
|
123
|
+
"""
|
|
124
|
+
data = self._request("GET", "/keyword_search", {
|
|
125
|
+
"q": query,
|
|
126
|
+
"fields": "id,text,username,timestamp,like_count,reply_count,repost_count",
|
|
127
|
+
"limit": str(min(limit, 50))
|
|
128
|
+
})
|
|
129
|
+
posts = data.get("data", [])
|
|
130
|
+
|
|
131
|
+
# Calculate heat score for each post
|
|
132
|
+
for post in posts:
|
|
133
|
+
likes = post.get("like_count", 0) or 0
|
|
134
|
+
replies = post.get("reply_count", 0) or 0
|
|
135
|
+
reposts = post.get("repost_count", 0) or 0
|
|
136
|
+
post["heat_score"] = likes + (replies * 3) + (reposts * 5)
|
|
137
|
+
|
|
138
|
+
# Sort by heat score descending
|
|
139
|
+
posts.sort(key=lambda p: p["heat_score"], reverse=True)
|
|
140
|
+
return posts
|
|
141
|
+
|
|
142
|
+
def search_multiple(self, queries: list, limit_per_query: int = 10) -> dict:
|
|
143
|
+
"""Search multiple keywords and return results grouped by query."""
|
|
144
|
+
results = {}
|
|
145
|
+
for q in queries:
|
|
146
|
+
try:
|
|
147
|
+
posts = self.search(q, limit_per_query)
|
|
148
|
+
results[q] = posts
|
|
149
|
+
time.sleep(1) # Rate limit courtesy
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(f"Search failed for '{q}': {e}", file=sys.stderr)
|
|
152
|
+
results[q] = []
|
|
153
|
+
return results
|
|
154
|
+
|
|
155
|
+
# ── Publishing ──
|
|
156
|
+
|
|
157
|
+
def create_container(self, text: str, reply_to: str = None,
|
|
158
|
+
image_url: str = None, poll_options: list = None,
|
|
159
|
+
tag: str = None) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Create a media container for a post.
|
|
162
|
+
Returns the creation_id needed for publishing.
|
|
163
|
+
Text limit: 500 characters.
|
|
164
|
+
Supports: text, image, poll attachments.
|
|
165
|
+
Poll options: list of 2-4 strings (max 25 chars each).
|
|
166
|
+
"""
|
|
167
|
+
if len(text) > 500:
|
|
168
|
+
print(f"WARNING: Text is {len(text)} chars, truncating to 500", file=sys.stderr)
|
|
169
|
+
text = text[:497] + "..."
|
|
170
|
+
|
|
171
|
+
if not self.user_id:
|
|
172
|
+
self.get_me()
|
|
173
|
+
|
|
174
|
+
if image_url:
|
|
175
|
+
params = {
|
|
176
|
+
"media_type": "IMAGE",
|
|
177
|
+
"image_url": image_url,
|
|
178
|
+
"text": text,
|
|
179
|
+
}
|
|
180
|
+
else:
|
|
181
|
+
params = {
|
|
182
|
+
"media_type": "TEXT",
|
|
183
|
+
"text": text,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if reply_to:
|
|
187
|
+
params["reply_to_id"] = reply_to
|
|
188
|
+
|
|
189
|
+
if poll_options and not image_url:
|
|
190
|
+
poll = {}
|
|
191
|
+
keys = ["option_a", "option_b", "option_c", "option_d"]
|
|
192
|
+
for i, opt in enumerate(poll_options[:4]):
|
|
193
|
+
poll[keys[i]] = opt[:25]
|
|
194
|
+
params["poll_attachment"] = json.dumps(poll)
|
|
195
|
+
|
|
196
|
+
if tag:
|
|
197
|
+
params["tag"] = tag
|
|
198
|
+
|
|
199
|
+
data = self._request("POST", f"/{self.user_id}/threads", data=params)
|
|
200
|
+
return data.get("id")
|
|
201
|
+
|
|
202
|
+
def publish_container(self, creation_id: str) -> dict:
|
|
203
|
+
"""Publish a previously created media container."""
|
|
204
|
+
if not self.user_id:
|
|
205
|
+
self.get_me()
|
|
206
|
+
|
|
207
|
+
data = self._request("POST", f"/{self.user_id}/threads_publish", data={
|
|
208
|
+
"creation_id": creation_id
|
|
209
|
+
})
|
|
210
|
+
return data
|
|
211
|
+
|
|
212
|
+
def publish_text(self, text: str, image_url: str = None,
|
|
213
|
+
poll_options: list = None, link_comment: str = None,
|
|
214
|
+
tag: str = None) -> dict:
|
|
215
|
+
"""
|
|
216
|
+
Full publish flow: create container → wait → publish.
|
|
217
|
+
If link_comment is provided, auto-reply with the link (avoids reach penalty).
|
|
218
|
+
If poll_options is provided, attach a native poll (2-4 options, max 25 chars each).
|
|
219
|
+
"""
|
|
220
|
+
container_id = self.create_container(text, image_url=image_url,
|
|
221
|
+
poll_options=poll_options,
|
|
222
|
+
tag=tag)
|
|
223
|
+
if not container_id:
|
|
224
|
+
raise Exception("Failed to create media container")
|
|
225
|
+
|
|
226
|
+
# Wait for processing (Meta recommends at least a few seconds)
|
|
227
|
+
time.sleep(5)
|
|
228
|
+
|
|
229
|
+
result = self.publish_container(container_id)
|
|
230
|
+
post_id = result.get("id")
|
|
231
|
+
|
|
232
|
+
output = {
|
|
233
|
+
"post_id": post_id,
|
|
234
|
+
"container_id": container_id,
|
|
235
|
+
"text_length": len(text),
|
|
236
|
+
"published_at": datetime.now().isoformat()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# Auto-reply with link to avoid reach penalty from URL in main post
|
|
240
|
+
if link_comment and post_id:
|
|
241
|
+
time.sleep(3)
|
|
242
|
+
try:
|
|
243
|
+
reply_cid = self.create_container(link_comment, reply_to=post_id)
|
|
244
|
+
time.sleep(5)
|
|
245
|
+
reply_result = self.publish_container(reply_cid)
|
|
246
|
+
output["link_reply_id"] = reply_result.get("id")
|
|
247
|
+
print(f" ✓ Link reply posted: {output['link_reply_id']}", file=sys.stderr)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
output["link_reply_error"] = str(e)
|
|
250
|
+
print(f" ✗ Link reply failed: {e}", file=sys.stderr)
|
|
251
|
+
|
|
252
|
+
if poll_options:
|
|
253
|
+
output["poll_options"] = poll_options[:4]
|
|
254
|
+
if image_url:
|
|
255
|
+
output["image_url"] = image_url
|
|
256
|
+
if tag:
|
|
257
|
+
output["tag"] = tag
|
|
258
|
+
|
|
259
|
+
return output
|
|
260
|
+
|
|
261
|
+
def publish_thread(self, posts: list) -> list:
|
|
262
|
+
"""
|
|
263
|
+
Publish a thread (串文) — a series of connected posts.
|
|
264
|
+
First post is the parent, subsequent posts are replies.
|
|
265
|
+
"""
|
|
266
|
+
results = []
|
|
267
|
+
parent_id = None
|
|
268
|
+
|
|
269
|
+
for i, text in enumerate(posts):
|
|
270
|
+
if i == 0:
|
|
271
|
+
result = self.publish_text(text)
|
|
272
|
+
parent_id = result["post_id"]
|
|
273
|
+
else:
|
|
274
|
+
container_id = self.create_container(text, reply_to=parent_id)
|
|
275
|
+
time.sleep(5)
|
|
276
|
+
pub = self.publish_container(container_id)
|
|
277
|
+
result = {
|
|
278
|
+
"post_id": pub.get("id"),
|
|
279
|
+
"container_id": container_id,
|
|
280
|
+
"text_length": len(text),
|
|
281
|
+
"reply_to": parent_id,
|
|
282
|
+
"published_at": datetime.now().isoformat()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
results.append(result)
|
|
286
|
+
time.sleep(3) # Brief pause between thread posts
|
|
287
|
+
|
|
288
|
+
return results
|
|
289
|
+
|
|
290
|
+
def batch_publish(self, posts: list, delay_seconds: int = 1800) -> list:
|
|
291
|
+
"""
|
|
292
|
+
Publish multiple independent posts with delay between each.
|
|
293
|
+
Default delay: 30 minutes (1800 seconds).
|
|
294
|
+
"""
|
|
295
|
+
results = []
|
|
296
|
+
total = len(posts)
|
|
297
|
+
|
|
298
|
+
for i, post in enumerate(posts):
|
|
299
|
+
text = post if isinstance(post, str) else post.get("text", "")
|
|
300
|
+
print(f"[{i+1}/{total}] Publishing ({len(text)} chars)...", file=sys.stderr)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
result = self.publish_text(text)
|
|
304
|
+
result["index"] = i
|
|
305
|
+
result["status"] = "published"
|
|
306
|
+
results.append(result)
|
|
307
|
+
print(f" ✓ Published: {result['post_id']}", file=sys.stderr)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
results.append({
|
|
310
|
+
"index": i,
|
|
311
|
+
"status": "failed",
|
|
312
|
+
"error": str(e),
|
|
313
|
+
"text_preview": text[:50]
|
|
314
|
+
})
|
|
315
|
+
print(f" ✗ Failed: {e}", file=sys.stderr)
|
|
316
|
+
|
|
317
|
+
if i < total - 1:
|
|
318
|
+
print(f" Waiting {delay_seconds}s before next post...", file=sys.stderr)
|
|
319
|
+
time.sleep(delay_seconds)
|
|
320
|
+
|
|
321
|
+
return results
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ── CLI ──
|
|
325
|
+
|
|
326
|
+
def cmd_me(args):
|
|
327
|
+
token = resolve_token(args)
|
|
328
|
+
api = ThreadsAPI(token)
|
|
329
|
+
data = api.get_me()
|
|
330
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def cmd_search(args):
|
|
334
|
+
token = resolve_token(args)
|
|
335
|
+
api = ThreadsAPI(token)
|
|
336
|
+
api.get_me()
|
|
337
|
+
|
|
338
|
+
if args.multi:
|
|
339
|
+
queries = [q.strip() for q in args.query.split(",")]
|
|
340
|
+
results = api.search_multiple(queries, args.limit)
|
|
341
|
+
output = {
|
|
342
|
+
"searched_at": datetime.now().isoformat(),
|
|
343
|
+
"queries": queries,
|
|
344
|
+
"results": {}
|
|
345
|
+
}
|
|
346
|
+
for q, posts in results.items():
|
|
347
|
+
output["results"][q] = {
|
|
348
|
+
"count": len(posts),
|
|
349
|
+
"top_heat": posts[0]["heat_score"] if posts else 0,
|
|
350
|
+
"posts": posts[:args.limit]
|
|
351
|
+
}
|
|
352
|
+
else:
|
|
353
|
+
posts = api.search(args.query, args.limit)
|
|
354
|
+
output = {
|
|
355
|
+
"searched_at": datetime.now().isoformat(),
|
|
356
|
+
"query": args.query,
|
|
357
|
+
"count": len(posts),
|
|
358
|
+
"posts": posts
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
print(json.dumps(output, indent=2, ensure_ascii=False))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def cmd_publish(args):
|
|
365
|
+
token = resolve_token(args)
|
|
366
|
+
api = ThreadsAPI(token)
|
|
367
|
+
api.get_me()
|
|
368
|
+
|
|
369
|
+
if args.thread:
|
|
370
|
+
# Split text by "---" delimiter for thread posts
|
|
371
|
+
parts = [p.strip() for p in args.text.split("---") if p.strip()]
|
|
372
|
+
results = api.publish_thread(parts)
|
|
373
|
+
else:
|
|
374
|
+
poll_opts = None
|
|
375
|
+
if args.poll:
|
|
376
|
+
poll_opts = [o.strip() for o in args.poll.split("|") if o.strip()]
|
|
377
|
+
results = api.publish_text(
|
|
378
|
+
args.text,
|
|
379
|
+
image_url=args.image,
|
|
380
|
+
poll_options=poll_opts,
|
|
381
|
+
link_comment=args.link_comment,
|
|
382
|
+
tag=args.tag,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
print(json.dumps(results, indent=2, ensure_ascii=False))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def cmd_batch_publish(args):
|
|
389
|
+
token = resolve_token(args)
|
|
390
|
+
api = ThreadsAPI(token)
|
|
391
|
+
api.get_me()
|
|
392
|
+
|
|
393
|
+
with open(args.file, "r", encoding="utf-8") as f:
|
|
394
|
+
data = json.load(f)
|
|
395
|
+
|
|
396
|
+
posts = data if isinstance(data, list) else data.get("posts", [])
|
|
397
|
+
results = api.batch_publish(posts, args.delay)
|
|
398
|
+
|
|
399
|
+
output = {
|
|
400
|
+
"batch_id": datetime.now().strftime("%Y%m%d_%H%M%S"),
|
|
401
|
+
"total": len(posts),
|
|
402
|
+
"published": sum(1 for r in results if r.get("status") == "published"),
|
|
403
|
+
"failed": sum(1 for r in results if r.get("status") == "failed"),
|
|
404
|
+
"delay_seconds": args.delay,
|
|
405
|
+
"results": results
|
|
406
|
+
}
|
|
407
|
+
print(json.dumps(output, indent=2, ensure_ascii=False))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def cmd_schedule(args):
|
|
411
|
+
"""Generate a scheduling plan based on optimal posting times."""
|
|
412
|
+
with open(args.file, "r", encoding="utf-8") as f:
|
|
413
|
+
data = json.load(f)
|
|
414
|
+
|
|
415
|
+
posts = data if isinstance(data, list) else data.get("posts", [])
|
|
416
|
+
|
|
417
|
+
# Parse target times
|
|
418
|
+
if args.times:
|
|
419
|
+
times = [t.strip() for t in args.times.split(",")]
|
|
420
|
+
else:
|
|
421
|
+
# Default optimal times from patent analysis
|
|
422
|
+
times = ["21:00", "12:00", "17:30", "07:30"]
|
|
423
|
+
|
|
424
|
+
now = datetime.now()
|
|
425
|
+
schedule = []
|
|
426
|
+
|
|
427
|
+
for i, post in enumerate(posts):
|
|
428
|
+
text = post if isinstance(post, str) else post.get("text", "")
|
|
429
|
+
time_slot = times[i % len(times)]
|
|
430
|
+
hour, minute = map(int, time_slot.split(":"))
|
|
431
|
+
|
|
432
|
+
target = now.replace(hour=hour, minute=minute, second=0)
|
|
433
|
+
if target <= now:
|
|
434
|
+
target += timedelta(days=1)
|
|
435
|
+
target += timedelta(days=i // len(times))
|
|
436
|
+
|
|
437
|
+
schedule.append({
|
|
438
|
+
"index": i,
|
|
439
|
+
"text_preview": text[:60] + "..." if len(text) > 60 else text,
|
|
440
|
+
"text_length": len(text),
|
|
441
|
+
"scheduled_time": target.isoformat(),
|
|
442
|
+
"time_slot": time_slot,
|
|
443
|
+
"full_text": text
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
output = {
|
|
447
|
+
"schedule_created": now.isoformat(),
|
|
448
|
+
"total_posts": len(posts),
|
|
449
|
+
"time_slots": times,
|
|
450
|
+
"schedule": schedule
|
|
451
|
+
}
|
|
452
|
+
print(json.dumps(output, indent=2, ensure_ascii=False))
|
|
453
|
+
|
|
454
|
+
# If --execute flag, actually wait and publish
|
|
455
|
+
if args.execute:
|
|
456
|
+
token = resolve_token(args)
|
|
457
|
+
api = ThreadsAPI(token)
|
|
458
|
+
api.get_me()
|
|
459
|
+
print(f"\n--- EXECUTING SCHEDULE (User: @{api.username}) ---", file=sys.stderr)
|
|
460
|
+
|
|
461
|
+
for item in schedule:
|
|
462
|
+
target_time = datetime.fromisoformat(item["scheduled_time"])
|
|
463
|
+
wait_seconds = (target_time - datetime.now()).total_seconds()
|
|
464
|
+
|
|
465
|
+
if wait_seconds > 0:
|
|
466
|
+
print(f"Waiting {wait_seconds:.0f}s until {item['time_slot']}...", file=sys.stderr)
|
|
467
|
+
time.sleep(wait_seconds)
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
result = api.publish_text(item["full_text"])
|
|
471
|
+
print(f"✓ [{item['index']+1}] Published at {item['time_slot']}: {result['post_id']}", file=sys.stderr)
|
|
472
|
+
except Exception as e:
|
|
473
|
+
print(f"✗ [{item['index']+1}] Failed: {e}", file=sys.stderr)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def main():
|
|
477
|
+
parser = argparse.ArgumentParser(description="Threads API Client")
|
|
478
|
+
sub = parser.add_subparsers(dest="command")
|
|
479
|
+
|
|
480
|
+
# me
|
|
481
|
+
me_parser = sub.add_parser("me", help="Get current user info")
|
|
482
|
+
me_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
|
|
483
|
+
me_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
|
|
484
|
+
|
|
485
|
+
# search
|
|
486
|
+
search_parser = sub.add_parser("search", help="Search trending posts")
|
|
487
|
+
search_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
|
|
488
|
+
search_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
|
|
489
|
+
search_parser.add_argument("--query", "-q", required=True)
|
|
490
|
+
search_parser.add_argument("--limit", "-l", type=int, default=20)
|
|
491
|
+
search_parser.add_argument("--multi", action="store_true", help="Comma-separated queries")
|
|
492
|
+
|
|
493
|
+
# publish
|
|
494
|
+
pub_parser = sub.add_parser("publish", help="Publish a single post")
|
|
495
|
+
pub_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
|
|
496
|
+
pub_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
|
|
497
|
+
pub_parser.add_argument("--text", "-t", required=True)
|
|
498
|
+
pub_parser.add_argument("--image", default=None, help="Public image URL to attach")
|
|
499
|
+
pub_parser.add_argument("--poll", default=None, help="Poll options separated by | (2-4, max 25 chars each)")
|
|
500
|
+
pub_parser.add_argument("--link-comment", default=None, help="Auto-reply with this link (avoids reach penalty)")
|
|
501
|
+
pub_parser.add_argument("--tag", default=None, help="Topic tag (no # prefix, one per post)")
|
|
502
|
+
pub_parser.add_argument("--thread", action="store_true", help="Split by --- into thread")
|
|
503
|
+
|
|
504
|
+
# batch-publish
|
|
505
|
+
batch_parser = sub.add_parser("batch-publish", help="Publish multiple posts")
|
|
506
|
+
batch_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
|
|
507
|
+
batch_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
|
|
508
|
+
batch_parser.add_argument("--file", "-f", required=True)
|
|
509
|
+
batch_parser.add_argument("--delay", "-d", type=int, default=1800)
|
|
510
|
+
|
|
511
|
+
# schedule
|
|
512
|
+
sched_parser = sub.add_parser("schedule", help="Schedule posts at optimal times")
|
|
513
|
+
sched_parser.add_argument("--token", default=None, help="Access token (falls back to .env)")
|
|
514
|
+
sched_parser.add_argument("--account", choices=["cw", "lf"], default="cw", help="Account alias (configure tokens in .env)")
|
|
515
|
+
sched_parser.add_argument("--file", "-f", required=True)
|
|
516
|
+
sched_parser.add_argument("--times", help="Comma-separated times, e.g. '21:00,12:00'")
|
|
517
|
+
sched_parser.add_argument("--execute", action="store_true", help="Actually wait and publish")
|
|
518
|
+
|
|
519
|
+
args = parser.parse_args()
|
|
520
|
+
|
|
521
|
+
commands = {
|
|
522
|
+
"me": cmd_me,
|
|
523
|
+
"search": cmd_search,
|
|
524
|
+
"publish": cmd_publish,
|
|
525
|
+
"batch-publish": cmd_batch_publish,
|
|
526
|
+
"schedule": cmd_schedule,
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if args.command in commands:
|
|
530
|
+
commands[args.command](args)
|
|
531
|
+
else:
|
|
532
|
+
parser.print_help()
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
main()
|