first commit
247
backend/PUBLISH_FEATURE_SUMMARY.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# 小红书发布脚本 - 功能总结
|
||||
|
||||
## 🎉 最新更新(v1.1.0)
|
||||
|
||||
已成功增强发布脚本,现在支持**网络图片 URL**!
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 1. Cookie 认证
|
||||
- 支持验证码登录
|
||||
- 支持 Cookie 注入
|
||||
- 自动验证登录状态
|
||||
|
||||
### 2. 内容发布
|
||||
- ✅ 标题和正文
|
||||
- ✅ 多图上传(最多 9 张)
|
||||
- ✅ 标签自动添加
|
||||
- ✅ 发布状态追踪
|
||||
|
||||
### 3. 图片支持(新增)
|
||||
- ✅ **本地文件路径**(绝对/相对路径)
|
||||
- ✅ **网络图片 URL**(HTTP/HTTPS)
|
||||
- ✅ **混合使用**(本地 + 网络)
|
||||
- ✅ **自动下载**网络图片
|
||||
- ✅ **自动清理**临时文件
|
||||
|
||||
## 📝 使用示例
|
||||
|
||||
### 基础使用
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [...],
|
||||
"title": "笔记标题",
|
||||
"content": "笔记内容",
|
||||
"images": [
|
||||
"https://picsum.photos/800/600?random=1",
|
||||
"D:/local/image.jpg"
|
||||
],
|
||||
"tags": ["标签1", "标签2"]
|
||||
}
|
||||
```
|
||||
|
||||
### 命令行
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 从配置文件发布
|
||||
python xhs_publish.py --config my_config.json
|
||||
|
||||
# 测试网络图片功能
|
||||
python test_network_images.py
|
||||
```
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 图片处理流程
|
||||
|
||||
```python
|
||||
1. 判断是否为网络 URL
|
||||
↓ 是
|
||||
2. 使用 aiohttp 下载图片
|
||||
↓
|
||||
3. 保存到 temp_downloads/
|
||||
↓
|
||||
4. 返回本地路径
|
||||
↓
|
||||
5. 上传到小红书
|
||||
↓
|
||||
6. 发布完成后清理临时文件
|
||||
```
|
||||
|
||||
### 关键代码
|
||||
|
||||
```python
|
||||
class XHSPublishService:
|
||||
async def download_image(self, url: str) -> str:
|
||||
"""下载网络图片"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=30) as response:
|
||||
content = await response.read()
|
||||
# 保存文件
|
||||
return local_path
|
||||
|
||||
async def process_images(self, images: List[str]) -> List[str]:
|
||||
"""处理图片列表(下载网络图片)"""
|
||||
local_images = []
|
||||
for img in images:
|
||||
if self.is_url(img):
|
||||
local_path = await self.download_image(img)
|
||||
local_images.append(local_path)
|
||||
else:
|
||||
local_images.append(img)
|
||||
return local_images
|
||||
```
|
||||
|
||||
## 📦 文件结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── xhs_publish.py # 发布脚本(已增强)
|
||||
├── xhs_login.py # 登录服务
|
||||
├── xhs_cli.py # 命令行工具
|
||||
├── test_publish.py # 基础测试
|
||||
├── test_network_images.py # 网络图片测试(新增)
|
||||
├── publish_config_example.json # 配置示例(已更新)
|
||||
├── requirements.txt # 依赖列表(已更新)
|
||||
├── temp_downloads/ # 临时下载目录(自动创建)
|
||||
└── 文档/
|
||||
├── XHS_PUBLISH_README.md # 发布脚本文档
|
||||
├── NETWORK_IMAGE_SUPPORT.md # 网络图片文档(新增)
|
||||
└── PUBLISH_FEATURE_SUMMARY.md # 功能总结(本文档)
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
### 2. 获取 Cookie
|
||||
|
||||
```bash
|
||||
python xhs_cli.py login 13800138000 123456
|
||||
```
|
||||
|
||||
### 3. 准备配置文件
|
||||
|
||||
```bash
|
||||
cp publish_config_example.json my_publish.json
|
||||
# 编辑 my_publish.json,填入实际数据
|
||||
```
|
||||
|
||||
### 4. 执行发布
|
||||
|
||||
```bash
|
||||
python xhs_publish.py --config my_publish.json
|
||||
```
|
||||
|
||||
## 📊 性能指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 图片下载超时 | 30 秒/张 |
|
||||
| 最大图片数 | 9 张 |
|
||||
| 建议图片大小 | < 5MB |
|
||||
| 临时文件清理 | 自动 |
|
||||
|
||||
## 🔍 实际应用
|
||||
|
||||
### 从数据库获取图片 URL 发布
|
||||
|
||||
```python
|
||||
# 查询文章信息
|
||||
article = db.query("SELECT * FROM ai_articles WHERE id = ?", article_id)
|
||||
images = db.query("SELECT image_url FROM ai_article_images WHERE article_id = ?", article_id)
|
||||
tags = db.query("SELECT coze_tag FROM ai_article_tags WHERE article_id = ?", article_id)
|
||||
|
||||
# 准备发布
|
||||
publisher = XHSPublishService(cookies)
|
||||
result = await publisher.publish(
|
||||
title=article['title'],
|
||||
content=article['content'],
|
||||
images=[img['image_url'] for img in images], # 使用数据库中的 URL
|
||||
tags=tags[0]['coze_tag'].split(',') if tags else []
|
||||
)
|
||||
```
|
||||
|
||||
### Go 后端调用示例
|
||||
|
||||
```go
|
||||
func PublishArticle(articleID int) error {
|
||||
// 1. 查询文章信息
|
||||
article := db.GetArticle(articleID)
|
||||
images := db.GetArticleImages(articleID)
|
||||
|
||||
// 2. 构造配置
|
||||
config := map[string]interface{}{
|
||||
"cookies": loadCookies(),
|
||||
"title": article.Title,
|
||||
"content": article.Content,
|
||||
"images": images, // 直接使用 URL 数组
|
||||
"tags": splitTags(article.Tags),
|
||||
}
|
||||
|
||||
// 3. 保存配置文件
|
||||
configFile := fmt.Sprintf("temp/publish_%d.json", articleID)
|
||||
saveJSON(configFile, config)
|
||||
|
||||
// 4. 调用 Python 脚本
|
||||
cmd := exec.Command("python", "backend/xhs_publish.py", "--config", configFile)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// 5. 解析结果
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(output, &result)
|
||||
|
||||
return checkResult(result)
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 网络图片
|
||||
- 确保 URL 可公开访问
|
||||
- 避免使用需要认证的图片
|
||||
- 注意图片服务器的访问速度
|
||||
|
||||
### 2. 临时文件
|
||||
- 默认保存在 `temp_downloads/`
|
||||
- 发布完成后自动清理
|
||||
- 可设置 `cleanup=False` 保留文件
|
||||
|
||||
### 3. 错误处理
|
||||
- 单张图片下载失败不影响其他图片
|
||||
- 会跳过失败的图片继续发布
|
||||
- 详细的错误日志输出
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [XHS_PUBLISH_README.md](XHS_PUBLISH_README.md) - 详细使用文档
|
||||
- [NETWORK_IMAGE_SUPPORT.md](NETWORK_IMAGE_SUPPORT.md) - 网络图片支持文档
|
||||
- [XHS_CLI_README.md](XHS_CLI_README.md) - 命令行工具文档
|
||||
|
||||
## 🐛 问题反馈
|
||||
|
||||
如遇到问题,请检查:
|
||||
1. 是否安装了 `aiohttp` 库
|
||||
2. 网络连接是否正常
|
||||
3. Cookie 是否有效
|
||||
4. 图片 URL 是否可访问
|
||||
5. 磁盘空间是否充足
|
||||
|
||||
## 🎯 下一步计划
|
||||
|
||||
- [ ] 支持视频发布
|
||||
- [ ] 批量发布功能
|
||||
- [ ] 定时发布功能
|
||||
- [ ] 发布结果追踪
|
||||
- [ ] 图片压缩优化
|
||||
- [ ] 并发下载优化
|
||||
313
backend/QUICK_START.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# 快速开始指南
|
||||
|
||||
## 问题说明
|
||||
|
||||
如果您在使用 `xhs_publish.py` 时遇到 JSON 解析错误:
|
||||
|
||||
```bash
|
||||
python xhs_publish.py --cookies '[...]' --title "标题" --content "内容"
|
||||
# 错误: JSON解析失败: Expecting property name enclosed in double quotes
|
||||
```
|
||||
|
||||
这是因为命令行中的 JSON 字符串转义问题。
|
||||
|
||||
## 解决方案
|
||||
|
||||
我们提供了 **3 种简单的使用方式**:
|
||||
|
||||
---
|
||||
|
||||
## 方式 1:快速发布脚本(推荐)⭐
|
||||
|
||||
使用 `quick_publish.py`,无需处理 JSON 转义。
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
python quick_publish.py "标题" "内容" "图片1,图片2,图片3" "标签1,标签2"
|
||||
```
|
||||
|
||||
### Windows PowerShell 用法
|
||||
|
||||
**在 Windows PowerShell 中,强烈推荐使用此方式,避免 JSON 转义问题:**
|
||||
|
||||
```powershell
|
||||
python quick_publish.py "测试笔记" "这是测试内容" "https://picsum.photos/800/600,https://picsum.photos/800/600" "测试,自动化"
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
1. **标题**(必需)
|
||||
2. **内容**(必需)
|
||||
3. **图片**(可选)- 用逗号分隔,支持本地路径和网络 URL
|
||||
4. **标签**(可选)- 用逗号分隔
|
||||
|
||||
### 示例
|
||||
|
||||
**1. 发布纯文字**
|
||||
```bash
|
||||
python quick_publish.py "测试笔记" "这是测试内容" "" "测试,自动化"
|
||||
```
|
||||
|
||||
**2. 使用网络图片**
|
||||
```bash
|
||||
python quick_publish.py "测试笔记" "这是测试内容" "https://picsum.photos/800/600,https://picsum.photos/800/600" "测试,图片"
|
||||
```
|
||||
|
||||
**3. 使用本地图片**
|
||||
```bash
|
||||
python quick_publish.py "测试笔记" "这是测试内容" "D:/image1.jpg,D:/image2.jpg" "测试"
|
||||
```
|
||||
|
||||
**4. 混合使用**
|
||||
```bash
|
||||
python quick_publish.py "测试笔记" "这是测试内容" "https://picsum.photos/800/600,D:/local.jpg" "测试"
|
||||
```
|
||||
|
||||
### Cookie 文件
|
||||
|
||||
脚本默认从 `cookies.json` 读取 Cookie。
|
||||
|
||||
如果 Cookie 在其他文件,可以指定:
|
||||
```bash
|
||||
python quick_publish.py "标题" "内容" "图片" "标签" "my_cookies.json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式 2:配置文件发布
|
||||
|
||||
### 步骤 1:准备 Cookie 文件
|
||||
|
||||
将您的 Cookie 保存为 `test_cookies.json`(已为您创建)。
|
||||
|
||||
### 步骤 2:创建配置文件
|
||||
|
||||
创建 `my_publish.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": "@test_cookies.json",
|
||||
"title": "💧夏日必备!2元一杯的柠檬水竟然这么好喝?",
|
||||
"content": "今天给大家分享一个超级实惠的夏日饮品!",
|
||||
"images": [
|
||||
"https://picsum.photos/800/600?random=1",
|
||||
"https://picsum.photos/800/600?random=2"
|
||||
],
|
||||
"tags": ["夏日清爽", "饮品", "柠檬水"]
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 3:执行发布
|
||||
|
||||
```bash
|
||||
python xhs_publish.py --config my_publish.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式 3:使用命令行参数(支持Cookie文件)⭐
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
# Cookie 使用文件路径(推荐)
|
||||
python xhs_publish.py --cookies test_cookies.json --title "标题" --content "内容" --images '["https://picsum.photos/800/600"]' --tags '["标签1"]'
|
||||
|
||||
# Cookie 使用 JSON 字符串(需要转义,不推荐)
|
||||
python xhs_publish.py --cookies '[{...}]' --title "标题" --content "内容"
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
- `--cookies`: **Cookie JSON 字符串 或 Cookie 文件路径**(推荐使用文件路径)
|
||||
- `--title`: 标题
|
||||
- `--content`: 内容
|
||||
- `--images`: 图片列表的 JSON 字符串(可选)
|
||||
- `--tags`: 标签列表的 JSON 字符串(可选)
|
||||
|
||||
### 示例
|
||||
|
||||
**1. 使用 Cookie 文件(推荐)**
|
||||
```bash
|
||||
python xhs_publish.py --cookies test_cookies.json --title "测试笔记" --content "这是测试内容" --images '["https://picsum.photos/800/600"]' --tags '["测试","自动化"]'
|
||||
```
|
||||
|
||||
**2. 只发布文字**
|
||||
```bash
|
||||
python xhs_publish.py --cookies test_cookies.json --title "纯文字笔记" --content "这是一条纯文字笔记"
|
||||
```
|
||||
|
||||
## 方式 4:Python 脚本调用
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
from xhs_publish import XHSPublishService
|
||||
|
||||
async def publish_my_note():
|
||||
# 读取 Cookie
|
||||
with open('test_cookies.json', 'r') as f:
|
||||
cookies = json.load(f)
|
||||
|
||||
# 创建发布服务
|
||||
publisher = XHSPublishService(cookies)
|
||||
|
||||
# 发布笔记
|
||||
result = await publisher.publish(
|
||||
title="测试笔记",
|
||||
content="这是测试内容",
|
||||
images=[
|
||||
"https://picsum.photos/800/600?random=1",
|
||||
"https://picsum.photos/800/600?random=2"
|
||||
],
|
||||
tags=["测试", "自动化"]
|
||||
)
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
# 运行
|
||||
asyncio.run(publish_my_note())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 使用您提供的 Cookie
|
||||
|
||||
Cookie 已保存到 `test_cookies.json`,现在可以直接发布:
|
||||
|
||||
```bash
|
||||
# 方式 1:快速发布(推荐)
|
||||
cd backend
|
||||
python quick_publish.py "测试笔记" "这是一条测试笔记,使用网络图片自动发布" "https://picsum.photos/800/600,https://picsum.photos/800/600" "测试,自动化" test_cookies.json
|
||||
```
|
||||
|
||||
### 输出示例
|
||||
|
||||
```
|
||||
==================================================
|
||||
快速发布小红书笔记
|
||||
==================================================
|
||||
标题: 测试笔记
|
||||
内容: 这是一条测试笔记,使用网络图片自动发布
|
||||
图片: 2 张
|
||||
1. https://picsum.photos/800/600
|
||||
2. https://picsum.photos/800/600
|
||||
标签: ['测试', '自动化']
|
||||
Cookie: test_cookies.json
|
||||
==================================================
|
||||
|
||||
正在处理 2 张图片...
|
||||
正在下载图片 [1]: https://picsum.photos/800/600
|
||||
✅ 下载成功: image_0_xxx.jpg (45.2KB)
|
||||
正在下载图片 [2]: https://picsum.photos/800/600
|
||||
✅ 下载成功: image_1_xxx.jpg (52.8KB)
|
||||
|
||||
成功处理 2/2 张图片
|
||||
|
||||
1. 初始化浏览器...
|
||||
浏览器初始化成功
|
||||
|
||||
2. 验证登录状态...
|
||||
✅ 登录状态有效
|
||||
|
||||
3. 开始发布笔记...
|
||||
[...]
|
||||
|
||||
==================================================
|
||||
发布结果:
|
||||
{
|
||||
"success": true,
|
||||
"message": "笔记发布成功",
|
||||
"url": "https://www.xiaohongshu.com/explore/..."
|
||||
}
|
||||
==================================================
|
||||
|
||||
✅ 发布成功!
|
||||
📎 笔记链接: https://www.xiaohongshu.com/explore/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么会出现 JSON 解析错误?
|
||||
|
||||
A: 在命令行中直接使用 JSON 字符串时,Shell 会对引号进行特殊处理,导致解析失败。建议使用:
|
||||
- **方式 1**:`quick_publish.py`(无需处理 JSON)
|
||||
- **方式 2**:配置文件(JSON 在文件中)
|
||||
|
||||
### Q: Cookie 从哪里获取?
|
||||
|
||||
A: 三种方式:
|
||||
1. 使用 `xhs_cli.py login` 登录获取
|
||||
2. 从浏览器开发者工具复制
|
||||
3. 使用现有的 Cookie JSON
|
||||
|
||||
### Q: 图片支持哪些格式?
|
||||
|
||||
A:
|
||||
- **本地文件**:JPG、PNG、GIF、WEBP
|
||||
- **网络 URL**:HTTP/HTTPS 链接
|
||||
- **混合使用**:可以同时使用本地和网络图片
|
||||
|
||||
### Q: 如何验证 Cookie 是否有效?
|
||||
|
||||
A: 使用快速发布脚本,会自动验证:
|
||||
```bash
|
||||
python quick_publish.py "测试" "测试内容" "" ""
|
||||
```
|
||||
|
||||
如果 Cookie 失效,会提示:
|
||||
```
|
||||
❌ 发布失败: Cookie已失效或未登录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 进阶使用
|
||||
|
||||
### 批量发布
|
||||
|
||||
创建脚本 `batch_publish.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from quick_publish import quick_publish
|
||||
|
||||
async def main():
|
||||
articles = [
|
||||
{
|
||||
"title": "文章1",
|
||||
"content": "内容1",
|
||||
"images": ["https://picsum.photos/800/600"],
|
||||
"tags": ["标签1"]
|
||||
},
|
||||
{
|
||||
"title": "文章2",
|
||||
"content": "内容2",
|
||||
"images": ["https://picsum.photos/800/600"],
|
||||
"tags": ["标签2"]
|
||||
}
|
||||
]
|
||||
|
||||
for article in articles:
|
||||
result = await quick_publish(**article)
|
||||
print(f"发布结果: {result['success']}")
|
||||
await asyncio.sleep(60) # 间隔60秒
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **推荐使用 `quick_publish.py`**
|
||||
- 无需处理 JSON 转义
|
||||
- 参数简单直观
|
||||
- 自动读取 Cookie 文件
|
||||
|
||||
现在您可以尝试使用快速发布脚本了!🚀
|
||||
170
backend/README.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 小红书登录后端服务
|
||||
|
||||
基于 Playwright 的小红书登录服务,支持手机号+验证码登录。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 手机号+验证码登录
|
||||
- ✅ 自动化浏览器操作
|
||||
- ✅ 获取登录后的 Cookies 和用户信息
|
||||
- ✅ RESTful API 接口
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Python 3.8+
|
||||
- FastAPI - Web 框架
|
||||
- Playwright - 浏览器自动化
|
||||
- Uvicorn - ASGI 服务器
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 1. 创建虚拟环境(如果还没有)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
```
|
||||
|
||||
### 2. 激活虚拟环境
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
**Linux/Mac:**
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 3. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. 安装 Playwright 浏览器
|
||||
|
||||
```bash
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
服务将在 `http://localhost:8000` 启动。
|
||||
|
||||
### API 接口
|
||||
|
||||
#### 1. 发送验证码
|
||||
|
||||
**POST** `/api/xhs/send-code`
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"phone": "13800138000",
|
||||
"country_code": "+86"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "验证码已发送,请在小红书APP中查看",
|
||||
"data": {
|
||||
"sent_at": "2025-12-10T10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 登录验证
|
||||
|
||||
**POST** `/api/xhs/login`
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"phone": "13800138000",
|
||||
"code": "123456",
|
||||
"country_code": "+86"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "登录成功",
|
||||
"data": {
|
||||
"user_info": {...},
|
||||
"cookies": {...},
|
||||
"login_time": "2025-12-10T10:01:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 健康检查
|
||||
|
||||
**GET** `/`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "小红书登录服务运行中"
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **滑块验证**: 小红书可能会要求滑块验证,需要手动完成
|
||||
2. **验证码**: 验证码会发送到小红书 APP,需要在 APP 中查看
|
||||
3. **浏览器模式**:
|
||||
- 开发时使用 `headless=False` 可以看到浏览器操作
|
||||
- 生产环境可设置 `headless=True` 在后台运行
|
||||
4. **反爬虫**: 小红书有反爬虫机制,可能需要调整策略
|
||||
|
||||
## 开发调试
|
||||
|
||||
### 查看 API 文档
|
||||
|
||||
访问 `http://localhost:8000/docs` 可以看到自动生成的 Swagger 文档。
|
||||
|
||||
### 日志输出
|
||||
|
||||
服务会在控制台输出详细的操作日志,便于调试。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── main.py # FastAPI 主程序
|
||||
├── xhs_login.py # 小红书登录服务
|
||||
├── requirements.txt # Python 依赖
|
||||
├── venv/ # Python 虚拟环境
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 验证码发送失败?
|
||||
A: 检查手机号格式是否正确,确保网络连接正常。
|
||||
|
||||
### Q: 登录失败?
|
||||
A: 确认验证码是否正确,验证码有时效性,请及时输入。
|
||||
|
||||
### Q: 浏览器无法启动?
|
||||
A: 确保已经运行 `playwright install chromium` 安装浏览器。
|
||||
|
||||
## 安全提示
|
||||
|
||||
- 不要在公网暴露此服务
|
||||
- 生产环境建议添加认证机制
|
||||
- 妥善保管获取到的 Cookies 和用户信息
|
||||
123
backend/XHS_CLI_README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 小红书登录 CLI 工具
|
||||
|
||||
## 概述
|
||||
|
||||
这是一个可以被 Go 服务直接调用的 Python CLI 工具,用于小红书登录功能。
|
||||
使用此工具后,不再需要单独启动 Python Web 服务。
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 1. 发送验证码
|
||||
|
||||
```bash
|
||||
python xhs_cli.py send_code <手机号> [国家区号]
|
||||
```
|
||||
|
||||
示例:
|
||||
```bash
|
||||
python xhs_cli.py send_code 13800138000 +86
|
||||
```
|
||||
|
||||
返回 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "验证码发送成功"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 登录
|
||||
|
||||
```bash
|
||||
python xhs_cli.py login <手机号> <验证码> [国家区号]
|
||||
```
|
||||
|
||||
示例:
|
||||
```bash
|
||||
python xhs_cli.py login 13800138000 123456 +86
|
||||
```
|
||||
|
||||
返回 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user_info": {...},
|
||||
"cookies": {...},
|
||||
"url": "https://www.xiaohongshu.com/"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 注入 Cookie (验证登录状态)
|
||||
|
||||
```bash
|
||||
python xhs_cli.py inject_cookies '<cookies_json>'
|
||||
```
|
||||
|
||||
示例:
|
||||
```bash
|
||||
python xhs_cli.py inject_cookies '[{"name":"web_session","value":"xxx","domain":".xiaohongshu.com"}]'
|
||||
```
|
||||
|
||||
返回 JSON 格式:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"logged_in": true,
|
||||
"cookies": {...},
|
||||
"user_info": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## Go 服务集成
|
||||
|
||||
Go 服务已经修改为直接调用 Python CLI 脚本,无需启动 Python Web 服务。
|
||||
|
||||
### 修改的文件
|
||||
|
||||
1. **backend/xhs_cli.py** (新增)
|
||||
- 命令行接口工具
|
||||
|
||||
2. **go_backend/service/xhs_service.go** (修改)
|
||||
- 使用 `exec.Command` 调用 Python 脚本
|
||||
- 不再通过 HTTP 调用 Python 服务
|
||||
|
||||
3. **go_backend/service/employee_service.go** (修改)
|
||||
- 使用 `exec.Command` 调用 Python 脚本
|
||||
|
||||
### 优点
|
||||
|
||||
- ✅ 只需启动一个 Go 服务
|
||||
- ✅ 部署更简单,不需要管理多个服务进程
|
||||
- ✅ 减少网络开销
|
||||
- ✅ 更容易调试和维护
|
||||
|
||||
## 依赖要求
|
||||
|
||||
确保已安装 Python 依赖:
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
主要依赖:
|
||||
- playwright
|
||||
- asyncio
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. Python 命令需要在系统 PATH 中可用
|
||||
2. 确保 `xhs_login.py` 和 `xhs_cli.py` 在同一目录
|
||||
3. Go 服务会在相对路径 `../backend` 下查找 Python 脚本
|
||||
4. 所有输出均为 JSON 格式,便于 Go 服务解析
|
||||
|
||||
## 错误处理
|
||||
|
||||
如果执行失败,会返回包含错误信息的 JSON:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误描述信息"
|
||||
}
|
||||
```
|
||||
|
||||
Go 服务会捕获 stderr 输出并作为错误信息的一部分返回。
|
||||
313
backend/XHS_PUBLISH_README.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# 小红书笔记发布脚本使用说明
|
||||
|
||||
## 功能介绍
|
||||
|
||||
`xhs_publish.py` 是一个用于自动发布小红书笔记的 Python 脚本,支持通过 Cookie 认证,自动完成图文笔记发布。
|
||||
|
||||
## 环境准备
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
主要依赖:
|
||||
- playwright (浏览器自动化)
|
||||
- asyncio (异步处理)
|
||||
|
||||
### 2. 安装浏览器驱动
|
||||
|
||||
```bash
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:使用配置文件(推荐)
|
||||
|
||||
#### 1. 准备配置文件
|
||||
|
||||
复制 `publish_config_example.json` 并修改为实际参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "a1",
|
||||
"value": "your_cookie_value_here",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"title": "笔记标题",
|
||||
"content": "笔记内容",
|
||||
"images": [
|
||||
"D:/path/to/image1.jpg",
|
||||
"D:/path/to/image2.jpg"
|
||||
],
|
||||
"tags": [
|
||||
"标签1",
|
||||
"标签2"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 执行发布
|
||||
|
||||
```bash
|
||||
python xhs_publish.py --config publish_config.json
|
||||
```
|
||||
|
||||
### 方式二:命令行参数
|
||||
|
||||
```bash
|
||||
python xhs_publish.py \
|
||||
--cookies '[{"name":"a1","value":"xxx","domain":".xiaohongshu.com"}]' \
|
||||
--title "笔记标题" \
|
||||
--content "笔记内容" \
|
||||
--images '["D:/image1.jpg","D:/image2.jpg"]' \
|
||||
--tags '["标签1","标签2"]'
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
### cookies (必需)
|
||||
|
||||
Cookie 数组,每个 Cookie 对象包含以下字段:
|
||||
|
||||
- `name`: Cookie 名称
|
||||
- `value`: Cookie 值
|
||||
- `domain`: 域名(通常为 `.xiaohongshu.com`)
|
||||
- `path`: 路径(通常为 `/`)
|
||||
- `expires`: 过期时间(-1 表示会话 Cookie)
|
||||
- `httpOnly`: 是否仅 HTTP
|
||||
- `secure`: 是否安全
|
||||
- `sameSite`: 同站策略(Lax/Strict/None)
|
||||
|
||||
**重要 Cookie(必需):**
|
||||
- `a1`: 用户身份认证
|
||||
- `webId`: 设备标识
|
||||
- `web_session`: 会话信息
|
||||
|
||||
### title (必需)
|
||||
|
||||
笔记标题,字符串类型。
|
||||
|
||||
**示例:**
|
||||
```
|
||||
"💧夏日必备!2元一杯的柠檬水竟然这么好喝?"
|
||||
```
|
||||
|
||||
### content (必需)
|
||||
|
||||
笔记正文内容,字符串类型,支持换行符 `\n`。
|
||||
|
||||
**示例:**
|
||||
```
|
||||
"今天给大家分享一个超级实惠的夏日饮品!\n\n蜜雪冰城的柠檬水只要2元一杯,性价比真的太高了!"
|
||||
```
|
||||
|
||||
### images (可选)
|
||||
|
||||
图片文件路径数组,支持本地绝对路径。
|
||||
|
||||
**要求:**
|
||||
- 图片必须是本地文件
|
||||
- 支持 jpg、png、gif 等格式
|
||||
- 最多上传 9 张图片
|
||||
- 建议尺寸:800x600 或更高
|
||||
|
||||
**示例:**
|
||||
```json
|
||||
[
|
||||
"D:/project/Work/ai_xhs/backend/temp_uploads/image1.jpg",
|
||||
"D:/project/Work/ai_xhs/backend/temp_uploads/image2.jpg"
|
||||
]
|
||||
```
|
||||
|
||||
### tags (可选)
|
||||
|
||||
标签数组,会自动添加 `#` 前缀。
|
||||
|
||||
**示例:**
|
||||
```json
|
||||
["夏日清爽", "饮品", "柠檬水"]
|
||||
```
|
||||
|
||||
## 获取 Cookie 的方法
|
||||
|
||||
### 方法一:使用登录脚本
|
||||
|
||||
```bash
|
||||
python xhs_cli.py login <手机号> <验证码>
|
||||
```
|
||||
|
||||
登录成功后会自动保存 Cookie 到 `cookies.json` 文件。
|
||||
|
||||
### 方法二:浏览器手动获取
|
||||
|
||||
1. 在浏览器中登录小红书网页版
|
||||
2. 打开开发者工具(F12)
|
||||
3. 切换到 Network(网络)标签
|
||||
4. 刷新页面
|
||||
5. 找到任意请求,查看 Request Headers
|
||||
6. 复制 Cookie 字段内容
|
||||
7. 使用在线工具或脚本转换为 JSON 格式
|
||||
|
||||
### 方法三:使用 Cookie 注入验证
|
||||
|
||||
```bash
|
||||
python xhs_cli.py inject_cookies '<cookies_json>'
|
||||
```
|
||||
|
||||
## 返回结果
|
||||
|
||||
### 成功示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "笔记发布成功",
|
||||
"url": "https://www.xiaohongshu.com/explore/xxxx"
|
||||
}
|
||||
```
|
||||
|
||||
### 失败示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Cookie已失效或未登录"
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. Cookie 有效期
|
||||
|
||||
- Cookie 会在一段时间后失效
|
||||
- 需要定期重新登录获取新 Cookie
|
||||
- 建议使用 Cookie 注入验证接口检查状态
|
||||
|
||||
### 2. 图片上传
|
||||
|
||||
- 确保图片文件存在且可访问
|
||||
- 图片路径使用绝对路径
|
||||
- Windows 系统路径使用 `/` 或 `\\` 分隔符
|
||||
|
||||
### 3. 发布限制
|
||||
|
||||
- 小红书可能有发布频率限制
|
||||
- 建议控制发布间隔,避免被限流
|
||||
- 内容需符合小红书社区规范
|
||||
|
||||
### 4. 错误处理
|
||||
|
||||
常见错误及解决方法:
|
||||
|
||||
- **"Cookie已失效"**: 重新登录获取新 Cookie
|
||||
- **"图片文件不存在"**: 检查图片路径是否正确
|
||||
- **"未找到发布按钮"**: 小红书页面结构可能变化,需要更新选择器
|
||||
- **"输入内容失败"**: 等待时间不足,增加延迟时间
|
||||
|
||||
## 与 Go 后端集成
|
||||
|
||||
在 Go 后端中调用此脚本:
|
||||
|
||||
```go
|
||||
import (
|
||||
"os/exec"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// 发布笔记
|
||||
func PublishNote(cookies []Cookie, title, content string, images, tags []string) error {
|
||||
// 构造配置文件
|
||||
config := map[string]interface{}{
|
||||
"cookies": cookies,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"images": images,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
// 保存到临时文件
|
||||
configFile := "temp_publish_config.json"
|
||||
data, _ := json.Marshal(config)
|
||||
ioutil.WriteFile(configFile, data, 0644)
|
||||
|
||||
// 调用 Python 脚本
|
||||
cmd := exec.Command("python", "backend/xhs_publish.py", "--config", configFile)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析结果
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(output, &result)
|
||||
|
||||
if !result["success"].(bool) {
|
||||
return errors.New(result["error"].(string))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## 开发调试
|
||||
|
||||
### 启用浏览器可视模式
|
||||
|
||||
修改 `xhs_login.py` 中的 `headless` 参数:
|
||||
|
||||
```python
|
||||
self.browser = await self.playwright.chromium.launch(
|
||||
headless=False, # 改为 False 可以看到浏览器操作过程
|
||||
args=['--disable-blink-features=AutomationControlled']
|
||||
)
|
||||
```
|
||||
|
||||
### 查看详细日志
|
||||
|
||||
脚本会在控制台输出详细的执行日志,包括:
|
||||
- 浏览器初始化
|
||||
- 登录状态验证
|
||||
- 图片上传进度
|
||||
- 内容输入状态
|
||||
- 发布结果
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么上传图片后没有显示?
|
||||
|
||||
A: 可能是图片上传时间较长,脚本已经增加了等待时间。如果仍有问题,可以调整 `xhs_login.py` 中的等待时间。
|
||||
|
||||
### Q: 如何批量发布多条笔记?
|
||||
|
||||
A: 准备多个配置文件,使用循环调用脚本:
|
||||
|
||||
```bash
|
||||
for config in publish_config_*.json; do
|
||||
python xhs_publish.py --config "$config"
|
||||
sleep 60 # 间隔60秒
|
||||
done
|
||||
```
|
||||
|
||||
### Q: Cookie 多久失效?
|
||||
|
||||
A: 小红书 Cookie 通常在 7-30 天后失效,具体取决于 Cookie 的过期时间设置。
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请查看:
|
||||
1. 脚本执行日志
|
||||
2. 小红书页面结构是否变化
|
||||
3. Cookie 是否有效
|
||||
4. 图片文件是否存在
|
||||
0
backend/fix_print.py
Normal file
282
backend/main.py
Normal file
@@ -0,0 +1,282 @@
|
||||
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)
|
||||
38
backend/publish_config_example.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "a1",
|
||||
"value": "your_cookie_value_here",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "webId",
|
||||
"value": "your_webid_here",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"title": "💧夏日必备!2元一杯的柠檬水竟然这么好喝?",
|
||||
"content": "今天给大家分享一个超级实惠的夏日饮品!\n\n蜜雪冰城的柠檬水只要2元一杯,性价比真的太高了!\n酸酸甜甜的口感,冰冰凉凉超解渴~\n\n夏天必备!强烈推荐给大家!",
|
||||
"images": [
|
||||
"https://picsum.photos/800/600?random=1",
|
||||
"https://picsum.photos/800/600?random=2",
|
||||
"D:/project/Work/ai_xhs/backend/temp_uploads/image3.jpg"
|
||||
],
|
||||
"tags": [
|
||||
"夏日清爽",
|
||||
"饮品",
|
||||
"柠檬水",
|
||||
"性价比",
|
||||
"蜜雪冰城"
|
||||
]
|
||||
}
|
||||
15
backend/publish_example.bat
Normal file
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo 小红书快速发布示例
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM 使用 Cookie 文件路径发布
|
||||
python xhs_publish.py --cookies "./cookies.json" --title "【测试】批处理发布" --content "这是通过批处理文件发布的测试笔记" --images "[\"https://picsum.photos/800/600\",\"https://picsum.photos/800/600\"]" --tags "[\"测试\",\"批处理\",\"自动化\"]"
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo 发布完成
|
||||
echo ========================================
|
||||
pause
|
||||
136
backend/quick_publish.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
快速发布脚本
|
||||
简化命令行调用,避免 JSON 转义问题
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
from xhs_publish import XHSPublishService
|
||||
|
||||
|
||||
def load_cookies_from_file(filepath='cookies.json'):
|
||||
"""从文件加载 Cookie"""
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"❌ Cookie 文件不存在: {filepath}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"❌ Cookie 文件格式错误: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def quick_publish(
|
||||
title: str,
|
||||
content: str,
|
||||
images: list = None,
|
||||
tags: list = None,
|
||||
cookies_file: str = 'cookies.json'
|
||||
):
|
||||
"""
|
||||
快速发布
|
||||
|
||||
Args:
|
||||
title: 标题
|
||||
content: 内容
|
||||
images: 图片列表(支持本地路径和网络 URL)
|
||||
tags: 标签列表
|
||||
cookies_file: Cookie 文件路径
|
||||
"""
|
||||
# 加载 Cookie
|
||||
cookies = load_cookies_from_file(cookies_file)
|
||||
if not cookies:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "无法加载 Cookie"
|
||||
}
|
||||
|
||||
# 创建发布服务
|
||||
publisher = XHSPublishService(cookies)
|
||||
|
||||
# 执行发布
|
||||
result = await publisher.publish(
|
||||
title=title,
|
||||
content=content,
|
||||
images=images,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
命令行入口
|
||||
|
||||
使用方式:
|
||||
python quick_publish.py "标题" "内容" "图片1,图片2,图片3" "标签1,标签2"
|
||||
python quick_publish.py "标题" "内容" "" "标签1,标签2" # 不使用图片
|
||||
"""
|
||||
if len(sys.argv) < 3:
|
||||
print("使用方式:")
|
||||
print(' python quick_publish.py "标题" "内容" ["图片1,图片2"] ["标签1,标签2"]')
|
||||
print()
|
||||
print("示例:")
|
||||
print(' python quick_publish.py "测试笔记" "这是内容" "https://picsum.photos/800/600,D:/test.jpg" "测试,自动化"')
|
||||
sys.exit(1)
|
||||
|
||||
# 解析参数
|
||||
title = sys.argv[1]
|
||||
content = sys.argv[2]
|
||||
|
||||
# 解析图片(逗号分隔)
|
||||
images = []
|
||||
if len(sys.argv) > 3 and sys.argv[3].strip():
|
||||
images = [img.strip() for img in sys.argv[3].split(',') if img.strip()]
|
||||
|
||||
# 解析标签(逗号分隔)
|
||||
tags = []
|
||||
if len(sys.argv) > 4 and sys.argv[4].strip():
|
||||
tags = [tag.strip() for tag in sys.argv[4].split(',') if tag.strip()]
|
||||
|
||||
# Cookie 文件路径(可选)
|
||||
cookies_file = sys.argv[5] if len(sys.argv) > 5 else 'cookies.json'
|
||||
|
||||
print("="*50)
|
||||
print("快速发布小红书笔记")
|
||||
print("="*50)
|
||||
print(f"标题: {title}")
|
||||
print(f"内容: {content[:100]}{'...' if len(content) > 100 else ''}")
|
||||
print(f"图片: {len(images)} 张")
|
||||
if images:
|
||||
for i, img in enumerate(images, 1):
|
||||
print(f" {i}. {img}")
|
||||
print(f"标签: {tags}")
|
||||
print(f"Cookie: {cookies_file}")
|
||||
print("="*50)
|
||||
print()
|
||||
|
||||
# 执行发布
|
||||
result = asyncio.run(quick_publish(
|
||||
title=title,
|
||||
content=content,
|
||||
images=images if images else None,
|
||||
tags=tags if tags else None,
|
||||
cookies_file=cookies_file
|
||||
))
|
||||
|
||||
# 输出结果
|
||||
print()
|
||||
print("="*50)
|
||||
print("发布结果:")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print("="*50)
|
||||
|
||||
if result.get('success'):
|
||||
print("\n✅ 发布成功!")
|
||||
if 'url' in result:
|
||||
print(f"📎 笔记链接: {result['url']}")
|
||||
else:
|
||||
print(f"\n❌ 发布失败: {result.get('error')}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
playwright==1.40.0
|
||||
pydantic==2.5.0
|
||||
python-multipart==0.0.6
|
||||
aiohttp==3.9.1
|
||||
8
backend/start.bat
Normal file
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
echo 正在激活虚拟环境...
|
||||
venv\Scripts\activate
|
||||
|
||||
echo 正在启动小红书登录服务...
|
||||
python main.py
|
||||
|
||||
pause
|
||||
7
backend/start.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "正在激活虚拟环境..."
|
||||
source venv/bin/activate
|
||||
|
||||
echo "正在启动小红书登录服务..."
|
||||
python main.py
|
||||
BIN
backend/temp_uploads/20251210_155442_144626.jpg
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
backend/temp_uploads/20251216_114303_912126.jpg
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
backend/temp_uploads/20251216_114321_739110.jpg
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
backend/temp_uploads/20251216_193730_208100.jpg
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
backend/temp_uploads/20251216_202154_985776.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
backend/temp_uploads/20251216_202823_315658.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
backend/temp_uploads/20251216_202828_835648.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
backend/temp_uploads/20251216_202837_337689.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
backend/temp_uploads/20251216_203949_751793.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
188
backend/test_api_response.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
测试 API 返回格式
|
||||
验证登录 API 是否正确返回 cookies 和 cookies_full
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
def test_api_response_format():
|
||||
"""测试 API 响应格式"""
|
||||
|
||||
# 模拟 API 返回的数据
|
||||
mock_response = {
|
||||
"code": 0,
|
||||
"message": "登录成功",
|
||||
"data": {
|
||||
"user_info": {},
|
||||
"cookies": {
|
||||
"a1": "xxx",
|
||||
"webId": "yyy",
|
||||
"web_session": "zzz"
|
||||
},
|
||||
"cookies_full": [
|
||||
{
|
||||
"name": "a1",
|
||||
"value": "xxx",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": 1797066496,
|
||||
"httpOnly": False,
|
||||
"secure": False,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "webId",
|
||||
"value": "yyy",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": 1797066496,
|
||||
"httpOnly": False,
|
||||
"secure": False,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "web_session",
|
||||
"value": "zzz",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": 1797066497,
|
||||
"httpOnly": True,
|
||||
"secure": True,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"login_time": "2025-12-12T23:30:00"
|
||||
}
|
||||
}
|
||||
|
||||
print("="*60)
|
||||
print("API 响应格式测试")
|
||||
print("="*60)
|
||||
print()
|
||||
|
||||
# 检查响应结构
|
||||
assert "code" in mock_response, "缺少 code 字段"
|
||||
assert "message" in mock_response, "缺少 message 字段"
|
||||
assert "data" in mock_response, "缺少 data 字段"
|
||||
|
||||
data = mock_response["data"]
|
||||
|
||||
# 检查 cookies 字段(键值对格式)
|
||||
print("✅ 检查 cookies 字段(键值对格式):")
|
||||
assert "cookies" in data, "缺少 cookies 字段"
|
||||
assert isinstance(data["cookies"], dict), "cookies 应该是字典类型"
|
||||
print(f" 类型: {type(data['cookies']).__name__}")
|
||||
print(f" 示例: {json.dumps(data['cookies'], ensure_ascii=False, indent=2)}")
|
||||
print()
|
||||
|
||||
# 检查 cookies_full 字段(Playwright 完整格式)
|
||||
print("✅ 检查 cookies_full 字段(Playwright 完整格式):")
|
||||
assert "cookies_full" in data, "缺少 cookies_full 字段"
|
||||
assert isinstance(data["cookies_full"], list), "cookies_full 应该是列表类型"
|
||||
print(f" 类型: {type(data['cookies_full']).__name__}")
|
||||
print(f" 数量: {len(data['cookies_full'])} 个 Cookie")
|
||||
print(f" 示例(第一个):")
|
||||
print(f"{json.dumps(data['cookies_full'][0], ensure_ascii=False, indent=6)}")
|
||||
print()
|
||||
|
||||
# 检查 cookies_full 的每个元素
|
||||
print("✅ 检查 cookies_full 的结构:")
|
||||
for i, cookie in enumerate(data["cookies_full"]):
|
||||
assert "name" in cookie, f"Cookie[{i}] 缺少 name 字段"
|
||||
assert "value" in cookie, f"Cookie[{i}] 缺少 value 字段"
|
||||
assert "domain" in cookie, f"Cookie[{i}] 缺少 domain 字段"
|
||||
assert "path" in cookie, f"Cookie[{i}] 缺少 path 字段"
|
||||
assert "expires" in cookie, f"Cookie[{i}] 缺少 expires 字段"
|
||||
assert "httpOnly" in cookie, f"Cookie[{i}] 缺少 httpOnly 字段"
|
||||
assert "secure" in cookie, f"Cookie[{i}] 缺少 secure 字段"
|
||||
assert "sameSite" in cookie, f"Cookie[{i}] 缺少 sameSite 字段"
|
||||
print(f" Cookie[{i}] ({cookie['name']}): ✅ 所有字段完整")
|
||||
|
||||
print()
|
||||
print("="*60)
|
||||
print("🎉 所有检查通过!API 返回格式正确")
|
||||
print("="*60)
|
||||
print()
|
||||
|
||||
# 使用场景说明
|
||||
print("📝 使用场景:")
|
||||
print()
|
||||
print("1. 前端展示 - 使用 cookies(键值对格式):")
|
||||
print(" const cookies = response.data.cookies;")
|
||||
print(" console.log(cookies.a1, cookies.webId);")
|
||||
print()
|
||||
|
||||
print("2. 数据库存储 - 使用 cookies_full(完整格式):")
|
||||
print(" const cookiesFull = response.data.cookies_full;")
|
||||
print(" await db.saveCookies(userId, JSON.stringify(cookiesFull));")
|
||||
print()
|
||||
|
||||
print("3. Python 脚本使用 - 使用 cookies_full:")
|
||||
print(" cookies_full = response['data']['cookies_full']")
|
||||
print(" publisher = XHSPublishService(cookies_full)")
|
||||
print()
|
||||
|
||||
|
||||
def compare_formats():
|
||||
"""对比两种格式"""
|
||||
|
||||
print("="*60)
|
||||
print("格式对比分析")
|
||||
print("="*60)
|
||||
print()
|
||||
|
||||
# 键值对格式
|
||||
cookies_dict = {
|
||||
"a1": "xxx",
|
||||
"webId": "yyy",
|
||||
"web_session": "zzz"
|
||||
}
|
||||
|
||||
# Playwright 完整格式
|
||||
cookies_full = [
|
||||
{
|
||||
"name": "a1",
|
||||
"value": "xxx",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": 1797066496,
|
||||
"httpOnly": False,
|
||||
"secure": False,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
]
|
||||
|
||||
print("📊 键值对格式:")
|
||||
dict_str = json.dumps(cookies_dict, ensure_ascii=False, indent=2)
|
||||
print(dict_str)
|
||||
print(f" 大小: {len(dict_str)} 字符")
|
||||
print()
|
||||
|
||||
print("📊 Playwright 完整格式:")
|
||||
full_str = json.dumps(cookies_full, ensure_ascii=False, indent=2)
|
||||
print(full_str)
|
||||
print(f" 大小: {len(full_str)} 字符")
|
||||
print()
|
||||
|
||||
print("📊 对比结果:")
|
||||
print(f" 完整格式 vs 键值对格式: {len(full_str)} / {len(dict_str)} = {len(full_str)/len(dict_str):.1f}x")
|
||||
print(f" 每个 Cookie 完整格式约增加: {(len(full_str) - len(dict_str)) // len(cookies_dict)} 字符")
|
||||
print()
|
||||
|
||||
print("✅ 结论:")
|
||||
print(" - 完整格式虽然较大,但包含所有必要属性")
|
||||
print(" - 对于数据库存储,建议使用完整格式")
|
||||
print(" - 对于前端展示,可以使用键值对格式")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试 API 响应格式
|
||||
test_api_response_format()
|
||||
|
||||
# 对比两种格式
|
||||
compare_formats()
|
||||
|
||||
print("="*60)
|
||||
print("✅ 测试完成!")
|
||||
print("="*60)
|
||||
162
backend/test_cookie_file.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
测试 Cookie 文件路径支持
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import json
|
||||
|
||||
|
||||
def test_cookie_file_param():
|
||||
"""测试 --cookies 参数支持文件路径"""
|
||||
|
||||
print("="*60)
|
||||
print("测试 Cookie 文件路径参数支持")
|
||||
print("="*60)
|
||||
print()
|
||||
|
||||
# 测试命令
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"xhs_publish.py",
|
||||
"--cookies", "test_cookies.json", # 使用文件路径
|
||||
"--title", "【测试】Cookie文件路径参数",
|
||||
"--content", "测试使用 --cookies 参数传递文件路径,而不是 JSON 字符串",
|
||||
"--images", '["https://picsum.photos/800/600","https://picsum.photos/800/600"]',
|
||||
"--tags", '["测试","Cookie文件","自动化"]'
|
||||
]
|
||||
|
||||
print("执行命令:")
|
||||
print(" ".join(cmd))
|
||||
print()
|
||||
print("-"*60)
|
||||
print()
|
||||
|
||||
# 执行命令
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
# 输出结果
|
||||
print("标准输出:")
|
||||
print(result.stdout)
|
||||
|
||||
if result.stderr:
|
||||
print("\n标准错误:")
|
||||
print(result.stderr)
|
||||
|
||||
print()
|
||||
print("-"*60)
|
||||
|
||||
# 解析结果
|
||||
try:
|
||||
# 尝试从输出中提取 JSON 结果
|
||||
lines = result.stdout.strip().split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith('{'):
|
||||
json_str = '\n'.join(lines[i:])
|
||||
response = json.loads(json_str)
|
||||
|
||||
print("\n解析结果:")
|
||||
print(json.dumps(response, ensure_ascii=False, indent=2))
|
||||
|
||||
if response.get('success'):
|
||||
print("\n✅ 测试成功!Cookie 文件路径参数工作正常")
|
||||
if 'url' in response:
|
||||
print(f"📎 笔记链接: {response['url']}")
|
||||
else:
|
||||
print(f"\n❌ 测试失败: {response.get('error')}")
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
print("⚠️ 无法解析 JSON 输出")
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 执行失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def test_quick_publish():
|
||||
"""测试 quick_publish.py 脚本"""
|
||||
|
||||
print("\n")
|
||||
print("="*60)
|
||||
print("测试 quick_publish.py 脚本")
|
||||
print("="*60)
|
||||
print()
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"quick_publish.py",
|
||||
"【测试】快速发布脚本",
|
||||
"测试 quick_publish.py 的简化调用方式",
|
||||
"https://picsum.photos/800/600,https://picsum.photos/800/600",
|
||||
"测试,快速发布,自动化",
|
||||
"test_cookies.json"
|
||||
]
|
||||
|
||||
print("执行命令:")
|
||||
print(" ".join(cmd))
|
||||
print()
|
||||
print("-"*60)
|
||||
print()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
print(result.stdout)
|
||||
|
||||
if result.stderr:
|
||||
print("\n标准错误:")
|
||||
print(result.stderr)
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 执行失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print()
|
||||
print("🧪 Cookie 文件路径支持测试")
|
||||
print()
|
||||
|
||||
# 检查 Cookie 文件是否存在
|
||||
import os
|
||||
if not os.path.exists('test_cookies.json'):
|
||||
print("❌ 错误: test_cookies.json 文件不存在")
|
||||
print("请先创建 Cookie 文件")
|
||||
sys.exit(1)
|
||||
|
||||
print("✅ 找到 Cookie 文件: test_cookies.json")
|
||||
print()
|
||||
|
||||
# 测试1: xhs_publish.py 使用文件路径
|
||||
success1 = test_cookie_file_param()
|
||||
|
||||
# 测试2: quick_publish.py
|
||||
success2 = test_quick_publish()
|
||||
|
||||
# 总结
|
||||
print()
|
||||
print("="*60)
|
||||
print("测试总结")
|
||||
print("="*60)
|
||||
print(f"xhs_publish.py (Cookie文件): {'✅ 通过' if success1 else '❌ 失败'}")
|
||||
print(f"quick_publish.py: {'✅ 通过' if success2 else '❌ 失败'}")
|
||||
print()
|
||||
|
||||
if success1 and success2:
|
||||
print("🎉 所有测试通过!")
|
||||
else:
|
||||
print("⚠️ 部分测试失败,请检查错误信息")
|
||||
143
backend/test_cookie_formats.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
测试两种 Cookie 格式支持
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from xhs_publish import XHSPublishService
|
||||
|
||||
|
||||
# 格式1: Playwright 完整格式(从文件读取)
|
||||
playwright_cookies = [
|
||||
{
|
||||
"name": "a1",
|
||||
"value": "19b11d16e24t3h3xmlvojbrw1cr55xwamiacluw3c50000231766",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": 1797066496,
|
||||
"httpOnly": False,
|
||||
"secure": False,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "web_session",
|
||||
"value": "030037ae088f0acf2c81329d432e4a12fcb0ca",
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": 1797066497.112584,
|
||||
"httpOnly": True,
|
||||
"secure": True,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
]
|
||||
|
||||
# 格式2: 键值对格式(从数据库读取)
|
||||
keyvalue_cookies = {
|
||||
"a1": "19b11d16e24t3h3xmlvojbrw1cr55xwamiacluw3c50000231766",
|
||||
"abRequestId": "b273b4d0-3ef7-5b8f-bba4-2d19e63ad883",
|
||||
"acw_tc": "0a4ae09717655304937202738e4b75c08d6eb78f2c8d30d7dc5a465429e1e6",
|
||||
"gid": "yjDyyfyKiD6DyjDyyfyKd37EJ49qxqC61hlV0qSDFEySFS2822CE01888JqyWKK8Djdi8d2j",
|
||||
"loadts": "1765530496548",
|
||||
"sec_poison_id": "a589e333-c364-477c-9d14-53af8a1e7f1c",
|
||||
"unread": "{%22ub%22:%22648455690000000014025d90%22%2C%22ue%22:%2264b34737000000002f0262f9%22%2C%22uc%22:22}",
|
||||
"webBuild": "5.0.6",
|
||||
"webId": "fdf2dccee4bec7534aff5581310c0e26",
|
||||
"web_session": "030037ae088f0acf2c81329d432e4a12fcb0ca",
|
||||
"websectiga": "984412fef754c018e472127b8effd174be8a5d51061c991aadd200c69a2801d6",
|
||||
"xsecappid": "xhs-pc-web"
|
||||
}
|
||||
|
||||
|
||||
async def test_playwright_format():
|
||||
"""测试 Playwright 格式"""
|
||||
print("="*60)
|
||||
print("测试 1: Playwright 格式(完整格式)")
|
||||
print("="*60)
|
||||
|
||||
try:
|
||||
publisher = XHSPublishService(playwright_cookies)
|
||||
print("✅ 初始化成功")
|
||||
print(f" 转换后的 Cookie 数量: {len(publisher.cookies)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 初始化失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_keyvalue_format():
|
||||
"""测试键值对格式"""
|
||||
print("\n" + "="*60)
|
||||
print("测试 2: 键值对格式(数据库格式)")
|
||||
print("="*60)
|
||||
|
||||
try:
|
||||
publisher = XHSPublishService(keyvalue_cookies)
|
||||
print("✅ 初始化成功")
|
||||
print(f" 转换后的 Cookie 数量: {len(publisher.cookies)}")
|
||||
|
||||
# 显示转换后的一个示例
|
||||
print("\n转换示例(第一个 Cookie):")
|
||||
print(json.dumps(publisher.cookies[0], ensure_ascii=False, indent=2))
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 初始化失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_from_file():
|
||||
"""从文件读取测试"""
|
||||
print("\n" + "="*60)
|
||||
print("测试 3: 从 cookies.json 文件读取")
|
||||
print("="*60)
|
||||
|
||||
try:
|
||||
with open('cookies.json', 'r', encoding='utf-8') as f:
|
||||
cookies = json.load(f)
|
||||
|
||||
publisher = XHSPublishService(cookies)
|
||||
print("✅ 初始化成功")
|
||||
print(f" Cookie 数量: {len(publisher.cookies)}")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
print("⚠️ cookies.json 文件不存在,跳过此测试")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ 初始化失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("\n🧪 Cookie 格式兼容性测试\n")
|
||||
|
||||
# 测试1: Playwright格式
|
||||
result1 = await test_playwright_format()
|
||||
|
||||
# 测试2: 键值对格式
|
||||
result2 = await test_keyvalue_format()
|
||||
|
||||
# 测试3: 从文件读取
|
||||
result3 = await test_from_file()
|
||||
|
||||
# 总结
|
||||
print("\n" + "="*60)
|
||||
print("测试总结")
|
||||
print("="*60)
|
||||
print(f"Playwright 格式: {'✅ 通过' if result1 else '❌ 失败'}")
|
||||
print(f"键值对格式: {'✅ 通过' if result2 else '❌ 失败'}")
|
||||
if result3 is not None:
|
||||
print(f"文件读取: {'✅ 通过' if result3 else '❌ 失败'}")
|
||||
else:
|
||||
print(f"文件读取: ⚠️ 跳过")
|
||||
|
||||
if result1 and result2:
|
||||
print("\n🎉 所有格式测试通过!")
|
||||
print("\n💡 使用说明:")
|
||||
print(" - 从 Python 脚本保存的 cookies.json → Playwright 格式")
|
||||
print(" - 从数据库读取的 Cookie → 键值对格式")
|
||||
print(" - 两种格式都可以正常使用!")
|
||||
else:
|
||||
print("\n⚠️ 部分测试失败")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
80
backend/test_network_images.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
测试网络图片下载功能
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from xhs_publish import XHSPublishService
|
||||
|
||||
|
||||
async def test_network_images():
|
||||
"""测试网络图片功能"""
|
||||
|
||||
print("="*50)
|
||||
print("网络图片下载功能测试")
|
||||
print("="*50)
|
||||
print()
|
||||
|
||||
# 1. 准备测试 Cookie(从 cookies.json 读取)
|
||||
try:
|
||||
with open('cookies.json', 'r', encoding='utf-8') as f:
|
||||
cookies = json.load(f)
|
||||
print(f"✅ 成功读取 {len(cookies)} 个 Cookie")
|
||||
except FileNotFoundError:
|
||||
print("❌ cookies.json 文件不存在")
|
||||
print("请先运行登录获取 Cookie:")
|
||||
print(" python xhs_cli.py login <手机号> <验证码>")
|
||||
return
|
||||
|
||||
# 2. 准备测试数据
|
||||
title = "【测试】网络图片发布测试"
|
||||
content = """测试使用网络 URL 图片发布笔记 📸
|
||||
|
||||
本次测试使用了:
|
||||
✅ 网络 URL 图片(picsum.photos)
|
||||
✅ 自动下载功能
|
||||
✅ 临时文件管理
|
||||
|
||||
如果你看到这条笔记,说明网络图片功能正常!"""
|
||||
|
||||
# 3. 使用网络图片 URL
|
||||
images = [
|
||||
"https://picsum.photos/800/600?random=test1",
|
||||
"https://picsum.photos/800/600?random=test2",
|
||||
"https://picsum.photos/800/600?random=test3"
|
||||
]
|
||||
|
||||
print(f"\n测试图片 URL:")
|
||||
for i, url in enumerate(images, 1):
|
||||
print(f" {i}. {url}")
|
||||
|
||||
tags = ["测试", "网络图片", "自动发布"]
|
||||
|
||||
# 4. 创建发布服务
|
||||
print("\n开始测试发布...")
|
||||
publisher = XHSPublishService(cookies)
|
||||
|
||||
# 5. 执行发布
|
||||
result = await publisher.publish(
|
||||
title=title,
|
||||
content=content,
|
||||
images=images,
|
||||
tags=tags,
|
||||
cleanup=True # 自动清理临时文件
|
||||
)
|
||||
|
||||
# 6. 显示结果
|
||||
print("\n" + "="*50)
|
||||
print("测试结果:")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print("="*50)
|
||||
|
||||
if result.get('success'):
|
||||
print("\n✅ 测试成功!网络图片功能正常")
|
||||
if 'url' in result:
|
||||
print(f"📎 笔记链接: {result['url']}")
|
||||
else:
|
||||
print(f"\n❌ 测试失败: {result.get('error')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_network_images())
|
||||
90
backend/test_publish.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
小红书发布功能测试脚本
|
||||
快速测试发布功能是否正常工作
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from xhs_publish import XHSPublishService
|
||||
|
||||
|
||||
async def test_publish():
|
||||
"""测试发布功能"""
|
||||
|
||||
# 1. 从 cookies.json 读取 Cookie
|
||||
try:
|
||||
with open('cookies.json', 'r', encoding='utf-8') as f:
|
||||
cookies = json.load(f)
|
||||
print(f"✅ 成功读取 {len(cookies)} 个 Cookie")
|
||||
except FileNotFoundError:
|
||||
print("❌ cookies.json 文件不存在")
|
||||
print("请先运行登录获取 Cookie:")
|
||||
print(" python xhs_cli.py login <手机号> <验证码>")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"❌ 读取 cookies.json 失败: {e}")
|
||||
return
|
||||
|
||||
# 2. 准备测试数据
|
||||
title = "【测试】小红书发布功能测试"
|
||||
content = """这是一条测试笔记 📝
|
||||
|
||||
今天测试一下自动发布功能是否正常~
|
||||
|
||||
如果你看到这条笔记,说明发布成功了!
|
||||
|
||||
#测试 #自动化"""
|
||||
|
||||
# 3. 准备测试图片(可选)
|
||||
images = []
|
||||
test_image_dir = "temp_uploads"
|
||||
if os.path.exists(test_image_dir):
|
||||
for file in os.listdir(test_image_dir):
|
||||
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
|
||||
img_path = os.path.abspath(os.path.join(test_image_dir, file))
|
||||
images.append(img_path)
|
||||
if len(images) >= 3: # 最多3张测试图片
|
||||
break
|
||||
|
||||
if images:
|
||||
print(f"✅ 找到 {len(images)} 张测试图片")
|
||||
else:
|
||||
print("⚠️ 未找到测试图片,将只发布文字")
|
||||
|
||||
# 4. 准备标签
|
||||
tags = ["测试", "自动化发布"]
|
||||
|
||||
# 5. 创建发布服务
|
||||
print("\n开始发布测试笔记...")
|
||||
publisher = XHSPublishService(cookies)
|
||||
|
||||
# 6. 执行发布
|
||||
result = await publisher.publish(
|
||||
title=title,
|
||||
content=content,
|
||||
images=images if images else None,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
# 7. 显示结果
|
||||
print("\n" + "="*50)
|
||||
print("发布结果:")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print("="*50)
|
||||
|
||||
if result.get('success'):
|
||||
print("\n✅ 测试成功!笔记已发布")
|
||||
if 'url' in result:
|
||||
print(f"📎 笔记链接: {result['url']}")
|
||||
else:
|
||||
print(f"\n❌ 测试失败: {result.get('error')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("="*50)
|
||||
print("小红书发布功能测试")
|
||||
print("="*50)
|
||||
print()
|
||||
|
||||
# 运行测试
|
||||
asyncio.run(test_publish())
|
||||
138
backend/xhs_cli.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
小红书登录CLI工具
|
||||
供Go服务调用的命令行脚本
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
from xhs_login import XHSLoginService
|
||||
|
||||
# 设置标准输出为UTF-8编码
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
# 将xhs_login.py中的print输出重定向到stderr
|
||||
original_print = print
|
||||
def silent_print(*args, **kwargs):
|
||||
""" 将print输出重定向到stderr """
|
||||
kwargs['file'] = sys.stderr
|
||||
original_print(*args, **kwargs)
|
||||
|
||||
# 替换全局print函数
|
||||
import builtins
|
||||
builtins.print = silent_print
|
||||
|
||||
|
||||
async def send_code(phone: str, country_code: str = "+86"):
|
||||
"""发送验证码"""
|
||||
service = XHSLoginService()
|
||||
try:
|
||||
result = await service.send_verification_code(phone, country_code)
|
||||
return result
|
||||
finally:
|
||||
await service.close_browser()
|
||||
|
||||
|
||||
async def login(phone: str, code: str, country_code: str = "+86"):
|
||||
"""登录"""
|
||||
service = XHSLoginService()
|
||||
try:
|
||||
# 先发送验证码(页面已打开)
|
||||
await service.send_verification_code(phone, country_code)
|
||||
# 等待一下确保验证码发送完成
|
||||
await asyncio.sleep(1)
|
||||
# 执行登录
|
||||
result = await service.login(phone, code, country_code)
|
||||
return result
|
||||
finally:
|
||||
await service.close_browser()
|
||||
|
||||
|
||||
async def inject_cookies(cookies_json: str):
|
||||
"""注入Cookie并验证登录状态"""
|
||||
service = XHSLoginService()
|
||||
try:
|
||||
cookies = json.loads(cookies_json)
|
||||
await service.init_browser(cookies=cookies)
|
||||
result = await service.verify_login_status()
|
||||
return result
|
||||
finally:
|
||||
await service.close_browser()
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "缺少命令参数"
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
try:
|
||||
if command == "send_code":
|
||||
# python xhs_cli.py send_code <phone> [country_code]
|
||||
if len(sys.argv) < 3:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "缺少手机号参数"
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
phone = sys.argv[2]
|
||||
country_code = sys.argv[3] if len(sys.argv) > 3 else "+86"
|
||||
|
||||
result = asyncio.run(send_code(phone, country_code))
|
||||
sys.stdout.write(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
elif command == "login":
|
||||
# python xhs_cli.py login <phone> <code> [country_code]
|
||||
if len(sys.argv) < 4:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "缺少手机号或验证码参数"
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
phone = sys.argv[2]
|
||||
code = sys.argv[3]
|
||||
country_code = sys.argv[4] if len(sys.argv) > 4 else "+86"
|
||||
|
||||
result = asyncio.run(login(phone, code, country_code))
|
||||
sys.stdout.write(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
elif command == "inject_cookies":
|
||||
# python xhs_cli.py inject_cookies <cookies_json>
|
||||
if len(sys.argv) < 3:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "缺少Cookie参数"
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
cookies_json = sys.argv[2]
|
||||
result = asyncio.run(inject_cookies(cookies_json))
|
||||
sys.stdout.write(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
else:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": f"未知命令: {command}"
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1414
backend/xhs_login.py
Normal file
571
backend/xhs_publish.py
Normal file
@@ -0,0 +1,571 @@
|
||||
"""
|
||||
小红书笔记发布脚本
|
||||
提供Cookie、文案(标题、内容、标签、图片)完成发布操作
|
||||
支持本地图片路径和网络URL图片
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import aiohttp
|
||||
import hashlib
|
||||
import unicodedata
|
||||
from typing import List, Dict, Any, Union
|
||||
from pathlib import Path
|
||||
from xhs_login import XHSLoginService
|
||||
|
||||
|
||||
class XHSPublishService:
|
||||
"""小红书笔记发布服务"""
|
||||
|
||||
def __init__(self, cookies: Union[List[Dict[str, Any]], Dict[str, str]], proxy: str | None = None, user_agent: str | None = None):
|
||||
"""
|
||||
初始化发布服务
|
||||
|
||||
Args:
|
||||
cookies: Cookie数据,支持两种格式:
|
||||
1. Playwright格式(列表): [{"name": "a1", "value": "xxx", "domain": "...", ...}]
|
||||
2. 键值对格式(字典): {"a1": "xxx", "webId": "yyy", ...}
|
||||
proxy: 可选的代理地址(例如 http://user:pass@ip:port)
|
||||
user_agent: 可选的自定义User-Agent
|
||||
"""
|
||||
# 转换Cookie格式
|
||||
self.cookies = self._normalize_cookies(cookies)
|
||||
self.proxy = proxy
|
||||
self.user_agent = user_agent
|
||||
self.service = XHSLoginService()
|
||||
self.temp_dir = "temp_downloads" # 临时下载目录
|
||||
self.downloaded_files = [] # 记录下载的文件,用于清理
|
||||
|
||||
def _normalize_cookies(self, cookies: Union[List[Dict[str, Any]], Dict[str, str]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将Cookie标准化为Playwright格式
|
||||
|
||||
Args:
|
||||
cookies: 输入的Cookie(支持两种格式)
|
||||
|
||||
Returns:
|
||||
Playwright格式的Cookie列表
|
||||
"""
|
||||
# 如果已经是列表格式(Playwright格式)
|
||||
if isinstance(cookies, list):
|
||||
# 检查是否包含必要字段
|
||||
if cookies and 'name' in cookies[0] and 'value' in cookies[0]:
|
||||
print("✅ 使用 Playwright 格式的 Cookie", file=sys.stderr)
|
||||
return cookies
|
||||
|
||||
# 如果是字典格式(键值对格式),转换为Playwright格式
|
||||
if isinstance(cookies, dict):
|
||||
print("✅ 检测到键值对格式的 Cookie,转换为 Playwright 格式", file=sys.stderr)
|
||||
playwright_cookies = []
|
||||
for name, value in cookies.items():
|
||||
cookie = {
|
||||
"name": name,
|
||||
"value": str(value),
|
||||
"domain": ".xiaohongshu.com",
|
||||
"path": "/",
|
||||
"expires": -1, # 会话Cookie
|
||||
"httpOnly": False,
|
||||
"secure": False,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
|
||||
# 特殊处理某些Cookie的属性
|
||||
if name == "web_session":
|
||||
cookie["httpOnly"] = True
|
||||
cookie["secure"] = True
|
||||
elif name in ["acw_tc"]:
|
||||
cookie["httpOnly"] = True
|
||||
|
||||
playwright_cookies.append(cookie)
|
||||
|
||||
print(f" 转换了 {len(playwright_cookies)} 个 Cookie", file=sys.stderr)
|
||||
return playwright_cookies
|
||||
|
||||
# 如果格式不支持,抛出异常
|
||||
raise ValueError(f"不支持的Cookie格式: {type(cookies)}。请使用列表或字典格式。")
|
||||
|
||||
def _calculate_title_width(self, title: str) -> int:
|
||||
width = 0
|
||||
for ch in title:
|
||||
if unicodedata.east_asian_width(ch) in ("F", "W"):
|
||||
width += 2
|
||||
else:
|
||||
width += 1
|
||||
return width
|
||||
|
||||
def is_url(self, path: str) -> bool:
|
||||
"""
|
||||
判断是否为网络URL
|
||||
|
||||
Args:
|
||||
path: 图片路径或URL
|
||||
|
||||
Returns:
|
||||
是否为URL
|
||||
"""
|
||||
url_pattern = re.compile(r'^https?://', re.IGNORECASE)
|
||||
return bool(url_pattern.match(path))
|
||||
|
||||
async def download_image(self, url: str, index: int = 0) -> str:
|
||||
"""
|
||||
下载网络图片到本地临时目录
|
||||
|
||||
Args:
|
||||
url: 图片URL
|
||||
index: 图片索引(用于命名)
|
||||
|
||||
Returns:
|
||||
本地文件路径
|
||||
"""
|
||||
try:
|
||||
print(f" 正在下载图片 [{index + 1}]: {url}", file=sys.stderr)
|
||||
|
||||
# 创建临时目录
|
||||
Path(self.temp_dir).mkdir(exist_ok=True)
|
||||
|
||||
# 生成文件名(使用URL的hash值)
|
||||
url_hash = hashlib.md5(url.encode()).hexdigest()[:10]
|
||||
|
||||
# 从URL提取文件扩展名
|
||||
ext = '.jpg' # 默认扩展名
|
||||
url_path = url.split('?')[0] # 去除URL参数
|
||||
if '.' in url_path:
|
||||
ext = '.' + url_path.split('.')[-1].lower()
|
||||
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
|
||||
ext = '.jpg'
|
||||
|
||||
filename = f"image_{index}_{url_hash}{ext}"
|
||||
filepath = os.path.join(self.temp_dir, filename)
|
||||
|
||||
# 下载图片
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response:
|
||||
if response.status == 200:
|
||||
content = await response.read()
|
||||
|
||||
# 保存文件
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
# 记录已下载文件
|
||||
self.downloaded_files.append(filepath)
|
||||
|
||||
# 获取文件大小
|
||||
file_size = len(content) / 1024 # KB
|
||||
print(f" ✅ 下载成功: {filename} ({file_size:.1f}KB)", file=sys.stderr)
|
||||
|
||||
return os.path.abspath(filepath)
|
||||
else:
|
||||
raise Exception(f"下载失败,HTTP状态码: {response.status}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception(f"下载超时: {url}")
|
||||
except Exception as e:
|
||||
raise Exception(f"下载图片失败 ({url}): {str(e)}")
|
||||
|
||||
async def process_images(self, images: List[str]) -> List[str]:
|
||||
"""
|
||||
处理图片列表,将网络URL下载到本地
|
||||
|
||||
Args:
|
||||
images: 图片路径列表(可以是本地路径或网络URL)
|
||||
|
||||
Returns:
|
||||
本地图片路径列表
|
||||
"""
|
||||
if not images:
|
||||
return []
|
||||
|
||||
local_images = []
|
||||
|
||||
print(f"\n正在处理 {len(images)} 张图片...", file=sys.stderr)
|
||||
|
||||
for i, img in enumerate(images):
|
||||
if self.is_url(img):
|
||||
# 网络URL,需要下载
|
||||
try:
|
||||
local_path = await self.download_image(img, i)
|
||||
local_images.append(local_path)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 图片下载失败: {str(e)}", file=sys.stderr)
|
||||
# 继续处理其他图片
|
||||
continue
|
||||
else:
|
||||
# 本地路径
|
||||
if os.path.exists(img):
|
||||
local_images.append(os.path.abspath(img))
|
||||
print(f" ✅ 本地图片 [{i + 1}]: {os.path.basename(img)}", file=sys.stderr)
|
||||
else:
|
||||
print(f" ⚠️ 本地图片不存在: {img}", file=sys.stderr)
|
||||
|
||||
print(f"\n成功处理 {len(local_images)}/{len(images)} 张图片", file=sys.stderr)
|
||||
return local_images
|
||||
|
||||
def cleanup_temp_files(self):
|
||||
"""
|
||||
清理临时下载的文件
|
||||
"""
|
||||
if not self.downloaded_files:
|
||||
return
|
||||
|
||||
print(f"\n清理 {len(self.downloaded_files)} 个临时文件...", file=sys.stderr)
|
||||
for filepath in self.downloaded_files:
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
print(f" 已删除: {os.path.basename(filepath)}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f" 删除失败 {filepath}: {e}", file=sys.stderr)
|
||||
|
||||
# 清空记录
|
||||
self.downloaded_files = []
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
images: List[str] = None,
|
||||
tags: List[str] = None,
|
||||
cleanup: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发布笔记
|
||||
|
||||
Args:
|
||||
title: 笔记标题
|
||||
content: 笔记内容
|
||||
images: 图片路径列表(支持本地文件路径或网络URL)
|
||||
tags: 标签列表(例如:["美食", "探店"])
|
||||
cleanup: 是否清理临时下载的图片文件(默认True)
|
||||
|
||||
Returns:
|
||||
Dict containing success status, message, and publish result
|
||||
"""
|
||||
try:
|
||||
print("\n========== 开始发布小红书笔记 ==========", file=sys.stderr)
|
||||
print(f"标题: {title}", file=sys.stderr)
|
||||
print(f"内容: {content[:100]}{'...' if len(content) > 100 else ''}", file=sys.stderr)
|
||||
print(f"图片: {len(images) if images else 0} 张", file=sys.stderr)
|
||||
print(f"标签: {tags if tags else []}", file=sys.stderr)
|
||||
|
||||
width = self._calculate_title_width(title)
|
||||
if width > 40:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"标题长度超过限制(当前宽度 {width},平台限制 40)"
|
||||
}
|
||||
|
||||
if tags:
|
||||
if len(tags) > 10:
|
||||
tags = tags[:10]
|
||||
print("⚠️ 标签数量超过10,已截取前10个标签", file=sys.stderr)
|
||||
|
||||
local_images = None
|
||||
if images:
|
||||
local_images = await self.process_images(images)
|
||||
if not local_images:
|
||||
print("⚠️ 警告:没有可用的图片", file=sys.stderr)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有可用的图片,无法发布笔记"
|
||||
}
|
||||
|
||||
# 初始化浏览器并注入Cookie
|
||||
print("\n1. 初始化浏览器...", file=sys.stderr)
|
||||
await self.service.init_browser(cookies=self.cookies, proxy=self.proxy, user_agent=self.user_agent)
|
||||
|
||||
# 验证登录状态
|
||||
print("\n2. 验证登录状态...", file=sys.stderr)
|
||||
verify_result = await self.service.verify_login_status()
|
||||
|
||||
if not verify_result.get('logged_in'):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Cookie已失效或未登录",
|
||||
"details": verify_result
|
||||
}
|
||||
|
||||
print("✅ 登录状态有效", file=sys.stderr)
|
||||
|
||||
# 发布笔记
|
||||
print("\n3. 开始发布笔记...", file=sys.stderr)
|
||||
result = await self.service.publish_note(
|
||||
title=title,
|
||||
content=content,
|
||||
images=local_images,
|
||||
topics=tags
|
||||
)
|
||||
|
||||
print("\n========== 发布完成 ==========", file=sys.stderr)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n发布异常: {str(e)}", file=sys.stderr)
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
finally:
|
||||
# 关闭浏览器
|
||||
await self.service.close_browser()
|
||||
|
||||
# 清理临时文件
|
||||
if cleanup:
|
||||
self.cleanup_temp_files()
|
||||
|
||||
|
||||
async def publish_from_config(config_file: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从配置文件读取参数并发布
|
||||
|
||||
Args:
|
||||
config_file: JSON配置文件路径
|
||||
|
||||
Returns:
|
||||
发布结果
|
||||
"""
|
||||
try:
|
||||
# 读取配置文件
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# 提取参数
|
||||
cookies = config.get('cookies', [])
|
||||
title = config.get('title', '')
|
||||
content = config.get('content', '')
|
||||
images = config.get('images', [])
|
||||
tags = config.get('tags', [])
|
||||
proxy = config.get('proxy')
|
||||
user_agent = config.get('user_agent')
|
||||
|
||||
# 验证必需参数
|
||||
if not cookies:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "缺少Cookie参数"
|
||||
}
|
||||
|
||||
if not title or not content:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "标题和内容不能为空"
|
||||
}
|
||||
|
||||
# 注意:不再验证图片文件是否存在,因为可能是网络URL
|
||||
# 图片验证交给 process_images 方法处理
|
||||
|
||||
# 创建发布服务并执行
|
||||
publisher = XHSPublishService(cookies, proxy=proxy, user_agent=user_agent)
|
||||
result = await publisher.publish(
|
||||
title=title,
|
||||
content=content,
|
||||
images=images,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"读取配置文件失败: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
async def publish_from_params(
|
||||
cookies_json: str,
|
||||
title: str,
|
||||
content: str,
|
||||
images_json: str = None,
|
||||
tags_json: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
从命令行参数发布
|
||||
|
||||
Args:
|
||||
cookies_json: Cookie JSON字符串 或 Cookie文件路径
|
||||
title: 标题
|
||||
content: 内容
|
||||
images_json: 图片路径数组的JSON字符串 (可选)
|
||||
tags_json: 标签数组的JSON字符串 (可选)
|
||||
|
||||
Returns:
|
||||
发布结果
|
||||
"""
|
||||
try:
|
||||
# 解析Cookie - 支持JSON字符串或文件路径
|
||||
cookies = None
|
||||
|
||||
# 检查是否为文件路径
|
||||
if os.path.isfile(cookies_json):
|
||||
# 从文件读取
|
||||
try:
|
||||
with open(cookies_json, 'r', encoding='utf-8') as f:
|
||||
cookies = json.load(f)
|
||||
print(f"✅ 从文件加载 Cookie: {cookies_json}")
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"读取 Cookie 文件失败: {str(e)}"
|
||||
}
|
||||
else:
|
||||
# 解析JSON字符串
|
||||
try:
|
||||
cookies = json.loads(cookies_json)
|
||||
print("✅ 从 JSON 字符串解析 Cookie")
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cookie 参数既不是有效文件路径,也不是有效 JSON 字符串: {str(e)}"
|
||||
}
|
||||
|
||||
if not cookies:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Cookie 为空"
|
||||
}
|
||||
|
||||
# 解析图片列表
|
||||
images = []
|
||||
if images_json:
|
||||
images = json.loads(images_json)
|
||||
|
||||
# 解析标签列表
|
||||
tags = []
|
||||
if tags_json:
|
||||
tags = json.loads(tags_json)
|
||||
|
||||
# 创建发布服务并执行(命令行模式暂不支持传入代理和自定义UA)
|
||||
publisher = XHSPublishService(cookies)
|
||||
result = await publisher.publish(
|
||||
title=title,
|
||||
content=content,
|
||||
images=images,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"JSON解析失败: {str(e)}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
命令行主函数
|
||||
|
||||
使用方式:
|
||||
1. 从配置文件发布:
|
||||
python xhs_publish.py --config publish_config.json
|
||||
|
||||
2. 从命令行参数发布:
|
||||
python xhs_publish.py --cookies '<cookies_json>' --title '标题' --content '内容' [--images '<images_json>'] [--tags '<tags_json>']
|
||||
"""
|
||||
# 设置标准输出为UTF-8编码
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "缺少参数,请使用 --config 或 --cookies"
|
||||
}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# 解析命令行参数
|
||||
args = sys.argv[1:]
|
||||
|
||||
# 方式1: 从配置文件读取
|
||||
if args[0] == '--config':
|
||||
if len(args) < 2:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "缺少配置文件路径"
|
||||
}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
config_file = args[1]
|
||||
result = asyncio.run(publish_from_config(config_file))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
# 方式2: 从命令行参数
|
||||
elif args[0] == '--cookies':
|
||||
# 解析参数
|
||||
params = {}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == '--cookies' and i + 1 < len(args):
|
||||
params['cookies'] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == '--title' and i + 1 < len(args):
|
||||
params['title'] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == '--content' and i + 1 < len(args):
|
||||
params['content'] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == '--images' and i + 1 < len(args):
|
||||
params['images'] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == '--tags' and i + 1 < len(args):
|
||||
params['tags'] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# 验证必需参数
|
||||
if 'cookies' not in params:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "缺少 --cookies 参数"
|
||||
}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
if 'title' not in params or 'content' not in params:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "缺少 --title 或 --content 参数"
|
||||
}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
result = asyncio.run(publish_from_params(
|
||||
cookies_json=params['cookies'],
|
||||
title=params['title'],
|
||||
content=params['content'],
|
||||
images_json=params.get('images'),
|
||||
tags_json=params.get('tags')
|
||||
))
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
else:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": f"未知参数: {args[0]},请使用 --config 或 --cookies"
|
||||
}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||