This commit is contained in:
sjk
2026-01-06 19:36:42 +08:00
parent 15b579d64a
commit 19942144fb
261 changed files with 24034 additions and 5477 deletions

View File

@@ -1,14 +1,32 @@
# 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
import asyncio
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")
@@ -21,8 +39,54 @@ app.add_middleware(
allow_headers=["*"],
)
# 全局登录服务实例
login_service = XHSLoginService()
# 全局登录服务实例延迟初始化避免在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")
@@ -32,11 +96,19 @@ 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
@@ -44,8 +116,20 @@ class PublishNoteRequest(BaseModel):
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: list
cookies: Optional[list] = None # 兼容旧版仅传Cookies
login_state: Optional[dict] = None # 新版传完整的login_state
target_page: Optional[str] = "creator" # 目标页面creator 或 home
# 响应模型
class BaseResponse(BaseModel):
@@ -55,32 +139,241 @@ class BaseResponse(BaseModel):
@app.on_event("startup")
async def startup_event():
"""启动时不初始化浏览器,等待第一次请求时再初始化"""
pass
"""启动时启动后台清理任务和定时发布任务(已禁用预热)"""
# 初始化配置从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():
"""关闭时清理浏览器"""
await login_service.close_browser()
"""关闭时清理浏览器池和停止调度器"""
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 login_service.send_verification_code(
result = await request_login_service.send_verification_code(
phone=request.phone,
country_code=request.country_code
country_code=request.country_code,
login_page=login_page # 传递登录页面参数
)
if result["success"]:
return BaseResponse(
code=0,
message="验证码已发送请在小红书APP中查看",
data={"sent_at": datetime.now().isoformat()}
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(
@@ -90,28 +383,104 @@ async def send_code(request: SendCodeRequest):
)
except Exception as e:
print(f"发送验证码异常: {str(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 login_service.login(
result = await request_login_service.login(
phone=request.phone,
code=request.code,
country_code=request.country_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="登录成功",
@@ -119,10 +488,16 @@ async def login(request: LoginRequest):
"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", "登录失败"),
@@ -130,7 +505,16 @@ async def login(request: LoginRequest):
)
except Exception as e:
print(f"登录异常: {str(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)}",
@@ -140,31 +524,99 @@ async def login(request: LoginRequest):
@app.get("/")
async def root():
"""健康检查"""
return {"status": "ok", "message": "小红书登录服务运行中"}
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并验证登录状态
允许使用之前保存的Cookies跳过登录
注入Cookies或完整登录状态并验证
支持两种模式:
1. 仅注入Cookies兼容旧版
2. 注入完整login_state包含Cookies + localStorage + sessionStorage
支持选择跳转到创作者中心或小红书首页
重要:为了避免检测,不使用浏览器池,每次创建全新的浏览器实例
"""
try:
# 关闭旧的浏览器(如果有)
if login_service.browser:
await login_service.close_browser()
# 使用Cookies初始化浏览器
await login_service.init_browser(cookies=request.cookies)
# 创建一个独立的登录服务实例,不使用浏览器
print("✅ 为注入Cookie创建全新的浏览器实例不使用浏览器池", file=sys.stderr)
inject_service = XHSLoginService(use_pool=False, headless=False) # 不使用浏览器池,使用有头模式方便调试
# 验证登录状态
result = await login_service.verify_login_status()
# 优先使用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="Cookie注入成功已登录",
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完整格式
@@ -172,37 +624,117 @@ async def inject_cookies(request: InjectCookiesRequest):
}
)
else:
# 失败时关闭浏览器
await inject_service.close_browser()
return BaseResponse(
code=1,
message=result.get("message", "Cookie已失效,请重新登录"),
message=result.get("message", "{'login_state' if request.login_state else 'Cookie'}已失效,请重新登录"),
data={
"logged_in": False
}
)
except Exception as e:
print(f"注入Cookies异常: {str(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", response_model=BaseResponse)
async def publish_note(request: PublishNoteRequest):
@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:
# 调用登录服务发布笔记
result = await login_service.publish_note(
# 获取代理(如果启用)
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
topics=request.topics,
cookies=None, # 已经注入,不需要再传
proxy=None, # 已经设置,不需要再传
)
# 关闭独立的浏览器实例
await publish_service.close_browser()
if result["success"]:
return BaseResponse(
code=0,
@@ -220,13 +752,55 @@ async def publish_note(request: PublishNoteRequest):
)
except Exception as e:
print(f"发布笔记异常: {str(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(...)):
"""
@@ -279,4 +853,20 @@ async def upload_images(files: List[UploadFile] = File(...)):
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
# 从配置文件读取服务器配置
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"
)