This commit is contained in:
Kevin Wong
2026-02-06 16:02:58 +08:00
parent be6a3436bb
commit 945262a7fc
20 changed files with 3709 additions and 482 deletions

View File

@@ -7,10 +7,42 @@ class Settings(BaseSettings):
UPLOAD_DIR: Path = BASE_DIR.parent / "uploads"
OUTPUT_DIR: Path = BASE_DIR.parent / "outputs"
ASSETS_DIR: Path = BASE_DIR.parent / "assets"
PUBLISH_SCREENSHOT_DIR: Path = BASE_DIR.parent / "private_outputs" / "publish_screenshots"
# 数据库/缓存
REDIS_URL: str = "redis://localhost:6379/0"
DEBUG: bool = True
# 数据库/缓存
REDIS_URL: str = "redis://localhost:6379/0"
DEBUG: bool = True
# Playwright 配置
WEIXIN_HEADLESS_MODE: str = "headless-new"
WEIXIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
WEIXIN_LOCALE: str = "zh-CN"
WEIXIN_TIMEZONE_ID: str = "Asia/Shanghai"
WEIXIN_CHROME_PATH: str = "/usr/bin/google-chrome"
WEIXIN_BROWSER_CHANNEL: str = ""
WEIXIN_FORCE_SWIFTSHADER: bool = True
WEIXIN_TRANSCODE_MODE: str = "reencode"
WEIXIN_DEBUG_ARTIFACTS: bool = False
WEIXIN_RECORD_VIDEO: bool = False
WEIXIN_KEEP_SUCCESS_VIDEO: bool = False
WEIXIN_RECORD_VIDEO_WIDTH: int = 1280
WEIXIN_RECORD_VIDEO_HEIGHT: int = 720
# Douyin Playwright 配置
DOUYIN_HEADLESS_MODE: str = "headless-new"
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
DOUYIN_LOCALE: str = "zh-CN"
DOUYIN_TIMEZONE_ID: str = "Asia/Shanghai"
DOUYIN_CHROME_PATH: str = "/usr/bin/google-chrome"
DOUYIN_BROWSER_CHANNEL: str = ""
DOUYIN_FORCE_SWIFTSHADER: bool = True
# Douyin 调试录屏
DOUYIN_DEBUG_ARTIFACTS: bool = False
DOUYIN_RECORD_VIDEO: bool = False
DOUYIN_KEEP_SUCCESS_VIDEO: bool = False
DOUYIN_RECORD_VIDEO_WIDTH: int = 1280
DOUYIN_RECORD_VIDEO_HEIGHT: int = 720
# TTS 配置
DEFAULT_TTS_VOICE: str = "zh-CN-YunxiNeural"

View File

@@ -15,17 +15,19 @@ async def login_helper_page(platform: str, request: Request):
登录后JavaScript自动提取Cookie并POST回服务器
"""
platform_urls = {
"bilibili": "https://www.bilibili.com/",
"douyin": "https://creator.douyin.com/",
"xiaohongshu": "https://creator.xiaohongshu.com/"
}
platform_urls = {
"bilibili": "https://www.bilibili.com/",
"douyin": "https://creator.douyin.com/",
"xiaohongshu": "https://creator.xiaohongshu.com/",
"weixin": "https://channels.weixin.qq.com/"
}
platform_names = {
"bilibili": "B站",
"douyin": "抖音",
"xiaohongshu": "小红书"
}
platform_names = {
"bilibili": "B站",
"douyin": "抖音",
"xiaohongshu": "小红书",
"weixin": "微信视频号"
}
if platform not in platform_urls:
return "<h1>不支持的平台</h1>"

View File

@@ -1,13 +1,17 @@
"""
发布管理 API (支持用户认证)
"""
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import re
from loguru import logger
from app.services.publish_service import PublishService
from app.core.response import success_response
from app.core.config import settings
from app.core.deps import get_current_user
router = APIRouter()
publish_service = PublishService()
@@ -29,7 +33,7 @@ class PublishResponse(BaseModel):
url: Optional[str] = None
# Supported platforms for validation
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu", "weixin"}
def _get_user_id(request: Request) -> Optional[str]:
@@ -118,7 +122,7 @@ async def get_login_status(platform: str, req: Request):
message = result.get("message", "")
return success_response(result, message=message)
@router.post("/cookies/save/{platform}")
@router.post("/cookies/save/{platform}")
async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
"""
保存从客户端浏览器提取的Cookie
@@ -139,3 +143,23 @@ async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
message = result.get("message", "")
return success_response(result, message=message)
@router.get("/screenshot/{filename}")
async def get_publish_screenshot(
filename: str,
current_user: dict = Depends(get_current_user),
):
if not re.match(r"^[A-Za-z0-9_.-]+$", filename):
raise HTTPException(status_code=400, detail="非法文件名")
user_id = str(current_user.get("id") or "")
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
user_dir = re.sub(r"[^A-Za-z0-9_-]", "_", user_id)[:64] or "legacy"
file_path = settings.PUBLISH_SCREENSHOT_DIR / user_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="截图不存在")
return FileResponse(path=str(file_path), media_type="image/png")

View File

@@ -17,7 +17,8 @@ from app.services.storage import storage_service
# Import platform uploaders
from .uploader.bilibili_uploader import BilibiliUploader
from .uploader.douyin_uploader import DouyinUploader
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
from .uploader.weixin_uploader import WeixinUploader
class PublishService:
@@ -26,7 +27,7 @@ class PublishService:
# 支持的平台配置
PLATFORMS: Dict[str, Dict[str, Any]] = {
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
}
@@ -174,25 +175,36 @@ class PublishService:
tid=kwargs.get('tid', 122),
copyright=kwargs.get('copyright', 1)
)
elif platform == "douyin":
uploader = DouyinUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
)
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
)
else:
elif platform == "douyin":
uploader = DouyinUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
user_id=user_id,
)
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
)
elif platform == "weixin":
uploader = WeixinUploader(
title=title,
file_path=local_video_path,
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
user_id=user_id,
)
else:
logger.warning(f"[发布] {platform} 上传功能尚未实现")
return {
"success": False,

View File

@@ -2,23 +2,25 @@
QR码自动登录服务
后端Playwright无头模式获取二维码前端扫码后自动保存Cookie
"""
import asyncio
import asyncio
import time
import base64
import json
from pathlib import Path
from typing import Optional, Dict, Any, List
from playwright.async_api import async_playwright, Page, BrowserContext, Browser, Playwright as PW
from loguru import logger
from typing import Optional, Dict, Any, List, Sequence, Mapping, Union
from playwright.async_api import async_playwright, Page, Frame, BrowserContext, Browser, Playwright as PW
from loguru import logger
from app.core.config import settings
class QRLoginService:
class QRLoginService:
"""QR码登录服务"""
# 登录监控超时 (秒)
LOGIN_TIMEOUT = 120
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
self.cookies_dir = cookies_dir
self.qr_code_image: Optional[str] = None
self.login_success: bool = False
@@ -30,7 +32,7 @@ class QRLoginService:
self.context: Optional[BrowserContext] = None
# 每个平台使用多个选择器 (使用逗号分隔Playwright会同时等待它们)
self.platform_configs = {
self.platform_configs = {
"bilibili": {
"url": "https://passport.bilibili.com/login",
"qr_selectors": [
@@ -52,17 +54,110 @@ class QRLoginService:
],
"success_indicator": "https://creator.douyin.com/creator-micro"
},
"xiaohongshu": {
"url": "https://creator.xiaohongshu.com/",
"qr_selectors": [
".qrcode img",
"img[alt*='二维码']",
"canvas.qr-code",
"img[class*='qr']"
],
"success_indicator": "https://creator.xiaohongshu.com/publish"
}
}
"xiaohongshu": {
"url": "https://creator.xiaohongshu.com/",
"qr_selectors": [
".qrcode img",
"img[alt*='二维码']",
"canvas.qr-code",
"img[class*='qr']"
],
"success_indicator": "https://creator.xiaohongshu.com/publish"
},
"weixin": {
"url": "https://channels.weixin.qq.com/platform/",
"qr_selectors": [
"div[class*='qrcode'] img",
"img[alt*='二维码']",
"img[src*='qr']",
"canvas",
"svg",
"img[class*='qr']"
],
"success_indicator": "https://channels.weixin.qq.com/platform"
}
}
def _resolve_headless_mode(self) -> str:
if self.platform != "weixin":
return "headless"
mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
return mode or "headful"
def _is_square_bbox(self, bbox: Optional[Dict[str, float]], min_side: int = 100) -> bool:
if not bbox:
return False
width = bbox.get("width", 0)
height = bbox.get("height", 0)
if width < min_side or height < min_side:
return False
if height == 0:
return False
ratio = width / height
return 0.75 <= ratio <= 1.33
async def _pick_best_candidate(self, locator, min_side: int = 100):
best = None
best_area = 0
try:
count = await locator.count()
except Exception:
return None
for i in range(count):
try:
candidate = locator.nth(i)
if not await candidate.is_visible():
continue
bbox = await candidate.bounding_box()
if not self._is_square_bbox(bbox, min_side=min_side):
continue
area = bbox["width"] * bbox["height"]
if area > best_area:
best = candidate
best_area = area
except Exception:
continue
return best
async def _find_qr_in_frames(self, page: Page, selectors: List[str], min_side: int):
combined_selector = ", ".join(selectors)
for frame in page.frames:
if frame == page.main_frame:
continue
try:
locator = frame.locator(combined_selector)
candidate = await self._pick_best_candidate(locator, min_side=min_side)
if candidate:
return candidate
except Exception:
continue
return None
async def _scan_qr_candidates(self, page: Page, selectors: List[str], min_side: int):
combined_selector = ", ".join(selectors)
try:
locator = page.locator(combined_selector)
candidate = await self._pick_best_candidate(locator, min_side=min_side)
if candidate:
return candidate
except Exception:
pass
return await self._find_qr_in_frames(page, selectors, min_side=min_side)
async def _try_text_strategy_in_frames(self, page: Page):
for frame in page.frames:
if frame == page.main_frame:
continue
try:
candidate = await self._try_text_strategy(frame)
if candidate:
return candidate
except Exception:
continue
return None
async def start_login(self) -> Dict[str, Any]:
"""
@@ -77,26 +172,45 @@ class QRLoginService:
config = self.platform_configs[self.platform]
try:
# 1. 启动 Playwright (不使用 async with手动管理生命周期)
self.playwright = await async_playwright().start()
# 1. 启动 Playwright (不使用 async with手动管理生命周期)
self.playwright = await async_playwright().start()
mode = self._resolve_headless_mode()
headless = mode not in ("headful", "false", "0", "no")
launch_args = [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage'
]
if headless and mode in ("new", "headless-new", "headless_new"):
launch_args.append("--headless=new")
# Stealth模式启动浏览器
launch_options: Dict[str, Any] = {
"headless": headless,
"args": launch_args,
}
if self.platform == "weixin":
chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
if chrome_path:
if Path(chrome_path).exists():
launch_options["executable_path"] = chrome_path
else:
logger.warning(f"[weixin] WEIXIN_CHROME_PATH not found: {chrome_path}")
else:
channel = (settings.WEIXIN_BROWSER_CHANNEL or "").strip()
if channel:
launch_options["channel"] = channel
self.browser = await self.playwright.chromium.launch(**launch_options)
# Stealth模式启动浏览器
self.browser = await self.playwright.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage'
]
)
# 配置真实浏览器特征
self.context = await self.browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale='zh-CN',
timezone_id='Asia/Shanghai'
)
# 配置真实浏览器特征
self.context = await self.browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent=settings.WEIXIN_USER_AGENT,
locale=settings.WEIXIN_LOCALE,
timezone_id=settings.WEIXIN_TIMEZONE_ID
)
page = await self.context.new_page()
@@ -106,14 +220,26 @@ class QRLoginService:
await page.add_init_script(path=str(stealth_path))
logger.debug(f"[{self.platform}] Stealth模式已启用")
logger.info(f"[{self.platform}] 打开登录页...")
await page.goto(config["url"], wait_until='networkidle')
# 等待页面加载 (缩短等待)
await asyncio.sleep(2)
# 提取二维码 (并行策略)
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
urls_to_try = [config["url"]]
if self.platform == "weixin":
urls_to_try = [
"https://channels.weixin.qq.com/platform/",
"https://channels.weixin.qq.com/",
]
qr_image = None
for url in urls_to_try:
logger.info(f"[{self.platform}] 打开登录页: {url}")
wait_until = "domcontentloaded" if self.platform == "weixin" else "networkidle"
await page.goto(url, wait_until=wait_until)
# 等待页面加载
await asyncio.sleep(1 if self.platform == "weixin" else 2)
# 提取二维码 (并行策略)
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
if qr_image:
break
if not qr_image:
await self._cleanup()
@@ -180,21 +306,35 @@ class QRLoginService:
if qr_element:
break
else:
# 其他平台 (小红书等):保持原顺序 CSS -> Text
# 策略1: CSS 选择器
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
if el:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
qr_element = el
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
# 其他平台 (小红书/微信等):保持原顺序 CSS -> Text
# 策略1: CSS 选择器
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
if self.platform == "weixin":
min_side = 120
start_time = time.monotonic()
while time.monotonic() - start_time < 12:
qr_element = await self._scan_qr_candidates(page, selectors, min_side=min_side)
if qr_element:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
break
await asyncio.sleep(0.5)
else:
await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
locator = page.locator(combined_selector)
qr_element = await self._pick_best_candidate(locator, min_side=100)
if qr_element:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
# 策略2: Text
if not qr_element:
qr_element = await self._try_text_strategy(page)
if not qr_element:
qr_element = await self._try_text_strategy(page)
if not qr_element and self.platform == "weixin":
qr_element = await self._try_text_strategy_in_frames(page)
# 如果找到元素,截图返回
if qr_element:
@@ -214,11 +354,20 @@ class QRLoginService:
return None
async def _try_text_strategy(self, page: Page) -> Optional[Any]:
async def _try_text_strategy(self, page: Union[Page, Frame]) -> Optional[Any]:
"""基于文本查找二维码图片"""
try:
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP", "使用APP扫码"]
keywords = [
"扫码登录",
"二维码",
"打开抖音",
"抖音APP",
"使用APP扫码",
"微信扫码",
"请使用微信扫码",
"视频号"
]
for kw in keywords:
try:
@@ -229,15 +378,12 @@ class QRLoginService:
parent = text_el
for _ in range(5):
parent = parent.locator("..")
imgs = parent.locator("img")
for i in range(await imgs.count()):
img = imgs.nth(i)
if await img.is_visible():
bbox = await img.bounding_box()
if bbox and bbox['width'] > 100:
logger.info(f"[{self.platform}] 策略Text: 成功")
return img
candidates = parent.locator("img, canvas")
min_side = 120 if self.platform == "weixin" else 100
best = await self._pick_best_candidate(candidates, min_side=min_side)
if best:
logger.info(f"[{self.platform}] 策略Text: 成功")
return best
except Exception:
continue
except Exception as e:
@@ -248,8 +394,23 @@ class QRLoginService:
"""监控登录状态"""
try:
logger.info(f"[{self.platform}] 开始监控登录状态...")
key_cookies = {"bilibili": "SESSDATA", "douyin": "sessionid", "xiaohongshu": "web_session"}
target_cookie = key_cookies.get(self.platform, "")
key_cookies = {
"bilibili": ["SESSDATA"],
"douyin": ["sessionid"],
"xiaohongshu": ["web_session"],
"weixin": [
"wxuin",
"wxsid",
"pass_ticket",
"webwx_data_ticket",
"uin",
"skey",
"p_uin",
"p_skey",
"pac_uid",
],
}
target_cookies = key_cookies.get(self.platform, [])
for i in range(self.LOGIN_TIMEOUT):
await asyncio.sleep(1)
@@ -257,9 +418,9 @@ class QRLoginService:
try:
if not self.context: break # 避免意外关闭
cookies = await self.context.cookies()
cookies = [dict(cookie) for cookie in await self.context.cookies()]
current_url = page.url
has_cookie = any(c['name'] == target_cookie for c in cookies)
has_cookie = any((c.get('name') in target_cookies) for c in cookies) if target_cookies else False
if i % 5 == 0:
logger.debug(f"[{self.platform}] 等待登录... HasCookie: {has_cookie}")
@@ -270,7 +431,7 @@ class QRLoginService:
await asyncio.sleep(2) # 缓冲
# 保存Cookie
final_cookies = await self.context.cookies()
final_cookies = [dict(cookie) for cookie in await self.context.cookies()]
await self._save_cookies(final_cookies)
break
@@ -307,14 +468,14 @@ class QRLoginService:
pass
self.playwright = None
async def _save_cookies(self, cookies: List[Dict[str, Any]]) -> None:
async def _save_cookies(self, cookies: Sequence[Mapping[str, Any]]) -> None:
"""保存Cookie到文件"""
try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
if self.platform == "bilibili":
# Bilibili 使用简单格式 (biliup库需要)
cookie_dict = {c['name']: c['value'] for c in cookies}
cookie_dict = {c.get('name'): c.get('value') for c in cookies if c.get('name')}
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}

View File

@@ -4,6 +4,7 @@ Platform uploader base classes and utilities
from .base_uploader import BaseUploader
from .bilibili_uploader import BilibiliUploader
from .douyin_uploader import DouyinUploader
from .xiaohongshu_uploader import XiaohongshuUploader
from .xiaohongshu_uploader import XiaohongshuUploader
from .weixin_uploader import WeixinUploader
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader']
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader', 'WeixinUploader']

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff