mirror of
https://github.com/dreammis/social-auto-upload.git
synced 2026-08-28 17:43:28 +08:00
Merge pull request #270: Hupu uploader
This commit is contained in:
+100
@@ -48,6 +48,11 @@ from uploader.weibo_uploader.main import (
|
||||
weibo_setup,
|
||||
cookie_auth as weibo_cookie_auth,
|
||||
)
|
||||
from uploader.hupu_uploader.main import (
|
||||
HuPuVideo,
|
||||
hupu_setup,
|
||||
cookie_auth as hupu_cookie_auth,
|
||||
)
|
||||
from uploader.xiaohongshu_uploader.main import (
|
||||
XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE,
|
||||
XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED,
|
||||
@@ -225,6 +230,18 @@ class WeiboVideoUploadRequest:
|
||||
headless: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HupuVideoUploadRequest:
|
||||
account_name: str
|
||||
video_file: Path
|
||||
title: str
|
||||
description: str
|
||||
tags: list[str]
|
||||
thumbnail_file: Path | None = None
|
||||
debug: bool = True
|
||||
headless: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class YouTubeVideoUploadRequest:
|
||||
account_name: str
|
||||
@@ -714,6 +731,40 @@ async def upload_weibo_video(request: WeiboVideoUploadRequest) -> Path:
|
||||
return account_file
|
||||
|
||||
|
||||
async def login_hupu_account(account_name: str, headless: bool = False, qrcode_callback=None) -> dict:
|
||||
account_file = resolve_account_file("hupu", account_name)
|
||||
return await hupu_setup(str(account_file), handle=True, return_detail=True, headless=headless, qrcode_callback=qrcode_callback)
|
||||
|
||||
|
||||
async def check_hupu_account(account_name: str) -> bool:
|
||||
account_file = resolve_account_file("hupu", account_name)
|
||||
if not account_file.exists():
|
||||
return False
|
||||
return await hupu_cookie_auth(str(account_file))
|
||||
|
||||
|
||||
async def upload_hupu_video(request: HupuVideoUploadRequest) -> Path:
|
||||
account_file = resolve_account_file("hupu", request.account_name)
|
||||
is_ready = await hupu_setup(str(account_file), handle=False)
|
||||
if not is_ready:
|
||||
raise RuntimeError(
|
||||
f"Hupu cookie is missing or expired: {account_file}. Run `sau hupu login --account {request.account_name}` first."
|
||||
)
|
||||
|
||||
app = HuPuVideo(
|
||||
title=request.title,
|
||||
file_path=str(request.video_file),
|
||||
tags=request.tags,
|
||||
account_file=str(account_file),
|
||||
desc=request.description,
|
||||
thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None,
|
||||
debug=request.debug,
|
||||
headless=request.headless,
|
||||
)
|
||||
await app.main()
|
||||
return account_file
|
||||
|
||||
|
||||
def existing_file_path(value: str) -> Path:
|
||||
path = Path(value)
|
||||
if not path.is_file():
|
||||
@@ -923,6 +974,25 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
weibo_upload_video_parser.add_argument("--collection", default=None, help="Optional collection name")
|
||||
add_runtime_flags(weibo_upload_video_parser)
|
||||
|
||||
# ── Hupu ──
|
||||
hupu_parser = platform_parsers.add_parser("hupu", help="Hupu (虎扑) operations")
|
||||
hupu_actions = hupu_parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
for action_name in ("login", "check"):
|
||||
action_parser = hupu_actions.add_parser(action_name, help=f"Hupu {action_name}")
|
||||
action_parser.add_argument("--account", required=True, help="Hupu user-defined account_name")
|
||||
if action_name == "login":
|
||||
add_runtime_flags(action_parser)
|
||||
|
||||
hupu_upload_video_parser = hupu_actions.add_parser("upload-video", help="Upload one video to Hupu")
|
||||
hupu_upload_video_parser.add_argument("--account", required=True, help="Hupu user-defined account_name")
|
||||
hupu_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
|
||||
hupu_upload_video_parser.add_argument("--title", required=True, help="Video title (4-40 chars)")
|
||||
hupu_upload_video_parser.add_argument("--desc", default="", help="Optional video description")
|
||||
hupu_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
|
||||
hupu_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional cover image path")
|
||||
add_runtime_flags(hupu_upload_video_parser)
|
||||
|
||||
youtube_parser = platform_parsers.add_parser("youtube", help="YouTube operations")
|
||||
youtube_actions = youtube_parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
@@ -1276,6 +1346,36 @@ async def dispatch(args: argparse.Namespace) -> int:
|
||||
|
||||
raise RuntimeError(f"Unsupported Weibo action: {args.action}")
|
||||
|
||||
if args.platform == "hupu":
|
||||
if args.action == "login":
|
||||
result = await login_hupu_account(args.account, headless=args.headless)
|
||||
if not result["success"]:
|
||||
raise RuntimeError(result["message"])
|
||||
print(f"Hupu login flow completed: {result['account_file']}")
|
||||
return 0
|
||||
|
||||
if args.action == "check":
|
||||
is_valid = await check_hupu_account(args.account)
|
||||
print("valid" if is_valid else "invalid")
|
||||
return 0 if is_valid else 1
|
||||
|
||||
if args.action == "upload-video":
|
||||
request = HupuVideoUploadRequest(
|
||||
account_name=args.account,
|
||||
video_file=args.file,
|
||||
title=args.title,
|
||||
description=args.desc,
|
||||
tags=parse_tags(args.tags),
|
||||
thumbnail_file=args.thumbnail,
|
||||
debug=args.debug,
|
||||
headless=args.headless,
|
||||
)
|
||||
await upload_hupu_video(request)
|
||||
print(f"Hupu video upload submitted: {request.video_file}")
|
||||
return 0
|
||||
|
||||
raise RuntimeError(f"Unsupported Hupu action: {args.action}")
|
||||
|
||||
if args.platform == "youtube":
|
||||
if args.action == "login":
|
||||
result = await login_youtube_account(args.account, headless=args.headless)
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""虎扑视频上传 + 手动登录保存 cookie。
|
||||
|
||||
功能:
|
||||
- hupu_cookie_gen: 打开浏览器让用户手动登录(QQ/手机号等),保存 storage_state
|
||||
- cookie_auth: 验证 cookie 是否有效
|
||||
- hupu_setup: 统一入口(检查/触发登录)
|
||||
- HuPuVideo: 视频上传类
|
||||
|
||||
基于 playwright codegen 录制脚本改写。
|
||||
发布页:https://bbs.hupu.com/newpost?tabkey=2(视频发布标签页)
|
||||
专区:固定选择「步行街 → 步行街主干道」
|
||||
|
||||
注意:虎扑有 headless 检测,需配合反检测参数才能正常操作。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import BrowserContext, Page, Playwright, async_playwright
|
||||
|
||||
from conf import BASE_DIR, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
|
||||
from uploader.base_video import BaseVideoUploader
|
||||
from utils.log import hupu_logger
|
||||
|
||||
|
||||
HUPU_HOME_URL = "https://www.hupu.com/"
|
||||
HUPU_LOGIN_URL = "https://passport.hupu.com/v2/login?pcPhone=1&jumpurl=https://www.hupu.com&from=https://www.hupu.com"
|
||||
HUPU_PUBLISH_URL = "https://bbs.hupu.com/newpost?tabkey=2"
|
||||
|
||||
# 虎扑发布成功后跳转的帖子 URL 模式
|
||||
HUPU_POST_URL_PATTERN = re.compile(r"bbs\.hupu\.com/\d+\.html")
|
||||
|
||||
# 反检测 UA
|
||||
_CHROME_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
|
||||
|
||||
# 反 webdriver 检测脚本
|
||||
_STEALTH_SCRIPT = """
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
"""
|
||||
|
||||
|
||||
def _msg(emoji: str, text: str) -> str:
|
||||
return f"{emoji} {text}"
|
||||
|
||||
|
||||
def _build_login_result(success: bool, status: str, message: str, account_file: str, current_url: str = "") -> dict:
|
||||
return {
|
||||
"success": success,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"account_file": str(account_file),
|
||||
"current_url": current_url,
|
||||
}
|
||||
|
||||
|
||||
async def _emit_qrcode_callback(qrcode_callback, payload: dict):
|
||||
if not qrcode_callback:
|
||||
return
|
||||
callback_result = qrcode_callback(payload)
|
||||
if inspect.isawaitable(callback_result):
|
||||
await callback_result
|
||||
|
||||
|
||||
def _build_launch_kwargs(headless: bool) -> dict:
|
||||
launch_kwargs = {
|
||||
"headless": headless,
|
||||
"args": ["--disable-blink-features=AutomationControlled"],
|
||||
}
|
||||
if LOCAL_CHROME_PATH:
|
||||
launch_kwargs["executable_path"] = LOCAL_CHROME_PATH
|
||||
return launch_kwargs
|
||||
|
||||
|
||||
def _resolve_account_file(account_file: str | Path) -> str:
|
||||
path = Path(account_file).expanduser()
|
||||
if path.is_absolute():
|
||||
return str(path)
|
||||
if len(path.parts) == 1:
|
||||
return str((Path(BASE_DIR) / "cookies" / path).resolve())
|
||||
return str(path.resolve())
|
||||
|
||||
|
||||
async def _create_stealth_context(browser, account_file: str | None = None) -> BrowserContext:
|
||||
"""创建带反检测的 context。"""
|
||||
kwargs = {
|
||||
"user_agent": _CHROME_UA,
|
||||
"viewport": {"width": 1920, "height": 1080},
|
||||
}
|
||||
if account_file and os.path.exists(account_file):
|
||||
kwargs["storage_state"] = account_file
|
||||
context = await browser.new_context(**kwargs)
|
||||
return context
|
||||
|
||||
|
||||
async def _new_stealth_page(context: BrowserContext) -> Page:
|
||||
"""创建带反检测 init script 的 page。"""
|
||||
page = await context.new_page()
|
||||
await page.add_init_script(_STEALTH_SCRIPT)
|
||||
return page
|
||||
|
||||
|
||||
async def hupu_cookie_gen(account_file, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 120, headless: bool = False):
|
||||
"""QQ 扫码登录虎扑,保存 cookie。
|
||||
|
||||
流程:打开虎扑登录页 → 点击 QQ 登录 → 截取 QQ 二维码 → 等待扫码完成 → 保存 storage_state。
|
||||
支持 headless 模式(终端显示二维码)。
|
||||
"""
|
||||
account_file = _resolve_account_file(account_file)
|
||||
Path(account_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
result = _build_login_result(False, "failed", "虎扑登录失败", account_file)
|
||||
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless))
|
||||
context = await _create_stealth_context(browser)
|
||||
try:
|
||||
page = await _new_stealth_page(context)
|
||||
await page.goto(HUPU_LOGIN_URL, timeout=60000, wait_until="load")
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# 点击 QQ 登录按钮
|
||||
qq_btn = page.get_by_role("button", name="qq QQ登录")
|
||||
if not await qq_btn.count():
|
||||
hupu_logger.error(_msg("😢", "未找到 QQ 登录按钮"))
|
||||
result = _build_login_result(False, "failed", "未找到 QQ 登录按钮", account_file, page.url)
|
||||
return result
|
||||
|
||||
await qq_btn.click()
|
||||
await page.wait_for_timeout(5000)
|
||||
hupu_logger.info(_msg("🏃", "已跳转到 QQ 登录页"))
|
||||
|
||||
# 从 QQ iframe 中获取二维码
|
||||
qrcode_info = await _grab_qq_qrcode(page, context, account_file)
|
||||
|
||||
if qrcode_info:
|
||||
await _emit_qrcode_callback(qrcode_callback, qrcode_info)
|
||||
hupu_logger.info(_msg("🧍", "请用 QQ 手机版扫码登录"))
|
||||
else:
|
||||
hupu_logger.warning(_msg("⚠️", "未能获取 QQ 二维码,请在浏览器中手动扫码"))
|
||||
|
||||
# 轮询等待登录完成
|
||||
for _ in range(max_checks):
|
||||
current_url = page.url
|
||||
# QQ 授权成功后会跳回虎扑首页
|
||||
if "www.hupu.com" in current_url and "passport" not in current_url and "graph.qq.com" not in current_url:
|
||||
hupu_logger.info(_msg("🥳", f"登录成功,跳转到: {current_url}"))
|
||||
result = _build_login_result(True, "success", "虎扑 QQ 扫码登录成功", account_file, current_url)
|
||||
break
|
||||
# 检查是否实际跳转到虎扑 passport 回调页(不是 redirect_uri 参数中包含)
|
||||
if current_url.startswith("https://passport.hupu.com/pc/qqcallback"):
|
||||
await page.wait_for_timeout(5000)
|
||||
current_url = page.url
|
||||
hupu_logger.info(_msg("🥳", f"QQ 回调成功,当前: {current_url}"))
|
||||
result = _build_login_result(True, "success", "虎扑 QQ 扫码登录成功", account_file, current_url)
|
||||
break
|
||||
# 检查 cookies 中是否出现 u(核心登录态)
|
||||
cookies = await context.cookies()
|
||||
if any(c.get("name") == "u" and c.get("value") for c in cookies):
|
||||
hupu_logger.info(_msg("🥳", f"登录成功(检测到 u cookie),当前: {current_url}"))
|
||||
result = _build_login_result(True, "success", "虎扑 QQ 扫码登录成功", account_file, current_url)
|
||||
break
|
||||
await page.wait_for_timeout(poll_interval * 1000)
|
||||
else:
|
||||
result = _build_login_result(False, "timeout", "等待 QQ 扫码登录超时", account_file, page.url)
|
||||
|
||||
if result["success"]:
|
||||
await asyncio.sleep(2)
|
||||
# 确保跳转到首页加载完 cookie
|
||||
if "www.hupu.com" not in page.url:
|
||||
await page.goto(HUPU_HOME_URL, timeout=30000, wait_until="domcontentloaded")
|
||||
await page.wait_for_timeout(3000)
|
||||
await context.storage_state(path=account_file)
|
||||
hupu_logger.success(_msg("🥳", f"cookie 已保存: {account_file}"))
|
||||
except Exception as exc:
|
||||
result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "")
|
||||
finally:
|
||||
# 清理临时二维码文件
|
||||
qr_path = Path(account_file).parent / f"{Path(account_file).stem}_qq_qrcode.png"
|
||||
if qr_path.exists():
|
||||
qr_path.unlink()
|
||||
if not result["success"]:
|
||||
hupu_logger.error(_msg("😢", f"登录失败: {result['message']}"))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return result
|
||||
|
||||
|
||||
async def _grab_qq_qrcode(page: Page, context: BrowserContext, account_file: str) -> dict | None:
|
||||
"""从 QQ 登录 iframe 中获取二维码。"""
|
||||
from utils.login_qrcode import decode_qrcode_from_path, print_terminal_qrcode
|
||||
|
||||
# 等待 QQ iframe 加载(最多 15 秒)
|
||||
qq_frame = None
|
||||
for _ in range(5):
|
||||
for frame in page.frames:
|
||||
if "xui.ptlogin2.qq.com" in frame.url or "ptlogin2.qq.com" in frame.url:
|
||||
qq_frame = frame
|
||||
break
|
||||
if qq_frame:
|
||||
break
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if not qq_frame:
|
||||
return None
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 获取二维码图片(id="qrlogin_img")
|
||||
qr_selectors = ["#qrlogin_img", 'img[src*="ptqrshow"]', 'img[id*="qr"]']
|
||||
for sel in qr_selectors:
|
||||
qr_loc = qq_frame.locator(sel).first
|
||||
if await qr_loc.count():
|
||||
src = await qr_loc.get_attribute("src")
|
||||
if src and src.startswith("http"):
|
||||
# 下载二维码图片
|
||||
try:
|
||||
resp = await context.request.get(src)
|
||||
qr_path = Path(account_file).parent / f"{Path(account_file).stem}_qq_qrcode.png"
|
||||
qr_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
qr_path.write_bytes(await resp.body())
|
||||
|
||||
# 尝试解码并在终端显示
|
||||
qrcode_content = decode_qrcode_from_path(qr_path)
|
||||
if qrcode_content:
|
||||
print_terminal_qrcode(qrcode_content, qr_path, "QQ手机版")
|
||||
else:
|
||||
hupu_logger.warning(_msg("😵", f"终端无法显示二维码,请打开 {qr_path} 扫码"))
|
||||
|
||||
return {"image_path": str(qr_path), "image_data_url": ""}
|
||||
except Exception as exc:
|
||||
hupu_logger.warning(_msg("⚠️", f"下载 QQ 二维码失败: {exc}"))
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def cookie_auth(account_file):
|
||||
"""验证虎扑 cookie 是否有效。访问发布页,检测是否能正常加载。"""
|
||||
account_file = _resolve_account_file(account_file)
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True))
|
||||
try:
|
||||
context = await _create_stealth_context(browser, account_file)
|
||||
page = await _new_stealth_page(context)
|
||||
await page.goto(HUPU_PUBLISH_URL, timeout=60000, wait_until="domcontentloaded")
|
||||
await page.wait_for_timeout(5000)
|
||||
|
||||
# 检查是否被跳转到登录页
|
||||
if "passport" in page.url or "login" in page.url:
|
||||
hupu_logger.info(_msg("🥹", "cookie 已失效(跳转到登录页)"))
|
||||
return False
|
||||
|
||||
# 检查发布页是否出现「上传视频」按钮(登录后才有)
|
||||
upload_btn = page.get_by_role("button", name="上传视频")
|
||||
if await upload_btn.count():
|
||||
hupu_logger.success(_msg("🥳", "cookie 有效"))
|
||||
return True
|
||||
|
||||
# 兜底:检查 cookie 中是否有 u 字段
|
||||
cookies = await context.cookies()
|
||||
if any(c.get("name") == "u" and c.get("value") for c in cookies):
|
||||
hupu_logger.success(_msg("🥳", "cookie 有效(u cookie 存在)"))
|
||||
return True
|
||||
|
||||
hupu_logger.info(_msg("🥹", "cookie 已失效(未检测到登录态)"))
|
||||
return False
|
||||
except Exception as exc:
|
||||
hupu_logger.warning(_msg("😵", f"cookie 校验出错,按失效处理: {exc}"))
|
||||
return False
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
async def hupu_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = False):
|
||||
"""统一入口:检查 cookie → 如无效且 handle=True 则触发手动登录。"""
|
||||
account_file = _resolve_account_file(account_file)
|
||||
if not os.path.exists(account_file) or not await cookie_auth(account_file):
|
||||
if not handle:
|
||||
result = _build_login_result(False, "cookie_invalid", "cookie 文件不存在或已失效", account_file)
|
||||
return result if return_detail else False
|
||||
hupu_logger.info(_msg("🥹", "cookie 文件不存在或已失效,打开浏览器请手动登录"))
|
||||
result = await hupu_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
|
||||
return result if return_detail else result["success"]
|
||||
|
||||
result = _build_login_result(True, "cookie_valid", "cookie 有效", account_file)
|
||||
return result if return_detail else True
|
||||
|
||||
|
||||
class HuPuVideo(BaseVideoUploader):
|
||||
"""虎扑视频上传。
|
||||
|
||||
流程:直接跳转发布页 → 上传视频文件 → 填标题 → 填简介 →
|
||||
上传封面 → 选专区(步行街主干道)→ 选原创/二创 → 选 AI 声明 →
|
||||
点击「确定发布」→ 等待跳转到帖子页面。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title,
|
||||
file_path,
|
||||
tags,
|
||||
account_file,
|
||||
publish_date=0,
|
||||
desc: str | None = None,
|
||||
thumbnail_path: str | None = None,
|
||||
debug: bool = True,
|
||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||
):
|
||||
self.title = title
|
||||
self.file_path = file_path
|
||||
self.tags = tags or []
|
||||
self.account_file = _resolve_account_file(account_file)
|
||||
self.publish_date = publish_date
|
||||
self.desc = desc or ""
|
||||
self.thumbnail_path = thumbnail_path
|
||||
self.debug = debug
|
||||
self.headless = headless
|
||||
self.local_executable_path = LOCAL_CHROME_PATH
|
||||
self.max_title_length = 40
|
||||
self.min_title_length = 4
|
||||
|
||||
async def validate_upload_args(self):
|
||||
if not os.path.exists(self.account_file):
|
||||
raise RuntimeError(f"cookie文件不存在,请先完成虎扑登录: {self.account_file}")
|
||||
if not await cookie_auth(self.account_file):
|
||||
raise RuntimeError(f"cookie文件已失效,请先完成虎扑登录: {self.account_file}")
|
||||
if not self.title or not str(self.title).strip():
|
||||
raise ValueError("视频标题不能为空")
|
||||
if len(self.title) < self.min_title_length:
|
||||
raise ValueError(f"视频标题至少{self.min_title_length}个字")
|
||||
self.file_path = str(self.validate_video_file(self.file_path))
|
||||
if self.thumbnail_path:
|
||||
self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path))
|
||||
|
||||
async def upload(self, playwright: Playwright) -> None:
|
||||
hupu_logger.info(_msg("🧍", "先检查 cookie 和视频文件"))
|
||||
await self.validate_upload_args()
|
||||
hupu_logger.info(_msg("🥳", "上传前检查通过"))
|
||||
|
||||
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
|
||||
context = await _create_stealth_context(browser, self.account_file)
|
||||
|
||||
try:
|
||||
page = await _new_stealth_page(context)
|
||||
# 直接跳转到视频发布页(绕过首页点击)
|
||||
await page.goto(HUPU_PUBLISH_URL, timeout=60000, wait_until="load")
|
||||
await page.wait_for_timeout(5000)
|
||||
hupu_logger.info(_msg("🏃", f"开始上传视频: {self.title}"))
|
||||
|
||||
# 1) 上传视频文件
|
||||
await self._upload_video_file(page)
|
||||
|
||||
# 2) 填写标题
|
||||
await self._fill_title(page)
|
||||
|
||||
# 3) 填写简介
|
||||
await self._fill_description(page)
|
||||
|
||||
# 4) 上传封面(如有)
|
||||
if self.thumbnail_path:
|
||||
await self._upload_thumbnail(page)
|
||||
|
||||
# 5) 选择专区(步行街 → 步行街主干道)
|
||||
await self._select_zone(page)
|
||||
|
||||
# 6) 选择原创/二创 + AI 声明
|
||||
await self._check_declarations(page)
|
||||
|
||||
# 7) 点击发布
|
||||
await self._submit_publish(page)
|
||||
|
||||
# 保存 cookie
|
||||
await context.storage_state(path=self.account_file)
|
||||
hupu_logger.success(_msg("🥳", "cookie 更新完毕"))
|
||||
finally:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
async def _upload_video_file(self, page: Page) -> None:
|
||||
"""点击「上传视频」按钮并设置文件。"""
|
||||
upload_btn = page.get_by_role("button", name="上传视频")
|
||||
await upload_btn.wait_for(state="visible", timeout=15000)
|
||||
|
||||
# 通过 file chooser 设置文件
|
||||
async with page.expect_file_chooser(timeout=10000) as fc_info:
|
||||
await upload_btn.click()
|
||||
file_chooser = await fc_info.value
|
||||
await file_chooser.set_files(self.file_path)
|
||||
hupu_logger.info(_msg("🏃", f"已选择视频文件: {self.file_path}"))
|
||||
|
||||
# 等待视频上传就绪(标题输入框出现即可填写)
|
||||
title_field = page.get_by_placeholder("请输入标题(最少4个字,最多40个字)")
|
||||
await title_field.wait_for(state="visible", timeout=300000)
|
||||
hupu_logger.info(_msg("🥳", "视频已就绪"))
|
||||
|
||||
async def _fill_title(self, page: Page) -> None:
|
||||
"""填写标题(4-40字)。"""
|
||||
title_field = page.get_by_placeholder("请输入标题(最少4个字,最多40个字)")
|
||||
await title_field.wait_for(state="visible", timeout=15000)
|
||||
title = self.title[:self.max_title_length]
|
||||
await title_field.click()
|
||||
await title_field.fill(title)
|
||||
hupu_logger.info(_msg("🏷️", f"标题已填写: {title}"))
|
||||
|
||||
async def _fill_description(self, page: Page) -> None:
|
||||
"""填写简介。"""
|
||||
desc_field = page.get_by_placeholder("请输入简介")
|
||||
if not await desc_field.count():
|
||||
hupu_logger.warning(_msg("⚠️", "未找到简介输入框"))
|
||||
return
|
||||
|
||||
# 组装描述:正文 + 标签
|
||||
content = self.desc
|
||||
if self.tags:
|
||||
tag_str = " ".join(f"#{t}#" for t in self.tags)
|
||||
content = f"{content}\n{tag_str}" if content else tag_str
|
||||
|
||||
if content:
|
||||
await desc_field.click()
|
||||
await desc_field.fill(content)
|
||||
hupu_logger.info(_msg("📝", f"简介已填写({len(content)}字)"))
|
||||
|
||||
async def _upload_thumbnail(self, page: Page) -> None:
|
||||
"""上传封面:点击「更换封面」→ 设置文件。"""
|
||||
try:
|
||||
cover_span = page.locator("span").filter(has_text="更换封面")
|
||||
await cover_span.wait_for(state="visible", timeout=10000)
|
||||
|
||||
# 通过 file chooser 设置封面图片
|
||||
async with page.expect_file_chooser(timeout=10000) as fc_info:
|
||||
await cover_span.click()
|
||||
file_chooser = await fc_info.value
|
||||
await file_chooser.set_files(self.thumbnail_path)
|
||||
hupu_logger.info(_msg("🏃", f"已选择封面图片: {self.thumbnail_path}"))
|
||||
await page.wait_for_timeout(3000)
|
||||
hupu_logger.success(_msg("🖼️", "封面已上传"))
|
||||
except Exception as exc:
|
||||
hupu_logger.warning(_msg("⚠️", f"封面上传失败: {exc},继续发布(使用默认封面)"))
|
||||
|
||||
async def _select_zone(self, page: Page) -> None:
|
||||
"""选择专区:步行街 → 步行街主干道。"""
|
||||
try:
|
||||
# 录制脚本:page.get_by_label("发视频").get_by_text("添加专区").click()
|
||||
add_zone_btn = page.get_by_label("发视频").get_by_text("添加专区")
|
||||
await add_zone_btn.wait_for(state="visible", timeout=10000)
|
||||
await add_zone_btn.click()
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
# 选择「步行街」分类
|
||||
zone_dialog = page.get_by_label("添加专区")
|
||||
step_street = zone_dialog.locator("div").filter(has_text=re.compile(r"^步行街$"))
|
||||
await step_street.click()
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
# 选择「步行街主干道」子分类
|
||||
main_road = page.get_by_text("步行街主干道")
|
||||
await main_road.click()
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
# 点击确定
|
||||
confirm_btn = page.get_by_role("button", name="确 定")
|
||||
await confirm_btn.click()
|
||||
await page.wait_for_timeout(1000)
|
||||
hupu_logger.info(_msg("🏷️", "已选择专区:步行街主干道"))
|
||||
except Exception as exc:
|
||||
hupu_logger.warning(_msg("⚠️", f"选择专区失败: {exc}"))
|
||||
|
||||
async def _check_declarations(self, page: Page) -> None:
|
||||
"""选择原创/二创声明 + AI 声明。"""
|
||||
try:
|
||||
# 1) 点击「原创/二创」按钮
|
||||
declaration_btn = page.get_by_role("button", name="原创/二创")
|
||||
if await declaration_btn.count():
|
||||
await declaration_btn.click(timeout=5000)
|
||||
await page.wait_for_timeout(1000)
|
||||
hupu_logger.info(_msg("🏷️", "已点击「原创/二创」"))
|
||||
except Exception as exc:
|
||||
hupu_logger.warning(_msg("⚠️", f"点击原创/二创失败: {exc}"))
|
||||
|
||||
try:
|
||||
# 2) 选择「含AI生成内容」
|
||||
combobox = page.get_by_role("combobox")
|
||||
if await combobox.count():
|
||||
await combobox.click(timeout=5000)
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
ai_option = page.get_by_text("含AI生成内容")
|
||||
if await ai_option.count():
|
||||
await ai_option.click(timeout=5000)
|
||||
await page.wait_for_timeout(500)
|
||||
hupu_logger.info(_msg("🏷️", "已选择「含AI生成内容」"))
|
||||
except Exception as exc:
|
||||
hupu_logger.warning(_msg("⚠️", f"选择 AI 声明失败: {exc}"))
|
||||
|
||||
async def _submit_publish(self, page: Page) -> None:
|
||||
"""点击「确定发布」并等待跳转到帖子页面。"""
|
||||
# 录制脚本:page.get_by_label("发视频").get_by_text("确定发布").click()
|
||||
publish_btn = page.get_by_label("发视频").get_by_text("确定发布")
|
||||
await publish_btn.wait_for(state="visible", timeout=15000)
|
||||
await publish_btn.click()
|
||||
hupu_logger.info(_msg("🏃", "已点击「确定发布」"))
|
||||
|
||||
# 等待跳转到帖子详情页(URL 匹配 bbs.hupu.com/{数字}.html)
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < 60:
|
||||
current_url = page.url
|
||||
if HUPU_POST_URL_PATTERN.search(current_url):
|
||||
hupu_logger.success(_msg("🥳", f"视频发布成功: {current_url}"))
|
||||
return
|
||||
|
||||
await page.wait_for_timeout(2000)
|
||||
|
||||
# 超时 - 可能已成功但检测不到
|
||||
hupu_logger.warning(_msg("⚠️", f"发布后 60s 未检测到帖子页面跳转,当前 URL: {page.url}"))
|
||||
|
||||
async def main(self):
|
||||
async with async_playwright() as playwright:
|
||||
await self.upload(playwright)
|
||||
@@ -60,3 +60,4 @@ xiaohongshu_logger = create_logger('xiaohongshu', 'logs/xiaohongshu.log')
|
||||
youtube_logger = create_logger('youtube', 'logs/youtube.log')
|
||||
alipay_logger = create_logger('alipay', 'logs/alipay.log')
|
||||
weibo_logger = create_logger('weibo', 'logs/weibo.log')
|
||||
hupu_logger = create_logger('hupu', 'logs/hupu.log')
|
||||
|
||||
Reference in New Issue
Block a user