Files
EveryPublish/uploader/hupu_uploader/main.py
T
suihf 254d0beb20 feat(hupu): add Hupu (虎扑) video uploader
- New uploader/hupu_uploader module: QR-code cookie login, direct publish
  page flow with title/desc/tags, cover upload, zone/declaration selection
- sau_cli: add 'hupu login/check/upload-video' subcommands
- utils/log.py: register hupu logger
2026-08-13 10:01:23 +08:00

524 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)