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.
@@ -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}