dy-cli 0.2.0__py3-none-any.whl
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.
- dy_cli/__init__.py +3 -0
- dy_cli/commands/__init__.py +0 -0
- dy_cli/commands/account.py +103 -0
- dy_cli/commands/analytics.py +120 -0
- dy_cli/commands/auth.py +159 -0
- dy_cli/commands/config_cmd.py +67 -0
- dy_cli/commands/download.py +212 -0
- dy_cli/commands/init.py +200 -0
- dy_cli/commands/interact.py +140 -0
- dy_cli/commands/live.py +141 -0
- dy_cli/commands/profile.py +78 -0
- dy_cli/commands/publish.py +123 -0
- dy_cli/commands/search.py +131 -0
- dy_cli/commands/trending.py +82 -0
- dy_cli/engines/__init__.py +0 -0
- dy_cli/engines/api_client.py +665 -0
- dy_cli/engines/playwright_client.py +836 -0
- dy_cli/main.py +144 -0
- dy_cli/utils/__init__.py +0 -0
- dy_cli/utils/config.py +99 -0
- dy_cli/utils/envelope.py +49 -0
- dy_cli/utils/export.py +68 -0
- dy_cli/utils/index_cache.py +83 -0
- dy_cli/utils/output.py +283 -0
- dy_cli/utils/signature.py +183 -0
- dy_cli-0.2.0.dist-info/METADATA +376 -0
- dy_cli-0.2.0.dist-info/RECORD +34 -0
- dy_cli-0.2.0.dist-info/WHEEL +4 -0
- dy_cli-0.2.0.dist-info/entry_points.txt +2 -0
- dy_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- scripts/chrome_launcher.py +71 -0
- scripts/douyin_analytics.py +99 -0
- scripts/douyin_login.py +64 -0
- scripts/douyin_publisher.py +199 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Playwright Client — 浏览器自动化引擎。
|
|
3
|
+
|
|
4
|
+
通过 Playwright 操控 creator.douyin.com 实现发布、登录、数据看板等功能。
|
|
5
|
+
参考: dreammis/social-auto-upload, withwz/douyin_upload
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from dy_cli.utils import config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PlaywrightError(Exception):
|
|
21
|
+
"""Playwright 操作错误。"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run_async(coro):
|
|
25
|
+
"""在同步上下文中运行异步函数。"""
|
|
26
|
+
try:
|
|
27
|
+
loop = asyncio.get_event_loop()
|
|
28
|
+
if loop.is_running():
|
|
29
|
+
import concurrent.futures
|
|
30
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
31
|
+
return pool.submit(asyncio.run, coro).result()
|
|
32
|
+
return loop.run_until_complete(coro)
|
|
33
|
+
except RuntimeError:
|
|
34
|
+
return asyncio.run(coro)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PlaywrightClient:
|
|
38
|
+
"""
|
|
39
|
+
抖音 Playwright 自动化客户端。
|
|
40
|
+
|
|
41
|
+
功能:
|
|
42
|
+
- 扫码登录 / Cookie 管理
|
|
43
|
+
- 视频发布 / 图文发布
|
|
44
|
+
- 数据看板抓取
|
|
45
|
+
- 通知获取
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
CREATOR_URL = "https://creator.douyin.com"
|
|
49
|
+
UPLOAD_URL = "https://creator.douyin.com/creator-micro/content/upload"
|
|
50
|
+
PUBLISH_IMAGE_URL = "https://creator.douyin.com/creator-micro/content/publish/image"
|
|
51
|
+
ANALYTICS_URL = "https://creator.douyin.com/creator-micro/data/stats/self-content"
|
|
52
|
+
DOUYIN_URL = "https://www.douyin.com"
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
account: str | None = None,
|
|
57
|
+
headless: bool = False,
|
|
58
|
+
slow_mo: int = 0,
|
|
59
|
+
):
|
|
60
|
+
self.account = account or "default"
|
|
61
|
+
self.headless = headless
|
|
62
|
+
self.slow_mo = slow_mo
|
|
63
|
+
self.cookie_file = config.get_cookie_file(self.account)
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Cookie management
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def cookie_exists(self) -> bool:
|
|
70
|
+
"""检查 Cookie 文件是否存在。"""
|
|
71
|
+
return os.path.isfile(self.cookie_file)
|
|
72
|
+
|
|
73
|
+
def check_login(self) -> bool:
|
|
74
|
+
"""验证 Cookie 是否有效。"""
|
|
75
|
+
if not self.cookie_exists():
|
|
76
|
+
return False
|
|
77
|
+
return _run_async(self._check_login_async())
|
|
78
|
+
|
|
79
|
+
async def _check_login_async(self) -> bool:
|
|
80
|
+
from playwright.async_api import async_playwright
|
|
81
|
+
async with async_playwright() as pw:
|
|
82
|
+
browser = await pw.chromium.launch(headless=True)
|
|
83
|
+
try:
|
|
84
|
+
context = await browser.new_context(storage_state=self.cookie_file)
|
|
85
|
+
page = await context.new_page()
|
|
86
|
+
await page.goto(self.UPLOAD_URL, wait_until="domcontentloaded")
|
|
87
|
+
try:
|
|
88
|
+
await page.wait_for_url(
|
|
89
|
+
"**/creator-micro/content/upload**",
|
|
90
|
+
timeout=8000,
|
|
91
|
+
)
|
|
92
|
+
except Exception:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
# Check if redirected to login page
|
|
96
|
+
if await page.get_by_text("手机号登录").count() > 0:
|
|
97
|
+
return False
|
|
98
|
+
if await page.get_by_text("扫码登录").count() > 0:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
return True
|
|
102
|
+
finally:
|
|
103
|
+
await browser.close()
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Login
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def login(self) -> bool:
|
|
110
|
+
"""打开浏览器扫码登录,保存 Cookie。"""
|
|
111
|
+
return _run_async(self._login_async())
|
|
112
|
+
|
|
113
|
+
async def _login_async(self) -> bool:
|
|
114
|
+
from playwright.async_api import async_playwright
|
|
115
|
+
async with async_playwright() as pw:
|
|
116
|
+
browser = await pw.chromium.launch(headless=False, slow_mo=self.slow_mo)
|
|
117
|
+
context = await browser.new_context()
|
|
118
|
+
page = await context.new_page()
|
|
119
|
+
await page.goto(self.CREATOR_URL, wait_until="domcontentloaded")
|
|
120
|
+
|
|
121
|
+
print("[dy] 请使用抖音 App 扫码登录...")
|
|
122
|
+
print("[dy] 登录成功后,浏览器会自动关闭")
|
|
123
|
+
|
|
124
|
+
# Wait for user to login — detect navigation to creator dashboard
|
|
125
|
+
try:
|
|
126
|
+
await page.wait_for_url(
|
|
127
|
+
"**/creator-micro/**",
|
|
128
|
+
timeout=120000, # 2 minutes
|
|
129
|
+
)
|
|
130
|
+
await page.wait_for_timeout(3000)
|
|
131
|
+
except Exception:
|
|
132
|
+
print("[dy] 登录超时")
|
|
133
|
+
await browser.close()
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
# Visit multiple pages to collect ALL cookies
|
|
137
|
+
print("[dy] 正在收集完整 Cookie...")
|
|
138
|
+
for url in [
|
|
139
|
+
"https://www.douyin.com/",
|
|
140
|
+
"https://creator.douyin.com/creator-micro/content/manage",
|
|
141
|
+
]:
|
|
142
|
+
try:
|
|
143
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
144
|
+
await page.wait_for_timeout(2000)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# Save cookies
|
|
149
|
+
os.makedirs(os.path.dirname(self.cookie_file), exist_ok=True)
|
|
150
|
+
await context.storage_state(path=self.cookie_file)
|
|
151
|
+
|
|
152
|
+
cookies = await context.cookies()
|
|
153
|
+
douyin_count = len([c for c in cookies if "douyin" in c.get("domain", "")])
|
|
154
|
+
print(f"[dy] Cookie 已保存: {douyin_count} 个 ({self.cookie_file})")
|
|
155
|
+
await browser.close()
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def logout(self) -> bool:
|
|
159
|
+
"""删除 Cookie 文件。"""
|
|
160
|
+
if os.path.isfile(self.cookie_file):
|
|
161
|
+
os.remove(self.cookie_file)
|
|
162
|
+
return True
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Publish video
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def publish_video(
|
|
170
|
+
self,
|
|
171
|
+
title: str,
|
|
172
|
+
content: str,
|
|
173
|
+
video_path: str,
|
|
174
|
+
tags: list[str] | None = None,
|
|
175
|
+
visibility: str = "公开",
|
|
176
|
+
schedule_at: str | None = None,
|
|
177
|
+
thumbnail_path: str | None = None,
|
|
178
|
+
) -> dict:
|
|
179
|
+
"""发布视频到抖音。"""
|
|
180
|
+
if not os.path.isfile(video_path):
|
|
181
|
+
raise PlaywrightError(f"视频文件不存在: {video_path}")
|
|
182
|
+
if not self.cookie_exists():
|
|
183
|
+
raise PlaywrightError("未登录,请先运行: dy login")
|
|
184
|
+
|
|
185
|
+
return _run_async(
|
|
186
|
+
self._publish_video_async(
|
|
187
|
+
title, content, video_path, tags, visibility, schedule_at, thumbnail_path
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def _publish_video_async(
|
|
192
|
+
self,
|
|
193
|
+
title: str,
|
|
194
|
+
content: str,
|
|
195
|
+
video_path: str,
|
|
196
|
+
tags: list[str] | None,
|
|
197
|
+
visibility: str,
|
|
198
|
+
schedule_at: str | None,
|
|
199
|
+
thumbnail_path: str | None,
|
|
200
|
+
) -> dict:
|
|
201
|
+
from playwright.async_api import async_playwright
|
|
202
|
+
async with async_playwright() as pw:
|
|
203
|
+
browser = await pw.chromium.launch(
|
|
204
|
+
headless=self.headless,
|
|
205
|
+
slow_mo=self.slow_mo,
|
|
206
|
+
)
|
|
207
|
+
context = await browser.new_context(storage_state=self.cookie_file)
|
|
208
|
+
page = await context.new_page()
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
# Navigate to upload page
|
|
212
|
+
await page.goto(self.UPLOAD_URL, wait_until="domcontentloaded")
|
|
213
|
+
await page.wait_for_timeout(3000)
|
|
214
|
+
|
|
215
|
+
# Check login
|
|
216
|
+
if await page.get_by_text("扫码登录").count() > 0:
|
|
217
|
+
raise PlaywrightError("Cookie 已失效,请重新登录: dy login")
|
|
218
|
+
|
|
219
|
+
# Upload video file
|
|
220
|
+
upload_input = page.locator('input[type="file"]').first
|
|
221
|
+
await upload_input.set_input_files(video_path)
|
|
222
|
+
print(f"[dy] 正在上传视频: {os.path.basename(video_path)}")
|
|
223
|
+
|
|
224
|
+
# Wait for upload to complete (look for editor/title input)
|
|
225
|
+
await page.wait_for_timeout(5000)
|
|
226
|
+
|
|
227
|
+
# Wait for upload progress to finish
|
|
228
|
+
for _ in range(120): # max 10 minutes
|
|
229
|
+
# Check if upload is complete
|
|
230
|
+
ready = await page.locator('[class*="title"] input, [class*="title"] textarea, [contenteditable="true"]').count()
|
|
231
|
+
if ready > 0:
|
|
232
|
+
break
|
|
233
|
+
await page.wait_for_timeout(5000)
|
|
234
|
+
|
|
235
|
+
# Fill title — find the title input
|
|
236
|
+
title_input = page.locator('[class*="title"] input, [class*="title"] textarea').first
|
|
237
|
+
try:
|
|
238
|
+
await title_input.wait_for(timeout=5000)
|
|
239
|
+
await title_input.clear()
|
|
240
|
+
await title_input.fill(title)
|
|
241
|
+
except Exception:
|
|
242
|
+
# Try contenteditable
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
# Fill description/content
|
|
246
|
+
content_editor = page.locator('[contenteditable="true"]').first
|
|
247
|
+
try:
|
|
248
|
+
await content_editor.wait_for(timeout=5000)
|
|
249
|
+
await content_editor.click()
|
|
250
|
+
|
|
251
|
+
# Type content
|
|
252
|
+
full_text = content
|
|
253
|
+
if tags:
|
|
254
|
+
tag_text = " ".join(f"#{t}" for t in tags)
|
|
255
|
+
full_text = f"{content} {tag_text}"
|
|
256
|
+
|
|
257
|
+
await page.keyboard.type(full_text, delay=50)
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
# Handle visibility
|
|
262
|
+
if visibility == "私密" or visibility == "仅自己可见":
|
|
263
|
+
try:
|
|
264
|
+
perm_btn = page.locator('text=谁可以看').first
|
|
265
|
+
if await perm_btn.count() > 0:
|
|
266
|
+
await perm_btn.click()
|
|
267
|
+
await page.wait_for_timeout(500)
|
|
268
|
+
private_opt = page.locator('text=仅自己可见').first
|
|
269
|
+
if await private_opt.count() > 0:
|
|
270
|
+
await private_opt.click()
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
# Handle schedule
|
|
275
|
+
if schedule_at:
|
|
276
|
+
await self._set_schedule_time(page, schedule_at)
|
|
277
|
+
|
|
278
|
+
# Set thumbnail if provided
|
|
279
|
+
if thumbnail_path and os.path.isfile(thumbnail_path):
|
|
280
|
+
try:
|
|
281
|
+
cover_btn = page.locator('text=选择封面').first
|
|
282
|
+
if await cover_btn.count() > 0:
|
|
283
|
+
await cover_btn.click()
|
|
284
|
+
await page.wait_for_timeout(1000)
|
|
285
|
+
cover_upload = page.locator('input[type="file"]').last
|
|
286
|
+
await cover_upload.set_input_files(thumbnail_path)
|
|
287
|
+
await page.wait_for_timeout(2000)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
# Handle cover (required by Douyin)
|
|
292
|
+
await self._select_cover(page)
|
|
293
|
+
|
|
294
|
+
# Handle visibility
|
|
295
|
+
if visibility == "私密" or visibility == "仅自己可见":
|
|
296
|
+
try:
|
|
297
|
+
private_opt = page.locator('text=仅自己可见').first
|
|
298
|
+
if await private_opt.count() > 0:
|
|
299
|
+
await private_opt.click(force=True)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
# Click the EXACT "发布" button (not "高清发布")
|
|
304
|
+
await page.wait_for_timeout(2000)
|
|
305
|
+
published = await page.evaluate("""() => {
|
|
306
|
+
const btns = document.querySelectorAll('button');
|
|
307
|
+
for (const b of btns) {
|
|
308
|
+
if (b.textContent.trim() === '发布' && !b.disabled) {
|
|
309
|
+
b.click();
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}""")
|
|
315
|
+
|
|
316
|
+
if published:
|
|
317
|
+
await page.wait_for_timeout(5000)
|
|
318
|
+
# Check for error toast
|
|
319
|
+
toast = await page.evaluate(
|
|
320
|
+
'()=>Array.from(document.querySelectorAll("[class*=toast]"))'
|
|
321
|
+
'.map(t=>t.textContent.trim()).filter(Boolean)'
|
|
322
|
+
)
|
|
323
|
+
if toast and "封面" in str(toast):
|
|
324
|
+
print("[dy] 封面设置失败,请手动设置封面后发布")
|
|
325
|
+
elif toast and "成功" in str(toast):
|
|
326
|
+
print("[dy] 发布成功!")
|
|
327
|
+
else:
|
|
328
|
+
# Wait for navigation to manage page
|
|
329
|
+
for _ in range(15):
|
|
330
|
+
await page.wait_for_timeout(2000)
|
|
331
|
+
if "manage" in page.url:
|
|
332
|
+
print("[dy] 发布成功!")
|
|
333
|
+
break
|
|
334
|
+
else:
|
|
335
|
+
print("[dy] 发布请求已提交")
|
|
336
|
+
else:
|
|
337
|
+
print("[dy] 未找到发布按钮,内容已填写,请手动确认")
|
|
338
|
+
if not self.headless:
|
|
339
|
+
await page.wait_for_timeout(30000)
|
|
340
|
+
|
|
341
|
+
return {"status": "published", "title": title}
|
|
342
|
+
|
|
343
|
+
finally:
|
|
344
|
+
await context.storage_state(path=self.cookie_file)
|
|
345
|
+
await browser.close()
|
|
346
|
+
|
|
347
|
+
async def _select_cover(self, page):
|
|
348
|
+
"""选择视频封面(必填项)。"""
|
|
349
|
+
try:
|
|
350
|
+
# Dismiss any overlay guides
|
|
351
|
+
await page.evaluate(
|
|
352
|
+
'()=>document.querySelectorAll("[class*=shepherd]").forEach(e=>e.remove())'
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Wait for AI cover generation
|
|
356
|
+
for _ in range(15):
|
|
357
|
+
await page.wait_for_timeout(1000)
|
|
358
|
+
if await page.locator('text=生成中').count() == 0:
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
# Click the cover area to open cover editor
|
|
362
|
+
cover_divs = await page.evaluate("""() => {
|
|
363
|
+
const els = document.querySelectorAll('[class*="cover"]');
|
|
364
|
+
for (const el of els) {
|
|
365
|
+
const r = el.getBoundingClientRect();
|
|
366
|
+
if (r.width > 100 && r.height > 80 && r.width < 300 &&
|
|
367
|
+
el.textContent.includes('选择封面') && el.onclick !== undefined) {
|
|
368
|
+
return {x: r.x + r.width/2, y: r.y + r.height/2};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Fallback: find by text
|
|
372
|
+
const all = document.querySelectorAll('div');
|
|
373
|
+
for (const el of all) {
|
|
374
|
+
const r = el.getBoundingClientRect();
|
|
375
|
+
if (el.textContent.trim() === '选择封面' && r.width > 50 && r.height > 50) {
|
|
376
|
+
return {x: r.x + r.width/2, y: r.y + r.height/2};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}""")
|
|
381
|
+
|
|
382
|
+
if cover_divs:
|
|
383
|
+
await page.mouse.click(int(cover_divs["x"]), int(cover_divs["y"]))
|
|
384
|
+
await page.wait_for_timeout(5000)
|
|
385
|
+
|
|
386
|
+
# Click "完成" in cover editor to accept default frame
|
|
387
|
+
done_btn = page.get_by_role("button", name="完成")
|
|
388
|
+
if await done_btn.count() > 0:
|
|
389
|
+
await done_btn.last.click(force=True)
|
|
390
|
+
await page.wait_for_timeout(2000)
|
|
391
|
+
print("[dy] 封面已设置")
|
|
392
|
+
else:
|
|
393
|
+
print("[dy] 未找到封面选择区域")
|
|
394
|
+
except Exception as e:
|
|
395
|
+
print(f"[dy] 封面设置跳过: {e}")
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
# Publish image/text
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def publish_image_text(
|
|
402
|
+
self,
|
|
403
|
+
title: str,
|
|
404
|
+
content: str,
|
|
405
|
+
images: list[str],
|
|
406
|
+
tags: list[str] | None = None,
|
|
407
|
+
visibility: str = "公开",
|
|
408
|
+
schedule_at: str | None = None,
|
|
409
|
+
) -> dict:
|
|
410
|
+
"""发布图文到抖音。"""
|
|
411
|
+
for img in images:
|
|
412
|
+
if not img.startswith("http") and not os.path.isfile(img):
|
|
413
|
+
raise PlaywrightError(f"图片文件不存在: {img}")
|
|
414
|
+
if not self.cookie_exists():
|
|
415
|
+
raise PlaywrightError("未登录,请先运行: dy login")
|
|
416
|
+
|
|
417
|
+
return _run_async(
|
|
418
|
+
self._publish_image_text_async(title, content, images, tags, visibility, schedule_at)
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def _publish_image_text_async(
|
|
422
|
+
self,
|
|
423
|
+
title: str,
|
|
424
|
+
content: str,
|
|
425
|
+
images: list[str],
|
|
426
|
+
tags: list[str] | None,
|
|
427
|
+
visibility: str,
|
|
428
|
+
schedule_at: str | None,
|
|
429
|
+
) -> dict:
|
|
430
|
+
from playwright.async_api import async_playwright
|
|
431
|
+
async with async_playwright() as pw:
|
|
432
|
+
browser = await pw.chromium.launch(
|
|
433
|
+
headless=self.headless,
|
|
434
|
+
slow_mo=self.slow_mo,
|
|
435
|
+
)
|
|
436
|
+
context = await browser.new_context(storage_state=self.cookie_file)
|
|
437
|
+
page = await context.new_page()
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
# Navigate to image publish page
|
|
441
|
+
await page.goto(self.UPLOAD_URL, wait_until="domcontentloaded")
|
|
442
|
+
await page.wait_for_timeout(3000)
|
|
443
|
+
|
|
444
|
+
# Check login
|
|
445
|
+
if await page.get_by_text("扫码登录").count() > 0:
|
|
446
|
+
raise PlaywrightError("Cookie 已失效,请重新登录: dy login")
|
|
447
|
+
|
|
448
|
+
# Switch to image tab if present
|
|
449
|
+
try:
|
|
450
|
+
img_tab = page.locator('text=图文').first
|
|
451
|
+
if await img_tab.count() > 0:
|
|
452
|
+
await img_tab.click()
|
|
453
|
+
await page.wait_for_timeout(1000)
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
# Upload images — only local files
|
|
458
|
+
local_images = [img for img in images if not img.startswith("http")]
|
|
459
|
+
if local_images:
|
|
460
|
+
upload_input = page.locator('input[type="file"][accept*="image"]').first
|
|
461
|
+
try:
|
|
462
|
+
await upload_input.wait_for(timeout=5000)
|
|
463
|
+
await upload_input.set_input_files(local_images)
|
|
464
|
+
print(f"[dy] 正在上传 {len(local_images)} 张图片")
|
|
465
|
+
await page.wait_for_timeout(3000)
|
|
466
|
+
except Exception:
|
|
467
|
+
# Try generic file input
|
|
468
|
+
upload_input = page.locator('input[type="file"]').first
|
|
469
|
+
await upload_input.set_input_files(local_images)
|
|
470
|
+
await page.wait_for_timeout(3000)
|
|
471
|
+
|
|
472
|
+
# Fill title
|
|
473
|
+
title_input = page.locator('[class*="title"] input, [class*="title"] textarea').first
|
|
474
|
+
try:
|
|
475
|
+
await title_input.wait_for(timeout=5000)
|
|
476
|
+
await title_input.clear()
|
|
477
|
+
await title_input.fill(title)
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
|
|
481
|
+
# Fill content
|
|
482
|
+
content_editor = page.locator('[contenteditable="true"]').first
|
|
483
|
+
try:
|
|
484
|
+
await content_editor.wait_for(timeout=5000)
|
|
485
|
+
await content_editor.click()
|
|
486
|
+
|
|
487
|
+
full_text = content
|
|
488
|
+
if tags:
|
|
489
|
+
tag_text = " ".join(f"#{t}" for t in tags)
|
|
490
|
+
full_text = f"{content} {tag_text}"
|
|
491
|
+
|
|
492
|
+
await page.keyboard.type(full_text, delay=50)
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
# Handle visibility
|
|
497
|
+
if visibility == "私密" or visibility == "仅自己可见":
|
|
498
|
+
try:
|
|
499
|
+
private_opt = page.locator('text=仅自己可见').first
|
|
500
|
+
if await private_opt.count() > 0:
|
|
501
|
+
await private_opt.click(force=True)
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
# Handle schedule
|
|
506
|
+
if schedule_at:
|
|
507
|
+
await self._set_schedule_time(page, schedule_at)
|
|
508
|
+
|
|
509
|
+
# Click the EXACT "发布" button (not "高清发布")
|
|
510
|
+
await page.wait_for_timeout(2000)
|
|
511
|
+
published = await page.evaluate("""() => {
|
|
512
|
+
const btns = document.querySelectorAll('button');
|
|
513
|
+
for (const b of btns) {
|
|
514
|
+
if (b.textContent.trim() === '发布' && !b.disabled) {
|
|
515
|
+
b.click();
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
}""")
|
|
521
|
+
|
|
522
|
+
if published:
|
|
523
|
+
await page.wait_for_timeout(5000)
|
|
524
|
+
print("[dy] 发布请求已提交")
|
|
525
|
+
else:
|
|
526
|
+
print("[dy] 未找到发布按钮,内容已填写,请手动确认")
|
|
527
|
+
if not self.headless:
|
|
528
|
+
await page.wait_for_timeout(30000)
|
|
529
|
+
|
|
530
|
+
return {"status": "published", "title": title}
|
|
531
|
+
|
|
532
|
+
finally:
|
|
533
|
+
await context.storage_state(path=self.cookie_file)
|
|
534
|
+
await browser.close()
|
|
535
|
+
|
|
536
|
+
# ------------------------------------------------------------------
|
|
537
|
+
# Schedule helper
|
|
538
|
+
# ------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
async def _set_schedule_time(self, page, schedule_at: str):
|
|
541
|
+
"""设置定时发布时间。"""
|
|
542
|
+
try:
|
|
543
|
+
# Parse datetime
|
|
544
|
+
dt = datetime.fromisoformat(schedule_at)
|
|
545
|
+
date_str = dt.strftime("%Y年%m月%d日 %H:%M")
|
|
546
|
+
|
|
547
|
+
# Find schedule checkbox/toggle
|
|
548
|
+
schedule_toggle = page.locator('text=定时发布').first
|
|
549
|
+
if await schedule_toggle.count() > 0:
|
|
550
|
+
await schedule_toggle.click()
|
|
551
|
+
await page.wait_for_timeout(1000)
|
|
552
|
+
|
|
553
|
+
# Find and fill the datetime picker
|
|
554
|
+
time_input = page.locator('[class*="schedule"] input, [class*="time"] input').first
|
|
555
|
+
if await time_input.count() > 0:
|
|
556
|
+
await time_input.clear()
|
|
557
|
+
await time_input.fill(date_str)
|
|
558
|
+
await page.keyboard.press("Enter")
|
|
559
|
+
except Exception:
|
|
560
|
+
print(f"[dy] 定时发布设置失败,将立即发布")
|
|
561
|
+
|
|
562
|
+
# ------------------------------------------------------------------
|
|
563
|
+
# Analytics
|
|
564
|
+
# ------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
def get_analytics(self, page_size: int = 10) -> dict:
|
|
567
|
+
"""获取创作者数据看板。"""
|
|
568
|
+
if not self.cookie_exists():
|
|
569
|
+
raise PlaywrightError("未登录")
|
|
570
|
+
return _run_async(self._get_analytics_async(page_size))
|
|
571
|
+
|
|
572
|
+
async def _get_analytics_async(self, page_size: int) -> dict:
|
|
573
|
+
from playwright.async_api import async_playwright
|
|
574
|
+
async with async_playwright() as pw:
|
|
575
|
+
browser = await pw.chromium.launch(headless=True)
|
|
576
|
+
context = await browser.new_context(storage_state=self.cookie_file)
|
|
577
|
+
page = await context.new_page()
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
# First go to creator center to establish session
|
|
581
|
+
await page.goto(self.CREATOR_URL, wait_until="domcontentloaded")
|
|
582
|
+
await page.wait_for_timeout(3000)
|
|
583
|
+
|
|
584
|
+
# Check if logged in
|
|
585
|
+
if await page.get_by_text("扫码登录").count() > 0:
|
|
586
|
+
raise PlaywrightError("Cookie 已失效")
|
|
587
|
+
|
|
588
|
+
# Navigate to analytics page
|
|
589
|
+
await page.goto(self.ANALYTICS_URL, wait_until="domcontentloaded")
|
|
590
|
+
await page.wait_for_timeout(5000)
|
|
591
|
+
|
|
592
|
+
# Try clicking "作品数据" tab if present
|
|
593
|
+
try:
|
|
594
|
+
content_tab = page.locator('text=作品数据').first
|
|
595
|
+
if await content_tab.count() > 0:
|
|
596
|
+
await content_tab.click()
|
|
597
|
+
await page.wait_for_timeout(3000)
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
# Extract data from the page — try multiple selectors
|
|
602
|
+
data = await page.evaluate("""() => {
|
|
603
|
+
const rows = [];
|
|
604
|
+
|
|
605
|
+
// Strategy 1: table rows
|
|
606
|
+
document.querySelectorAll('table tr, [class*="table"] tr').forEach(tr => {
|
|
607
|
+
const cells = tr.querySelectorAll('td');
|
|
608
|
+
if (cells.length >= 3) {
|
|
609
|
+
const texts = Array.from(cells).map(c => c.textContent.trim());
|
|
610
|
+
rows.push({
|
|
611
|
+
'标题': texts[0] || '-',
|
|
612
|
+
'发布时间': texts[1] || '-',
|
|
613
|
+
'播放': texts[2] || '-',
|
|
614
|
+
'完播率': texts[3] || '-',
|
|
615
|
+
'点赞': texts[4] || '-',
|
|
616
|
+
'评论': texts[5] || '-',
|
|
617
|
+
'分享': texts[6] || '-',
|
|
618
|
+
'涨粉': texts[7] || '-',
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// Strategy 2: card-style content items
|
|
624
|
+
if (rows.length === 0) {
|
|
625
|
+
document.querySelectorAll('[class*="content-card"], [class*="item-wrap"], [class*="data-row"]').forEach(item => {
|
|
626
|
+
const title = item.querySelector('[class*="title"]')?.textContent?.trim() || '-';
|
|
627
|
+
const numbers = [];
|
|
628
|
+
item.querySelectorAll('[class*="num"], [class*="count"], [class*="data"]').forEach(n => {
|
|
629
|
+
numbers.push(n.textContent.trim());
|
|
630
|
+
});
|
|
631
|
+
if (title !== '-' || numbers.length > 0) {
|
|
632
|
+
rows.push({
|
|
633
|
+
'标题': title,
|
|
634
|
+
'发布时间': numbers[0] || '-',
|
|
635
|
+
'播放': numbers[1] || '-',
|
|
636
|
+
'完播率': numbers[2] || '-',
|
|
637
|
+
'点赞': numbers[3] || '-',
|
|
638
|
+
'评论': numbers[4] || '-',
|
|
639
|
+
'分享': numbers[5] || '-',
|
|
640
|
+
'涨粉': numbers[6] || '-',
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Get summary stats
|
|
647
|
+
const summary = {};
|
|
648
|
+
document.querySelectorAll('[class*="overview"] [class*="item"], [class*="summary"] [class*="item"]').forEach(item => {
|
|
649
|
+
const label = item.querySelector('[class*="label"], [class*="name"]')?.textContent?.trim();
|
|
650
|
+
const value = item.querySelector('[class*="value"], [class*="num"]')?.textContent?.trim();
|
|
651
|
+
if (label && value) summary[label] = value;
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
return { rows, summary, url: window.location.href };
|
|
655
|
+
}""")
|
|
656
|
+
|
|
657
|
+
return data
|
|
658
|
+
|
|
659
|
+
finally:
|
|
660
|
+
await browser.close()
|
|
661
|
+
|
|
662
|
+
# ------------------------------------------------------------------
|
|
663
|
+
# Notifications
|
|
664
|
+
# ------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
def get_notifications(self) -> dict:
|
|
667
|
+
"""获取消息通知。"""
|
|
668
|
+
if not self.cookie_exists():
|
|
669
|
+
raise PlaywrightError("未登录")
|
|
670
|
+
return _run_async(self._get_notifications_async())
|
|
671
|
+
|
|
672
|
+
async def _get_notifications_async(self) -> dict:
|
|
673
|
+
from playwright.async_api import async_playwright
|
|
674
|
+
async with async_playwright() as pw:
|
|
675
|
+
browser = await pw.chromium.launch(headless=True)
|
|
676
|
+
context = await browser.new_context(storage_state=self.cookie_file)
|
|
677
|
+
page = await context.new_page()
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
await page.goto(
|
|
681
|
+
"https://creator.douyin.com/creator-micro/message",
|
|
682
|
+
wait_until="domcontentloaded",
|
|
683
|
+
)
|
|
684
|
+
await page.wait_for_timeout(5000)
|
|
685
|
+
|
|
686
|
+
data = await page.evaluate("""() => {
|
|
687
|
+
const notifications = [];
|
|
688
|
+
const items = document.querySelectorAll('[class*="message-item"], [class*="notification-item"]');
|
|
689
|
+
items.forEach(item => {
|
|
690
|
+
notifications.push({
|
|
691
|
+
type: item.querySelector('[class*="type"]')?.textContent?.trim() || '-',
|
|
692
|
+
user: item.querySelector('[class*="name"]')?.textContent?.trim() || '-',
|
|
693
|
+
content: item.querySelector('[class*="content"]')?.textContent?.trim() || '-',
|
|
694
|
+
time: item.querySelector('[class*="time"]')?.textContent?.trim() || '-',
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
return { mentions: notifications };
|
|
698
|
+
}""")
|
|
699
|
+
|
|
700
|
+
return data
|
|
701
|
+
|
|
702
|
+
finally:
|
|
703
|
+
await browser.close()
|
|
704
|
+
|
|
705
|
+
# ------------------------------------------------------------------
|
|
706
|
+
# Interactions (like / comment / favorite / follow)
|
|
707
|
+
# ------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
def interact(self, aweme_id: str, action: str, **kwargs) -> dict:
|
|
710
|
+
"""
|
|
711
|
+
在 douyin.com 视频页面执行互动操作。
|
|
712
|
+
|
|
713
|
+
action: "like" | "unlike" | "favorite" | "unfavorite" | "comment" | "follow" | "unfollow"
|
|
714
|
+
kwargs: content (for comment), sec_user_id (for follow)
|
|
715
|
+
"""
|
|
716
|
+
if not self.cookie_exists():
|
|
717
|
+
raise PlaywrightError("未登录,请先运行: dy login")
|
|
718
|
+
return _run_async(self._interact_async(aweme_id, action, **kwargs))
|
|
719
|
+
|
|
720
|
+
async def _interact_async(self, aweme_id: str, action: str, **kwargs) -> dict:
|
|
721
|
+
from playwright.async_api import async_playwright
|
|
722
|
+
async with async_playwright() as pw:
|
|
723
|
+
browser = await pw.chromium.launch(headless=True)
|
|
724
|
+
context = await browser.new_context(
|
|
725
|
+
storage_state=self.cookie_file,
|
|
726
|
+
viewport={"width": 1920, "height": 1080},
|
|
727
|
+
)
|
|
728
|
+
page = await context.new_page()
|
|
729
|
+
|
|
730
|
+
try:
|
|
731
|
+
if action in ("follow", "unfollow"):
|
|
732
|
+
return await self._do_follow(page, kwargs.get("sec_user_id", aweme_id), action)
|
|
733
|
+
|
|
734
|
+
# Navigate to video page
|
|
735
|
+
url = f"https://www.douyin.com/video/{aweme_id}"
|
|
736
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
737
|
+
await page.wait_for_timeout(5000)
|
|
738
|
+
# Wait for action buttons to load
|
|
739
|
+
for _ in range(10):
|
|
740
|
+
if await page.locator('[data-e2e="video-player-digg"]').count() > 0:
|
|
741
|
+
break
|
|
742
|
+
await page.wait_for_timeout(1000)
|
|
743
|
+
|
|
744
|
+
if action == "like":
|
|
745
|
+
return await self._do_like(page, aweme_id)
|
|
746
|
+
elif action == "unlike":
|
|
747
|
+
return await self._do_like(page, aweme_id, undo=True)
|
|
748
|
+
elif action == "favorite":
|
|
749
|
+
return await self._do_favorite(page, aweme_id)
|
|
750
|
+
elif action == "unfavorite":
|
|
751
|
+
return await self._do_favorite(page, aweme_id, undo=True)
|
|
752
|
+
elif action == "comment":
|
|
753
|
+
return await self._do_comment(page, aweme_id, kwargs.get("content", ""))
|
|
754
|
+
else:
|
|
755
|
+
raise PlaywrightError(f"未知操作: {action}")
|
|
756
|
+
|
|
757
|
+
finally:
|
|
758
|
+
await context.storage_state(path=self.cookie_file)
|
|
759
|
+
await browser.close()
|
|
760
|
+
|
|
761
|
+
async def _do_like(self, page, aweme_id: str, undo: bool = False) -> dict:
|
|
762
|
+
"""点赞/取消点赞 — JS 直接点击,绕过可见性检查。"""
|
|
763
|
+
clicked = await page.evaluate("""() => {
|
|
764
|
+
const el = document.querySelector('[data-e2e="video-player-digg"]');
|
|
765
|
+
if (el) { el.click(); return true; }
|
|
766
|
+
return false;
|
|
767
|
+
}""")
|
|
768
|
+
await page.wait_for_timeout(1500)
|
|
769
|
+
return {"action": "unlike" if undo else "like", "aweme_id": aweme_id, "success": clicked}
|
|
770
|
+
|
|
771
|
+
async def _do_favorite(self, page, aweme_id: str, undo: bool = False) -> dict:
|
|
772
|
+
"""收藏/取消收藏 — JS 直接点击。"""
|
|
773
|
+
clicked = await page.evaluate("""() => {
|
|
774
|
+
const el = document.querySelector('[data-e2e="video-player-collect"]');
|
|
775
|
+
if (el) { el.click(); return true; }
|
|
776
|
+
return false;
|
|
777
|
+
}""")
|
|
778
|
+
await page.wait_for_timeout(1500)
|
|
779
|
+
return {"action": "unfavorite" if undo else "favorite", "aweme_id": aweme_id, "success": clicked}
|
|
780
|
+
|
|
781
|
+
async def _do_comment(self, page, aweme_id: str, content: str) -> dict:
|
|
782
|
+
"""发表评论。"""
|
|
783
|
+
if not content:
|
|
784
|
+
raise PlaywrightError("评论内容不能为空")
|
|
785
|
+
|
|
786
|
+
commented = False
|
|
787
|
+
# Click comment icon to focus the input area
|
|
788
|
+
comment_icon = page.locator('[data-e2e="feed-comment-icon"]')
|
|
789
|
+
if await comment_icon.count() > 0:
|
|
790
|
+
await comment_icon.first.click()
|
|
791
|
+
await page.wait_for_timeout(1000)
|
|
792
|
+
|
|
793
|
+
# Find comment input (contenteditable or textarea)
|
|
794
|
+
input_sel = page.locator(
|
|
795
|
+
'[data-e2e="comment-input"], '
|
|
796
|
+
'[class*="comment"] [contenteditable="true"], '
|
|
797
|
+
'[placeholder*="善语结善缘"], [placeholder*="说点什么"]'
|
|
798
|
+
)
|
|
799
|
+
if await input_sel.count() > 0:
|
|
800
|
+
await input_sel.first.click()
|
|
801
|
+
await page.wait_for_timeout(500)
|
|
802
|
+
await page.keyboard.type(content, delay=30)
|
|
803
|
+
await page.wait_for_timeout(500)
|
|
804
|
+
|
|
805
|
+
# Submit
|
|
806
|
+
send = page.locator(
|
|
807
|
+
'[data-e2e="comment-post"], '
|
|
808
|
+
'button:has-text("发布")'
|
|
809
|
+
).last
|
|
810
|
+
if await send.count() > 0:
|
|
811
|
+
await send.click()
|
|
812
|
+
commented = True
|
|
813
|
+
else:
|
|
814
|
+
await page.keyboard.press("Enter")
|
|
815
|
+
commented = True
|
|
816
|
+
|
|
817
|
+
await page.wait_for_timeout(2000)
|
|
818
|
+
return {"action": "comment", "aweme_id": aweme_id, "content": content, "success": commented}
|
|
819
|
+
|
|
820
|
+
async def _do_follow(self, page, sec_user_id: str, action: str) -> dict:
|
|
821
|
+
"""关注/取消关注用户。"""
|
|
822
|
+
await page.goto(f"https://www.douyin.com/user/{sec_user_id}", wait_until="domcontentloaded")
|
|
823
|
+
await page.wait_for_timeout(4000)
|
|
824
|
+
|
|
825
|
+
if action == "follow":
|
|
826
|
+
btn = page.locator('[data-e2e="user-info-follow"], button:has-text("关注")')
|
|
827
|
+
else:
|
|
828
|
+
btn = page.locator('button:has-text("已关注"), button:has-text("互相关注")')
|
|
829
|
+
|
|
830
|
+
clicked = False
|
|
831
|
+
if await btn.count() > 0:
|
|
832
|
+
await btn.first.click()
|
|
833
|
+
clicked = True
|
|
834
|
+
await page.wait_for_timeout(1500)
|
|
835
|
+
|
|
836
|
+
return {"action": action, "sec_user_id": sec_user_id, "success": clicked}
|