first commit

This commit is contained in:
mzl
2026-01-12 12:04:05 +08:00
commit 8b6e431c12
17 changed files with 3358 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# 虚拟环境
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
# 数据文件(运行时生成)
data/*.json
data/logs.json
data/auth_state.json
# Git
.git/
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# 文档
README.md
*.md
# 环境变量
.env
# 日志
*.log
+21
View File
@@ -0,0 +1,21 @@
# 环境变量配置示例
# 复制此文件为 .env 并根据需要修改
# Web 服务配置
HOST=0.0.0.0
PORT=18080
# 浏览器配置
HEADLESS=false
SCREENSHOT_ON_ERROR=true
# 采集配置
MAX_THREADS=5
# Git 自动更新配置(可选)
# 启用后,容器会自动从 Git 仓库拉取最新代码
# 注意:Python 代码变化后需要重启容器才能生效
ENABLE_GIT_PULL=false
GIT_REPO_URL=http://219.157.255.213:25311/xinyouhe/xhs_crawler_claude.git
GIT_BRANCH=master
AUTO_PULL_INTERVAL=3600
+61
View File
@@ -0,0 +1,61 @@
FROM python:3.10-slim
# 安装系统依赖(包含 Git)
RUN apt-get update && apt-get install -y \
wget \
gnupg \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libwayland-client0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
xdg-utils \
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 复制依赖文件
COPY requirements.txt .
# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt
# 安装 Playwright 浏览器
RUN playwright install chromium
RUN playwright install-deps chromium
# 复制应用代码
COPY . .
# 创建数据目录
RUN mkdir -p /app/data /app/data/screenshots
# 暴露端口
EXPOSE 18080
# 设置环境变量
ENV HOST=0.0.0.0
ENV PORT=18080
ENV HEADLESS=false
ENV SCREENSHOT_ON_ERROR=true
# 启动脚本(支持 Git 自动更新)
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
+296
View File
@@ -0,0 +1,296 @@
# 小红书数据采集器
一个带 Web 可视化监控界面的小红书批量数据采集器,支持多账号增量采集、实时进度展示、持久化日志追踪、单/多线程切换。
## 功能特性
- **Web 可视化界面**: 实时监控采集进度和日志
- **单/多线程模式**: 支持切换单线程或多线程采集,可配置线程数
- **增量采集**: 自动识别已采集内容,仅采集新增笔记
- **持久化日志**: 日志保存到文件,重启后可查看历史记录
- **风控策略**: 拟人化延迟、平滑滚动、风控检测
- **云部署友好**: 支持 Docker 部署
## 项目结构
```
demo/
├── app/
│ ├── __init__.py
│ ├── config.py # 配置文件
│ ├── log_manager.py # 日志管理器
│ ├── scraper.py # 核心采集逻辑
│ └── web.py # Web API
├── data/
│ ├── users.txt # 用户 ID 输入文件
│ ├── logs.json # 持久化日志
│ └── {user_id}.json # 采集数据
├── templates/
│ └── index.html # 前端页面
├── static/
│ ├── style.css # 样式
│ └── app.js # 前端逻辑
├── main.py # 程序入口
├── requirements.txt # 依赖列表
└── README.md
```
## 安装
### 1. 克隆项目
```bash
cd demo
```
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 安装 Playwright 浏览器
```bash
playwright install chromium
```
## 使用
### 1. 启动服务
```bash
python main.py
```
服务将在 `http://localhost:18080` 启动。
### 2. 添加用户 ID
在 `data/users.txt` 中添加要采集的用户 ID,每行一个:
```
5ef74296000000000100003a
5b6c1f9e0000000001000388
```
### 3. 配置采集模式
在 Web 界面中选择:
- **单线程模式**: 一次采集一个用户,更安全
- **多线程模式**: 同时采集多个用户,更快
多线程模式下可设置线程数(1-10)。
### 4. 启动采集
点击「启动采集」按钮,在弹出的浏览器窗口中扫码登录。
登录成功后,采集器会自动开始工作,你可以在 Web 界面实时查看:
- 整体进度
- 用户状态
- 线程监控(多线程模式)
- 实时日志
## 配置
### 环境变量
创建 `.env` 文件配置以下参数:
```env
HOST=0.0.0.0
PORT=18080
HEADLESS=false
SCREENSHOT_ON_ERROR=true
MAX_THREADS=5
```
### 采集参数
修改 `app/config.py` 中的参数:
```python
# 延迟配置(秒)
PAGE_LOAD_DELAY = (2, 5) # 页面加载后等待
NOTE_READ_DELAY = (3, 8) # 笔记详情页停留
NOTE_INTERVAL_DELAY = (3, 6) # 采集笔记间隔
USER_SWITCH_DELAY = (30, 60) # 切换用户间隔
# 多线程配置
THREAD_MIN_DELAY = 2 # 线程间最小间隔
THREAD_OFFSET = 5 # 线程启动偏移
```
## 数据格式
### 输入文件 (data/users.txt)
```
5ef74296000000000100003a
5b6c1f9e0000000001000388
```
### 输出文件 (data/{user_id}.json)
```json
{
"user_id": "5ef74296000000000100003a",
"last_updated": "2025-01-12T10:30:00",
"total_notes": 12,
"notes": [
{
"title": "笔记标题",
"publish_time": "2025-01-10 14:30",
"location": "上海",
"comment_count": 128,
"like_count": 5234,
"collect_count": 892,
"note_url": "https://www.xiaohongshu.com/explore/..."
}
]
}
```
## Docker 部署
### 方式一:使用 Docker Compose(推荐)
```bash
# 构建并启动
docker-compose up -d
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
```
### 方式二:使用 Docker 命令
#### 构建镜像
```bash
docker build -t xiaohongshu-scraper .
```
#### 运行容器
```bash
# 本地开发模式(可以看到浏览器窗口)
docker run -d \
-p 18080:18080 \
-v $(pwd)/data:/app/data \
-e HEADLESS=false \
--name xiaohongshu-scraper \
xiaohongshu-scraper
# 云服务器模式(无头模式)
docker run -d \
-p 18080:18080 \
-v $(pwd)/data:/app/data \
-e HEADLESS=true \
--name xiaohongshu-scraper \
xiaohongshu-scraper
```
#### 管理容器
```bash
# 查看日志
docker logs -f xiaohongshu-scraper
# 停止容器
docker stop xiaohongshu-scraper
# 启动容器
docker start xiaohongshu-scraper
# 删除容器
docker rm xiaohongshu-scraper
# 进入容器
docker exec -it xiaohongshu-scraper bash
```
### Docker 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| HOST | 0.0.0.0 | 监听地址 |
| PORT | 18080 | 监听端口(范围 10000-50000)|
| HEADLESS | false | 是否无头模式(云服务器设为 true)|
| SCREENSHOT_ON_ERROR | true | 错误时截图 |
| MAX_THREADS | 5 | 最大线程数 |
| ENABLE_GIT_PULL | false | 启用 Git 自动更新 |
| GIT_REPO_URL | - | Git 仓库地址 |
| GIT_BRANCH | main | Git 分支 |
| AUTO_PULL_INTERVAL | 3600 | 自动拉取间隔(秒)|
### 数据持久化
通过挂载 `./data` 目录到容器的 `/app/data`,确保以下数据持久化:
- `users.txt` - 用户 ID 列表
- `{user_id}.json` - 采集数据
- `logs.json` - 运行日志
- `auth_state.json` - 登录状态(Cookie)
- `screenshots/` - 错误截图
### Git 自动更新
支持通过 Git 自动更新代码(适用于云部署):
#### 1. 配置 Git 自动更新
在 `docker-compose.yml` 中添加环境变量:
```yaml
environment:
- ENABLE_GIT_PULL=true
- GIT_REPO_URL=https://github.com/yourusername/xiaohongshu-scraper.git
- GIT_BRANCH=main
- AUTO_PULL_INTERVAL=3600
```
#### 2. 工作原理
- 容器启动后,会定期从 Git 仓库拉取最新代码
- 如果检测到 Python 文件变化,会提示需要重启容器
- 静态文件(HTML/CSS/JS)变化会立即生效,无需重启
#### 3. 手动更新
通过 API 手动触发更新:
```bash
# 检查 Git 状态
curl http://your-server:18080/api/git/status
# 拉取最新代码
curl -X POST http://your-server:18080/api/git/pull
```
#### 4. 重启容器
如果 Python 代码有变化,需要重启容器:
```bash
# 使用 Docker Compose
docker-compose restart
# 使用 Docker 命令
docker restart xiaohongshu-scraper
```
## 注意事项
1. **风控风险**: 本工具仅供学习交流使用,请遵守小红书的使用条款
2. **采集频率**: 建议使用单线程模式,多线程模式可能增加风控风险
3. **登录状态**: 首次登录后会保存 Cookie,后续无需重新登录
4. **页面结构**: 小红书页面结构可能变化,如遇采集失败请检查 CSS 选择器
## 许可证
MIT License
+2
View File
@@ -0,0 +1,2 @@
# XiaoHongShu Scraper Package
__version__ = "1.0.0"
+115
View File
@@ -0,0 +1,115 @@
"""
XiaoHongShu Scraper Configuration
采集器配置文件
"""
import os
from dotenv import load_dotenv
load_dotenv()
# ========== 基础配置 ==========
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
SCREENSHOT_DIR = os.path.join(DATA_DIR, "screenshots")
# 确保目录存在
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
# ========== 采集字段配置 ==========
FIELDS = ['title', 'publish_time', 'location', 'comment_count', 'like_count', 'collect_count', 'note_url']
# ========== 延迟配置(秒)==========
PAGE_LOAD_DELAY = (2, 5) # 页面加载后等待
NOTE_READ_DELAY = (3, 8) # 笔记详情页停留(模拟阅读)
NOTE_INTERVAL_DELAY = (3, 6) # 采集完一个笔记后的间隔
USER_SWITCH_DELAY = (30, 60) # 切换用户时的间隔
# ========== 多线程配置 ==========
THREAD_MIN_DELAY = 2 # 线程间最小间隔(秒)
THREAD_OFFSET = 5 # 线程启动偏移(秒)
MAX_CONCURRENT_THREADS = 10 # 最大线程数
# ========== 滚动配置 ==========
SCROLL_STEP_MIN = 100 # 每次滚动最小像素
SCROLL_STEP_MAX = 300 # 每次滚动最大像素
SCROLL_PAUSE_MIN = 0.5 # 滚动后暂停最小时间(秒)
SCROLL_PAUSE_MAX = 2.0 # 滚动后暂停最大时间(秒)
MAX_SCROLL_COUNT = 50 # 最大滚动次数(防止无限滚动)
# ========== 风控检测关键词 ==========
RISK_KEYWORDS = [
'验证码',
'访问过于频繁',
'页面不存在',
'验证',
'流量异常',
'安全验证',
'人机验证',
'滑块验证'
]
# ========== 小红书 URL 配置 ==========
XIAOHONGSHU_BASE_URL = "https://www.xiaohongshu.com"
USER_PROFILE_URL_TEMPLATE = f"{XIAOHONGSHU_BASE_URL}/user/profile/{{user_id}}"
# ========== 浏览器配置 ==========
HEADLESS = os.getenv("HEADLESS", "false").lower() == "true"
BROWSER_ARGS = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled'
]
# 视口配置
VIEWPORT_WIDTH = 1920
VIEWPORT_HEIGHT = 1080
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
# ========== 日志配置 ==========
LOG_FILE = os.path.join(DATA_DIR, "logs.json")
MAX_LOGS = 10000 # 最大日志条数
# ========== Web 配置 ==========
WEB_HOST = os.getenv("HOST", "0.0.0.0")
WEB_PORT = int(os.getenv("PORT", "18080")) # 默认端口 18080,范围 10000-50000
SCREENSHOT_ON_ERROR = os.getenv("SCREENSHOT_ON_ERROR", "true").lower() == "true"
# ========== Git 更新配置 ==========
ENABLE_GIT_PULL = os.getenv("ENABLE_GIT_PULL", "false").lower() == "true"
GIT_REPO_URL = os.getenv("GIT_REPO_URL", "")
GIT_BRANCH = os.getenv("GIT_BRANCH", "main")
AUTO_PULL_INTERVAL = int(os.getenv("AUTO_PULL_INTERVAL", "3600")) # 自动拉取间隔(秒),默认1小时
# ========== CSS 选择器(可能需要根据小红书页面结构调整)==========
# 用户主页
SELECTOR_USER_CONTAINER = "div.user-notes"
SELECTOR_NOTE_CARD = "div[class*='note-item'], section[class*='note']"
SELECTOR_NOTE_LINK = "a[href*='/explore/']"
SELECTOR_NOTE_TITLE = "span[class*='title'], div[class*='title']"
SELECTOR_NOTE_CONTENT = "div[class*='content'], span[class*='desc']"
# 笔记详情页
SELECTOR_DETAIL_TITLE = "div[class*='title'], span[class*='title']"
SELECTOR_DETAIL_PUBLISH_TIME = "span[class*='time'], div[class*='time']"
SELECTOR_DETAIL_LOCATION = "span[class*='location'], div[class*='location']"
SELECTOR_DETAIL_COMMENT_COUNT = "span[class*='comment'], button[class*='comment']"
SELECTOR_DETAIL_LIKE_COUNT = "span[class*='like'], button[class*='like']"
SELECTOR_DETAIL_COLLECT_COUNT = "span[class*='collect'], button[class*='star']"
# ========== 登录配置 ==========
LOGIN_URL = f"{XIAOHONGSHU_BASE_URL}/login"
AUTH_STATE_FILE = os.path.join(DATA_DIR, "auth_state.json")
LOGIN_TIMEOUT = 300 # 登录等待超时(秒)
+153
View File
@@ -0,0 +1,153 @@
"""
Log Manager - 持久化日志管理器
支持日志加载、保存、追加、清空
"""
import json
import os
import threading
from datetime import datetime
from typing import List, Dict, Optional
from app import config
class LogManager:
"""日志管理器 - 线程安全"""
def __init__(self, log_file: str = None, max_logs: int = None):
self.log_file = log_file or config.LOG_FILE
self.max_logs = max_logs or config.MAX_LOGS
self._lock = threading.Lock()
self.logs: List[Dict] = []
self._load_logs()
def _load_logs(self):
"""启动时加载历史日志"""
with self._lock:
if os.path.exists(self.log_file):
try:
with open(self.log_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.logs = data.get('logs', [])
except (json.JSONDecodeError, IOError) as e:
print(f"[LogManager] 加载日志文件失败: {e}")
self.logs = []
else:
self.logs = []
def append(self, level: str, message: str, thread_id: Optional[str] = None) -> Dict:
"""
追加新日志
返回日志条目
"""
with self._lock:
log_entry = {
"timestamp": datetime.now().isoformat(),
"level": level,
"message": message,
"thread_id": thread_id
}
self.logs.append(log_entry)
# 限制日志数量(保留最新的 max_logs 条)
if len(self.logs) > self.max_logs:
self.logs = self.logs[-self.max_logs:]
# 异步保存到文件
self._save()
return log_entry
def _save(self):
"""保存到文件(需在锁内调用)"""
try:
with open(self.log_file, 'w', encoding='utf-8') as f:
json.dump({
"logs": self.logs,
"last_updated": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
except IOError as e:
print(f"[LogManager] 保存日志文件失败: {e}")
def get_logs(self, level: Optional[str] = None, limit: Optional[int] = None) -> List[Dict]:
"""
获取日志
:param level: 过滤级别 (None/INFO/SUCCESS/WARNING/ERROR/DEBUG)
:param limit: 限制返回数量
"""
with self._lock:
if level:
filtered = [log for log in self.logs if log['level'] == level]
else:
filtered = self.logs
if limit:
return filtered[-limit:]
return filtered
def search_logs(self, keyword: str, limit: Optional[int] = None) -> List[Dict]:
"""
搜索日志
:param keyword: 搜索关键词
:param limit: 限制返回数量
"""
with self._lock:
filtered = [
log for log in self.logs
if keyword.lower() in log['message'].lower()
]
if limit:
return filtered[-limit:]
return filtered
def clear(self):
"""清空日志"""
with self._lock:
self.logs = []
self._save()
def get_stats(self) -> Dict:
"""获取日志统计信息"""
with self._lock:
stats = {
"total": len(self.logs),
"by_level": {}
}
for log in self.logs:
level = log['level']
stats["by_level"][level] = stats["by_level"].get(level, 0) + 1
return stats
def export_to_text(self) -> str:
"""导出为文本格式"""
with self._lock:
lines = []
lines.append("=" * 80)
lines.append("小红书采集器日志")
lines.append(f"导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append(f"总日志数: {len(self.logs)}")
lines.append("=" * 80)
lines.append("")
for log in self.logs:
timestamp = log['timestamp'][:19] # 去掉微秒
level = log['level'].ljust(8)
thread = f"[{log['thread_id']}]" if log.get('thread_id') else ""
message = log['message']
lines.append(f"{timestamp} | {level} | {thread} {message}")
return "\n".join(lines)
# 全局日志管理器实例
_log_manager_instance = None
def get_log_manager() -> LogManager:
"""获取全局日志管理器实例"""
global _log_manager_instance
if _log_manager_instance is None:
_log_manager_instance = LogManager()
return _log_manager_instance
+641
View File
@@ -0,0 +1,641 @@
"""
XiaoHongShu Scraper - 核心采集逻辑
支持单线程和多线程模式
"""
import asyncio
import json
import os
import random
from datetime import datetime
from typing import List, Dict, Optional, AsyncIterator
from playwright.async_api import async_playwright, Browser, BrowserContext, Page, TimeoutError as PlaywrightTimeoutError
from app import config
from app.log_manager import LogManager
class RiskControlException(Exception):
"""风控异常"""
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"触发风控: {reason}")
class XiaoHongShuScraper:
"""小红书采集器"""
def __init__(self, event_queue: asyncio.Queue, log_manager: LogManager):
self.event_queue = event_queue
self.log_manager = log_manager
self.playwright = None
self.browser: Optional[Browser] = None
self.is_running = False
self._stop_requested = False
async def init_browser(self):
"""初始化浏览器"""
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.launch(
headless=config.HEADLESS,
args=config.BROWSER_ARGS
)
await self.emit_log("SUCCESS", "浏览器初始化完成")
async def close_browser(self):
"""关闭浏览器"""
if self.browser:
await self.browser.close()
if self.playwright:
await self.playwright.stop()
await self.emit_log("INFO", "浏览器已关闭")
async def emit_log(self, level: str, message: str, thread_id: Optional[str] = None):
"""发送日志事件"""
# 持久化到日志管理器
log_entry = self.log_manager.append(level, message, thread_id)
# 推送到 Web 前端
await self.event_queue.put({
"type": "log",
**log_entry
})
async def emit_progress(self, **kwargs):
"""发送进度更新"""
await self.event_queue.put({
"type": "progress",
**kwargs
})
async def emit_user_status(self, user_id: str, status: str, note_count: int = 0, thread_id: Optional[str] = None):
"""发送用户状态"""
await self.event_queue.put({
"type": "user_status",
"user_id": user_id,
"status": status,
"note_count": note_count,
"thread_id": thread_id
})
async def emit_thread_status(self, thread_id: str, status: str, current_user: Optional[str] = None):
"""发送线程状态"""
await self.event_queue.put({
"type": "thread_status",
"thread_id": thread_id,
"status": status,
"current_user": current_user
})
async def handle_login(self, context: BrowserContext) -> bool:
"""
处理登录
返回是否已登录
"""
page = await context.new_page()
await page.goto(config.LOGIN_URL)
await self.emit_log("WARNING", "请在浏览器中完成扫码登录...")
await self.emit_log("INFO", "等待登录确认...")
# 等待登录成功(检查 URL 变化或特定元素)
try:
# 等待跳转到首页或出现用户头像
await page.wait_for_url(
f"{config.XIAOHONGSHU_BASE_URL}/**",
timeout=config.LOGIN_TIMEOUT * 1000
)
await self.emit_log("SUCCESS", "登录成功!")
# 保存登录状态
await context.storage_state(path=config.AUTH_STATE_FILE)
await self.emit_log("INFO", "登录状态已保存")
await page.close()
return True
except PlaywrightTimeoutError:
await self.emit_log("ERROR", "登录超时,请重试")
await page.close()
return False
async def copy_cookies(self, target_context: BrowserContext, source_context: BrowserContext):
"""复制 Cookies 到目标 Context"""
cookies = await source_context.cookies()
await target_context.add_cookies(cookies)
async def check_risk_control(self, page: Page) -> tuple[bool, str]:
"""
检测页面是否触发风控
返回 (是否触发, 触发原因)
"""
try:
content = await page.content()
for keyword in config.RISK_KEYWORDS:
if keyword in content:
return True, f"检测到关键词: {keyword}"
return False, ""
except Exception as e:
return False, f"检测失败: {str(e)}"
async def human_like_scroll(self, page: Page, thread_id: Optional[str] = None):
"""
模拟人类间歇性平滑滚动
"""
await self.emit_log("DEBUG", f"[{thread_id}] 开始平滑滚动加载笔记...", thread_id=thread_id)
scroll_count = 0
last_height = 0
while scroll_count < config.MAX_SCROLL_COUNT:
# 获取当前页面高度
current_height = await page.evaluate("document.body.scrollHeight")
# 如果高度没有变化,可能到底了
if current_height == last_height:
await self.emit_log("DEBUG", f"[{thread_id}] 已滚动到底部", thread_id=thread_id)
break
last_height = current_height
# 随机滚动距离
scroll_step = random.randint(config.SCROLL_STEP_MIN, config.SCROLL_STEP_MAX)
scroll_position = await page.evaluate("window.scrollY")
# 平滑滚动
await page.evaluate(f'''
window.scrollTo({{
top: {scroll_position + scroll_step},
behavior: 'smooth'
}});
''')
# 随机暂停
pause_time = random.uniform(config.SCROLL_PAUSE_MIN, config.SCROLL_PAUSE_MAX)
await asyncio.sleep(pause_time)
scroll_count += 1
# 每滚动几次检查一下风控
if scroll_count % 5 == 0:
is_risk, reason = await self.check_risk_control(page)
if is_risk:
raise RiskControlException(reason)
await self.emit_log("DEBUG", f"[{thread_id}] 滚动完成,共滚动 {scroll_count} 次", thread_id=thread_id)
async def extract_note_cards(self, page: Page, thread_id: Optional[str] = None) -> List[Dict]:
"""
从用户主页提取笔记卡片
返回 [{'url': '...', 'title': '...'}]
"""
await self.emit_log("DEBUG", f"[{thread_id}] 提取笔记卡片...", thread_id=thread_id)
note_urls = []
try:
# 尝试多种选择器
selectors = [
"a[href*='/explore/']",
"section[class*='note'] a[href*='/explore/']",
"div[class*='note-item'] a[href*='/explore/']",
]
links = []
for selector in selectors:
try:
elements = await page.query_selector_all(selector)
if elements:
links = elements
await self.emit_log("DEBUG", f"[{thread_id}] 使用选择器: {selector}", thread_id=thread_id)
break
except Exception:
continue
# 提取 URL
for link in links:
url = await link.get_attribute('href')
if url and '/explore/' in url:
# 补全 URL
if url.startswith('/'):
url = f"{config.XIAOHONGSHU_BASE_URL}{url}"
# 尝试获取标题
try:
title_elem = await link.query_selector("span, div")
title = await title_elem.inner_text() if title_elem else "无标题"
except Exception:
title = "无标题"
note_urls.append({
'url': url,
'title': title[:50] # 限制标题长度
})
await self.emit_log("INFO", f"[{thread_id}] 提取到 {len(note_urls)} 条笔记", thread_id=thread_id)
return note_urls
except Exception as e:
await self.emit_log("ERROR", f"[{thread_id}] 提取笔记卡片失败: {str(e)}", thread_id=thread_id)
return []
async def extract_note_detail(self, page: Page, thread_id: Optional[str] = None) -> Optional[Dict]:
"""
从笔记详情页提取数据
"""
try:
# 等待页面加载
await asyncio.sleep(random.uniform(*config.PAGE_LOAD_DELAY))
# 检查风控
is_risk, reason = await self.check_risk_control(page)
if is_risk:
raise RiskControlException(reason)
data = {}
# 提取标题
try:
title_elem = await page.query_selector(config.SELECTOR_DETAIL_TITLE)
data['title'] = await title_elem.inner_text() if title_elem else "无标题"
except Exception:
data['title'] = "无标题"
# 提取发布时间
try:
time_elem = await page.query_selector(config.SELECTOR_DETAIL_PUBLISH_TIME)
data['publish_time'] = await time_elem.inner_text() if time_elem else ""
except Exception:
data['publish_time'] = ""
# 提取发布地
try:
loc_elem = await page.query_selector(config.SELECTOR_DETAIL_LOCATION)
data['location'] = await loc_elem.inner_text() if loc_elem else ""
except Exception:
data['location'] = ""
# 提取互动数据(尝试从页面内容解析)
content = await page.content()
# 评论数
data['comment_count'] = self._extract_count(content, ['评论', 'comment'])
# 点赞数
data['like_count'] = self._extract_count(content, ['点赞', '赞', 'like'])
# 收藏数
data['collect_count'] = self._extract_count(content, ['收藏', 'star', 'collect'])
# 笔记链接
data['note_url'] = page.url
return data
except RiskControlException:
raise
except Exception as e:
await self.emit_log("ERROR", f"[{thread_id}] 提取详情失败: {str(e)}", thread_id=thread_id)
return None
def _extract_count(self, content: str, keywords: List[str]) -> int:
"""从页面内容提取计数"""
import re
for keyword in keywords:
# 匹配数字
pattern = f"{keyword}[\\s::]*([\\d.]+)"
match = re.search(pattern, content)
if match:
count_str = match.group(1)
# 处理万、k等单位
if '万' in count_str:
return int(float(count_str.replace('万', '')) * 10000)
elif 'k' in count_str.lower():
return int(float(count_str.lower().replace('k', '')) * 1000)
else:
return int(count_str)
return 0
async def save_user_data(self, user_id: str, new_notes: List[Dict]):
"""保存用户数据(增量)"""
file_path = os.path.join(config.DATA_DIR, f"{user_id}.json")
# 读取现有数据
existing_notes = []
if os.path.exists(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
existing_notes = data.get('notes', [])
except Exception as e:
await self.emit_log("WARNING", f"读取现有数据失败: {e}")
# 提取现有 URL
existing_urls = {note['note_url'] for note in existing_notes}
# 追加新笔记
added_count = 0
for note in new_notes:
if note['note_url'] not in existing_urls:
existing_notes.append(note)
existing_urls.add(note['note_url'])
added_count += 1
# 保存
with open(file_path, 'w', encoding='utf-8') as f:
json.dump({
"user_id": user_id,
"last_updated": datetime.now().isoformat(),
"total_notes": len(existing_notes),
"notes": existing_notes
}, f, ensure_ascii=False, indent=2)
return added_count
async def process_user(self, context: BrowserContext, user_id: str, thread_id: str = "Main") -> int:
"""
处理单个用户
返回新增笔记数
"""
await self.emit_user_status(user_id, "processing", thread_id=thread_id)
await self.emit_thread_status(thread_id, "busy", user_id)
try:
await self.emit_log("INFO", f"[{thread_id}] 开始处理用户: {user_id}", thread_id=thread_id)
# 创建新页面
page = await context.new_page()
# 访问用户主页
url = config.USER_PROFILE_URL_TEMPLATE.format(user_id=user_id)
await self.emit_log("INFO", f"[{thread_id}] 访问用户主页: {url}", thread_id=thread_id)
try:
await page.goto(url, timeout=30000)
except PlaywrightTimeoutError:
await self.emit_log("ERROR", f"[{thread_id}] 访问用户主页超时: {user_id}", thread_id=thread_id)
await page.close()
await self.emit_user_status(user_id, "failed", thread_id=thread_id)
await self.emit_thread_status(thread_id, "idle", None)
return 0
# 检查风控
is_risk, reason = await self.check_risk_control(page)
if is_risk:
await self.emit_log("ERROR", f"[{thread_id}] 访问用户主页触发风控: {reason}", thread_id=thread_id)
await page.close()
await self.emit_user_status(user_id, "failed", thread_id=thread_id)
await self.emit_thread_status(thread_id, "idle", None)
raise RiskControlException(reason)
await asyncio.sleep(random.uniform(*config.PAGE_LOAD_DELAY))
# 滚动加载所有笔记
await self.human_like_scroll(page, thread_id)
# 提取笔记卡片
note_cards = await self.extract_note_cards(page, thread_id)
if not note_cards:
await self.emit_log("WARNING", f"[{thread_id}] 未找到笔记,可能页面结构变化", thread_id=thread_id)
await page.close()
await self.emit_user_status(user_id, "completed", 0, thread_id=thread_id)
await self.emit_thread_status(thread_id, "idle", None)
return 0
# 采集每条笔记
collected_notes = []
for i, card in enumerate(note_cards):
if self._stop_requested:
await self.emit_log("WARNING", f"[{thread_id}] 收到停止信号", thread_id=thread_id)
break
await self.emit_log("INFO", f"[{thread_id}] 采集笔记 {i+1}/{len(note_cards)}: {card['title']}", thread_id=thread_id)
try:
# 访问详情页
await page.goto(card['url'], timeout=30000)
# 模拟阅读停留
await asyncio.sleep(random.uniform(*config.NOTE_READ_DELAY))
# 提取数据
note_data = await self.extract_note_detail(page, thread_id)
if note_data:
collected_notes.append(note_data)
await self.emit_log("SUCCESS", f"[{thread_id}] 成功采集: {note_data['title'][:30]}", thread_id=thread_id)
# 返回列表页
await page.go_back(url, wait_until="networkidle")
# 间隔
await asyncio.sleep(random.uniform(*config.NOTE_INTERVAL_DELAY))
except RiskControlException:
await self.emit_log("ERROR", f"[{thread_id}] 采集笔记触发风控", thread_id=thread_id)
raise
except Exception as e:
await self.emit_log("WARNING", f"[{thread_id}] 采集笔记失败: {str(e)}", thread_id=thread_id)
continue
await page.close()
# 保存数据
if collected_notes:
added_count = await self.save_user_data(user_id, collected_notes)
await self.emit_log("SUCCESS", f"[{thread_id}] 用户 {user_id} 采集完成,新增 {added_count} 条笔记", thread_id=thread_id)
await self.emit_user_status(user_id, "completed", len(collected_notes), thread_id=thread_id)
else:
await self.emit_log("INFO", f"[{thread_id}] 用户 {user_id} 无新笔记", thread_id=thread_id)
await self.emit_user_status(user_id, "completed", 0, thread_id=thread_id)
await self.emit_thread_status(thread_id, "idle", None)
return len(collected_notes)
except RiskControlException:
await self.emit_user_status(user_id, "failed", thread_id=thread_id)
await self.emit_thread_status(thread_id, "error", None)
raise
except Exception as e:
await self.emit_log("ERROR", f"[{thread_id}] 处理用户 {user_id} 失败: {str(e)}", thread_id=thread_id)
await self.emit_user_status(user_id, "failed", thread_id=thread_id)
await self.emit_thread_status(thread_id, "error", None)
return 0
async def run_single_thread(self, user_ids: List[str]):
"""单线程模式"""
await self.emit_log("INFO", "启动单线程采集模式")
# 创建 Context 并登录
context = await self.browser.new_context(
viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT},
user_agent=config.USER_AGENT
)
# 检查是否有保存的登录状态
if os.path.exists(config.AUTH_STATE_FILE):
await self.emit_log("INFO", "加载已保存的登录状态")
await context.add_cookies([json.load(open(config.AUTH_STATE_FILE))])
else:
# 需要登录
login_success = await self.handle_login(context)
if not login_success:
await context.close()
return
self.is_running = True
for i, user_id in enumerate(user_ids):
if self._stop_requested:
await self.emit_log("WARNING", "收到停止请求")
break
await self.emit_log("INFO", f"处理用户 {i+1}/{len(user_ids)}: {user_id}")
try:
await self.process_user(context, user_id, thread_id="Main")
# 用户切换延迟
if i < len(user_ids) - 1:
delay = random.uniform(*config.USER_SWITCH_DELAY)
await self.emit_log("INFO", f"等待 {delay:.1f} 秒后处理下一个用户...")
await asyncio.sleep(delay)
except RiskControlException as e:
await self.emit_log("ERROR", f"触发风控: {e.reason},终止采集")
break
except Exception as e:
await self.emit_log("ERROR", f"处理用户 {user_id} 失败: {str(e)}")
continue
await context.close()
self.is_running = False
await self.emit_log("SUCCESS", "单线程采集完成")
async def worker(self, context: BrowserContext, task_queue: asyncio.Queue, thread_id: str):
"""
Worker 处理任务(多线程模式)
"""
# 线程启动时错开
await asyncio.sleep(int(thread_id.split('-')[1]) * config.THREAD_OFFSET)
await self.emit_thread_status(thread_id, "idle", None)
while True:
if self._stop_requested:
break
try:
# 获取任务
user_id = await asyncio.wait_for(task_queue.get(), timeout=1.0)
await self.emit_thread_status(thread_id, "busy", user_id)
try:
# 线程间最小间隔
await asyncio.sleep(random.uniform(
config.THREAD_MIN_DELAY,
config.THREAD_MIN_DELAY + 3
))
await self.process_user(context, user_id, thread_id)
except RiskControlException as e:
await self.emit_log("ERROR", f"[{thread_id}] 触发风控: {e.reason},停止该线程", thread_id=thread_id)
await self.emit_thread_status(thread_id, "error", None)
break
except Exception as e:
await self.emit_log("ERROR", f"[{thread_id}] 处理失败: {str(e)}", thread_id=thread_id)
finally:
task_queue.task_done()
await self.emit_thread_status(thread_id, "idle", None)
except asyncio.TimeoutError:
continue
except Exception as e:
await self.emit_log("ERROR", f"[{thread_id}] Worker 错误: {str(e)}", thread_id=thread_id)
break
async def run_multi_thread(self, user_ids: List[str], max_threads: int = 3):
"""多线程模式"""
await self.emit_log("INFO", f"启动多线程采集模式,线程数: {max_threads}")
# 创建多个 Context
contexts = []
for i in range(max_threads):
ctx = await self.browser.new_context(
viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT},
user_agent=config.USER_AGENT
)
# 第一个 Context 登录,其他复制 Cookies
if i == 0:
if os.path.exists(config.AUTH_STATE_FILE):
await self.emit_log("INFO", "加载已保存的登录状态")
await ctx.add_cookies([json.load(open(config.AUTH_STATE_FILE))])
else:
login_success = await self.handle_login(ctx)
if not login_success:
await ctx.close()
return
else:
await self.copy_cookies(ctx, contexts[0])
await self.emit_log("INFO", f"Thread-{i+1} 已复制登录状态")
contexts.append(ctx)
# 创建任务队列
task_queue = asyncio.Queue()
for user_id in user_ids:
await task_queue.put(user_id)
self.is_running = True
# 创建 workers
workers = [
asyncio.create_task(
self.worker(contexts[i], task_queue, f"Thread-{i+1}")
)
for i in range(max_threads)
]
# 等待完成
await task_queue.join()
# 取消 workers
for worker in workers:
worker.cancel()
# 关闭 contexts
for ctx in contexts:
await ctx.close()
self.is_running = False
await self.emit_log("SUCCESS", "多线程采集完成")
def stop(self):
"""请求停止采集"""
self._stop_requested = True
self.is_running = False
async def run(self, user_ids: List[str], mode: str = "single", max_threads: int = 3):
"""
运行采集
:param user_ids: 用户 ID 列表
:param mode: single / multi
:param max_threads: 多线程模式下的线程数
"""
self._stop_requested = False
await self.emit_log("INFO", f"开始采集,共 {len(user_ids)} 个用户")
await self.init_browser()
try:
if mode == "single":
await self.run_single_thread(user_ids)
else:
await self.run_multi_thread(user_ids, max_threads)
finally:
await self.close_browser()
await self.emit_log("SUCCESS", "所有采集任务完成")
+516
View File
@@ -0,0 +1,516 @@
"""
Web Backend - FastAPI 应用
提供 API 和 SSE 事件推送
"""
import asyncio
import json
import os
import subprocess
from datetime import datetime
from typing import Dict, List, Optional
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
from pydantic import BaseModel
from typing import List as ListType
from app import config
from app.log_manager import get_log_manager
from app.scraper import XiaoHongShuScraper
# ========== Pydantic 模型 ==========
class StartRequest(BaseModel):
mode: str = "single" # single / multi
max_threads: int = 3
class StatusResponse(BaseModel):
is_running: bool
mode: str
max_threads: int
total_users: int
completed_users: int
failed_users: int
active_threads: int
users_status: Dict[str, Dict]
# ========== 全局状态 ==========
class AppState:
def __init__(self):
self.is_running = False
self.mode = "single"
self.max_threads = 1
self.total_users = 0
self.completed_users = 0
self.failed_users = 0
self.active_threads = 0
self.users_status: Dict[str, Dict] = {}
self.thread_status: Dict[str, Dict] = {}
self.event_queue: asyncio.Queue = None
self.scraper: Optional[XiaoHongShuScraper] = None
self.scraper_task: Optional[asyncio.Task] = None
app_state = AppState()
log_manager = get_log_manager()
# ========== FastAPI 应用 ==========
app = FastAPI(title="小红书数据采集器", version="1.0.0")
# 挂载静态文件和模板
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# ========== 路由 ==========
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""首页"""
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/api/status", response_model=StatusResponse)
async def get_status():
"""获取当前状态"""
return StatusResponse(
is_running=app_state.is_running,
mode=app_state.mode,
max_threads=app_state.max_threads,
total_users=app_state.total_users,
completed_users=app_state.completed_users,
failed_users=app_state.failed_users,
active_threads=app_state.active_threads,
users_status=app_state.users_status
)
@app.post("/api/start")
async def start_scraping(request: StartRequest):
"""启动采集"""
if app_state.is_running:
raise HTTPException(status_code=400, detail="采集任务正在运行中")
# 读取用户 ID 列表
users_file = os.path.join(config.DATA_DIR, "users.txt")
if not os.path.exists(users_file):
raise HTTPException(status_code=400, detail="用户文件不存在,请先创建 data/users.txt")
with open(users_file, 'r', encoding='utf-8') as f:
user_ids = [line.strip() for line in f if line.strip()]
if not user_ids:
raise HTTPException(status_code=400, detail="用户 ID 列表为空")
# 验证模式
if request.mode not in ["single", "multi"]:
raise HTTPException(status_code=400, detail="无效的模式,必须是 single 或 multi")
# 验证线程数
if request.mode == "multi":
if request.max_threads < 1 or request.max_threads > config.MAX_CONCURRENT_THREADS:
raise HTTPException(status_code=400, detail=f"线程数必须在 1-{config.MAX_CONCURRENT_THREADS} 之间")
# 重置状态
app_state.is_running = True
app_state.mode = request.mode
app_state.max_threads = request.max_threads if request.mode == "multi" else 1
app_state.total_users = len(user_ids)
app_state.completed_users = 0
app_state.failed_users = 0
app_state.active_threads = 0
app_state.users_status = {}
app_state.thread_status = {}
# 初始化用户状态
for user_id in user_ids:
app_state.users_status[user_id] = {
"status": "pending",
"note_count": 0,
"thread_id": None
}
# 创建事件队列
app_state.event_queue = asyncio.Queue()
# 创建采集器
app_state.scraper = XiaoHongShuScraper(app_state.event_queue, log_manager)
# 启动采集任务
async def run_scraper():
try:
await app_state.scraper.run(
user_ids=user_ids,
mode=request.mode,
max_threads=request.max_threads
)
finally:
app_state.is_running = False
await app_state.event_queue.put({
"type": "finished"
})
app_state.scraper_task = asyncio.create_task(run_scraper())
return {
"success": True,
"message": f"采集任务已启动,模式: {request.mode}, 用户数: {len(user_ids)}",
"total_users": len(user_ids),
"mode": request.mode,
"max_threads": app_state.max_threads
}
@app.post("/api/stop")
async def stop_scraping():
"""停止采集"""
if not app_state.is_running:
raise HTTPException(status_code=400, detail="没有正在运行的采集任务")
if app_state.scraper:
app_state.scraper.stop()
# 取消任务
if app_state.scraper_task:
app_state.scraper_task.cancel()
try:
await app_state.scraper_task
except asyncio.CancelledError:
pass
app_state.is_running = False
return {
"success": True,
"message": "采集任务已停止"
}
@app.post("/api/logs/clear")
async def clear_logs():
"""清空日志"""
log_manager.clear()
return {
"success": True,
"message": "日志已清空"
}
@app.get("/api/logs")
async def get_logs(level: Optional[str] = None, limit: Optional[int] = None):
"""获取日志"""
logs = log_manager.get_logs(level=level, limit=limit)
return {
"logs": logs,
"stats": log_manager.get_stats()
}
@app.get("/api/logs/search")
async def search_logs(keyword: str, limit: Optional[int] = None):
"""搜索日志"""
logs = log_manager.search_logs(keyword, limit)
return {
"logs": logs,
"count": len(logs)
}
@app.get("/api/logs/download")
async def download_logs():
"""下载日志文件"""
text = log_manager.export_to_text()
return StreamingResponse(
iter([text]),
media_type="text/plain",
headers={
"Content-Disposition": f"attachment; filename=xiaohongshu_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
}
)
@app.get("/api/users")
async def get_users():
"""获取用户列表"""
users_file = os.path.join(config.DATA_DIR, "users.txt")
if not os.path.exists(users_file):
return {"users": []}
with open(users_file, 'r', encoding='utf-8') as f:
users = [{"id": line.strip()} for line in f if line.strip()]
# 添加采集状态
for user in users:
user_id = user["id"]
if user_id in app_state.users_status:
user.update(app_state.users_status[user_id])
else:
user["status"] = "pending"
user["note_count"] = 0
return {"users": users}
@app.get("/api/data/{user_id}")
async def get_user_data(user_id: str):
"""获取用户采集数据"""
file_path = os.path.join(config.DATA_DIR, f"{user_id}.json")
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="用户数据不存在")
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
@app.get("/api/events")
async def event_stream():
"""SSE 事件流"""
async def event_generator():
while True:
try:
# 等待事件,超时 1 秒发送心跳
event = await asyncio.wait_for(
app_state.event_queue.get(),
timeout=1.0
)
# 更新状态
if event["type"] == "log":
# 日志不需要更新状态
pass
elif event["type"] == "progress":
app_state.completed_users = event.get("completed", app_state.completed_users)
app_state.failed_users = event.get("failed", app_state.failed_users)
app_state.active_threads = event.get("active_threads", app_state.active_threads)
elif event["type"] == "user_status":
if event["user_id"] in app_state.users_status:
app_state.users_status[event["user_id"]]["status"] = event["status"]
app_state.users_status[event["user_id"]]["note_count"] = event.get("note_count", 0)
app_state.users_status[event["user_id"]]["thread_id"] = event.get("thread_id")
elif event["type"] == "thread_status":
app_state.thread_status[event["thread_id"]] = {
"status": event["status"],
"current_user": event.get("current_user")
}
# 更新活跃线程数
app_state.active_threads = sum(
1 for s in app_state.thread_status.values() if s.get("status") == "busy"
)
# 发送事件
yield f"data: {json.dumps(event)}\n\n"
except asyncio.TimeoutError:
# 发送心跳
yield ": heartbeat\n\n"
except Exception as e:
# 错误事件
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
}
)
# ========== Git 相关 API ==========
@app.get("/api/git/status")
async def git_status():
"""获取 Git 状态"""
if not os.path.exists("/app/.git"):
return {
"enabled": False,
"message": "不是 Git 仓库"
}
try:
# 获取当前分支
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True,
cwd="/app"
)
current_branch = result.stdout.strip()
# 获取最新提交
result = subprocess.run(
["git", "log", "-1", "--format=%h %s %cr"],
capture_output=True,
text=True,
cwd="/app"
)
latest_commit = result.stdout.strip()
# 检查是否有未提交的更改
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
cwd="/app"
)
has_changes = len(result.stdout.strip()) > 0
return {
"enabled": True,
"branch": current_branch,
"latest_commit": latest_commit,
"has_changes": has_changes,
"auto_pull_enabled": config.ENABLE_GIT_PULL,
"auto_pull_interval": config.AUTO_PULL_INTERVAL
}
except Exception as e:
return {
"enabled": False,
"error": str(e)
}
@app.post("/api/git/pull")
async def git_pull():
"""手动拉取最新代码"""
if not os.path.exists("/app/.git"):
raise HTTPException(status_code=400, detail="不是 Git 仓库")
try:
# 获取当前分支
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True,
cwd="/app"
)
current_branch = result.stdout.strip()
# 拉取最新代码
result = subprocess.run(
["git", "fetch", "origin", current_branch],
capture_output=True,
text=True,
cwd="/app"
)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=f"拉取失败: {result.stderr}")
# 检查是否有更新
result = subprocess.run(
["git", "rev-list", "HEAD..origin/" + current_branch, "--count"],
capture_output=True,
text=True,
cwd="/app"
)
commits_behind = int(result.stdout.strip())
if commits_behind == 0:
return {
"success": True,
"message": "已是最新代码",
"needs_restart": False
}
# 重置到最新代码
result = subprocess.run(
["git", "reset", "--hard", "origin/" + current_branch],
capture_output=True,
text=True,
cwd="/app"
)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=f"重置失败: {result.stderr}")
# 检查 Python 文件是否有变化
result = subprocess.run(
["git", "diff", "--name-only", "HEAD@{1}", "HEAD"],
capture_output=True,
text=True,
cwd="/app"
)
changed_files = result.stdout.strip().split('\n')
python_changed = any(f.endswith('.py') for f in changed_files if f)
return {
"success": True,
"message": f"已拉取 {commits_behind} 个新提交",
"changed_files": changed_files,
"needs_restart": python_changed,
"restart_hint": "Python 代码有变化,建议重启容器以应用更新"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"拉取失败: {str(e)}")
@app.post("/api/git/restart")
async def restart_container():
"""
请求重启容器
注意:这需要在容器外配置重启策略
"""
return {
"success": True,
"message": "重启请求已接收",
"methods": [
"docker restart xiaohongshu-scraper",
"docker-compose restart"
]
}
# ========== 启动事件 ==========
@app.on_event("startup")
async def startup_event():
"""启动时初始化"""
# 确保数据目录存在
os.makedirs(config.DATA_DIR, exist_ok=True)
os.makedirs(config.SCREENSHOT_DIR, exist_ok=True)
# 初始化事件队列
app_state.event_queue = asyncio.Queue()
print("=" * 60)
print("小红书数据采集器 Web 服务启动")
print(f"访问地址: http://{config.WEB_HOST}:{config.WEB_PORT}")
print("=" * 60)
@app.on_event("shutdown")
async def shutdown_event():
"""关闭时清理"""
if app_state.scraper:
app_state.scraper.stop()
if app_state.scraper_task:
app_state.scraper_task.cancel()
print("Web 服务已关闭")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.web:app",
host=config.WEB_HOST,
port=config.WEB_PORT,
reload=True
)
+4
View File
@@ -0,0 +1,4 @@
# 小红书用户 ID 列表
# 每行一个用户 ID,例如:
# 5ef74296000000000100003a
# 5b6c1f9e0000000001000388
+23
View File
@@ -0,0 +1,23 @@
version: '3.8'
services:
xiaohongshu-scraper:
build: .
container_name: xiaohongshu-scraper
ports:
- "18080:18080"
volumes:
# 持久化数据目录(采集结果、日志、登录状态)
- ./data:/app/data
environment:
- HOST=0.0.0.0
- PORT=18080
- HEADLESS=false
- SCREENSHOT_ON_ERROR=true
- MAX_THREADS=5
# Git 自动更新配置(可选)
# - ENABLE_GIT_PULL=true
# - GIT_REPO_URL=https://github.com/yourusername/xiaohongshu-scraper.git
# - GIT_BRANCH=main
# - AUTO_PULL_INTERVAL=3600
restart: unless-stopped
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
set -e
echo "================================"
echo "小红书数据采集器启动脚本"
echo "================================"
# Git 自动更新功能
if [ "$ENABLE_GIT_PULL" = "true" ]; then
echo "Git 自动更新已启用"
# 如果是首次运行且配置了仓库地址,则克隆仓库
if [ ! -d "/app/.git" ] && [ -n "$GIT_REPO_URL" ]; then
echo "克隆仓库: $GIT_REPO_URL"
cd /tmp
git clone "$GIT_REPO_URL" repo
cd repo
git checkout "${GIT_BRANCH:-main}"
cp -r . /app/
cd /app
rm -rf /tmp/repo
echo "仓库克隆完成"
fi
# 启动 Git 自动拉取后台任务
if [ -d "/app/.git" ]; then
echo "启动 Git 自动拉取服务(间隔: ${AUTO_PULL_INTERVAL:-3600}秒)"
(
while true; do
sleep "${AUTO_PULL_INTERVAL:-3600}"
echo "[Git Pull] 检查更新..."
cd /app
# 获取当前分支
CURRENT_BRANCH=$(git branch --show-current)
# 拉取最新代码
if git fetch origin "${CURRENT_BRANCH}" && git reset --hard "origin/${CURRENT_BRANCH}"; then
echo "[Git Pull] 代码已更新"
# 检查是否有 Python 文件变化
if git diff --name-only HEAD@{1} HEAD | grep -q '\.py$'; then
echo "[Git Pull] Python 代码有变化,需要重启容器"
# 可以在这里发送重启信号
fi
else
echo "[Git Pull] 拉取失败"
fi
done
) &
fi
else
echo "Git 自动更新未启用"
fi
echo "启动 Web 服务..."
echo "端口: ${PORT:-18080}"
echo "无头模式: ${HEADLESS:-false}"
echo "================================"
# 启动应用
exec python main.py
+34
View File
@@ -0,0 +1,34 @@
"""
小红书数据采集器 - 程序入口
"""
import uvicorn
from app import config
from app.web import app
def main():
"""启动 Web 服务"""
print("=" * 60)
print("小红书数据采集器")
print("=" * 60)
print(f"版本: 1.0.0")
print(f"访问地址: http://{config.WEB_HOST}:{config.WEB_PORT}")
print(f"数据目录: {config.DATA_DIR}")
print("=" * 60)
print("\n请在浏览器中打开上述地址,然后:")
print("1. 在 data/users.txt 中添加要采集的用户 ID(每行一个)")
print("2. 选择采集模式(单线程/多线程)")
print("3. 点击「启动采集」")
print("4. 在弹出的浏览器窗口中扫码登录")
print("5. 实时监控采集进度\n")
uvicorn.run(
app,
host=config.WEB_HOST,
port=config.WEB_PORT,
log_level="info"
)
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
# Web Framework
fastapi==0.115.0
uvicorn[standard]==0.32.0
jinja2==3.1.4
python-multipart==0.0.12
# Browser Automation
playwright==1.48.0
# Async Utilities
aiofiles==24.1.0
# Utilities
python-dotenv==1.0.1
+586
View File
@@ -0,0 +1,586 @@
/**
* 小红书采集器前端逻辑
*/
// ========== 状态管理 ==========
const state = {
isRunning: false,
mode: 'single',
maxThreads: 3,
totalUsers: 0,
completedUsers: 0,
failedUsers: 0,
activeThreads: 0,
usersStatus: {},
threadStatus: {},
eventSource: null,
autoScrollLogs: true,
logFilter: 'all',
logs: []
};
// ========== DOM 元素 ==========
const elements = {
startBtn: null,
stopBtn: null,
clearLogsBtn: null,
exportDataBtn: null,
themeToggle: null,
themeIcon: null,
modeRadios: null,
threadControl: null,
threadCount: null,
progressBar: null,
progressText: null,
totalUsers: null,
completedUsers: null,
failedUsers: null,
activeThreadsStat: null,
activeThreads: null,
userList: null,
userCount: null,
threadMonitorPanel: null,
threadList: null,
logContainer: null,
logFilter: null,
logSearch: null,
screenshotPanel: null,
screenshotImage: null,
closeScreenshot: null
};
// ========== 初始化 ==========
function init() {
// 获取 DOM 元素
elements.startBtn = document.getElementById('startBtn');
elements.stopBtn = document.getElementById('stopBtn');
elements.clearLogsBtn = document.getElementById('clearLogsBtn');
elements.exportDataBtn = document.getElementById('exportDataBtn');
elements.themeToggle = document.getElementById('themeToggle');
elements.themeIcon = document.getElementById('themeIcon');
elements.modeRadios = document.querySelectorAll('input[name="mode"]');
elements.threadControl = document.getElementById('threadControl');
elements.threadCount = document.getElementById('threadCount');
elements.progressBar = document.getElementById('progressBar');
elements.progressText = document.getElementById('progressText');
elements.totalUsers = document.getElementById('totalUsers');
elements.completedUsers = document.getElementById('completedUsers');
elements.failedUsers = document.getElementById('failedUsers');
elements.activeThreadsStat = document.getElementById('activeThreadsStat');
elements.activeThreads = document.getElementById('activeThreads');
elements.userList = document.getElementById('userList');
elements.userCount = document.getElementById('userCount');
elements.threadMonitorPanel = document.getElementById('threadMonitorPanel');
elements.threadList = document.getElementById('threadList');
elements.logContainer = document.getElementById('logContainer');
elements.logFilter = document.getElementById('logFilter');
elements.logSearch = document.getElementById('logSearch');
elements.screenshotPanel = document.getElementById('screenshotPanel');
elements.screenshotImage = document.getElementById('screenshotImage');
elements.closeScreenshot = document.getElementById('closeScreenshot');
// 加载主题
loadTheme();
// 加载初始数据
loadInitialData();
// 绑定事件
bindEvents();
// 连接 SSE
connectEventStream();
}
// ========== 事件绑定 ==========
function bindEvents() {
// 启动按钮
elements.startBtn.addEventListener('click', startScraping);
// 停止按钮
elements.stopBtn.addEventListener('click', stopScraping);
// 清空日志
elements.clearLogsBtn.addEventListener('click', clearLogs);
// 导出数据
elements.exportDataBtn.addEventListener('click', exportData);
// 主题切换
elements.themeToggle.addEventListener('click', toggleTheme);
// 模式切换
elements.modeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
state.mode = e.target.value;
updateModeUI();
});
});
// 线程数变化
elements.threadCount.addEventListener('change', (e) => {
state.maxThreads = parseInt(e.target.value);
});
// 日志过滤
elements.logFilter.addEventListener('change', (e) => {
state.logFilter = e.target.value;
renderLogs();
});
// 日志搜索
elements.logSearch.addEventListener('input', debounce((e) => {
searchLogs(e.target.value);
}, 300));
// 关闭截图
elements.closeScreenshot.addEventListener('click', () => {
elements.screenshotPanel.style.display = 'none';
});
// 日志容器滚动监听(自动滚动控制)
elements.logContainer.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = elements.logContainer;
state.autoScrollLogs = scrollTop + clientHeight >= scrollHeight - 10;
});
}
// ========== 加载初始数据 ==========
async function loadInitialData() {
try {
// 加载状态
const statusRes = await fetch('/api/status');
const statusData = await statusRes.json();
updateStatus(statusData);
// 加载用户列表
const usersRes = await fetch('/api/users');
const usersData = await usersRes.json();
renderUserList(usersData.users);
// 加载历史日志
const logsRes = await fetch('/api/logs?limit=100');
const logsData = await logsRes.json();
state.logs = logsData.logs;
renderLogs();
} catch (error) {
console.error('加载初始数据失败:', error);
}
}
// ========== 启动采集 ==========
async function startScraping() {
try {
const response = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: state.mode,
max_threads: state.maxThreads
})
});
const data = await response.json();
if (response.ok) {
state.isRunning = true;
updateButtons();
addLog('SUCCESS', data.message);
} else {
addLog('ERROR', data.detail || '启动失败');
}
} catch (error) {
addLog('ERROR', `启动失败: ${error.message}`);
}
}
// ========== 停止采集 ==========
async function stopScraping() {
try {
const response = await fetch('/api/stop', { method: 'POST' });
const data = await response.json();
if (response.ok) {
state.isRunning = false;
updateButtons();
addLog('WARNING', data.message);
}
} catch (error) {
addLog('ERROR', `停止失败: ${error.message}`);
}
}
// ========== 清空日志 ==========
async function clearLogs() {
try {
const response = await fetch('/api/logs/clear', { method: 'POST' });
const data = await response.json();
if (response.ok) {
state.logs = [];
renderLogs();
addLog('SUCCESS', '日志已清空');
}
} catch (error) {
addLog('ERROR', `清空日志失败: ${error.message}`);
}
}
// ========== 导出数据 ==========
async function exportData() {
try {
// 下载日志
window.location.href = '/api/logs/download';
addLog('SUCCESS', '正在下载日志文件...');
} catch (error) {
addLog('ERROR', `导出失败: ${error.message}`);
}
}
// ========== 搜索日志 ==========
async function searchLogs(keyword) {
if (!keyword) {
// 加载所有日志
const logsRes = await fetch('/api/logs?limit=100');
const logsData = await logsRes.json();
state.logs = logsData.logs;
} else {
// 搜索日志
const searchRes = await fetch(`/api/logs/search?keyword=${encodeURIComponent(keyword)}&limit=100`);
const searchData = await searchRes.json();
state.logs = searchData.logs;
}
renderLogs();
}
// ========== SSE 事件流 ==========
function connectEventStream() {
if (state.eventSource) {
state.eventSource.close();
}
state.eventSource = new EventSource('/api/events');
state.eventSource.onmessage = (event) => {
if (event.data.startsWith(': heartbeat')) {
return; // 忽略心跳
}
try {
const data = JSON.parse(event.data);
handleEvent(data);
} catch (error) {
console.error('解析事件失败:', error);
}
};
state.eventSource.onerror = (error) => {
console.error('SSE 连接错误:', error);
// 5秒后重连
setTimeout(() => {
if (!state.eventSource || state.eventSource.readyState === EventSource.CLOSED) {
connectEventStream();
}
}, 5000);
};
}
// ========== 处理事件 ==========
function handleEvent(event) {
switch (event.type) {
case 'log':
addLog(event.level, event.message, event.thread_id, event.timestamp, false);
break;
case 'progress':
updateProgress(event);
break;
case 'user_status':
updateUserStatus(event);
break;
case 'thread_status':
updateThreadStatus(event);
break;
case 'screenshot':
showScreenshot(event.path);
break;
case 'finished':
state.isRunning = false;
updateButtons();
addLog('SUCCESS', '所有采集任务完成');
break;
case 'error':
addLog('ERROR', event.message);
break;
}
}
// ========== 更新状态 ==========
function updateStatus(data) {
state.isRunning = data.is_running;
state.totalUsers = data.total_users;
state.completedUsers = data.completed_users;
state.failedUsers = data.failed_users;
state.activeThreads = data.active_threads;
state.usersStatus = data.users_status || {};
updateButtons();
updateProgress();
renderUserListFromStatus();
}
// ========== 更新进度 ==========
function updateProgress(data) {
if (data) {
state.completedUsers = data.completed || state.completedUsers;
state.failedUsers = data.failed || state.failedUsers;
state.activeThreads = data.active_threads || state.activeThreads;
}
const total = state.totalUsers;
const completed = state.completedUsers;
const failed = state.failedUsers;
const progress = total > 0 ? Math.round(((completed + failed) / total) * 100) : 0;
elements.progressBar.style.width = `${progress}%`;
elements.progressText.textContent = `${progress}%`;
elements.totalUsers.textContent = total;
elements.completedUsers.textContent = completed;
elements.failedUsers.textContent = failed;
elements.activeThreads.textContent = state.activeThreads;
// 多线程模式显示活跃线程
if (state.mode === 'multi') {
elements.activeThreadsStat.style.display = 'flex';
}
}
// ========== 更新用户状态 ==========
function updateUserStatus(data) {
state.usersStatus[data.user_id] = {
status: data.status,
note_count: data.note_count,
thread_id: data.thread_id
};
renderUserListFromStatus();
}
// ========== 更新线程状态 ==========
function updateThreadStatus(data) {
state.threadStatus[data.thread_id] = {
status: data.status,
current_user: data.current_user
};
renderThreadList();
}
// ========== 渲染用户列表 ==========
function renderUserList(users) {
if (!users || users.length === 0) {
elements.userList.innerHTML = `
<div class="empty-state">
<p>暂无用户数据</p>
<p class="hint">请在 data/users.txt 中添加用户 ID</p>
</div>
`;
elements.userCount.textContent = '0';
return;
}
elements.userCount.textContent = users.length;
elements.userList.innerHTML = users.map(user => {
const status = user.status || 'pending';
const noteCount = user.note_count || 0;
return `
<div class="user-item" data-user-id="${user.id}">
<div class="user-item-header">
<span class="user-id">${user.id}</span>
<div class="user-status">
<span class="status-icon status-${status}"></span>
<span>${getStatusText(status)}</span>
</div>
</div>
<div class="user-meta">
${noteCount > 0 ? `已采集 ${noteCount} 条笔记` : '等待采集'}
</div>
</div>
`;
}).join('');
}
// ========== 从状态渲染用户列表 ==========
function renderUserListFromStatus() {
const users = Object.keys(state.usersStatus).map(userId => ({
id: userId,
...state.usersStatus[userId]
}));
renderUserList(users);
}
// ========== 渲染线程列表 ==========
function renderThreadList() {
const threads = Object.entries(state.threadStatus);
if (threads.length === 0) {
elements.threadList.innerHTML = `
<div class="empty-state">
<p>暂无线程活动</p>
</div>
`;
return;
}
elements.threadList.innerHTML = threads.map(([threadId, status]) => {
const statusClass = status.status;
const statusText = status.status === 'busy' ? '处理中' :
status.status === 'idle' ? '空闲' : '错误';
const currentUser = status.current_user || '-';
return `
<div class="thread-item">
<div class="thread-header">
<span class="thread-name">${threadId}</span>
<span class="thread-status ${statusClass}">${statusText}</span>
</div>
<div class="thread-current">${currentUser}</div>
</div>
`;
}).join('');
}
// ========== 添加日志 ==========
function addLog(level, message, threadId = null, timestamp = null, save = true) {
const logEntry = {
timestamp: timestamp || new Date().toISOString(),
level,
message,
thread_id: threadId
};
state.logs.push(logEntry);
// 限制日志数量
if (state.logs.length > 1000) {
state.logs = state.logs.slice(-1000);
}
renderLogs();
}
// ========== 渲染日志 ==========
function renderLogs() {
let logs = state.logs;
// 过滤
if (state.logFilter !== 'all') {
logs = logs.filter(log => log.level === state.logFilter);
}
elements.logContainer.innerHTML = logs.map(log => {
const time = formatTime(log.timestamp);
const thread = log.thread_id ? `<span class="log-thread">[${log.thread_id}]</span>` : '';
return `
<div class="log-entry">
<span class="log-time">${time}</span>
<span class="log-level ${log.level}">${log.level}</span>
<span class="log-message">${thread}${escapeHtml(log.message)}</span>
</div>
`;
}).join('');
// 自动滚动到底部
if (state.autoScrollLogs) {
elements.logContainer.scrollTop = elements.logContainer.scrollHeight;
}
}
// ========== 显示截图 ==========
function showScreenshot(path) {
elements.screenshotImage.src = path;
elements.screenshotPanel.style.display = 'block';
addLog('WARNING', `错误截图已生成: ${path}`);
}
// ========== 更新按钮状态 ==========
function updateButtons() {
elements.startBtn.disabled = state.isRunning;
elements.stopBtn.disabled = !state.isRunning;
}
// ========== 更新模式 UI ==========
function updateModeUI() {
if (state.mode === 'multi') {
elements.threadControl.style.display = 'flex';
elements.threadMonitorPanel.style.display = 'block';
document.querySelector('.main-content').classList.add('with-threads');
} else {
elements.threadControl.style.display = 'none';
elements.threadMonitorPanel.style.display = 'none';
document.querySelector('.main-content').classList.remove('with-threads');
}
}
// ========== 主题相关 ==========
function loadTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
if (savedTheme === 'dark') {
document.body.classList.add('dark-theme');
elements.themeIcon.textContent = '☀️';
}
}
function toggleTheme() {
document.body.classList.toggle('dark-theme');
const isDark = document.body.classList.contains('dark-theme');
elements.themeIcon.textContent = isDark ? '☀️' : '🌙';
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
// ========== 工具函数 ==========
function formatTime(isoString) {
const date = new Date(isoString);
return date.toLocaleTimeString('zh-CN', { hour12: false });
}
function getStatusText(status) {
const statusMap = {
'pending': '待处理',
'processing': '进行中',
'completed': '已完成',
'failed': '失败'
};
return statusMap[status] || status;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// ========== 启动 ==========
document.addEventListener('DOMContentLoaded', init);
+620
View File
@@ -0,0 +1,620 @@
/* 小红书采集器样式表 */
/* ========== CSS 变量 ========== */
:root {
--bg-primary: #f5f7fa;
--bg-secondary: #ffffff;
--bg-tertiary: #e8ecf1;
--text-primary: #2c3e50;
--text-secondary: #5a6c7d;
--text-muted: #8b95a5;
--border-color: #d1d9e6;
--primary-color: #ff2442; /* 小红书红 */
--primary-hover: #e01f3a;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--info-color: #3b82f6;
--debug-color: #8b95a5;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
}
/* 深色主题 */
body.dark-theme {
--bg-primary: #1a1d23;
--bg-secondary: #242830;
--bg-tertiary: #2d333d;
--text-primary: #e8eaed;
--text-secondary: #9aa0a6;
--text-muted: #5f6368;
--border-color: #3c4043;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
/* ========== 基础重置 ========== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
transition: background 0.3s, color 0.3s;
}
/* ========== 布局 ========== */
.container {
max-width: 1600px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
}
/* ========== 头部 ========== */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.header h1 {
font-size: 24px;
font-weight: 600;
color: var(--primary-color);
}
.theme-toggle {
background: var(--bg-tertiary);
border: none;
padding: 10px 16px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 20px;
transition: background 0.2s;
}
.theme-toggle:hover {
background: var(--border-color);
}
/* ========== 控制面板 ========== */
.control-panel {
background: var(--bg-secondary);
padding: 20px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: 20px;
}
.control-group {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.control-group:not(:last-child) {
margin-bottom: 16px;
}
/* ========== 按钮 ========== */
.btn {
padding: 10px 20px;
border: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-danger {
background: var(--error-color);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--border-color);
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
.btn-icon {
font-size: 16px;
}
/* ========== 单选按钮组 ========== */
.radio-group {
display: flex;
align-items: center;
gap: 16px;
}
.label-text {
font-weight: 500;
color: var(--text-secondary);
}
.radio-group label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.radio-group input[type="radio"] {
cursor: pointer;
}
/* ========== 下拉选择 ========== */
.select, .select-small {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
}
.select-small {
padding: 4px 8px;
font-size: 12px;
}
/* ========== 输入框 ========== */
.input-small {
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
width: 150px;
}
/* ========== 线程控制 ========== */
.thread-control {
display: flex;
align-items: center;
gap: 8px;
}
.thread-control label {
font-weight: 500;
color: var(--text-secondary);
}
/* ========== 进度卡片 ========== */
.progress-section {
margin-bottom: 20px;
}
.progress-card {
background: var(--bg-secondary);
padding: 20px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.progress-card h3 {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
}
.progress-bar-container {
width: 100%;
height: 24px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: 16px;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), #ff6b8a);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
transition: width 0.3s ease;
min-width: 40px;
}
.progress-text {
color: white;
font-size: 12px;
font-weight: 600;
}
.progress-stats {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.stat-success {
color: var(--success-color);
}
.stat-error {
color: var(--error-color);
}
.stat-info {
color: var(--info-color);
}
/* ========== 主内容区 ========== */
.main-content {
display: grid;
grid-template-columns: 300px 1fr;
gap: 20px;
}
.main-content.with-threads {
grid-template-columns: 300px 250px 1fr;
}
/* ========== 面板 ========== */
.panel {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 500px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h2 {
font-size: 16px;
font-weight: 600;
}
.badge {
background: var(--primary-color);
color: white;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* ========== 用户列表 ========== */
.user-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-item {
padding: 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
}
.user-item:hover {
background: var(--border-color);
}
.user-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.user-id {
font-family: monospace;
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.user-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.status-icon {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-pending { background: var(--text-muted); }
.status-processing { background: var(--info-color); }
.status-completed { background: var(--success-color); }
.status-failed { background: var(--error-color); }
.user-meta {
font-size: 12px;
color: var(--text-muted);
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.hint {
font-size: 12px;
margin-top: 8px;
}
/* ========== 线程监控 ========== */
.thread-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.thread-item {
padding: 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
}
.thread-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.thread-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.thread-status {
font-size: 11px;
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-muted);
}
.thread-status.busy {
background: var(--info-color);
color: white;
}
.thread-status.error {
background: var(--error-color);
color: white;
}
.thread-current {
font-size: 12px;
color: var(--text-muted);
font-family: monospace;
}
/* ========== 日志面板 ========== */
.log-controls {
display: flex;
gap: 8px;
}
.log-container {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-entry {
padding: 6px 8px;
border-radius: var(--radius-sm);
margin-bottom: 4px;
display: flex;
gap: 8px;
word-break: break-all;
}
.log-entry:hover {
background: var(--bg-tertiary);
}
.log-time {
color: var(--text-muted);
flex-shrink: 0;
}
.log-level {
font-weight: 600;
flex-shrink: 0;
padding: 0 6px;
border-radius: var(--radius-sm);
font-size: 11px;
display: inline-flex;
align-items: center;
}
.log-level.INFO {
background: rgba(59, 130, 246, 0.15);
color: var(--info-color);
}
.log-level.SUCCESS {
background: rgba(16, 185, 129, 0.15);
color: var(--success-color);
}
.log-level.WARNING {
background: rgba(245, 158, 11, 0.15);
color: var(--warning-color);
}
.log-level.ERROR {
background: rgba(239, 68, 68, 0.15);
color: var(--error-color);
}
.log-level.DEBUG {
background: rgba(139, 149, 165, 0.15);
color: var(--debug-color);
}
.log-message {
flex: 1;
}
.log-thread {
color: var(--primary-color);
font-weight: 600;
}
/* ========== 截图面板 ========== */
.screenshot-panel {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
margin-top: 20px;
}
.screenshot-panel img {
width: 100%;
height: auto;
display: block;
}
/* ========== 滚动条样式 ========== */
.panel-body::-webkit-scrollbar,
.log-container::-webkit-scrollbar {
width: 6px;
}
.panel-body::-webkit-scrollbar-track,
.log-container::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
.panel-body::-webkit-scrollbar-thumb,
.log-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.panel-body::-webkit-scrollbar-thumb:hover,
.log-container::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ========== 响应式 ========== */
@media (max-width: 1200px) {
.main-content {
grid-template-columns: 1fr;
}
.main-content.with-threads {
grid-template-columns: 1fr;
}
.thread-monitor-panel {
display: none !important;
}
}
@media (max-width: 768px) {
.control-group {
flex-direction: column;
align-items: stretch;
}
.progress-stats {
gap: 16px;
}
.stat-value {
font-size: 16px;
}
}
+164
View File
@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小红书数据采集器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<!-- 顶部标题栏 -->
<header class="header">
<h1>小红书数据采集器</h1>
<button class="theme-toggle" id="themeToggle" title="切换主题">
<span id="themeIcon">🌙</span>
</button>
</header>
<!-- 控制面板 -->
<section class="control-panel">
<div class="control-group">
<button class="btn btn-primary" id="startBtn">
<span class="btn-icon">▶</span> 启动采集
</button>
<button class="btn btn-danger" id="stopBtn" disabled>
<span class="btn-icon">⏹</span> 停止
</button>
<button class="btn btn-secondary" id="clearLogsBtn">
<span class="btn-icon">🗑</span> 清空日志
</button>
<button class="btn btn-secondary" id="exportDataBtn">
<span class="btn-icon">📥</span> 导出数据
</button>
</div>
<div class="control-group">
<label class="radio-group">
<span class="label-text">模式:</span>
<label>
<input type="radio" name="mode" value="single" checked>
<span>单线程</span>
</label>
<label>
<input type="radio" name="mode" value="multi">
<span>多线程</span>
</label>
</label>
<div class="thread-control" id="threadControl" style="display: none;">
<label for="threadCount">线程数:</label>
<select id="threadCount" class="select">
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="10">10</option>
</select>
</div>
</div>
</section>
<!-- 进度概览 -->
<section class="progress-section">
<div class="progress-card">
<h3>整体进度</h3>
<div class="progress-bar-container">
<div class="progress-bar" id="progressBar">
<span class="progress-text" id="progressText">0%</span>
</div>
</div>
<div class="progress-stats">
<div class="stat-item">
<span class="stat-label">总数</span>
<span class="stat-value" id="totalUsers">0</span>
</div>
<div class="stat-item">
<span class="stat-label">已完成</span>
<span class="stat-value stat-success" id="completedUsers">0</span>
</div>
<div class="stat-item">
<span class="stat-label">失败</span>
<span class="stat-value stat-error" id="failedUsers">0</span>
</div>
<div class="stat-item" id="activeThreadsStat" style="display: none;">
<span class="stat-label">活跃线程</span>
<span class="stat-value stat-info" id="activeThreads">0</span>
</div>
</div>
</div>
</section>
<!-- 主内容区 -->
<div class="main-content">
<!-- 左侧:用户列表 -->
<section class="panel user-list-panel">
<div class="panel-header">
<h2>用户列表</h2>
<span class="badge" id="userCount">0</span>
</div>
<div class="panel-body">
<div class="user-list" id="userList">
<!-- 用户项将动态添加 -->
<div class="empty-state">
<p>暂无用户数据</p>
<p class="hint">请在 data/users.txt 中添加用户 ID</p>
</div>
</div>
</div>
</section>
<!-- 中间:线程监控(多线程模式) -->
<section class="panel thread-monitor-panel" id="threadMonitorPanel" style="display: none;">
<div class="panel-header">
<h2>线程监控</h2>
</div>
<div class="panel-body">
<div class="thread-list" id="threadList">
<!-- 线程状态将动态添加 -->
</div>
</div>
</section>
<!-- 右侧:日志面板 -->
<section class="panel log-panel">
<div class="panel-header">
<h2>日志</h2>
<div class="log-controls">
<select id="logFilter" class="select-small">
<option value="all">ALL</option>
<option value="INFO">INFO</option>
<option value="SUCCESS">SUCCESS</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="DEBUG">DEBUG</option>
</select>
<input type="text" id="logSearch" placeholder="搜索日志..." class="input-small">
</div>
</div>
<div class="panel-body">
<div class="log-container" id="logContainer">
<!-- 日志将动态添加 -->
</div>
</div>
</section>
</div>
<!-- 错误截图区 -->
<section class="screenshot-panel" id="screenshotPanel" style="display: none;">
<div class="panel-header">
<h2>错误截图</h2>
<button class="btn-small btn-secondary" id="closeScreenshot">关闭</button>
</div>
<div class="panel-body">
<img id="screenshotImage" src="" alt="错误截图">
</div>
</section>
</div>
<script src="/static/app.js"></script>
</body>
</html>