返回 Social Auto Upload
main.py
1 # -*- coding: utf-8 -*-
2 from __future__ import annotations
3
4 import asyncio
5 import inspect
6 import os
7 from datetime import datetime
8 from pathlib import Path
9
10 from patchright.async_api import Page
11 from patchright.async_api import Playwright
12 from patchright.async_api import async_playwright
13
14 from conf import DEBUG_MODE, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
15 from uploader.base_video import BaseVideoUploader
16 from utils.base_social_media import set_init_script
17 from utils.login_qrcode import build_login_qrcode_path
18 from utils.login_qrcode import decode_qrcode_from_path
19 from utils.login_qrcode import print_terminal_qrcode
20 from utils.login_qrcode import remove_qrcode_file
21 from utils.login_qrcode import save_data_url_image
22 from utils.log import xiaohongshu_logger
23
24 XHS_DEFAULT_CREATOR_BASE_URL = "https://creator.xiaohongshu.com"
25 XHS_CREATOR_BASE_URL_ENV = "SAU_XHS_CREATOR_BASE_URL"
26 XHS_PUBLISH_SUCCESS_URL_PATTERN = "**/publish/success?**"
27 XHS_LOGIN_BOX_SELECTOR = "div[class*='login-box']"
28 XHS_LOGIN_SWITCH_SELECTOR = "img.css-wemwzq"
29 XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE = "immediate"
30 XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED = "scheduled"
31
32
33 def _build_xhs_creator_url(path: str) -> str:
34 base_url = os.getenv(
35 XHS_CREATOR_BASE_URL_ENV,
36 XHS_DEFAULT_CREATOR_BASE_URL,
37 ).strip().rstrip("/")
38 if not base_url:
39 base_url = XHS_DEFAULT_CREATOR_BASE_URL
40 return f"{base_url}/{path.lstrip('/')}"
41
42
43 def _msg(emoji: str, text: str) -> str:
44 return f"{emoji} {text}"
45
46
47 async def _js_click_by_text(page: Page, text: str) -> bool:
48 """用 JS 找到文字完全匹配的最内层元素并点击它及其祖先(绕过 span pointer-events:none / 遮罩拦截)。
49
50 小红书很多可点项文字在 <span class="d-text"> 里,pointer-events 常被禁用,
51 Playwright 常规 click 会超时。用原生 click 冒泡触发 Vue 事件更可靠。
52 """
53 return await page.evaluate(
54 """(t) => {
55 const nodes = [...document.querySelectorAll('*')].filter(
56 e => e.children.length === 0 && (e.textContent || '').trim() === t
57 );
58 if (!nodes.length) return false;
59 let el = nodes[nodes.length - 1];
60 for (let i = 0; i < 4 && el; i++) { try { el.click(); } catch (e) {} el = el.parentElement; }
61 return true;
62 }""",
63 text,
64 )
65
66
67 async def _emit_qrcode_callback(qrcode_callback, payload: dict):
68 if not qrcode_callback:
69 return
70
71 callback_result = qrcode_callback(payload)
72 if inspect.isawaitable(callback_result):
73 await callback_result
74
75
76 def _build_login_result(
77 success: bool,
78 status: str,
79 message: str,
80 account_file: str,
81 qrcode: dict | None = None,
82 current_url: str = "",
83 ) -> dict:
84 return {
85 "success": success,
86 "status": status,
87 "message": message,
88 "account_file": str(account_file),
89 "qrcode": qrcode,
90 "current_url": current_url,
91 }
92
93
94 async def _open_xhs_qrcode_panel(page: Page) -> None:
95 login_box = page.locator(XHS_LOGIN_BOX_SELECTOR).first
96 await login_box.wait_for(state="visible", timeout=30000)
97
98 scan_text = login_box.locator("div:has-text('扫一扫')").first
99 if await scan_text.count():
100 return
101
102 switch_img = login_box.locator(XHS_LOGIN_SWITCH_SELECTOR).first
103 await switch_img.wait_for(state="visible", timeout=10000)
104 await switch_img.click()
105 await login_box.locator("div:has-text('扫一扫')").first.wait_for(state="visible", timeout=10000)
106
107
108 async def _find_xhs_qrcode_locator(page: Page):
109 await _open_xhs_qrcode_panel(page)
110
111 qrcode_img = page.locator('.login-box-container').get_by_text("APP扫一扫登录").filter(visible=True).locator("xpath=..//following-sibling::div//img").nth(0)
112
113 if await qrcode_img.count():
114 return qrcode_img
115
116 raise RuntimeError("未在扫一扫登录区域找到小红书二维码图片")
117
118
119 async def _extract_xhs_qrcode_src(page: Page) -> str:
120 qrcode_img = await _find_xhs_qrcode_locator(page)
121 await qrcode_img.wait_for(state="visible", timeout=30000)
122 qrcode_src = await qrcode_img.get_attribute("src")
123 if not qrcode_src:
124 raise RuntimeError("未获取到小红书登录二维码地址")
125 return qrcode_src
126
127
128 async def _save_xhs_qrcode(
129 page: Page,
130 account_file: str,
131 previous_qrcode_path: Path | None = None,
132 qrcode_callback=None,
133 ) -> dict:
134 qrcode_src = await _extract_xhs_qrcode_src(page)
135 qrcode_path = build_login_qrcode_path(account_file, suffix="xhs_login_qrcode")
136 qrcode_img = await _find_xhs_qrcode_locator(page)
137
138 if qrcode_src.startswith("data:image/"):
139 save_data_url_image(qrcode_src, qrcode_path)
140 else:
141 qrcode_path.parent.mkdir(parents=True, exist_ok=True)
142 await qrcode_img.screenshot(path=str(qrcode_path))
143
144 if previous_qrcode_path and previous_qrcode_path != qrcode_path:
145 if remove_qrcode_file(previous_qrcode_path):
146 xiaohongshu_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}"))
147
148 xiaohongshu_logger.info(_msg("🖼️", f"二维码已经准备好啦,已保存到: {qrcode_path}"))
149 qrcode_content = decode_qrcode_from_path(qrcode_path)
150 if qrcode_content:
151 print_terminal_qrcode(qrcode_content, qrcode_path, "小红书APP")
152 else:
153 xiaohongshu_logger.warning(_msg("😵", f"终端没法完整显示二维码,请打开 {qrcode_path} 扫码"))
154
155 qrcode_info = {
156 "image_path": str(qrcode_path),
157 "image_data_url": qrcode_src,
158 }
159 await _emit_qrcode_callback(qrcode_callback, qrcode_info)
160 return qrcode_info
161
162
163 async def _is_xhs_login_completed(page: Page) -> bool:
164 if page.url.startswith(_build_xhs_creator_url("/login")):
165 return False
166
167 login_box = page.locator(XHS_LOGIN_BOX_SELECTOR).first
168 if not await login_box.count():
169 return True
170
171 try:
172 return not await login_box.is_visible()
173 except Exception:
174 return True
175
176
177 async def cookie_auth(account_file):
178 if not os.path.exists(account_file):
179 return False
180
181 async with async_playwright() as playwright:
182 if LOCAL_CHROME_PATH:
183 browser = await playwright.chromium.launch(headless=True, executable_path=LOCAL_CHROME_PATH)
184 else:
185 browser = await playwright.chromium.launch(headless=True, channel="chromium")
186 try:
187 context = await browser.new_context(storage_state=account_file)
188 context = await set_init_script(context)
189 page = await context.new_page()
190 await page.goto(
191 _build_xhs_creator_url(
192 "/publish/publish?from=homepage&target=video"
193 )
194 )
195 await page.wait_for_timeout(3000)
196
197 if page.url.startswith(_build_xhs_creator_url("/login")):
198 xiaohongshu_logger.info(_msg("🥹", "cookie 已失效,得重新登录一下"))
199 return False
200
201 login_box = page.locator(XHS_LOGIN_BOX_SELECTOR).first
202 if await login_box.count():
203 try:
204 if await login_box.is_visible():
205 xiaohongshu_logger.info(_msg("🥹", "页面仍然停留在登录二维码页,按 cookie 失效处理"))
206 return False
207 except Exception:
208 return False
209
210 xiaohongshu_logger.success(_msg("🥳", "cookie 有效"))
211 return True
212 except Exception as exc:
213 xiaohongshu_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}"))
214 return False
215 finally:
216 await browser.close()
217
218
219 async def xiaohongshu_setup(
220 account_file,
221 handle=False,
222 return_detail=False,
223 qrcode_callback=None,
224 headless: bool = LOCAL_CHROME_HEADLESS,
225 ):
226 if not os.path.exists(account_file) or not await cookie_auth(account_file):
227 if not handle:
228 result = _build_login_result(False, "cookie_invalid", "cookie文件不存在或已失效", account_file)
229 return result if return_detail else False
230 xiaohongshu_logger.info(_msg("🥹", "cookie 失效了,准备打开浏览器重新登录"))
231 result = await xiaohongshu_cookie_gen(
232 account_file,
233 qrcode_callback=qrcode_callback,
234 headless=headless,
235 )
236 return result if return_detail else result["success"]
237
238 result = _build_login_result(True, "cookie_valid", "cookie有效", account_file)
239 return result if return_detail else True
240
241
242 async def xiaohongshu_cookie_gen(
243 account_file,
244 qrcode_callback=None,
245 poll_interval: int = 3,
246 max_checks: int = 100,
247 headless: bool = LOCAL_CHROME_HEADLESS,
248 ):
249 if headless:
250 xiaohongshu_logger.info(_msg("🖼️", "小红书登录将以无头模式运行,小人会输出终端二维码并保存本地二维码图片"))
251
252 account_path = Path(account_file)
253 account_path.parent.mkdir(parents=True, exist_ok=True)
254
255 async with async_playwright() as playwright:
256 browser = await playwright.chromium.launch(headless=headless, channel="chromium")
257 context = await browser.new_context()
258 context = await set_init_script(context)
259 qrcode_path = None
260 qrcode_info = None
261 result = _build_login_result(False, "failed", "小红书登录失败", account_file)
262 try:
263 page = await context.new_page()
264 await page.goto(_build_xhs_creator_url("/login"))
265 qrcode_info = await _save_xhs_qrcode(page, account_file, qrcode_callback=qrcode_callback)
266 qrcode_path = Path(qrcode_info["image_path"])
267 xiaohongshu_logger.info(_msg("🧍", "请扫码,小人正在耐心等待登录完成"))
268
269 for _ in range(max_checks):
270 if await _is_xhs_login_completed(page):
271 await asyncio.sleep(2)
272 await context.storage_state(path=account_file)
273 if await cookie_auth(account_file):
274 xiaohongshu_logger.success(_msg("🥳", "小红书扫码登录成功,小人开心收工"))
275 result = _build_login_result(True, "success", "小红书扫码登录成功", account_file, qrcode_info, page.url)
276 else:
277 result = _build_login_result(
278 False,
279 "cookie_invalid",
280 "小红书扫码流程结束,但 cookie 校验失败",
281 account_file,
282 qrcode_info,
283 page.url,
284 )
285 return result
286
287 await asyncio.sleep(poll_interval)
288
289 result = _build_login_result(
290 False,
291 "timeout",
292 "等待小红书扫码登录超时",
293 account_file,
294 qrcode_info,
295 page.url,
296 )
297 except Exception as exc:
298 result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "")
299 finally:
300 if remove_qrcode_file(qrcode_path):
301 xiaohongshu_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
302 if not result["success"]:
303 xiaohongshu_logger.error(_msg("😢", f"登录失败: {result['message']}"))
304 await context.close()
305 await browser.close()
306 return result
307
308
309 class XiaoHongShuBaseUploader(BaseVideoUploader):
310 def __init__(
311 self,
312 publish_date: datetime | int,
313 account_file,
314 publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE,
315 debug: bool = DEBUG_MODE,
316 headless: bool = LOCAL_CHROME_HEADLESS,
317 ):
318 self.publish_date = publish_date
319 self.account_file = str(account_file)
320 self.publish_strategy = publish_strategy
321 self.debug = debug
322 self.date_format = "%Y年%m月%d日 %H:%M"
323 self.local_executable_path = LOCAL_CHROME_PATH
324 self.headless = headless
325
326 async def validate_base_args(self):
327 if not os.path.exists(self.account_file):
328 raise RuntimeError(f"cookie文件不存在,请先完成小红书登录: {self.account_file}")
329 if not await cookie_auth(self.account_file):
330 raise RuntimeError(f"cookie文件已失效,请先完成小红书登录: {self.account_file}")
331
332 if self.publish_strategy not in {
333 XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE,
334 XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED,
335 }:
336 raise ValueError(f"不支持的发布策略: {self.publish_strategy}")
337
338 if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED:
339 self.publish_date = self.validate_publish_date(self.publish_date)
340 else:
341 self.publish_date = 0
342
343 async def set_schedule_time_xiaohongshu(self, page: Page, publish_date: datetime):
344 xiaohongshu_logger.info(_msg("🕒", f"小人准备设置定时发布时间: {publish_date.strftime(self.date_format)}"))
345 await page.locator('.custom-switch-card').filter(has_text="定时发布").locator('.d-switch').click()
346 await asyncio.sleep(1)
347 publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
348 time_input = page.locator('.d-datepicker-input-filter input.d-text')
349 await time_input.fill(str(publish_date_hour))
350 await asyncio.sleep(1)
351
352 async def set_location(self, page: Page, location: str = "青岛市"):
353 if not location:
354 return True
355
356 xiaohongshu_logger.info(_msg("📍", f"小人准备设置位置: {location}"))
357 loc_ele = await page.wait_for_selector('div.d-text.d-select-placeholder.d-text-ellipsis.d-text-nowrap')
358 await loc_ele.click()
359 await page.wait_for_timeout(1000)
360 await page.keyboard.type(location)
361 dropdown_selector = 'div.d-popover.d-popover-default.d-dropdown.--size-min-width-large'
362 await page.wait_for_timeout(2000)
363 try:
364 await page.wait_for_selector(dropdown_selector, timeout=3000)
365 except Exception:
366 xiaohongshu_logger.warning(_msg("😵", "位置下拉列表没按预期出现,小人继续按旧逻辑查找"))
367 await page.wait_for_timeout(1000)
368 flexible_xpath = (
369 f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
370 f'//div[contains(@class, "d-options-wrapper")]'
371 f'//div[contains(@class, "d-grid") and contains(@class, "d-options")]'
372 f'//div[contains(@class, "name") and text()="{location}"]'
373 )
374 await page.wait_for_timeout(3000)
375 try:
376 location_option = await page.wait_for_selector(
377 flexible_xpath,
378 timeout=3000
379 )
380
381 if not location_option:
382 location_option = await page.wait_for_selector(
383 f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
384 f'//div[contains(@class, "d-options-wrapper")]'
385 f'//div[contains(@class, "d-grid") and contains(@class, "d-options")]'
386 f'/div[1]//div[contains(@class, "name") and text()="{location}"]',
387 timeout=2000
388 )
389
390 await location_option.scroll_into_view_if_needed()
391 await location_option.click()
392 xiaohongshu_logger.success(_msg("🥳", f"位置已经设置成 {location}"))
393 return True
394 except Exception as e:
395 xiaohongshu_logger.error(_msg("😢", f"设置位置失败: {e}"))
396 try:
397 all_options = await page.query_selector_all(
398 '//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
399 '//div[contains(@class, "d-options-wrapper")]'
400 '//div[contains(@class, "d-grid") and contains(@class, "d-options")]'
401 '/div'
402 )
403 xiaohongshu_logger.debug(_msg("🧍", f"位置下拉里一共找到 {len(all_options)} 个选项"))
404 for i, option in enumerate(all_options[:3]):
405 option_text = await option.inner_text()
406 xiaohongshu_logger.debug(_msg("🧾", f"候选位置 {i + 1}: {option_text.strip()[:50]}"))
407 except Exception as inner_e:
408 xiaohongshu_logger.debug(_msg("😵", f"读取位置候选列表失败: {inner_e}"))
409 return False
410
411 async def fill_title(self, page: Page) -> None:
412 title_container = page.locator('input[placeholder*="填写标题"]')
413 await title_container.fill(self.title[:20])
414
415 async def fill_desc(self, page: Page) -> None:
416 if not getattr(self, "desc", ""):
417 return
418
419 desc = page.locator('p[data-placeholder*="输入正文描述"]')
420 await desc.click()
421 await page.keyboard.press("Backspace")
422 await page.keyboard.press("Control+KeyA")
423 await page.keyboard.press("Delete")
424 await page.keyboard.type(self.desc)
425 await page.keyboard.press("Enter")
426
427 async def fill_tags(self, page: Page) -> None:
428 if not getattr(self, "tags", None):
429 return
430
431 # 小红书标签上限为 10 个,超过会导致死循环卡住发布
432 max_tags = 10
433 if len(self.tags) > max_tags:
434 xiaohongshu_logger.warning(
435 _msg("🏷️", f"标签数量 {len(self.tags)} 超过小红书上限 {max_tags},只取前 {max_tags} 个: {self.tags[:max_tags]}")
436 )
437 self.tags = self.tags[:max_tags]
438
439 if not getattr(self, "desc", ""):
440 desc = page.locator('p[data-placeholder*="输入正文描述"]')
441 await desc.click()
442
443 for tag in self.tags: # 循环处理所有 tags
444 # 话题候选下拉框依赖小红书联想接口实时返回,网络抖动/无匹配时会等不到。
445 # 标签是可选增强项:等不到候选框就跳过该标签继续,不让整条发布因此失败。
446 try:
447 await page.keyboard.type("#" + tag, delay=30)
448 await page.locator('#creator-editor-topic-container').wait_for(
449 state="visible",
450 timeout=6000
451 )
452 first_item = page.locator('#creator-editor-topic-container .item').first
453 await first_item.wait_for(state="visible", timeout=4000)
454 await first_item.click()
455 except Exception as exc:
456 xiaohongshu_logger.warning(
457 _msg("🏷️", f"话题『{tag}』未出现候选,跳过该标签继续发布: {exc}")
458 )
459 # 清掉已键入但未成词的 "#tag" 文本,避免它残留进正文
460 for _ in range(len("#" + tag)):
461 await page.keyboard.press("Backspace")
462 continue
463
464 async def fill_meta(self, page: Page) -> None:
465 await self.fill_title(page)
466 await self.fill_desc(page)
467 await self.fill_tags(page)
468
469 async def check_original_declaration(self, page: Page) -> None:
470 """设置「来源转载」声明,填写转载来源。
471
472 流程(对应 codegen 录制):
473 点「添加内容类型声明」→ 点包含「来源转载」的 div
474 → 填 placeholder「请输入媒体名称」→ 点 button「确认」。
475 容错:任一步失败记 warning 跳过、继续发布,不中断。
476 """
477 source = getattr(self, "repost_source", "") or ""
478 try:
479 # 1. 点「添加内容类型声明」
480 trigger = page.get_by_text("添加内容类型声明", exact=False).first
481 try:
482 await trigger.scroll_into_view_if_needed(timeout=5000)
483 except Exception:
484 pass
485 await trigger.click(force=True)
486 await page.wait_for_timeout(1500)
487
488 # 2. 选「来源转载」选项
489 import re as _re
490 repost_option = page.locator("#publish-container div").filter(
491 has_text=_re.compile(r"^来源转载$")
492 ).last
493 if await repost_option.count():
494 await repost_option.click(force=True)
495 else:
496 await _js_click_by_text(page, "来源转载")
497 await page.wait_for_timeout(1500)
498
499 # 3. 填写媒体名称
500 source_input = page.get_by_placeholder("请输入媒体名称").first
501 await source_input.wait_for(state="visible", timeout=8000)
502 await source_input.click()
503 await source_input.fill(source)
504 await page.wait_for_timeout(500)
505
506 # 4. 点「确认」按钮
507 confirm = page.get_by_role("button", name="确认").first
508 try:
509 await confirm.wait_for(state="visible", timeout=5000)
510 await confirm.click()
511 except Exception:
512 await _js_click_by_text(page, "确认")
513
514 await page.wait_for_timeout(1000)
515 xiaohongshu_logger.success(_msg("🧾", f"来源转载已声明(来源:{source})"))
516 except Exception as exc:
517 xiaohongshu_logger.warning(_msg("⚠️", f"设置来源转载失败,跳过继续发布: {exc}"))
518 try:
519 await page.keyboard.press("Escape")
520 except Exception:
521 pass
522
523
524 class XiaoHongShuVideo(XiaoHongShuBaseUploader):
525 def __init__(
526 self,
527 title,
528 file_path,
529 tags,
530 publish_date: datetime | int,
531 account_file,
532 thumbnail_path=None,
533 desc: str | None = None,
534 publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE,
535 debug: bool = DEBUG_MODE,
536 headless: bool = LOCAL_CHROME_HEADLESS,
537 ):
538 super().__init__(
539 publish_date=publish_date,
540 account_file=account_file,
541 publish_strategy=publish_strategy,
542 debug=debug,
543 headless=headless,
544 )
545 self.title = title
546 self.file_path = file_path
547 self.tags = tags or []
548 self.thumbnail_path = thumbnail_path
549 self.desc = desc or ""
550
551 async def validate_upload_args(self):
552 await self.validate_base_args()
553 if not self.title or not str(self.title).strip():
554 raise ValueError("视频模式下,title 是必须的")
555
556 self.file_path = str(self.validate_video_file(self.file_path))
557 if self.thumbnail_path:
558 self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path))
559
560 async def handle_upload_error(self, page: Page):
561 xiaohongshu_logger.warning(_msg("😵", "视频上传摔了一跤,小人马上重新上传"))
562 await page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(self.file_path)
563
564 async def set_thumbnail(self, page: Page, thumbnail_path: str):
565 if not thumbnail_path:
566 return
567
568 xiaohongshu_logger.info(_msg("🖼️", "小人准备设置封面"))
569
570 # 封面设置为增强步骤:失败时记 warning 跳过、继续发布(用视频首帧兜底)。
571 try:
572 # 发布页封面区域内嵌,点击 div.upload-cover 打开封面弹窗(d-modal)。
573 cover_section = page.locator("text=设置封面").first
574 try:
575 await cover_section.scroll_into_view_if_needed(timeout=5000)
576 except Exception:
577 pass
578 await page.wait_for_timeout(2000)
579
580 # 1. 点击 div.upload-cover 打开封面弹窗
581 upload_cover = page.locator("div.upload-cover").first
582 if not await upload_cover.count():
583 upload_cover = page.locator("div.cover-plugin-preview div.default.pointer").first
584 await upload_cover.click(force=True)
585 await page.wait_for_timeout(3000)
586
587 # 2. 切换到「上传封面」tab(默认在「截取封面」)
588 upload_tab = page.get_by_text("上传封面", exact=True).first
589 await upload_tab.wait_for(state="visible", timeout=10000)
590 await upload_tab.click()
591 await page.wait_for_timeout(2000)
592
593 # 3. 找到图片 file input(parent class: upload-wrapper)并上传
594 file_input = page.locator('div.upload-wrapper input[type="file"][accept*="image"]').first
595 if not await file_input.count():
596 file_input = page.locator('input[type="file"][accept*="image"]').last
597 await file_input.set_input_files(thumbnail_path)
598 await page.wait_for_timeout(4000) # 等图片加载+裁剪渲染
599
600 # 4. 点「确定」按钮
601 modal_footer = page.locator("div.d-modal-footer")
602 confirm = modal_footer.get_by_text("确定", exact=True).first
603 if not await confirm.count():
604 confirm = page.get_by_role("button", name="确定").first
605 await confirm.wait_for(state="visible", timeout=10000)
606 await confirm.click()
607
608 # 5. 等弹窗关闭
609 modal = page.locator("div.d-modal")
610 try:
611 await modal.first.wait_for(state="hidden", timeout=15000)
612 except Exception:
613 pass
614 xiaohongshu_logger.success(_msg("🥳", "封面已经设置完成"))
615 except Exception as exc:
616 xiaohongshu_logger.warning(_msg("🖼️", f"封面设置失败,跳过该步骤继续发布(用视频首帧):{exc}"))
617 try:
618 await page.keyboard.press("Escape")
619 await page.wait_for_timeout(500)
620 except Exception:
621 pass
622
623 async def upload_video_content(self, page: Page) -> None:
624 xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4"))
625 xiaohongshu_logger.info(_msg("🧭", "小人正在赶往视频发布页"))
626 publish_url = _build_xhs_creator_url(
627 "/publish/publish?from=homepage&target=video"
628 )
629 await page.goto(publish_url)
630 await page.wait_for_url(publish_url)
631 await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(self.file_path)
632
633 while True:
634 try:
635 upload_input = await page.wait_for_selector('input.upload-input', timeout=3000)
636 preview_new = await upload_input.query_selector(
637 'xpath=following-sibling::div[contains(@class, "preview-new")]')
638 if preview_new:
639 # 获取整个预览区域的文本,更鲁棒地判断上传状态
640 all_text = await preview_new.inner_text()
641 upload_success = any(keyword in all_text for keyword in ['上传成功', '分辨率', '重新上传', '编辑封面', '已上传', '已选择', '100%'])
642
643 if not upload_success:
644 # 检查是否有特定的状态码或百分比
645 stage_elements = await preview_new.query_selector_all('div.stage')
646 for stage in stage_elements:
647 text_content = await page.evaluate('(element) => element.textContent', stage)
648 if '上传成功' in text_content or '分辨率' in text_content:
649 upload_success = True
650 break
651
652 if upload_success:
653 xiaohongshu_logger.success(_msg("🥳", "视频已经传完啦"))
654 break
655
656 if self.debug:
657 normalized_text = all_text.strip().replace("\n", " ")
658 xiaohongshu_logger.debug(_msg("🧍", f"预览区域内容: {normalized_text}"))
659 xiaohongshu_logger.debug(_msg("🧍", "还没看到上传成功标识,小人继续等一会"))
660 else:
661 # 尝试检查标题输入框是否已经出现,如果是,说明已经进入编辑状态
662 title_container = page.locator('input[placeholder*="填写标题"]')
663 if await title_container.count() > 0 and await title_container.is_visible():
664 xiaohongshu_logger.success(_msg("🥳", "虽然没看到预览区,但标题框出来了,小人继续"))
665 break
666 xiaohongshu_logger.debug(_msg("🧍", "还没拿到预览区域,小人继续等一会"))
667 except Exception as e:
668 xiaohongshu_logger.debug(_msg("😵", f"上传状态还没稳定下来,小人继续观察: {e}"))
669 await asyncio.sleep(2)
670
671 xiaohongshu_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
672 await self.fill_meta(page)
673
674 await self.set_thumbnail(page, self.thumbnail_path)
675
676 # await self.set_location(page, "青岛市")
677
678 await self.check_original_declaration(page)
679
680 if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
681 await self.set_schedule_time_xiaohongshu(page, self.publish_date)
682
683 while True:
684 try:
685 if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED:
686 await page.locator('button:has-text("定时发布")').click()
687 else:
688 await page.locator('button:has-text("发布")').click()
689 await page.wait_for_url(
690 XHS_PUBLISH_SUCCESS_URL_PATTERN,
691 timeout=3000
692 )
693 xiaohongshu_logger.success(_msg("🥳", "视频发布成功,小人开心收工"))
694 break
695 except Exception:
696 xiaohongshu_logger.info(_msg("🏃", "小人正在冲刺发布视频"))
697 if self.debug:
698 await page.screenshot(full_page=True)
699 await asyncio.sleep(0.5)
700
701 async def upload(self, playwright: Playwright) -> None:
702 xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间"))
703 await self.validate_upload_args()
704 xiaohongshu_logger.info(_msg("🥳", "上传前检查通过"))
705 browser = await playwright.chromium.launch(headless=self.headless, channel="chromium")
706 context = await browser.new_context(
707 permissions=["geolocation"],
708 storage_state=self.account_file,
709 )
710 context = await set_init_script(context)
711
712 try:
713 page = await context.new_page()
714 await self.upload_video_content(page)
715 await context.storage_state(path=self.account_file)
716 xiaohongshu_logger.success(_msg("🥳", "cookie 更新完毕"))
717 finally:
718 await context.close()
719 await browser.close()
720
721 async def xiaohongshu_upload_video(self):
722 async with async_playwright() as playwright:
723 await self.upload(playwright)
724
725 async def main(self):
726 await self.xiaohongshu_upload_video()
727
728
729 class XiaoHongShuNote(XiaoHongShuBaseUploader):
730 def __init__(
731 self,
732 image_paths,
733 note,
734 tags,
735 publish_date: datetime | int,
736 account_file,
737 title: str | None = None,
738 desc: str | None = None,
739 publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE,
740 debug: bool = DEBUG_MODE,
741 headless: bool = LOCAL_CHROME_HEADLESS,
742 ):
743 super().__init__(
744 publish_date=publish_date,
745 account_file=account_file,
746 publish_strategy=publish_strategy,
747 debug=debug,
748 headless=headless,
749 )
750 self.image_paths = image_paths
751 self.note = note or ""
752 self.tags = tags or []
753 self.desc = desc if desc is not None else self.note
754 self.title = title or ((self.desc or self.note)[:20] if (self.desc or self.note) else "")
755
756 async def validate_upload_args(self):
757 await self.validate_base_args()
758 if not self.image_paths:
759 raise ValueError("图文模式下,图片是必须的")
760 if not self.title or not str(self.title).strip():
761 raise ValueError("图文模式下,title 是必须的")
762
763 if isinstance(self.image_paths, (str, Path)):
764 self.image_paths = [self.image_paths]
765
766 normalized_image_paths = []
767 for image_path in self.image_paths:
768 normalized_image_paths.append(str(self.validate_image_file(image_path)))
769 self.image_paths = normalized_image_paths
770
771 async def upload_note_content(self, page: Page) -> None:
772 xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片"))
773 xiaohongshu_logger.info(_msg("🧭", "小人正在赶往图文发布页"))
774 publish_url = _build_xhs_creator_url(
775 "/publish/publish?from=homepage&target=image"
776 )
777 await page.goto(publish_url)
778 await page.wait_for_url(publish_url)
779
780 upload_input = page.locator('input[type="file"][accept*="image"]').first
781 if not await upload_input.count():
782 upload_input = page.locator("div[class^='upload-content'] input[class='upload-input']").first
783
784 await upload_input.wait_for(state="attached", timeout=30000)
785 xiaohongshu_logger.info(_msg("📤", "小人正在上传图片"))
786 await upload_input.set_input_files(self.image_paths)
787
788 while True:
789 try:
790 title_container = page.locator('input[placeholder*="填写标题"]').first
791 await title_container.wait_for(state="visible", timeout=3000)
792 xiaohongshu_logger.success(_msg("🥳", "图文素材已经传完,可以开始填写内容了"))
793 break
794 except Exception:
795 xiaohongshu_logger.debug(_msg("🧍", "图文素材还在上传,小人继续等一会"))
796 await asyncio.sleep(1)
797
798 xiaohongshu_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
799 await self.fill_meta(page)
800
801 await self.check_original_declaration(page)
802
803 if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
804 await self.set_schedule_time_xiaohongshu(page, self.publish_date)
805
806 while True:
807 try:
808 if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED:
809 await page.locator('button:has-text("定时发布")').click()
810 else:
811 await page.locator('button:has-text("发布")').click()
812 await page.wait_for_url(
813 XHS_PUBLISH_SUCCESS_URL_PATTERN,
814 timeout=3000
815 )
816 xiaohongshu_logger.success(_msg("🥳", "图文发布成功,小人开心收工"))
817 break
818 except Exception:
819 xiaohongshu_logger.info(_msg("🏃", "小人正在冲刺发布图文"))
820 if self.debug:
821 await page.screenshot(full_page=True)
822 await asyncio.sleep(0.5)
823
824 async def upload(self, playwright: Playwright) -> None:
825 xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间"))
826 await self.validate_upload_args()
827 xiaohongshu_logger.info(_msg("🥳", "图文上传前检查通过"))
828 browser = await playwright.chromium.launch(headless=self.headless, channel="chromium")
829 context = await browser.new_context(
830 permissions=["geolocation"],
831 storage_state=self.account_file,
832 )
833 context = await set_init_script(context)
834
835 try:
836 page = await context.new_page()
837 await self.upload_note_content(page)
838 await context.storage_state(path=self.account_file)
839 xiaohongshu_logger.success(_msg("🥳", "cookie 更新完毕"))
840 finally:
841 await context.close()
842 await browser.close()
843
844 async def xiaohongshu_upload_note(self):
845 async with async_playwright() as playwright:
846 await self.upload(playwright)
847
848 async def main(self):
849 await self.xiaohongshu_upload_note()
850
850 lines PYTHON