This commit is contained in:
mzl
2026-01-12 18:58:23 +08:00
parent fb98a5d221
commit f2dcc4c0cf
13 changed files with 1183 additions and 1194 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+171
View File
@@ -0,0 +1,171 @@
"""auth_manager.py
登录态管理(单账号)
- 使用 Playwright storage_state 保存到 data/auth_state.json
- 额外保存元数据到 data/auth_meta.json(登录时间/最后验证时间等)
- 提供 verify_login / get_login_status 等方法
设计目标:
1) 不依赖“文件存在”判断登录,而是用 Playwright 打开页面做轻量验证
2) 单账号长期复用:只要不掉线,就一直用
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional, Tuple
from playwright.async_api import Browser, BrowserContext, TimeoutError as PlaywrightTimeoutError
from app import config
AUTH_META_FILE = os.path.join(config.DATA_DIR, "auth_meta.json")
@dataclass
class LoginStatus:
logged_in: bool
login_time: Optional[str] = None
last_verified_time: Optional[str] = None
message: Optional[str] = None
def _utc_iso() -> str:
# 使用本地时间 ISO 字符串,方便 UI 展示
return datetime.now().isoformat(timespec="seconds")
def load_auth_meta() -> dict:
if not os.path.exists(AUTH_META_FILE):
return {}
try:
with open(AUTH_META_FILE, "r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception:
return {}
def save_auth_meta(meta: dict) -> None:
tmp = AUTH_META_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
os.replace(tmp, AUTH_META_FILE)
async def _new_context(browser: Browser) -> BrowserContext:
"""基于已保存的 storage_state 创建 context。"""
if os.path.exists(config.AUTH_STATE_FILE):
return await browser.new_context(storage_state=config.AUTH_STATE_FILE)
return await browser.new_context()
async def open_login_page(browser: Browser) -> None:
"""打开登录页面(通常需要用户在 VNC/有界面环境里扫码)。"""
context = await _new_context(browser)
page = await context.new_page()
await page.goto(config.LOGIN_URL, wait_until="domcontentloaded")
# 不关闭页面:让用户扫码
# 注意:context/page 生命周期由浏览器进程维护;任务结束时统一关闭 browser 即可
async def verify_login(browser: Browser, timeout_sec: int = 20) -> Tuple[bool, str]:
"""验证当前 storage_state 是否仍然有效。
策略(尽量稳,避免依赖某个易变 selector):
1) 打开首页;
2) 若跳转到 /login 或出现明显登录按钮/文案,则判定未登录;
3) 若未跳登录页,并且存在一定数量 cookie,则判定登录有效(启发式)。
"""
if not os.path.exists(config.AUTH_STATE_FILE):
return False, "未找到登录态文件,请先登录"
context = await _new_context(browser)
page = await context.new_page()
try:
await page.goto(config.XIAOHONGSHU_BASE_URL + "/", wait_until="domcontentloaded", timeout=timeout_sec * 1000)
await page.wait_for_timeout(800)
url = page.url or ""
if "/login" in url:
return False, "已跳转到登录页(登录态可能已失效)"
# 启发式:页面上是否出现明显“登录”入口
login_text_hits = await page.locator("text=登录").count()
if login_text_hits > 0:
# 有些已登录页面也会出现“登录”字样(比如弹窗),再做一次 cookie 辅助判断
cookies = await context.cookies()
if len(cookies) < 3:
return False, "检测到登录入口且 cookie 数量过少,疑似未登录"
cookies = await context.cookies()
if len(cookies) < 3:
return False, "cookie 数量过少,疑似未登录"
# 通过:写入 meta
meta = load_auth_meta()
if not meta.get("login_time"):
meta["login_time"] = _utc_iso()
meta["last_verified_time"] = _utc_iso()
save_auth_meta(meta)
# 刷新保存一次 state(让 cookie 续期信息尽可能落盘)
try:
await context.storage_state(path=config.AUTH_STATE_FILE)
except Exception:
# 不要因为保存失败影响验证结果
pass
return True, "ok"
except PlaywrightTimeoutError:
return False, "验证登录超时(网络或风控),请稍后重试"
except Exception as e:
return False, f"验证登录异常: {e}"
finally:
try:
await page.close()
except Exception:
pass
try:
await context.close()
except Exception:
pass
async def get_login_status(browser: Optional[Browser]) -> LoginStatus:
"""获取登录状态(如果 browser 可用则做轻量验证,否则仅返回本地 meta 状态)。"""
meta = load_auth_meta()
if not os.path.exists(config.AUTH_STATE_FILE):
return LoginStatus(logged_in=False, message="未登录")
if browser is None:
return LoginStatus(
logged_in=True if meta.get("login_time") else True,
login_time=meta.get("login_time"),
last_verified_time=meta.get("last_verified_time"),
message="仅本地状态(未进行在线验证)"
)
ok, msg = await verify_login(browser)
meta = load_auth_meta()
return LoginStatus(
logged_in=ok,
login_time=meta.get("login_time"),
last_verified_time=meta.get("last_verified_time"),
message=None if ok else msg
)
def clear_login_state() -> None:
"""清理登录态。"""
for p in [config.AUTH_STATE_FILE, AUTH_META_FILE]:
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
+50 -126
View File
@@ -1,153 +1,77 @@
"""log_manager.py
简单的线程安全日志管理器:
- 追加日志(level/message/thread_id)
- 持久化到 data/logs.json
- 提供读取最近 N 条、清空
说明:采集器是异步运行,但日志写入使用 threading.Lock 足够。
"""
Log Manager - 持久化日志管理器
支持日志加载、保存、追加、清空
"""
from __future__ import annotations
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
def __init__(self, log_file: Optional[str] = None, max_logs: int = 2000):
self.log_file = log_file or os.path.join(config.DATA_DIR, "logs.json")
self.max_logs = max_logs
self._lock = threading.Lock()
self.logs: List[Dict] = []
self._load_logs()
self._load()
def _load_logs(self):
"""启动时加载历史日志"""
with self._lock:
if os.path.exists(self.log_file):
def _load(self) -> None:
if not os.path.exists(self.log_file):
self.logs = []
return
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:
with open(self.log_file, "r", encoding="utf-8") as f:
data = json.load(f) or {}
self.logs = data.get("logs", []) or []
except Exception:
self.logs = []
def _save_locked(self) -> None:
os.makedirs(os.path.dirname(self.log_file), exist_ok=True)
tmp = self.log_file + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(
{"logs": self.logs, "last_updated": datetime.now().isoformat(timespec="seconds")},
f,
ensure_ascii=False,
indent=2,
)
os.replace(tmp, self.log_file)
def append(self, level: str, message: str, thread_id: Optional[str] = None) -> Dict:
"""
追加新日志
返回日志条目
"""
with self._lock:
log_entry = {
"timestamp": datetime.now().isoformat(),
"level": level,
entry = {
"time": datetime.now().isoformat(timespec="seconds"),
"level": level.upper(),
"message": message,
"thread_id": thread_id
}
self.logs.append(log_entry)
if thread_id:
entry["thread_id"] = thread_id
# 限制日志数量(保留最新的 max_logs 条)
with self._lock:
self.logs.append(entry)
if len(self.logs) > self.max_logs:
self.logs = self.logs[-self.max_logs :]
self._save_locked()
return entry
# 异步保存到文件
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):
"""清空日志"""
def clear(self) -> None:
with self._lock:
self.logs = []
self._save()
self._save_locked()
def get_stats(self) -> Dict:
"""获取日志统计信息"""
def get_latest(self, limit: int = 200) -> List[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
return list(self.logs[-max(1, int(limit)) :])
+27 -21
View File
@@ -467,16 +467,20 @@ class XiaoHongShuScraper:
await self.emit_log("INFO", "启动单线程采集模式")
# 创建 Context 并登录
# 创建 Context(优先加载已保存的 storage_state)
if os.path.exists(config.AUTH_STATE_FILE):
await self.emit_log("INFO", "加载已保存的登录状态 (storage_state)")
context = await self.browser.new_context(
storage_state=config.AUTH_STATE_FILE,
viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT},
user_agent=config.USER_AGENT
)
else:
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:
@@ -559,29 +563,31 @@ class XiaoHongShuScraper:
"""多线程模式"""
await self.emit_log("INFO", f"启动多线程采集模式,线程数: {max_threads}")
# 创建多个 Context
# 创建多个 Context(同一账号并发会增加风控风险,请合理控制线程数)
contexts = []
for i in range(max_threads):
ctx = await self.browser.new_context(
# 确保已有登录态;若没有,先用第一个 context 登录并保存
if not os.path.exists(config.AUTH_STATE_FILE):
first = 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)
login_success = await self.handle_login(first)
if not login_success:
await ctx.close()
await first.close()
return
else:
await self.copy_cookies(ctx, contexts[0])
await self.emit_log("INFO", f"Thread-{i+1} 已复制登录状态")
await first.close()
# 基于 storage_state 为每个线程创建独立 context
for i in range(max_threads):
ctx = await self.browser.new_context(
storage_state=config.AUTH_STATE_FILE,
viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT},
user_agent=config.USER_AGENT
)
contexts.append(ctx)
await self.emit_log("INFO", f"Thread-{i+1} context ready")
# 创建任务队列
task_queue = asyncio.Queue()
+153
View File
@@ -0,0 +1,153 @@
"""user_resolver.py
将“数字小红书号(例如 11432840954)”解析成 profile_id(/user/profile/{profile_id} 里的那串)。
- 使用 Playwright 打开搜索结果页(更接近真实浏览器,风险更低)
- 解析 HTML:根据 '小红书号:<keyword>' 定位,再提取附近的 profile href
- 强制缓存:data/profile_map.json,避免重复搜索造成风控压力
"""
from __future__ import annotations
import json
import os
import random
import re
from html import unescape
from typing import Dict, Optional, Tuple
from playwright.async_api import Browser
from app import config
PROFILE_MAP_FILE = os.path.join(config.DATA_DIR, "profile_map.json")
def _load_map() -> Dict[str, str]:
if not os.path.exists(PROFILE_MAP_FILE):
return {}
try:
with open(PROFILE_MAP_FILE, "r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception:
return {}
def _save_map(m: Dict[str, str]) -> None:
tmp = PROFILE_MAP_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(m, f, ensure_ascii=False, indent=2)
os.replace(tmp, PROFILE_MAP_FILE)
def extract_profile_id_from_search_html(html: str, keyword: str) -> Optional[str]:
"""从 search_result HTML 中解析出对应 keyword 的 profile_id。"""
if not html:
return None
html = unescape(html)
marker = f"小红书号:{keyword}"
idx = html.find(marker)
if idx == -1:
return None
start = max(0, idx - 5000)
end = min(len(html), idx + 5000)
chunk = html[start:end]
m = re.search(r'href="\/user\/profile\/([0-9a-fA-F]+)', chunk)
if not m:
return None
return m.group(1)
def parse_line_to_keyword_or_profile(line: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""解析一行 txt,返回 (keyword数字, profile_id, label备注) 三者之一。
允许格式:
- 11432840954
- 11432840954 张三
- https://www.xiaohongshu.com/user/profile/<profile_id> ...
- /user/profile/<profile_id> ...
- profile_id(十六进制串)
- 支持行尾 # 备注
"""
raw = line.strip()
if not raw:
return None, None, None
# 整行注释
if raw.startswith("#") or raw.startswith("//"):
return None, None, None
# 拆备注:支持 # 以及连续空格
label = None
if "#" in raw:
raw, label = raw.split("#", 1)
raw = raw.strip()
label = label.strip() or None
parts = raw.split()
core = parts[0].strip() if parts else ""
if len(parts) > 1 and not label:
label = " ".join(parts[1:]).strip() or None
# URL/相对路径中直接提取 profile_id
m = re.search(r"/user/profile/([0-9a-fA-F]+)", core)
if m:
return None, m.group(1), label
# 可能直接就是 profile_id(十六进制串,长度>=8)
if re.fullmatch(r"[0-9a-fA-F]{8,}", core):
return None, core, label
# 纯数字:小红书号
if re.fullmatch(r"\d{5,20}", core):
return core, None, label
return None, None, label
async def resolve_keyword_to_profile_id(browser: Browser, keyword: str, *, slow_mode: bool = True) -> Tuple[Optional[str], str]:
"""将数字 keyword 解析成 profile_id,并写入缓存。返回 (profile_id, message)。"""
m = _load_map()
if keyword in m:
return m[keyword], "cache"
# 使用新的 context(不强制登录)
context = await browser.new_context()
page = await context.new_page()
try:
url = f"{config.XIAOHONGSHU_BASE_URL}/search_result?keyword={keyword}&source=unknown"
await page.goto(url, wait_until="domcontentloaded", timeout=20000)
# 轻微等待/滚动(更像真人浏览)
if slow_mode:
await page.wait_for_timeout(int(random.uniform(800, 1600)))
# 小幅滚动一下
try:
await page.mouse.wheel(0, random.randint(200, 600))
except Exception:
pass
await page.wait_for_timeout(int(random.uniform(400, 1000)))
html = await page.content()
pid = extract_profile_id_from_search_html(html, keyword)
if not pid:
return None, "not_found"
m[keyword] = pid
_save_map(m)
return pid, "ok"
except Exception as e:
return None, f"error:{e}"
finally:
try:
await page.close()
except Exception:
pass
try:
await context.close()
except Exception:
pass
+415 -1033
View File
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
# 小红书数据采集器使用指南
## 系统概述
这是一个基于 FastAPI + Playwright 的小红书数据采集器,具有 Web 可视化监控界面,支持多线程采集、实时进度展示和持久化日志记录。
## 系统特点
- 🌐 **Web 可视化界面**:实时监控采集进度和状态
- 🔐 **真实扫码登录**:打开小红书官方登录页面,扫码登录后验证状态
- 👥 **账号管理**:支持添加、删除用户账号
- 📊 **实时日志**:分类显示操作日志,支持过滤和搜索
- 🔄 **多线程采集**:支持单线程/多线程模式切换
- 🐳 **Docker 部署**:容器化部署,支持自动更新
- 📁 **数据持久化**:所有数据本地存储,重启不丢失
## 快速开始
### 1. Docker 部署(推荐)
```bash
# 克隆或下载项目
cd /path/to/demo
# 构建并启动容器(使用18085端口)
docker-compose up -d --build
# 查看容器状态
docker-compose ps
# 查看日志
docker-compose logs -f
```
### 2. 本地运行
```bash
# 安装依赖
pip install -r requirements.txt
# 安装浏览器
playwright install chromium
# 创建数据目录
mkdir -p data data/screenshots
# 运行程序
python main.py
```
### 3. 访问系统
打开浏览器访问:`http://localhost:18085`
## 系统界面说明
### 1. 账号管理区域
![账号管理](./images/account-management.png)
- **添加账号**:点击添加账号按钮,输入小红书用户 ID
- **刷新账号**:重新加载账号列表和状态
- **登录状态**:
- 🔴 红色圆点:未登录
- 🟢 绿色圆点:已登录
- 显示剩余登录时间(小时:分钟)
### 2. 控制面板
![控制面板](./images/control-panel.png)
- **启动采集**:开始数据采集任务
- **停止**:停止正在进行的采集任务
- **清空日志**:清空所有历史日志
- **导出数据**:下载采集到的数据文件
- **模式选择**:
- 单线程:逐个处理账号
- 多线程:同时处理多个账号(可配置线程数)
- **线程数**:多线程模式下的并发线程数(1-10)
### 3. 进度监控
![进度监控](./images/progress.png)
- **整体进度**:显示采集进度条和百分比
- **活跃线程**:多线程模式下显示正在运行的线程数
- **用户列表**:显示每个用户的采集状态
- ✅ 已完成:显示采集的笔记数量
- 🔄 进行中:显示当前处理的笔记数量
- ⏳ 待处理:尚未开始处理的账号
- ❌ 失败:采集失败的账号
### 4. 日志面板
![日志面板](./images/logs.png)
- **日志级别过滤**:可选择查看 ALL/INFO/SUCCESS/WARNING/ERROR/DEBUG 级别的日志
- **搜索功能**:搜索特定关键词的日志
- **自动滚动**:开启后自动滚动到最新日志
- **时间戳**:显示每条日志的具体时间
- **线程标识**:多线程模式下显示日志来源线程
### 5. 错误截图
![错误截图](./images/screenshots.png)
如果采集过程中遇到错误(如风控检测),系统会自动截图并保存:
- 截图保存在:`data/screenshots/` 目录
- 在界面中显示错误截图缩略图
- 可点击查看大图或下载所有截图
## 操作流程
### 1. 首次使用
```bash
# 1. 启动系统
docker-compose up -d --build
# 2. 访问 Web 界面
# 打开 http://localhost:18080
# 3. 添加要采集的用户 ID
# 点击"添加账号"按钮,输入小红书用户 ID
# 格式:长字符串(如 5ef74296000000000100003a)
# 可从浏览器地址栏获取:https://www.xiaohongshu.com/user/profile/用户ID
# 4. 真实扫码登录
# 点击"打开登录页"按钮,会在新窗口打开小红书官方登录页面
# 在打开的页面中使用小红书App扫描二维码完成登录
# 登录成功后,返回本页面点击"验证登录"按钮
# 登录状态有效期:24小时
```
### 2. 配置采集参数
1. 选择采集模式:
- **单线程**:稳定但速度慢,适合少量账号
- **多线程**:速度快,适合大量账号(推荐 3-5 个线程)
2. 设置线程数(多线程模式):
- 建议:3-5 个线程
- 过多线程可能触发风控
### 3. 开始采集
1. 确保已添加至少一个用户账号
2. 确保已登录(显示绿色状态)
3. 点击"启动采集"
4. 系统会:
- 检查登录状态
- 读取用户列表
- 开始采集并实时显示进度
### 4. 监控采集
采集过程中可以:
- 查看实时进度和状态
- 监控日志输出
- 观察各线程工作状态
- 随时停止采集(点击"停止")
### 5. 数据导出
采集完成后可以:
- 查看每个用户的数据文件:`data/{用户ID}.json`
- 下载所有日志:点击"导出数据"
- 下载错误截图(如有)
## 用户 ID 获取方法
### 方法1:浏览器地址栏
1. 打开小红书网页版
2. 访问目标用户主页:https://www.xiaohongshu.com/user/profile/用户名
3. 从浏览器地址栏复制用户 ID:
```
https://www.xiaohongshu.com/user/profile/5ef74296000000000100003a
```
复制 `5ef74296000000000100003a` 部分
### 方法2:开发者工具
1. 在用户页面按 F12 打开开发者工具
2. 切换到 Network(网络)标签
3. 刷新页面
4. 查找包含 "user" 或 "profile" 的请求
5. 从请求 URL 中提取用户 ID
## 数据文件说明
### 输入文件
```
data/users.txt # 用户 ID 列表,每行一个
```
### 输出文件
```
data/
├── auth_state.json # 登录状态缓存
├── logs.json # 操作日志历史
├── users.txt # 用户列表
├── 5ef74296000000000100003a.json # 用户采集数据
├── 5b6c1f9e0000000001000388.json # 用户采集数据
└── screenshots/ # 错误截图目录
├── error_xxx1.png
└── error_xxx2.png
```
### 数据格式示例
```json
// data/{用户ID}.json
{
"user_id": "5ef74296000000000100003a",
"last_updated": "2025-01-12T10:30:00",
"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/xxx"
}
]
}
```
## 常见问题
### Q1: 为什么提示"未登录"?
A: 登录状态 24 小时后会自动过期,请:
1. 点击"模拟登录"重新登录
2. 检查 `data/auth_state.json` 文件是否存在
### Q2: 采集速度很慢怎么办?
A: 可以尝试:
1. 使用多线程模式(建议 3-5 线程)
2. 检查网络连接
3. 避免在高峰期采集
### Q3: 如何避免被封禁?
A: 系统已内置防护措施:
1. 拟人化延迟(随机 2-60 秒)
2. 平滑滚动加载
3. 多线程错开启动
4. 风控检测自动截图
5. 建议不要设置过多线程
### Q4: 数据采集不全怎么办?
A: 可能原因:
1. 网络不稳定导致加载失败
2. 笔记数量过多,滚动加载不完整
3. 小红书页面结构变化
### Q5: Docker 容器无法启动?
A: 检查:
1. Docker 是否运行
2. 端口 18080 是否被占用
3. Docker Compose 配置是否正确
## 配置说明
### 环境变量
```env
# Web 服务器配置
PORT=18080
HOST=0.0.0.0
# 浏览器配置
HEADLESS=false # 是否无头模式
SCREENSHOT_ON_ERROR=true # 错误时截图
# 多线程配置
MAX_CONCURRENT_THREADS=5 # 最大并发线程数
THREAD_MIN_DELAY=2 # 线程间最小间隔(秒)
```
### 配置文件位置
```python
# app/config.py
DATA_DIR = "data" # 数据目录
SCREENSHOT_DIR = "data/screenshots" # 截图目录
MAX_CONCURRENT_THREADS = 5 # 最大并发线程数
```
## 更新和维护
### 1. 自动更新(Docker)
系统支持 Git 自动更新:
1. 代码提交到远程仓库
2. 系统自动拉取最新代码
3. 检测到 Python 代码变化时建议重启容器
### 2. 手动更新
```bash
# 拉取最新代码
git pull origin master
# 重新构建容器
docker-compose build
# 重启服务
docker-compose up -d
```
### 3. 数据备份
重要数据请定期备份:
```bash
# 备份数据目录
tar -czf backup-$(date +%Y%m%d).tar.gz data/
# 备份特定用户数据
cp data/*.json backup-$(date +%Y%m%d)/
```
## 技术支持
如果遇到问题:
1. 查看 Web 界面的日志面板
2. 检查 Docker 容器日志:`docker-compose logs`
3. 查看 `data/logs.json` 文件
4. 确保网络连接正常
## 版本更新日志
### v1.0.0 (2025-01-12)
- ✅ 实现基础采集功能
- ✅ Web 可视化界面
- ✅ 多线程支持
- ✅ 实时日志系统
- ✅ 账号管理功能
- ✅ 模拟登录系统
- ✅ Docker 部署支持
- ✅ 数据导出功能
---
**注意**:本工具仅供学习和研究使用,请遵守小红书的使用条款和相关法律法规。