873 lines
34 KiB
Python
873 lines
34 KiB
Python
# Windows兼容性:必须在任何异步操作之前设置事件循环策略
|
||
import sys
|
||
import asyncio
|
||
import aiohttp
|
||
import json
|
||
if sys.platform == 'win32':
|
||
# Windows下使用ProactorEventLoopPolicy来支持Playwright的子进程
|
||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||
print("[系统] Windows环境已设置ProactorEventLoopPolicy", file=sys.stderr)
|
||
|
||
# 加载配置
|
||
from config import init_config, get_config
|
||
from dotenv import load_dotenv
|
||
load_dotenv() # 从 .env 文件加载环境变量(可选,用于覆盖配置文件)
|
||
|
||
from fastapi import FastAPI, HTTPException, File, UploadFile, Form
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from pydantic import BaseModel
|
||
from typing import Optional, Dict, Any, List
|
||
from datetime import datetime
|
||
import os
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
from xhs_login import XHSLoginService
|
||
from browser_pool import get_browser_pool
|
||
from scheduler import XHSScheduler
|
||
from error_screenshot import cleanup_old_screenshots
|
||
from ali_sms_service import AliSmsService
|
||
|
||
app = FastAPI(title="小红书登录API")
|
||
|
||
# CORS配置
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"], # 生产环境应该限制具体域名
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# 全局登录服务实例(延迟初始化,避免在startup前创建浏览器池)
|
||
login_service = None
|
||
|
||
# 全局浏览器池实例(在startup时初始化)
|
||
browser_pool = None
|
||
|
||
# 全局调度器实例
|
||
scheduler = None
|
||
|
||
# 全局阿里云短信服务实例
|
||
sms_service = None
|
||
|
||
|
||
async def fetch_proxy_from_pool() -> Optional[str]:
|
||
"""从代理池接口获取一个代理地址(http://ip:port),获取失败返回None"""
|
||
config = get_config()
|
||
if not config.get_bool('proxy_pool.enabled', False):
|
||
return None
|
||
|
||
api_url = config.get_str('proxy_pool.api_url', '')
|
||
if not api_url:
|
||
return None
|
||
|
||
try:
|
||
timeout = aiohttp.ClientTimeout(total=10)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.get(api_url) as resp:
|
||
if resp.status != 200:
|
||
print(f"[代理池] 接口返回非200状态码: {resp.status}", file=sys.stderr)
|
||
return None
|
||
|
||
text = (await resp.text()).strip()
|
||
if not text:
|
||
print("[代理池] 返回内容为空", file=sys.stderr)
|
||
return None
|
||
|
||
line = text.splitlines()[0].strip()
|
||
if not line:
|
||
print("[代理池] 首行内容为空", file=sys.stderr)
|
||
return None
|
||
|
||
if line.startswith("http://") or line.startswith("https://"):
|
||
return line
|
||
return "http://" + line
|
||
except Exception as e:
|
||
print(f"[代理池] 请求失败: {str(e)}", file=sys.stderr)
|
||
return None
|
||
|
||
|
||
# 临时文件存储目录
|
||
TEMP_DIR = Path("temp_uploads")
|
||
TEMP_DIR.mkdir(exist_ok=True)
|
||
|
||
# 请求模型
|
||
class SendCodeRequest(BaseModel):
|
||
phone: str
|
||
country_code: str = "+86"
|
||
login_page: Optional[str] = None # 登录页面:creator 或 home,为None时使用配置文件默认值
|
||
|
||
class VerifyCodeRequest(BaseModel):
|
||
phone: str
|
||
code: str
|
||
country_code: str = "+86"
|
||
|
||
class LoginRequest(BaseModel):
|
||
phone: str
|
||
code: str
|
||
country_code: str = "+86"
|
||
login_page: Optional[str] = None # 登录页面:creator 或 home,为None时使用配置文件默认值
|
||
session_id: Optional[str] = None # 可选:复用send-code接口的session_id
|
||
|
||
class PublishNoteRequest(BaseModel):
|
||
title: str
|
||
content: str
|
||
images: Optional[list] = None
|
||
topics: Optional[list] = None
|
||
|
||
class PublishWithCookiesRequest(BaseModel):
|
||
cookies: Optional[list] = None # 兼容旧版,仅传Cookies
|
||
login_state: Optional[dict] = None # 新版,传完整的login_state
|
||
storage_state_path: Optional[str] = None # 新增:storage_state文件路径(最优先)
|
||
phone: Optional[str] = None # 新增:手机号,用于查找storage_state文件
|
||
title: str
|
||
content: str
|
||
images: Optional[list] = None
|
||
topics: Optional[list] = None
|
||
|
||
class InjectCookiesRequest(BaseModel):
|
||
cookies: Optional[list] = None # 兼容旧版,仅传Cookies
|
||
login_state: Optional[dict] = None # 新版,传完整的login_state
|
||
target_page: Optional[str] = "creator" # 目标页面:creator 或 home
|
||
|
||
# 响应模型
|
||
class BaseResponse(BaseModel):
|
||
code: int
|
||
message: str
|
||
data: Optional[Dict[str, Any]] = None
|
||
|
||
@app.on_event("startup")
|
||
async def startup_event():
|
||
"""启动时启动后台清理任务和定时发布任务(已禁用预热)"""
|
||
# 初始化配置(从ENV环境变量读取,默认dev)
|
||
config = init_config()
|
||
|
||
print("[服务启动] FastAPI服务启动,浏览器池已就绪")
|
||
|
||
# 清理旧的错误截图(保留最近7天)
|
||
try:
|
||
cleanup_old_screenshots(days=7)
|
||
except Exception as e:
|
||
print(f"[启动] 清理旧截图失败: {str(e)}")
|
||
|
||
# 从配置文件读取headless参数
|
||
headless = config.get_bool('scheduler.headless', True) # 定时发布的headless配置
|
||
login_headless = config.get_bool('login.headless', False) # 登录/绑定的headless配置,默认为有头模式
|
||
login_page = config.get_str('login.page', 'creator') # 登录页面类型,默认为创作者中心
|
||
|
||
# 根据配置自动调整预热URL
|
||
if login_page == "home":
|
||
preheat_url = "https://www.xiaohongshu.com"
|
||
else:
|
||
preheat_url = "https://creator.xiaohongshu.com/login"
|
||
|
||
# 初始化全局浏览器池(使用配置的headless参数)
|
||
global browser_pool, login_service, sms_service
|
||
browser_pool = get_browser_pool(idle_timeout=1800, headless=headless)
|
||
print(f"[服务启动] 浏览器池模式: {'headless(无头模式)' if headless else 'headed(有头模式)'}")
|
||
|
||
# 初始化登录服务(使用独立的login.headless配置)
|
||
login_service = XHSLoginService(use_pool=True, headless=login_headless)
|
||
print(f"[服务启动] 登录服务模式: {'headless(无头模式)' if login_headless else 'headed(有头模式)'}")
|
||
|
||
# 初始化阿里云短信服务
|
||
sms_dict = config.get_dict('ali_sms')
|
||
sms_service = AliSmsService(
|
||
access_key_id=sms_dict.get('access_key_id', ''),
|
||
access_key_secret=sms_dict.get('access_key_secret', ''),
|
||
sign_name=sms_dict.get('sign_name', ''),
|
||
template_code=sms_dict.get('template_code', '')
|
||
)
|
||
print("[服务启动] 阿里云短信服务已初始化")
|
||
|
||
# 启动浏览器池清理任务
|
||
asyncio.create_task(browser_cleanup_task())
|
||
|
||
# 已禁用预热功能,避免干扰正常业务流程
|
||
# asyncio.create_task(browser_preheat_task())
|
||
print("[服务启动] 浏览器预热功能已禁用")
|
||
|
||
# 启动定时发布任务
|
||
global scheduler
|
||
|
||
# 从配置文件读取数据库配置
|
||
db_dict = config.get_dict('database')
|
||
db_config = {
|
||
'host': db_dict.get('host', 'localhost'),
|
||
'port': db_dict.get('port', 3306),
|
||
'user': db_dict.get('username', 'root'),
|
||
'password': db_dict.get('password', ''),
|
||
'database': db_dict.get('dbname', 'ai_wht')
|
||
}
|
||
|
||
# 从配置文件读取调度器配置
|
||
scheduler_enabled = config.get_bool('scheduler.enabled', False)
|
||
proxy_pool_enabled = config.get_bool('proxy_pool.enabled', False)
|
||
proxy_pool_api_url = config.get_str('proxy_pool.api_url', '')
|
||
enable_random_ua = config.get_bool('scheduler.enable_random_ua', True)
|
||
min_publish_interval = config.get_int('scheduler.min_publish_interval', 30)
|
||
max_publish_interval = config.get_int('scheduler.max_publish_interval', 120)
|
||
# headless已经在上面读取了
|
||
|
||
if scheduler_enabled:
|
||
scheduler = XHSScheduler(
|
||
db_config=db_config,
|
||
max_concurrent=config.get_int('scheduler.max_concurrent', 2),
|
||
publish_timeout=config.get_int('scheduler.publish_timeout', 300),
|
||
max_articles_per_user_per_run=config.get_int('scheduler.max_articles_per_user_per_run', 2),
|
||
max_failures_per_user_per_run=config.get_int('scheduler.max_failures_per_user_per_run', 3),
|
||
max_daily_articles_per_user=config.get_int('scheduler.max_daily_articles_per_user', 6),
|
||
max_hourly_articles_per_user=config.get_int('scheduler.max_hourly_articles_per_user', 2),
|
||
proxy_pool_enabled=proxy_pool_enabled,
|
||
proxy_pool_api_url=proxy_pool_api_url,
|
||
enable_random_ua=enable_random_ua,
|
||
min_publish_interval=min_publish_interval,
|
||
max_publish_interval=max_publish_interval,
|
||
headless=headless, # 新增: 传递headless参数
|
||
)
|
||
|
||
cron_expr = config.get_str('scheduler.cron', '*/5 * * * * *')
|
||
scheduler.start(cron_expr)
|
||
print(f"[服务启动] 定时发布任务已启动,Cron: {cron_expr}")
|
||
else:
|
||
print("[服务启动] 定时发布任务未启用")
|
||
|
||
async def browser_cleanup_task():
|
||
"""后台任务:定期清理空闲浏览器"""
|
||
while True:
|
||
await asyncio.sleep(300) # 每5分钟检查一次
|
||
try:
|
||
await browser_pool.cleanup_if_idle()
|
||
except Exception as e:
|
||
print(f"[清理任务] 浏览器清理异常: {str(e)}")
|
||
|
||
async def browser_preheat_task():
|
||
"""后台任务:预热浏览器"""
|
||
try:
|
||
# 延迟3秒启动,避免影响服务启动速度
|
||
await asyncio.sleep(3)
|
||
print("[预热任务] 开始预热浏览器...")
|
||
await browser_pool.preheat("https://creator.xiaohongshu.com/login")
|
||
except Exception as e:
|
||
print(f"[预热任务] 预热失败: {str(e)}")
|
||
|
||
async def repreheat_browser_after_use():
|
||
"""后台任务:使用后补充预热浏览器(仅用于登录流程)"""
|
||
try:
|
||
# 延迟5秒,确保:
|
||
# 1. 响应已经返回给用户
|
||
# 2. Cookie已经完全获取并保存
|
||
# 3. 登录流程完全结束
|
||
await asyncio.sleep(5)
|
||
print("[补充预热任务] 开始补充预热浏览器...")
|
||
await browser_pool.repreheat("https://creator.xiaohongshu.com/login")
|
||
except Exception as e:
|
||
print(f"[补充预热任务] 补充预热失败: {str(e)}")
|
||
|
||
@app.on_event("shutdown")
|
||
async def shutdown_event():
|
||
"""关闭时清理浏览器池和停止调度器"""
|
||
print("[服务关闭] 正在关闭服务...")
|
||
|
||
# 停止调度器
|
||
global scheduler
|
||
if scheduler:
|
||
scheduler.stop()
|
||
print("[服务关闭] 调度器已停止")
|
||
|
||
# 关闭浏览器池
|
||
await browser_pool.close()
|
||
print("[服务关闭] 浏览器池已关闭")
|
||
|
||
@app.post("/api/xhs/send-code", response_model=BaseResponse)
|
||
async def send_code(request: SendCodeRequest):
|
||
"""
|
||
发送验证码
|
||
通过playwright访问小红书官网,输入手机号并触发验证码发送
|
||
支持选择从创作者中心或小红书首页登录
|
||
并发支持:为每个请求分配独立的浏览器实例
|
||
"""
|
||
# 使用手机号作为session_id,确保发送验证码和登录验证使用同一个浏览器
|
||
session_id = f"xhs_login_{request.phone}"
|
||
print(f"[发送验证码] session_id={session_id}, phone={request.phone}", file=sys.stderr)
|
||
|
||
# 获取配置中的默认login_page,如果API传入了则优先使用API参数
|
||
config = get_config()
|
||
default_login_page = config.get_str('login.page', 'creator')
|
||
login_page = request.login_page if request.login_page else default_login_page
|
||
|
||
print(f"[发送验证码] 使用登录页面: {login_page} (配置默认={default_login_page}, API参数={request.login_page})", file=sys.stderr)
|
||
|
||
try:
|
||
# 为此请求创建独立的登录服务实例,使用session_id实现并发隔离
|
||
request_login_service = XHSLoginService(
|
||
use_pool=True,
|
||
headless=login_service.headless, # 使用配置文件中的login.headless配置
|
||
session_id=session_id # 关键:传递session_id
|
||
)
|
||
|
||
# 调用登录服务发送验证码
|
||
result = await request_login_service.send_verification_code(
|
||
phone=request.phone,
|
||
country_code=request.country_code,
|
||
login_page=login_page # 传递登录页面参数
|
||
)
|
||
|
||
if result["success"]:
|
||
return BaseResponse(
|
||
code=0,
|
||
message="验证码已发送,请在小红书APP中查看",
|
||
data={
|
||
"sent_at": datetime.now().isoformat(),
|
||
"session_id": session_id # 返回session_id供前端使用
|
||
}
|
||
)
|
||
else:
|
||
# 发送失败,释放临时浏览器
|
||
if session_id and browser_pool:
|
||
try:
|
||
await browser_pool.release_temp_browser(session_id)
|
||
print(f"[发送验证码] 已释放临时浏览器: {session_id}", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[发送验证码] 释放临时浏览器失败: {str(e)}", file=sys.stderr)
|
||
|
||
return BaseResponse(
|
||
code=1,
|
||
message=result.get("error", "发送验证码失败"),
|
||
data=None
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"发送验证码异常: {str(e)}", file=sys.stderr)
|
||
|
||
# 异常情况,释放临时浏览器
|
||
if session_id and browser_pool:
|
||
try:
|
||
await browser_pool.release_temp_browser(session_id)
|
||
print(f"[发送验证码] 已释放临时浏览器: {session_id}", file=sys.stderr)
|
||
except Exception as release_error:
|
||
print(f"[发送验证码] 释放临时浏览器失败: {str(release_error)}", file=sys.stderr)
|
||
|
||
return BaseResponse(
|
||
code=1,
|
||
message=f"发送验证码失败: {str(e)}",
|
||
data=None
|
||
)
|
||
|
||
@app.post("/api/xhs/phone/send-code", response_model=BaseResponse)
|
||
async def send_phone_code(request: SendCodeRequest):
|
||
"""
|
||
发送手机短信验证码(使用阿里云短信服务)
|
||
用于小红书手机号验证码登录
|
||
"""
|
||
try:
|
||
# 调用阿里云短信服务发送验证码
|
||
result = await sms_service.send_verification_code(request.phone)
|
||
|
||
if result["success"]:
|
||
return BaseResponse(
|
||
code=0,
|
||
message=result.get("message", "验证码已发送"),
|
||
data={
|
||
"sent_at": datetime.now().isoformat(),
|
||
# 开发环境返回验证码,生产环境应移除
|
||
"code": result.get("code") if get_config().get_bool('server.debug', False) else None
|
||
}
|
||
)
|
||
else:
|
||
return BaseResponse(
|
||
code=1,
|
||
message=result.get("error", "发送验证码失败"),
|
||
data=None
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"发送短信验证码异常: {str(e)}")
|
||
return BaseResponse(
|
||
code=1,
|
||
message=f"发送验证码失败: {str(e)}",
|
||
data=None
|
||
)
|
||
|
||
@app.post("/api/xhs/phone/verify-code", response_model=BaseResponse)
|
||
async def verify_phone_code(request: VerifyCodeRequest):
|
||
"""
|
||
验证手机短信验证码
|
||
用于小红书手机号验证码登录
|
||
"""
|
||
try:
|
||
# 调用阿里云短信服务验证验证码
|
||
result = sms_service.verify_code(request.phone, request.code)
|
||
|
||
if result["success"]:
|
||
return BaseResponse(
|
||
code=0,
|
||
message="验证码验证成功",
|
||
data={"verified_at": datetime.now().isoformat()}
|
||
)
|
||
else:
|
||
return BaseResponse(
|
||
code=1,
|
||
message=result.get("error", "验证码验证失败"),
|
||
data=None
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"验证验证码异常: {str(e)}")
|
||
return BaseResponse(
|
||
code=1,
|
||
message=f"验证失败: {str(e)}",
|
||
data=None
|
||
)
|
||
|
||
@app.post("/api/xhs/login", response_model=BaseResponse)
|
||
async def login(request: LoginRequest):
|
||
"""
|
||
登录验证
|
||
用户填写验证码后,完成登录并获取小红书返回的数据
|
||
支持选择从创作者中心或小红书首页登录
|
||
并发支持:可复用send-code接口的session_id
|
||
"""
|
||
# 使用手机号作为session_id,复用发送验证码时的浏览器
|
||
# 如果前端传了session_id就使用前端的,否则根据手机号生成
|
||
if not request.session_id:
|
||
session_id = f"xhs_login_{request.phone}"
|
||
else:
|
||
session_id = request.session_id
|
||
|
||
print(f"[登录验证] session_id={session_id}, phone={request.phone}", file=sys.stderr)
|
||
|
||
# 获取配置中的默认login_page,如果API传入了则优先使用API参数
|
||
config = get_config()
|
||
default_login_page = config.get_str('login.page', 'creator')
|
||
login_page = request.login_page if request.login_page else default_login_page
|
||
|
||
print(f"[登录验证] 使用登录页面: {login_page} (配置默认={default_login_page}, API参数={request.login_page})", file=sys.stderr)
|
||
|
||
try:
|
||
# 如果有session_id,复用send-code的浏览器;否则创建新的
|
||
if session_id:
|
||
print(f"[登录验证] 复用send-code的浏览器: {session_id}", file=sys.stderr)
|
||
request_login_service = XHSLoginService(
|
||
use_pool=True,
|
||
headless=login_service.headless, # 使用配置文件中的login.headless配置
|
||
session_id=session_id
|
||
)
|
||
# 初始化浏览器,以便从浏览器池获取临时浏览器
|
||
await request_login_service.init_browser()
|
||
else:
|
||
# 旧逻辑:不传session_id,使用全局登录服务
|
||
print(f"[登录验证] 使用全局登录服务(旧逻辑)", file=sys.stderr)
|
||
request_login_service = login_service
|
||
|
||
# 调用登录服务进行登录
|
||
result = await request_login_service.login(
|
||
phone=request.phone,
|
||
code=request.code,
|
||
country_code=request.country_code,
|
||
login_page=login_page # 传递登录页面参数
|
||
)
|
||
|
||
# 释放临时浏览器(无论成功还是失败)
|
||
if session_id and browser_pool:
|
||
try:
|
||
await browser_pool.release_temp_browser(session_id)
|
||
print(f"[登录验证] 已释放临时浏览器: {session_id}", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[登录验证] 释放临时浏览器失败: {str(e)}", file=sys.stderr)
|
||
|
||
if result["success"]:
|
||
# 登录成功,不再触发预热(已禁用预热功能)
|
||
# asyncio.create_task(repreheat_browser_after_use())
|
||
|
||
return BaseResponse(
|
||
code=0,
|
||
message="登录成功",
|
||
data={
|
||
"user_info": result.get("user_info"),
|
||
"cookies": result.get("cookies"), # 键值对格式(前端展示)
|
||
"cookies_full": result.get("cookies_full"), # Playwright完整格式(数据库存储/脚本使用)
|
||
"login_state": result.get("login_state"), # 完整登录状态(包含cookies + localStorage + sessionStorage)
|
||
"localStorage": result.get("localStorage"), # localStorage数据
|
||
"sessionStorage": result.get("sessionStorage"), # sessionStorage数据
|
||
"url": result.get("url"), # 当前URL
|
||
"storage_state_path": result.get("storage_state_path"), # storage_state文件路径
|
||
"login_time": datetime.now().isoformat()
|
||
}
|
||
)
|
||
else:
|
||
# 登录失败
|
||
return BaseResponse(
|
||
code=1,
|
||
message=result.get("error", "登录失败"),
|
||
data=None
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"登录异常: {str(e)}", file=sys.stderr)
|
||
|
||
# 异常情况,释放临时浏览器
|
||
if session_id and browser_pool:
|
||
try:
|
||
await browser_pool.release_temp_browser(session_id)
|
||
print(f"[登录验证] 已释放临时浏览器: {session_id}", file=sys.stderr)
|
||
except Exception as release_error:
|
||
print(f"[登录验证] 释放临时浏览器失败: {str(release_error)}", file=sys.stderr)
|
||
|
||
return BaseResponse(
|
||
code=1,
|
||
message=f"登录失败: {str(e)}",
|
||
data=None
|
||
)
|
||
|
||
@app.get("/")
|
||
async def root():
|
||
"""健康检查"""
|
||
if browser_pool:
|
||
stats = browser_pool.get_stats()
|
||
return {
|
||
"status": "ok",
|
||
"message": "小红书登录服务运行中(浏览器池模式)",
|
||
"browser_pool": stats
|
||
}
|
||
return {"status": "ok", "message": "服务初始化中..."}
|
||
|
||
@app.get("/api/health")
|
||
async def health_check():
|
||
"""健康检查接口(详细)"""
|
||
if browser_pool:
|
||
stats = browser_pool.get_stats()
|
||
return {
|
||
"status": "healthy",
|
||
"service": "xhs-login-service",
|
||
"mode": "browser-pool",
|
||
"browser_pool_stats": stats,
|
||
"timestamp": datetime.now().isoformat()
|
||
}
|
||
return {
|
||
"status": "initializing",
|
||
"service": "xhs-login-service",
|
||
"timestamp": datetime.now().isoformat()
|
||
}
|
||
|
||
@app.post("/api/xhs/inject-cookies", response_model=BaseResponse)
|
||
async def inject_cookies(request: InjectCookiesRequest):
|
||
"""
|
||
注入Cookies或完整登录状态并验证
|
||
支持两种模式:
|
||
1. 仅注入Cookies(兼容旧版)
|
||
2. 注入完整login_state(包含Cookies + localStorage + sessionStorage)
|
||
支持选择跳转到创作者中心或小红书首页
|
||
|
||
重要:为了避免检测,不使用浏览器池,每次创建全新的浏览器实例
|
||
"""
|
||
try:
|
||
# 关闭旧的浏览器(如果有)
|
||
if login_service.browser:
|
||
await login_service.close_browser()
|
||
|
||
# 创建一个独立的登录服务实例,不使用浏览器池
|
||
print("✅ 为注入Cookie创建全新的浏览器实例,不使用浏览器池", file=sys.stderr)
|
||
inject_service = XHSLoginService(use_pool=False, headless=False) # 不使用浏览器池,使用有头模式方便调试
|
||
|
||
# 优先使用login_state,其次使用cookies
|
||
if request.login_state:
|
||
# 新版:使用完整的login_state
|
||
print("✅ 检测到login_state,将恢复完整登录状态", file=sys.stderr)
|
||
|
||
# 保存login_state到文件,供 init_browser 加载
|
||
with open('login_state.json', 'w', encoding='utf-8') as f:
|
||
json.dump(request.login_state, f, ensure_ascii=False, indent=2)
|
||
|
||
# 使用restore_state=True恢复完整状态
|
||
await inject_service.init_browser(restore_state=True)
|
||
|
||
elif request.cookies:
|
||
# 兼容旧版:仅使用Cookies
|
||
print("⚠️ 检测到仅有Cookies,建议使用login_state获取更好的兼容性", file=sys.stderr)
|
||
await inject_service.init_browser(cookies=request.cookies)
|
||
|
||
else:
|
||
return BaseResponse(
|
||
code=1,
|
||
message="请提供 cookies 或 login_state",
|
||
data=None
|
||
)
|
||
|
||
# 根据target_page参数确定验证URL
|
||
target_page = request.target_page or "creator"
|
||
if target_page == "home":
|
||
verify_url = "https://www.xiaohongshu.com"
|
||
page_name = "小红书首页"
|
||
else:
|
||
verify_url = "https://creator.xiaohongshu.com"
|
||
page_name = "创作者中心"
|
||
|
||
# 访问目标页面并验证登录状态
|
||
result = await inject_service.verify_login_status(url=verify_url)
|
||
|
||
# 关闭独立的浏览器实例(注:因为不是池模式,会真正关闭)
|
||
# await inject_service.close_browser() # 先不关闭,让用户看到结果
|
||
|
||
if result.get("logged_in"):
|
||
return BaseResponse(
|
||
code=0,
|
||
message=f"{'login_state' if request.login_state else 'Cookie'}注入成功,已跳转到{page_name}",
|
||
data={
|
||
"logged_in": True,
|
||
"target_page": page_name,
|
||
"user_info": result.get("user_info"),
|
||
"cookies": result.get("cookies"), # 键值对格式
|
||
"cookies_full": result.get("cookies_full"), # Playwright完整格式
|
||
"url": result.get("url")
|
||
}
|
||
)
|
||
else:
|
||
# 失败时关闭浏览器
|
||
await inject_service.close_browser()
|
||
|
||
return BaseResponse(
|
||
code=1,
|
||
message=result.get("message", "{'login_state' if request.login_state else 'Cookie'}已失效,请重新登录"),
|
||
data={
|
||
"logged_in": False
|
||
}
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"注入失败: {str(e)}", file=sys.stderr)
|
||
import traceback
|
||
traceback.print_exc()
|
||
return BaseResponse(
|
||
code=1,
|
||
message=f"注入失败: {str(e)}",
|
||
data=None
|
||
)
|
||
|
||
@app.post("/api/xhs/publish-with-cookies", response_model=BaseResponse)
|
||
async def publish_note_with_cookies(request: PublishWithCookiesRequest):
|
||
"""
|
||
使用Cookies或完整login_state或storage_state发布笔记(供Go后端定时任务调用)
|
||
支持三种模式(按优先级):
|
||
1. 使用storage_state_path(推荐,最完整的登录状态)
|
||
2. 传入完整login_state(次选,包含cookies + localStorage + sessionStorage)
|
||
3. 仅传入Cookies(兼容旧版)
|
||
|
||
重要:为了避免检测,不使用浏览器池,每次创建全新的浏览器实例
|
||
"""
|
||
try:
|
||
# 获取代理(如果启用)
|
||
proxy = await fetch_proxy_from_pool()
|
||
if proxy:
|
||
print(f"[发布接口] 使用代理: {proxy}", file=sys.stderr)
|
||
|
||
# 创建一个独立的登录服务实例,不使用浏览器池,应用所有反检测措施
|
||
print("✅ 为发布任务创建全新的浏览器实例,不使用浏览器池", file=sys.stderr)
|
||
|
||
# 从配置读取headless参数
|
||
config = get_config()
|
||
headless = config.get_bool('scheduler.headless', True)
|
||
|
||
publish_service = XHSLoginService(use_pool=False, headless=headless) # 不使用浏览器池
|
||
|
||
# 优先级判断:storage_state_path > login_state > cookies
|
||
if request.storage_state_path or request.phone:
|
||
# 模式1:使用storage_state(最优先)
|
||
storage_state_file = None
|
||
|
||
if request.storage_state_path:
|
||
# 直接指定了storage_state路径
|
||
storage_state_file = request.storage_state_path
|
||
elif request.phone:
|
||
# 根据手机号查找
|
||
storage_state_dir = 'storage_states'
|
||
storage_state_file = os.path.join(storage_state_dir, f"xhs_{request.phone}.json")
|
||
|
||
if storage_state_file and os.path.exists(storage_state_file):
|
||
print(f"✅ 检测到storage_state文件: {storage_state_file},将使用Playwright原生恢复", file=sys.stderr)
|
||
|
||
# 使用Playwright原生API恢复登录状态
|
||
await publish_service.init_browser_with_storage_state(
|
||
storage_state_path=storage_state_file,
|
||
proxy=proxy
|
||
)
|
||
else:
|
||
print(f"⚠️ storage_state文件不存在: {storage_state_file},回退到login_state或cookies模式", file=sys.stderr)
|
||
# 回退到旧模式
|
||
if request.login_state:
|
||
await _init_with_login_state(publish_service, request.login_state, proxy)
|
||
elif request.cookies:
|
||
await publish_service.init_browser(cookies=request.cookies, proxy=proxy)
|
||
else:
|
||
return BaseResponse(
|
||
code=1,
|
||
message="storage_state文件不存在,且未提供 login_state 或 cookies",
|
||
data=None
|
||
)
|
||
|
||
elif request.login_state:
|
||
# 模式2:使用login_state
|
||
print("✅ 检测到login_state,将恢复完整登录状态", file=sys.stderr)
|
||
await _init_with_login_state(publish_service, request.login_state, proxy)
|
||
|
||
elif request.cookies:
|
||
# 模式3:仅使用Cookies(兼容旧版)
|
||
print("⚠️ 检测到仅有Cookies,建议使用storage_state或login_state获取更好的兼容性", file=sys.stderr)
|
||
await publish_service.init_browser(cookies=request.cookies, proxy=proxy)
|
||
else:
|
||
return BaseResponse(
|
||
code=1,
|
||
message="请提供 storage_state_path、phone、login_state 或 cookies",
|
||
data=None
|
||
)
|
||
|
||
# 调用发布方法(使用已经初始化好的publish_service)
|
||
result = await publish_service.publish_note(
|
||
title=request.title,
|
||
content=request.content,
|
||
images=request.images,
|
||
topics=request.topics,
|
||
cookies=None, # 已经注入,不需要再传
|
||
proxy=None, # 已经设置,不需要再传
|
||
)
|
||
|
||
# 关闭独立的浏览器实例
|
||
await publish_service.close_browser()
|
||
|
||
if result["success"]:
|
||
return BaseResponse(
|
||
code=0,
|
||
message="笔记发布成功",
|
||
data={
|
||
"url": result.get("url"),
|
||
"publish_time": datetime.now().isoformat()
|
||
}
|
||
)
|
||
else:
|
||
return BaseResponse(
|
||
code=1,
|
||
message=result.get("error", "发布失败"),
|
||
data=None
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"发布笔记异常: {str(e)}", file=sys.stderr)
|
||
import traceback
|
||
traceback.print_exc()
|
||
return BaseResponse(
|
||
code=1,
|
||
message=f"发布失败: {str(e)}",
|
||
data=None
|
||
)
|
||
|
||
async def _init_with_login_state(publish_service, login_state, proxy):
|
||
"""使用login_state初始化浏览器"""
|
||
# 保存login_state到临时文件
|
||
import tempfile
|
||
import uuid
|
||
temp_file = os.path.join(tempfile.gettempdir(), f"login_state_{uuid.uuid4()}.json")
|
||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||
json.dump(login_state, f, ensure_ascii=False, indent=2)
|
||
|
||
# 使用restore_state=True恢复完整状态
|
||
await publish_service.init_browser(
|
||
cookies=login_state.get('cookies'),
|
||
proxy=proxy,
|
||
user_agent=login_state.get('user_agent')
|
||
)
|
||
|
||
# 恢夏localStorage和sessionStorage
|
||
try:
|
||
if login_state.get('localStorage') or login_state.get('sessionStorage'):
|
||
target_url = login_state.get('url', 'https://creator.xiaohongshu.com')
|
||
await publish_service.page.goto(target_url, wait_until='domcontentloaded', timeout=15000)
|
||
|
||
if login_state.get('localStorage'):
|
||
for key, value in login_state['localStorage'].items():
|
||
await publish_service.page.evaluate(f'localStorage.setItem("{key}", {json.dumps(value)})')
|
||
|
||
if login_state.get('sessionStorage'):
|
||
for key, value in login_state['sessionStorage'].items():
|
||
await publish_service.page.evaluate(f'sessionStorage.setItem("{key}", {json.dumps(value)})')
|
||
|
||
print("✅ 已恢夏localStorage和sessionStorage", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"⚠️ 恢夏storage失败: {str(e)}", file=sys.stderr)
|
||
|
||
# 清理临时文件
|
||
try:
|
||
os.remove(temp_file)
|
||
except:
|
||
pass
|
||
|
||
@app.post("/api/xhs/upload-images")
|
||
async def upload_images(files: List[UploadFile] = File(...)):
|
||
"""
|
||
上传图片到服务器临时目录
|
||
返回图片的服务器路径
|
||
"""
|
||
try:
|
||
uploaded_paths = []
|
||
|
||
for file in files:
|
||
# 检查文件类型
|
||
if not file.content_type.startswith('image/'):
|
||
return {
|
||
"code": 1,
|
||
"message": f"文件 {file.filename} 不是图片类型",
|
||
"data": None
|
||
}
|
||
|
||
# 生成唯一文件名
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||
file_ext = os.path.splitext(file.filename)[1]
|
||
safe_filename = f"{timestamp}{file_ext}"
|
||
file_path = TEMP_DIR / safe_filename
|
||
|
||
# 保存文件
|
||
with open(file_path, "wb") as buffer:
|
||
shutil.copyfileobj(file.file, buffer)
|
||
|
||
# 使用绝对路径
|
||
abs_path = str(file_path.absolute())
|
||
uploaded_paths.append(abs_path)
|
||
print(f"已上传图片: {abs_path}")
|
||
|
||
return {
|
||
"code": 0,
|
||
"message": f"成功上传 {len(uploaded_paths)} 张图片",
|
||
"data": {
|
||
"paths": uploaded_paths,
|
||
"count": len(uploaded_paths)
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"上传图片异常: {str(e)}")
|
||
return {
|
||
"code": 1,
|
||
"message": f"上传失败: {str(e)}",
|
||
"data": None
|
||
}
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
|
||
# 从配置文件读取服务器配置
|
||
config = get_config()
|
||
host = config.get_str('server.host', '0.0.0.0')
|
||
port = config.get_int('server.port', 8000)
|
||
debug = config.get_bool('server.debug', False)
|
||
reload = config.get_bool('server.reload', False)
|
||
|
||
print(f"[\u542f\u52a8\u670d\u52a1] \u4e3b\u673a: {host}, \u7aef\u53e3: {port}, \u8c03\u8bd5: {debug}, \u70ed\u91cd\u8f7d: {reload}")
|
||
|
||
uvicorn.run(
|
||
app,
|
||
host=host,
|
||
port=port,
|
||
reload=reload,
|
||
log_level="debug" if debug else "info"
|
||
)
|