返回 Social Auto Upload
main.py
1 # -*- coding: utf-8 -*-
2 """虎扑视频上传 + 手动登录保存 cookie。
3
4 功能:
5 - hupu_cookie_gen: 打开浏览器让用户手动登录(QQ/手机号等),保存 storage_state
6 - cookie_auth: 验证 cookie 是否有效
7 - hupu_setup: 统一入口(检查/触发登录)
8 - HuPuVideo: 视频上传类
9
10 基于 playwright codegen 录制脚本改写。
11 发布页:https://bbs.hupu.com/newpost?tabkey=2(视频发布标签页)
12 专区:固定选择「步行街 → 步行街主干道」
13
14 注意:虎扑有 headless 检测,需配合反检测参数才能正常操作。
15 """
16 from __future__ import annotations
17
18 import asyncio
19 import inspect
20 import os
21 import re
22 import time
23 from pathlib import Path
24
25 from playwright.async_api import BrowserContext, Page, Playwright, async_playwright
26
27 from conf import BASE_DIR, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
28 from uploader.base_video import BaseVideoUploader
29 from utils.log import hupu_logger
30
31
32 HUPU_HOME_URL = "https://www.hupu.com/"
33 HUPU_LOGIN_URL = "https://passport.hupu.com/v2/login?pcPhone=1&jumpurl=https://www.hupu.com&from=https://www.hupu.com"
34 HUPU_PUBLISH_URL = "https://bbs.hupu.com/newpost?tabkey=2"
35
36 # 虎扑发布成功后跳转的帖子 URL 模式
37 HUPU_POST_URL_PATTERN = re.compile(r"bbs\.hupu\.com/\d+\.html")
38
39 # 反检测 UA
40 _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"
41
42 # 反 webdriver 检测脚本
43 _STEALTH_SCRIPT = """
44 Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
45 """
46
47
48 def _msg(emoji: str, text: str) -> str:
49 return f"{emoji} {text}"
50
51
52 def _build_login_result(success: bool, status: str, message: str, account_file: str, current_url: str = "") -> dict:
53 return {
54 "success": success,
55 "status": status,
56 "message": message,
57 "account_file": str(account_file),
58 "current_url": current_url,
59 }
60
61
62 async def _emit_qrcode_callback(qrcode_callback, payload: dict):
63 if not qrcode_callback:
64 return
65 callback_result = qrcode_callback(payload)
66 if inspect.isawaitable(callback_result):
67 await callback_result
68
69
70 def _build_launch_kwargs(headless: bool) -> dict:
71 launch_kwargs = {
72 "headless": headless,
73 "args": ["--disable-blink-features=AutomationControlled"],
74 }
75 if LOCAL_CHROME_PATH:
76 launch_kwargs["executable_path"] = LOCAL_CHROME_PATH
77 return launch_kwargs
78
79
80 def _resolve_account_file(account_file: str | Path) -> str:
81 path = Path(account_file).expanduser()
82 if path.is_absolute():
83 return str(path)
84 if len(path.parts) == 1:
85 return str((Path(BASE_DIR) / "cookies" / path).resolve())
86 return str(path.resolve())
87
88
89 async def _create_stealth_context(browser, account_file: str | None = None) -> BrowserContext:
90 """创建带反检测的 context。"""
91 kwargs = {
92 "user_agent": _CHROME_UA,
93 "viewport": {"width": 1920, "height": 1080},
94 }
95 if account_file and os.path.exists(account_file):
96 kwargs["storage_state"] = account_file
97 context = await browser.new_context(**kwargs)
98 return context
99
100
101 async def _new_stealth_page(context: BrowserContext) -> Page:
102 """创建带反检测 init script 的 page。"""
103 page = await context.new_page()
104 await page.add_init_script(_STEALTH_SCRIPT)
105 return page
106
107
108 async def hupu_cookie_gen(account_file, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 120, headless: bool = False):
109 """QQ 扫码登录虎扑,保存 cookie。
110
111 流程:打开虎扑登录页 → 点击 QQ 登录 → 截取 QQ 二维码 → 等待扫码完成 → 保存 storage_state。
112 支持 headless 模式(终端显示二维码)。
113 """
114 account_file = _resolve_account_file(account_file)
115 Path(account_file).parent.mkdir(parents=True, exist_ok=True)
116 result = _build_login_result(False, "failed", "虎扑登录失败", account_file)
117
118 async with async_playwright() as playwright:
119 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless))
120 context = await _create_stealth_context(browser)
121 try:
122 page = await _new_stealth_page(context)
123 await page.goto(HUPU_LOGIN_URL, timeout=60000, wait_until="load")
124 await page.wait_for_timeout(3000)
125
126 # 点击 QQ 登录按钮
127 qq_btn = page.get_by_role("button", name="qq QQ登录")
128 if not await qq_btn.count():
129 hupu_logger.error(_msg("😢", "未找到 QQ 登录按钮"))
130 result = _build_login_result(False, "failed", "未找到 QQ 登录按钮", account_file, page.url)
131 return result
132
133 await qq_btn.click()
134 await page.wait_for_timeout(5000)
135 hupu_logger.info(_msg("🏃", "已跳转到 QQ 登录页"))
136
137 # 从 QQ iframe 中获取二维码
138 qrcode_info = await _grab_qq_qrcode(page, context, account_file)
139
140 if qrcode_info:
141 await _emit_qrcode_callback(qrcode_callback, qrcode_info)
142 hupu_logger.info(_msg("🧍", "请用 QQ 手机版扫码登录"))
143 else:
144 hupu_logger.warning(_msg("⚠️", "未能获取 QQ 二维码,请在浏览器中手动扫码"))
145
146 # 轮询等待登录完成
147 for _ in range(max_checks):
148 current_url = page.url
149 # QQ 授权成功后会跳回虎扑首页
150 if "www.hupu.com" in current_url and "passport" not in current_url and "graph.qq.com" not in current_url:
151 hupu_logger.info(_msg("🥳", f"登录成功,跳转到: {current_url}"))
152 result = _build_login_result(True, "success", "虎扑 QQ 扫码登录成功", account_file, current_url)
153 break
154 # 检查是否实际跳转到虎扑 passport 回调页(不是 redirect_uri 参数中包含)
155 if current_url.startswith("https://passport.hupu.com/pc/qqcallback"):
156 await page.wait_for_timeout(5000)
157 current_url = page.url
158 hupu_logger.info(_msg("🥳", f"QQ 回调成功,当前: {current_url}"))
159 result = _build_login_result(True, "success", "虎扑 QQ 扫码登录成功", account_file, current_url)
160 break
161 # 检查 cookies 中是否出现 u(核心登录态)
162 cookies = await context.cookies()
163 if any(c.get("name") == "u" and c.get("value") for c in cookies):
164 hupu_logger.info(_msg("🥳", f"登录成功(检测到 u cookie),当前: {current_url}"))
165 result = _build_login_result(True, "success", "虎扑 QQ 扫码登录成功", account_file, current_url)
166 break
167 await page.wait_for_timeout(poll_interval * 1000)
168 else:
169 result = _build_login_result(False, "timeout", "等待 QQ 扫码登录超时", account_file, page.url)
170
171 if result["success"]:
172 await asyncio.sleep(2)
173 # 确保跳转到首页加载完 cookie
174 if "www.hupu.com" not in page.url:
175 await page.goto(HUPU_HOME_URL, timeout=30000, wait_until="domcontentloaded")
176 await page.wait_for_timeout(3000)
177 await context.storage_state(path=account_file)
178 hupu_logger.success(_msg("🥳", f"cookie 已保存: {account_file}"))
179 except Exception as exc:
180 result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "")
181 finally:
182 # 清理临时二维码文件
183 qr_path = Path(account_file).parent / f"{Path(account_file).stem}_qq_qrcode.png"
184 if qr_path.exists():
185 qr_path.unlink()
186 if not result["success"]:
187 hupu_logger.error(_msg("😢", f"登录失败: {result['message']}"))
188 await context.close()
189 await browser.close()
190 return result
191
192
193 async def _grab_qq_qrcode(page: Page, context: BrowserContext, account_file: str) -> dict | None:
194 """从 QQ 登录 iframe 中获取二维码。"""
195 from utils.login_qrcode import decode_qrcode_from_path, print_terminal_qrcode
196
197 # 等待 QQ iframe 加载(最多 15 秒)
198 qq_frame = None
199 for _ in range(5):
200 for frame in page.frames:
201 if "xui.ptlogin2.qq.com" in frame.url or "ptlogin2.qq.com" in frame.url:
202 qq_frame = frame
203 break
204 if qq_frame:
205 break
206 await asyncio.sleep(3)
207
208 if not qq_frame:
209 return None
210
211 await asyncio.sleep(3)
212
213 # 获取二维码图片(id="qrlogin_img")
214 qr_selectors = ["#qrlogin_img", 'img[src*="ptqrshow"]', 'img[id*="qr"]']
215 for sel in qr_selectors:
216 qr_loc = qq_frame.locator(sel).first
217 if await qr_loc.count():
218 src = await qr_loc.get_attribute("src")
219 if src and src.startswith("http"):
220 # 下载二维码图片
221 try:
222 resp = await context.request.get(src)
223 qr_path = Path(account_file).parent / f"{Path(account_file).stem}_qq_qrcode.png"
224 qr_path.parent.mkdir(parents=True, exist_ok=True)
225 qr_path.write_bytes(await resp.body())
226
227 # 尝试解码并在终端显示
228 qrcode_content = decode_qrcode_from_path(qr_path)
229 if qrcode_content:
230 print_terminal_qrcode(qrcode_content, qr_path, "QQ手机版")
231 else:
232 hupu_logger.warning(_msg("😵", f"终端无法显示二维码,请打开 {qr_path} 扫码"))
233
234 return {"image_path": str(qr_path), "image_data_url": ""}
235 except Exception as exc:
236 hupu_logger.warning(_msg("⚠️", f"下载 QQ 二维码失败: {exc}"))
237 continue
238
239 return None
240
241
242 async def cookie_auth(account_file):
243 """验证虎扑 cookie 是否有效。访问发布页,检测是否能正常加载。"""
244 account_file = _resolve_account_file(account_file)
245 async with async_playwright() as playwright:
246 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True))
247 try:
248 context = await _create_stealth_context(browser, account_file)
249 page = await _new_stealth_page(context)
250 await page.goto(HUPU_PUBLISH_URL, timeout=60000, wait_until="domcontentloaded")
251 await page.wait_for_timeout(5000)
252
253 # 检查是否被跳转到登录页
254 if "passport" in page.url or "login" in page.url:
255 hupu_logger.info(_msg("🥹", "cookie 已失效(跳转到登录页)"))
256 return False
257
258 # 检查发布页是否出现「上传视频」按钮(登录后才有)
259 upload_btn = page.get_by_role("button", name="上传视频")
260 if await upload_btn.count():
261 hupu_logger.success(_msg("🥳", "cookie 有效"))
262 return True
263
264 # 兜底:检查 cookie 中是否有 u 字段
265 cookies = await context.cookies()
266 if any(c.get("name") == "u" and c.get("value") for c in cookies):
267 hupu_logger.success(_msg("🥳", "cookie 有效(u cookie 存在)"))
268 return True
269
270 hupu_logger.info(_msg("🥹", "cookie 已失效(未检测到登录态)"))
271 return False
272 except Exception as exc:
273 hupu_logger.warning(_msg("😵", f"cookie 校验出错,按失效处理: {exc}"))
274 return False
275 finally:
276 await browser.close()
277
278
279 async def hupu_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = False):
280 """统一入口:检查 cookie → 如无效且 handle=True 则触发手动登录。"""
281 account_file = _resolve_account_file(account_file)
282 if not os.path.exists(account_file) or not await cookie_auth(account_file):
283 if not handle:
284 result = _build_login_result(False, "cookie_invalid", "cookie 文件不存在或已失效", account_file)
285 return result if return_detail else False
286 hupu_logger.info(_msg("🥹", "cookie 文件不存在或已失效,打开浏览器请手动登录"))
287 result = await hupu_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
288 return result if return_detail else result["success"]
289
290 result = _build_login_result(True, "cookie_valid", "cookie 有效", account_file)
291 return result if return_detail else True
292
293
294 class HuPuVideo(BaseVideoUploader):
295 """虎扑视频上传。
296
297 流程:直接跳转发布页 → 上传视频文件 → 填标题 → 填简介 →
298 上传封面 → 选专区(步行街主干道)→ 选原创/二创 → 选 AI 声明 →
299 点击「确定发布」→ 等待跳转到帖子页面。
300 """
301
302 def __init__(
303 self,
304 title,
305 file_path,
306 tags,
307 account_file,
308 publish_date=0,
309 desc: str | None = None,
310 thumbnail_path: str | None = None,
311 debug: bool = True,
312 headless: bool = LOCAL_CHROME_HEADLESS,
313 ):
314 self.title = title
315 self.file_path = file_path
316 self.tags = tags or []
317 self.account_file = _resolve_account_file(account_file)
318 self.publish_date = publish_date
319 self.desc = desc or ""
320 self.thumbnail_path = thumbnail_path
321 self.debug = debug
322 self.headless = headless
323 self.local_executable_path = LOCAL_CHROME_PATH
324 self.max_title_length = 40
325 self.min_title_length = 4
326
327 async def validate_upload_args(self):
328 if not os.path.exists(self.account_file):
329 raise RuntimeError(f"cookie文件不存在,请先完成虎扑登录: {self.account_file}")
330 if not await cookie_auth(self.account_file):
331 raise RuntimeError(f"cookie文件已失效,请先完成虎扑登录: {self.account_file}")
332 if not self.title or not str(self.title).strip():
333 raise ValueError("视频标题不能为空")
334 if len(self.title) < self.min_title_length:
335 raise ValueError(f"视频标题至少{self.min_title_length}个字")
336 self.file_path = str(self.validate_video_file(self.file_path))
337 if self.thumbnail_path:
338 self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path))
339
340 async def upload(self, playwright: Playwright) -> None:
341 hupu_logger.info(_msg("🧍", "先检查 cookie 和视频文件"))
342 await self.validate_upload_args()
343 hupu_logger.info(_msg("🥳", "上传前检查通过"))
344
345 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
346 context = await _create_stealth_context(browser, self.account_file)
347
348 try:
349 page = await _new_stealth_page(context)
350 # 直接跳转到视频发布页(绕过首页点击)
351 await page.goto(HUPU_PUBLISH_URL, timeout=60000, wait_until="load")
352 await page.wait_for_timeout(5000)
353 hupu_logger.info(_msg("🏃", f"开始上传视频: {self.title}"))
354
355 # 1) 上传视频文件
356 await self._upload_video_file(page)
357
358 # 2) 填写标题
359 await self._fill_title(page)
360
361 # 3) 填写简介
362 await self._fill_description(page)
363
364 # 4) 上传封面(如有)
365 if self.thumbnail_path:
366 await self._upload_thumbnail(page)
367
368 # 5) 选择专区(步行街 → 步行街主干道)
369 await self._select_zone(page)
370
371 # 6) 选择原创/二创 + AI 声明
372 await self._check_declarations(page)
373
374 # 7) 点击发布
375 await self._submit_publish(page)
376
377 # 保存 cookie
378 await context.storage_state(path=self.account_file)
379 hupu_logger.success(_msg("🥳", "cookie 更新完毕"))
380 finally:
381 await context.close()
382 await browser.close()
383
384 async def _upload_video_file(self, page: Page) -> None:
385 """点击「上传视频」按钮并设置文件。"""
386 upload_btn = page.get_by_role("button", name="上传视频")
387 await upload_btn.wait_for(state="visible", timeout=15000)
388
389 # 通过 file chooser 设置文件
390 async with page.expect_file_chooser(timeout=10000) as fc_info:
391 await upload_btn.click()
392 file_chooser = await fc_info.value
393 await file_chooser.set_files(self.file_path)
394 hupu_logger.info(_msg("🏃", f"已选择视频文件: {self.file_path}"))
395
396 # 等待视频上传就绪(标题输入框出现即可填写)
397 title_field = page.get_by_placeholder("请输入标题(最少4个字,最多40个字)")
398 await title_field.wait_for(state="visible", timeout=300000)
399 hupu_logger.info(_msg("🥳", "视频已就绪"))
400
401 async def _fill_title(self, page: Page) -> None:
402 """填写标题(4-40字)。"""
403 title_field = page.get_by_placeholder("请输入标题(最少4个字,最多40个字)")
404 await title_field.wait_for(state="visible", timeout=15000)
405 title = self.title[:self.max_title_length]
406 await title_field.click()
407 await title_field.fill(title)
408 hupu_logger.info(_msg("🏷️", f"标题已填写: {title}"))
409
410 async def _fill_description(self, page: Page) -> None:
411 """填写简介。"""
412 desc_field = page.get_by_placeholder("请输入简介")
413 if not await desc_field.count():
414 hupu_logger.warning(_msg("⚠️", "未找到简介输入框"))
415 return
416
417 # 组装描述:正文 + 标签
418 content = self.desc
419 if self.tags:
420 tag_str = " ".join(f"#{t}#" for t in self.tags)
421 content = f"{content}\n{tag_str}" if content else tag_str
422
423 if content:
424 await desc_field.click()
425 await desc_field.fill(content)
426 hupu_logger.info(_msg("📝", f"简介已填写({len(content)}字)"))
427
428 async def _upload_thumbnail(self, page: Page) -> None:
429 """上传封面:点击「更换封面」→ 设置文件。"""
430 try:
431 cover_span = page.locator("span").filter(has_text="更换封面")
432 await cover_span.wait_for(state="visible", timeout=10000)
433
434 # 通过 file chooser 设置封面图片
435 async with page.expect_file_chooser(timeout=10000) as fc_info:
436 await cover_span.click()
437 file_chooser = await fc_info.value
438 await file_chooser.set_files(self.thumbnail_path)
439 hupu_logger.info(_msg("🏃", f"已选择封面图片: {self.thumbnail_path}"))
440 await page.wait_for_timeout(3000)
441 hupu_logger.success(_msg("🖼️", "封面已上传"))
442 except Exception as exc:
443 hupu_logger.warning(_msg("⚠️", f"封面上传失败: {exc},继续发布(使用默认封面)"))
444
445 async def _select_zone(self, page: Page) -> None:
446 """选择专区:步行街 → 步行街主干道。"""
447 try:
448 # 录制脚本:page.get_by_label("发视频").get_by_text("添加专区").click()
449 add_zone_btn = page.get_by_label("发视频").get_by_text("添加专区")
450 await add_zone_btn.wait_for(state="visible", timeout=10000)
451 await add_zone_btn.click()
452 await page.wait_for_timeout(1500)
453
454 # 选择「步行街」分类
455 zone_dialog = page.get_by_label("添加专区")
456 step_street = zone_dialog.locator("div").filter(has_text=re.compile(r"^步行街$"))
457 await step_street.click()
458 await page.wait_for_timeout(1000)
459
460 # 选择「步行街主干道」子分类
461 main_road = page.get_by_text("步行街主干道")
462 await main_road.click()
463 await page.wait_for_timeout(500)
464
465 # 点击确定
466 confirm_btn = page.get_by_role("button", name="确 定")
467 await confirm_btn.click()
468 await page.wait_for_timeout(1000)
469 hupu_logger.info(_msg("🏷️", "已选择专区:步行街主干道"))
470 except Exception as exc:
471 hupu_logger.warning(_msg("⚠️", f"选择专区失败: {exc}"))
472
473 async def _check_declarations(self, page: Page) -> None:
474 """选择原创/二创声明 + AI 声明。"""
475 try:
476 # 1) 点击「原创/二创」按钮
477 declaration_btn = page.get_by_role("button", name="原创/二创")
478 if await declaration_btn.count():
479 await declaration_btn.click(timeout=5000)
480 await page.wait_for_timeout(1000)
481 hupu_logger.info(_msg("🏷️", "已点击「原创/二创」"))
482 except Exception as exc:
483 hupu_logger.warning(_msg("⚠️", f"点击原创/二创失败: {exc}"))
484
485 try:
486 # 2) 选择「含AI生成内容」
487 combobox = page.get_by_role("combobox")
488 if await combobox.count():
489 await combobox.click(timeout=5000)
490 await page.wait_for_timeout(1000)
491
492 ai_option = page.get_by_text("含AI生成内容")
493 if await ai_option.count():
494 await ai_option.click(timeout=5000)
495 await page.wait_for_timeout(500)
496 hupu_logger.info(_msg("🏷️", "已选择「含AI生成内容」"))
497 except Exception as exc:
498 hupu_logger.warning(_msg("⚠️", f"选择 AI 声明失败: {exc}"))
499
500 async def _submit_publish(self, page: Page) -> None:
501 """点击「确定发布」并等待跳转到帖子页面。"""
502 # 录制脚本:page.get_by_label("发视频").get_by_text("确定发布").click()
503 publish_btn = page.get_by_label("发视频").get_by_text("确定发布")
504 await publish_btn.wait_for(state="visible", timeout=15000)
505 await publish_btn.click()
506 hupu_logger.info(_msg("🏃", "已点击「确定发布」"))
507
508 # 等待跳转到帖子详情页(URL 匹配 bbs.hupu.com/{数字}.html)
509 start = time.monotonic()
510 while time.monotonic() - start < 60:
511 current_url = page.url
512 if HUPU_POST_URL_PATTERN.search(current_url):
513 hupu_logger.success(_msg("🥳", f"视频发布成功: {current_url}"))
514 return
515
516 await page.wait_for_timeout(2000)
517
518 # 超时 - 可能已成功但检测不到
519 hupu_logger.warning(_msg("⚠️", f"发布后 60s 未检测到帖子页面跳转,当前 URL: {page.url}"))
520
521 async def main(self):
522 async with async_playwright() as playwright:
523 await self.upload(playwright)
524
524 lines PYTHON