ts
This commit is contained in:
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.
@@ -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
|
||||||
+55
-131
@@ -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 json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
|
||||||
|
|
||||||
class LogManager:
|
class LogManager:
|
||||||
"""日志管理器 - 线程安全"""
|
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")
|
||||||
def __init__(self, log_file: str = None, max_logs: int = None):
|
self.max_logs = max_logs
|
||||||
self.log_file = log_file or config.LOG_FILE
|
|
||||||
self.max_logs = max_logs or config.MAX_LOGS
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self.logs: List[Dict] = []
|
self.logs: List[Dict] = []
|
||||||
self._load_logs()
|
self._load()
|
||||||
|
|
||||||
def _load_logs(self):
|
def _load(self) -> None:
|
||||||
"""启动时加载历史日志"""
|
if not os.path.exists(self.log_file):
|
||||||
with self._lock:
|
self.logs = []
|
||||||
if os.path.exists(self.log_file):
|
return
|
||||||
try:
|
try:
|
||||||
with open(self.log_file, 'r', encoding='utf-8') as f:
|
with open(self.log_file, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f) or {}
|
||||||
self.logs = data.get('logs', [])
|
self.logs = data.get("logs", []) or []
|
||||||
except (json.JSONDecodeError, IOError) as e:
|
except Exception:
|
||||||
print(f"[LogManager] 加载日志文件失败: {e}")
|
self.logs = []
|
||||||
self.logs = []
|
|
||||||
else:
|
def _save_locked(self) -> None:
|
||||||
self.logs = []
|
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:
|
def append(self, level: str, message: str, thread_id: Optional[str] = None) -> Dict:
|
||||||
"""
|
entry = {
|
||||||
追加新日志
|
"time": datetime.now().isoformat(timespec="seconds"),
|
||||||
返回日志条目
|
"level": level.upper(),
|
||||||
"""
|
"message": message,
|
||||||
with self._lock:
|
}
|
||||||
log_entry = {
|
if thread_id:
|
||||||
"timestamp": datetime.now().isoformat(),
|
entry["thread_id"] = thread_id
|
||||||
"level": level,
|
|
||||||
"message": message,
|
|
||||||
"thread_id": thread_id
|
|
||||||
}
|
|
||||||
self.logs.append(log_entry)
|
|
||||||
|
|
||||||
# 限制日志数量(保留最新的 max_logs 条)
|
with self._lock:
|
||||||
|
self.logs.append(entry)
|
||||||
if len(self.logs) > self.max_logs:
|
if len(self.logs) > self.max_logs:
|
||||||
self.logs = self.logs[-self.max_logs:]
|
self.logs = self.logs[-self.max_logs :]
|
||||||
|
self._save_locked()
|
||||||
|
return entry
|
||||||
|
|
||||||
# 异步保存到文件
|
def clear(self) -> None:
|
||||||
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:
|
with self._lock:
|
||||||
self.logs = []
|
self.logs = []
|
||||||
self._save()
|
self._save_locked()
|
||||||
|
|
||||||
def get_stats(self) -> Dict:
|
def get_latest(self, limit: int = 200) -> List[Dict]:
|
||||||
"""获取日志统计信息"""
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
stats = {
|
return list(self.logs[-max(1, int(limit)) :])
|
||||||
"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
|
|
||||||
|
|||||||
+30
-24
@@ -467,16 +467,20 @@ class XiaoHongShuScraper:
|
|||||||
await self.emit_log("INFO", "启动单线程采集模式")
|
await self.emit_log("INFO", "启动单线程采集模式")
|
||||||
|
|
||||||
# 创建 Context 并登录
|
# 创建 Context 并登录
|
||||||
context = await self.browser.new_context(
|
|
||||||
viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT},
|
|
||||||
user_agent=config.USER_AGENT
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查是否有保存的登录状态
|
# 创建 Context(优先加载已保存的 storage_state)
|
||||||
if os.path.exists(config.AUTH_STATE_FILE):
|
if os.path.exists(config.AUTH_STATE_FILE):
|
||||||
await self.emit_log("INFO", "加载已保存的登录状态")
|
await self.emit_log("INFO", "加载已保存的登录状态 (storage_state)")
|
||||||
await context.add_cookies([json.load(open(config.AUTH_STATE_FILE))])
|
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:
|
else:
|
||||||
|
context = await self.browser.new_context(
|
||||||
|
viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT},
|
||||||
|
user_agent=config.USER_AGENT
|
||||||
|
)
|
||||||
# 需要登录
|
# 需要登录
|
||||||
login_success = await self.handle_login(context)
|
login_success = await self.handle_login(context)
|
||||||
if not login_success:
|
if not login_success:
|
||||||
@@ -559,29 +563,31 @@ class XiaoHongShuScraper:
|
|||||||
"""多线程模式"""
|
"""多线程模式"""
|
||||||
await self.emit_log("INFO", f"启动多线程采集模式,线程数: {max_threads}")
|
await self.emit_log("INFO", f"启动多线程采集模式,线程数: {max_threads}")
|
||||||
|
|
||||||
# 创建多个 Context
|
|
||||||
|
# 创建多个 Context(同一账号并发会增加风控风险,请合理控制线程数)
|
||||||
contexts = []
|
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},
|
viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT},
|
||||||
user_agent=config.USER_AGENT
|
user_agent=config.USER_AGENT
|
||||||
)
|
)
|
||||||
|
login_success = await self.handle_login(first)
|
||||||
|
if not login_success:
|
||||||
|
await first.close()
|
||||||
|
return
|
||||||
|
await first.close()
|
||||||
|
|
||||||
# 第一个 Context 登录,其他复制 Cookies
|
# 基于 storage_state 为每个线程创建独立 context
|
||||||
if i == 0:
|
for i in range(max_threads):
|
||||||
if os.path.exists(config.AUTH_STATE_FILE):
|
ctx = await self.browser.new_context(
|
||||||
await self.emit_log("INFO", "加载已保存的登录状态")
|
storage_state=config.AUTH_STATE_FILE,
|
||||||
await ctx.add_cookies([json.load(open(config.AUTH_STATE_FILE))])
|
viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT},
|
||||||
else:
|
user_agent=config.USER_AGENT
|
||||||
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)
|
contexts.append(ctx)
|
||||||
|
await self.emit_log("INFO", f"Thread-{i+1} context ready")
|
||||||
|
|
||||||
# 创建任务队列
|
# 创建任务队列
|
||||||
task_queue = asyncio.Queue()
|
task_queue = asyncio.Queue()
|
||||||
|
|||||||
@@ -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
|
||||||
+420
-1038
File diff suppressed because it is too large
Load Diff
+353
@@ -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. 账号管理区域
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **添加账号**:点击添加账号按钮,输入小红书用户 ID
|
||||||
|
- **刷新账号**:重新加载账号列表和状态
|
||||||
|
- **登录状态**:
|
||||||
|
- 🔴 红色圆点:未登录
|
||||||
|
- 🟢 绿色圆点:已登录
|
||||||
|
- 显示剩余登录时间(小时:分钟)
|
||||||
|
|
||||||
|
### 2. 控制面板
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **启动采集**:开始数据采集任务
|
||||||
|
- **停止**:停止正在进行的采集任务
|
||||||
|
- **清空日志**:清空所有历史日志
|
||||||
|
- **导出数据**:下载采集到的数据文件
|
||||||
|
- **模式选择**:
|
||||||
|
- 单线程:逐个处理账号
|
||||||
|
- 多线程:同时处理多个账号(可配置线程数)
|
||||||
|
- **线程数**:多线程模式下的并发线程数(1-10)
|
||||||
|
|
||||||
|
### 3. 进度监控
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **整体进度**:显示采集进度条和百分比
|
||||||
|
- **活跃线程**:多线程模式下显示正在运行的线程数
|
||||||
|
- **用户列表**:显示每个用户的采集状态
|
||||||
|
- ✅ 已完成:显示采集的笔记数量
|
||||||
|
- 🔄 进行中:显示当前处理的笔记数量
|
||||||
|
- ⏳ 待处理:尚未开始处理的账号
|
||||||
|
- ❌ 失败:采集失败的账号
|
||||||
|
|
||||||
|
### 4. 日志面板
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **日志级别过滤**:可选择查看 ALL/INFO/SUCCESS/WARNING/ERROR/DEBUG 级别的日志
|
||||||
|
- **搜索功能**:搜索特定关键词的日志
|
||||||
|
- **自动滚动**:开启后自动滚动到最新日志
|
||||||
|
- **时间戳**:显示每条日志的具体时间
|
||||||
|
- **线程标识**:多线程模式下显示日志来源线程
|
||||||
|
|
||||||
|
### 5. 错误截图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
如果采集过程中遇到错误(如风控检测),系统会自动截图并保存:
|
||||||
|
- 截图保存在:`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 部署支持
|
||||||
|
- ✅ 数据导出功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意**:本工具仅供学习和研究使用,请遵守小红书的使用条款和相关法律法规。
|
||||||
Reference in New Issue
Block a user