283 lines
8.2 KiB
Python
283 lines
8.2 KiB
Python
|
|
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
|
|||
|
|
|
|||
|
|
app = FastAPI(title="小红书登录API")
|
|||
|
|
|
|||
|
|
# CORS配置
|
|||
|
|
app.add_middleware(
|
|||
|
|
CORSMiddleware,
|
|||
|
|
allow_origins=["*"], # 生产环境应该限制具体域名
|
|||
|
|
allow_credentials=True,
|
|||
|
|
allow_methods=["*"],
|
|||
|
|
allow_headers=["*"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 全局登录服务实例
|
|||
|
|
login_service = XHSLoginService()
|
|||
|
|
|
|||
|
|
# 临时文件存储目录
|
|||
|
|
TEMP_DIR = Path("temp_uploads")
|
|||
|
|
TEMP_DIR.mkdir(exist_ok=True)
|
|||
|
|
|
|||
|
|
# 请求模型
|
|||
|
|
class SendCodeRequest(BaseModel):
|
|||
|
|
phone: str
|
|||
|
|
country_code: str = "+86"
|
|||
|
|
|
|||
|
|
class LoginRequest(BaseModel):
|
|||
|
|
phone: str
|
|||
|
|
code: str
|
|||
|
|
country_code: str = "+86"
|
|||
|
|
|
|||
|
|
class PublishNoteRequest(BaseModel):
|
|||
|
|
title: str
|
|||
|
|
content: str
|
|||
|
|
images: Optional[list] = None
|
|||
|
|
topics: Optional[list] = None
|
|||
|
|
|
|||
|
|
class InjectCookiesRequest(BaseModel):
|
|||
|
|
cookies: list
|
|||
|
|
|
|||
|
|
# 响应模型
|
|||
|
|
class BaseResponse(BaseModel):
|
|||
|
|
code: int
|
|||
|
|
message: str
|
|||
|
|
data: Optional[Dict[str, Any]] = None
|
|||
|
|
|
|||
|
|
@app.on_event("startup")
|
|||
|
|
async def startup_event():
|
|||
|
|
"""启动时不初始化浏览器,等待第一次请求时再初始化"""
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
@app.on_event("shutdown")
|
|||
|
|
async def shutdown_event():
|
|||
|
|
"""关闭时清理浏览器"""
|
|||
|
|
await login_service.close_browser()
|
|||
|
|
|
|||
|
|
@app.post("/api/xhs/send-code", response_model=BaseResponse)
|
|||
|
|
async def send_code(request: SendCodeRequest):
|
|||
|
|
"""
|
|||
|
|
发送验证码
|
|||
|
|
通过playwright访问小红书官网,输入手机号并触发验证码发送
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# 调用登录服务发送验证码
|
|||
|
|
result = await login_service.send_verification_code(
|
|||
|
|
phone=request.phone,
|
|||
|
|
country_code=request.country_code
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result["success"]:
|
|||
|
|
return BaseResponse(
|
|||
|
|
code=0,
|
|||
|
|
message="验证码已发送,请在小红书APP中查看",
|
|||
|
|
data={"sent_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):
|
|||
|
|
"""
|
|||
|
|
登录验证
|
|||
|
|
用户填写验证码后,完成登录并获取小红书返回的数据
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# 调用登录服务进行登录
|
|||
|
|
result = await login_service.login(
|
|||
|
|
phone=request.phone,
|
|||
|
|
code=request.code,
|
|||
|
|
country_code=request.country_code
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result["success"]:
|
|||
|
|
return BaseResponse(
|
|||
|
|
code=0,
|
|||
|
|
message="登录成功",
|
|||
|
|
data={
|
|||
|
|
"user_info": result.get("user_info"),
|
|||
|
|
"cookies": result.get("cookies"), # 键值对格式(前端展示)
|
|||
|
|
"cookies_full": result.get("cookies_full"), # Playwright完整格式(数据库存储/脚本使用)
|
|||
|
|
"login_time": 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.get("/")
|
|||
|
|
async def root():
|
|||
|
|
"""健康检查"""
|
|||
|
|
return {"status": "ok", "message": "小红书登录服务运行中"}
|
|||
|
|
|
|||
|
|
@app.post("/api/xhs/inject-cookies", response_model=BaseResponse)
|
|||
|
|
async def inject_cookies(request: InjectCookiesRequest):
|
|||
|
|
"""
|
|||
|
|
注入Cookies并验证登录状态
|
|||
|
|
允许使用之前保存的Cookies跳过登录
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# 关闭旧的浏览器(如果有)
|
|||
|
|
if login_service.browser:
|
|||
|
|
await login_service.close_browser()
|
|||
|
|
|
|||
|
|
# 使用Cookies初始化浏览器
|
|||
|
|
await login_service.init_browser(cookies=request.cookies)
|
|||
|
|
|
|||
|
|
# 验证登录状态
|
|||
|
|
result = await login_service.verify_login_status()
|
|||
|
|
|
|||
|
|
if result.get("logged_in"):
|
|||
|
|
return BaseResponse(
|
|||
|
|
code=0,
|
|||
|
|
message="Cookie注入成功,已登录",
|
|||
|
|
data={
|
|||
|
|
"logged_in": True,
|
|||
|
|
"user_info": result.get("user_info"),
|
|||
|
|
"cookies": result.get("cookies"), # 键值对格式
|
|||
|
|
"cookies_full": result.get("cookies_full"), # Playwright完整格式
|
|||
|
|
"url": result.get("url")
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
return BaseResponse(
|
|||
|
|
code=1,
|
|||
|
|
message=result.get("message", "Cookie已失效,请重新登录"),
|
|||
|
|
data={
|
|||
|
|
"logged_in": False
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"注入Cookies异常: {str(e)}")
|
|||
|
|
return BaseResponse(
|
|||
|
|
code=1,
|
|||
|
|
message=f"注入失败: {str(e)}",
|
|||
|
|
data=None
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@app.post("/api/xhs/publish", response_model=BaseResponse)
|
|||
|
|
async def publish_note(request: PublishNoteRequest):
|
|||
|
|
"""
|
|||
|
|
发布笔记
|
|||
|
|
登录后可以发布图文笔记到小红书
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# 调用登录服务发布笔记
|
|||
|
|
result = await login_service.publish_note(
|
|||
|
|
title=request.title,
|
|||
|
|
content=request.content,
|
|||
|
|
images=request.images,
|
|||
|
|
topics=request.topics
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
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)}")
|
|||
|
|
return BaseResponse(
|
|||
|
|
code=1,
|
|||
|
|
message=f"发布失败: {str(e)}",
|
|||
|
|
data=None
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@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
|
|||
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|