feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,14 @@
"""
AVE asset_manager — 素材资产管理包
提供素材索引、缓存管理、标签搜索三大能力。
模块:
index.py — AssetIndex: 扫描目录、提取元数据、入库 SQLite
cache.py — CacheManager: 增量扫描、缓存清理、孤立文件回收
tags.py — AssetSearch: 自动标签、模糊搜索、CLI 查询
依赖:
- lib/dashboard.py (assets 表 schema 已存在)
- ffprobe (FFmpeg, 用于提取视频元数据)
"""
@@ -0,0 +1,367 @@
"""
asset_manager/cache — 缓存管理与自动入库
职责:
1. 增量扫描: 跟踪目录 mtime, 只扫描新文件
2. 自动入库: 发现新文件 → 调用 AssetIndex 入库
3. 缓存清理: 按策略清理孤立/过期文件
4. 生产者钩子: Pexels/Kling 下载完成后自动通知
用法:
from asset_manager.cache import CacheManager
cm = CacheManager()
cm.scan_new() # 扫描所有目录的新文件
cm.cleanup(days=30) # 清理 30 天前无关联的素材
cm.register_scan("/tmp/") # 注册额外扫描目录
设计原则:
- 无持续进程/守护: 每次 CLI 调用时触发扫描
- mtime 记录在 SQLite meta_json 中, 跨会话持久
- 重要素材 (有 production_id 关联) 不会被清理
"""
import json
import os
import shutil
import sqlite3
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable, Optional, Union
from lib.logger import get_logger
from asset_manager.index import AssetIndex
logger = get_logger("asset_cache")
# ── 全局路径 (复用 index.py) ─────────────────────────────
_LOCAL_ROOT = Path(os.environ.get(
"AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local"),
))
_CACHE_DIR = _LOCAL_ROOT / "tools" / "ave" / "cache"
_DB_PATH = _LOCAL_ROOT / "tools" / "ave" / "data" / "ave.db"
# ═══════════════════════════════════════════════════════════
# 核心类
# ═══════════════════════════════════════════════════════════
class CacheManager:
"""缓存管理器
属性:
index: AssetIndex 实例 (共享 DB)
scan_records: {dir_path: last_mtime} 记录上次扫描时间
"""
def __init__(self, cache_dir: Union[str, Path] = _CACHE_DIR,
db_path: Union[str, Path] = _DB_PATH):
self.cache_dir = Path(cache_dir)
self.db_path = Path(db_path)
self.index = AssetIndex(cache_dir=cache_dir, db_path=db_path)
# 额外扫描目录注册表: {路径: 回调函数}
self._extra_scans: list[tuple[str, Optional[Callable]]] = []
# ── 注册额外扫描目录 ─────────────────────────────────
def register_scan(self, directory: str,
callback: Optional[Callable] = None):
"""注册额外目录到扫描列表 (如外部导入的素材目录)"""
self._extra_scans.append((directory, callback))
logger.info(f"注册扫描目录: {directory}")
# ── 增量扫描 ─────────────────────────────────────────
def _load_scan_record(self, subdir: str) -> float:
"""从 meta_json 读取上次扫描记录的 mtime"""
conn = sqlite3.connect(str(self.db_path))
try:
# 用特殊 hash 值 '_scan_record' 标记扫描记录
row = conn.execute(
"SELECT meta_json FROM assets WHERE hash = ? AND source = ?",
(f"_scan_{subdir}", "__cache_mgr__"),
).fetchone()
if row:
rec = json.loads(row[0])
return float(rec.get("last_mtime", 0))
except Exception:
pass
finally:
conn.close()
return 0.0
def _save_scan_record(self, subdir: str, mtime: float):
"""持久化扫描记录到 DB"""
conn = sqlite3.connect(str(self.db_path))
try:
# 先删除旧记录
conn.execute(
"DELETE FROM assets WHERE hash = ? AND source = ?",
(f"_scan_{subdir}", "__cache_mgr__"),
)
conn.execute(
"""INSERT INTO assets (asset_type, name, file_path, file_size, hash, source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
("__meta__", f"scan_record_{subdir}", "",
0, f"_scan_{subdir}", "__cache_mgr__",
json.dumps({"last_mtime": mtime, "updated_at": datetime.now().isoformat()})),
)
conn.commit()
except Exception as e:
logger.debug(f"保存扫描记录失败: {e}")
finally:
conn.close()
def scan_new(self, force_rescan: bool = False) -> dict:
"""
增量扫描: 只扫描 mtime 超过上次记录的文件。
参数:
force_rescan: 强制全量重新扫描
返回:
{"total_new": int, "per_dir": {subdir: new_count}}
"""
from asset_manager.index import WATCHED_SUBDIRS, EXT_TYPE_MAP
results: dict = {"total_new": 0, "per_dir": {}}
valid_exts = set(EXT_TYPE_MAP.keys())
for subdir in WATCHED_SUBDIRS:
target = self.cache_dir / subdir
if not target.is_dir():
continue
last_mtime = 0.0 if force_rescan else self._load_scan_record(subdir)
new_count = 0
for fpath in sorted(target.rglob("*")):
if not fpath.is_file():
continue
if fpath.suffix.lower() not in valid_exts:
continue
file_mtime = fpath.stat().st_mtime
if file_mtime > last_mtime:
ret = self.index.index_file(str(fpath))
if ret > 0:
new_count += 1
# 更新扫描记录 (用当前最大 mtime)
current_max = 0.0
for fpath in target.rglob("*"):
if fpath.is_file():
current_max = max(current_max, fpath.stat().st_mtime)
self._save_scan_record(subdir, current_max)
if new_count > 0:
logger.info(f" [{subdir}] 新增 {new_count} 个素材")
results["per_dir"][subdir] = new_count
results["total_new"] += new_count
# 额外扫描目录
for extra_dir, callback in self._extra_scans:
extra_path = Path(extra_dir)
if not extra_path.is_dir():
continue
for fpath in extra_path.rglob("*"):
if not fpath.is_file():
continue
if fpath.suffix.lower() not in valid_exts:
continue
ret = self.index.index_file(str(fpath))
if ret > 0:
results["total_new"] += 1
if callback:
try:
callback(str(fpath))
except Exception as e:
logger.warning(f"回调失败 {fpath}: {e}")
logger.info(f"增量扫描完成, 共新增 {results['total_new']} 个素材")
return results
# ── 全量扫描 ─────────────────────────────────────────
def scan_all(self, production_id: int = 0) -> dict:
"""全量扫描并重置所有扫描记录"""
results = self.index.scan_all(production_id)
# 重置扫描记录为最新 mtime
from asset_manager.index import WATCHED_SUBDIRS
for subdir in WATCHED_SUBDIRS:
target = self.cache_dir / subdir
if target.is_dir():
current_max = max(
(f.stat().st_mtime for f in target.rglob("*") if f.is_file()),
default=0.0,
)
self._save_scan_record(subdir, current_max)
return results
# ── 缓存清理 ─────────────────────────────────────────
def cleanup(self, days: int = 30, dry_run: bool = False) -> dict:
"""
清理过期缓存。
清理规则:
- 无 production_id 关联
- 文件创建时间 > days 天前
- 保留有 production 引用的素材
参数:
days: 保留天数
dry_run: 仅预览不删除
返回:
{"deleted": int, "freed_bytes": int, "files": [路径]}
"""
conn = sqlite3.connect(str(self.db_path))
cutoff = (datetime.now() - timedelta(days=days)).isoformat()
result: dict = {"deleted": 0, "freed_bytes": 0, "files": []}
try:
# 查找无关联的旧资产
rows = conn.execute(
"""SELECT id, file_path, file_size FROM assets
WHERE production_id IS NULL
AND created_at < ?
AND asset_type != '__meta__'
ORDER BY created_at ASC""",
(cutoff,),
).fetchall()
for row in rows:
aid, fpath, fsize = row["id"], row["file_path"], row["file_size"] or 0
if dry_run:
result["deleted"] += 1
result["freed_bytes"] += fsize
result["files"].append(f"{fpath} ({_human_size(fsize)})")
continue
# 删除磁盘文件
if fpath and os.path.isfile(fpath):
try:
os.remove(fpath)
logger.debug(f" 删除文件: {fpath}")
except OSError as e:
logger.warning(f" 删除失败: {fpath}: {e}")
# 删除 DB 记录
conn.execute("DELETE FROM asset_tags WHERE asset_id = ?", (aid,))
conn.execute("DELETE FROM production_assets WHERE asset_id = ?", (aid,))
conn.execute("DELETE FROM assets WHERE id = ?", (aid,))
result["deleted"] += 1
result["freed_bytes"] += fsize
result["files"].append(fpath)
conn.commit()
except Exception as e:
logger.error(f"cleanup 失败: {e}")
finally:
conn.close()
action = "预览" if dry_run else "清理"
logger.info(f"缓存{action}: {result['deleted']} 个文件, "
f"释放 {_human_size(result['freed_bytes'])}")
return result
# ── 目录统计 ─────────────────────────────────────────
def disk_usage(self) -> dict:
"""统计每个缓存目录的磁盘占用"""
usage: dict = {}
for subdir in os.listdir(self.cache_dir):
target = self.cache_dir / subdir
if not target.is_dir():
continue
total = 0
count = 0
for fpath in target.rglob("*"):
if fpath.is_file():
total += fpath.stat().st_size
count += 1
usage[subdir] = {
"count": count,
"size_bytes": total,
"size_human": _human_size(total),
}
return usage
def print_disk_usage(self):
"""打印磁盘占用表"""
usage = self.disk_usage()
print(f"\n{'='*50}")
print(f" 缓存磁盘占用")
print(f"{'='*50}")
print(f" {'目录':20s} {'文件数':>8s} {'大小':>12s}")
print(f" {'-'*40}")
total_size = 0
for subdir, info in sorted(usage.items(), key=lambda x: -x[1]["size_bytes"]):
print(f" {subdir:20s} {info['count']:>8d} {info['size_human']:>12s}")
total_size += info["size_bytes"]
print(f" {'-'*40}")
print(f" {'总计':20s} {'':>8s} {_human_size(total_size):>12s}")
print(f"{'='*50}\n")
# ═══════════════════════════════════════════════════════════
# 生产者自动入库钩子
# ═══════════════════════════════════════════════════════════
def auto_index_on_download(file_path: str, production_id: int = 0) -> int:
"""
生产者在下载/生成素材后, 调用此函数自动入库。
用法:
from asset_manager.cache import auto_index_on_download
path = pexels_search.download(url)
auto_index_on_download(path, production_id=pid)
"""
idx = AssetIndex()
return idx.index_file(file_path, production_id=production_id)
def index_output_video(output_path: str, production_id: int) -> int:
"""
最终成片入库 (特殊标记 source=output)。
"""
if not os.path.isfile(output_path):
return -1
idx = AssetIndex()
# 强制标记 source 为 output
conn = sqlite3.connect(str(idx.db_path))
try:
# 先通过 index_file 入库
aid = idx.index_file(output_path, production_id=production_id)
if aid > 0:
# 覆盖 source
conn.execute(
"UPDATE assets SET source = 'production_output' WHERE id = ?",
(aid,),
)
conn.commit()
return aid
return aid
except Exception as e:
logger.error(f"index_output 失败: {e}")
return -1
finally:
conn.close()
# ── 辅助函数 ─────────────────────────────────────────────
def _human_size(bytes_: int) -> str:
if bytes_ < 1024:
return f"{bytes_} B"
elif bytes_ < 1024**2:
return f"{bytes_/1024:.1f} KB"
elif bytes_ < 1024**3:
return f"{bytes_/(1024**2):.1f} MB"
return f"{bytes_/(1024**3):.2f} GB"
@@ -0,0 +1,469 @@
"""
asset_manager/index — 素材索引引擎
职责:
1. 扫描所有已知素材目录,发现新文件
2. 通过 ffprobe 提取视频/音频元数据 (时长/分辨率/编码/FPS)
3. 计算文件 hash (首 64KB + 文件大小, 快速去重)
4. 写入 SQLite assets 表, 避免重复入库
用法:
from asset_manager.index import AssetIndex
idx = AssetIndex()
stats = idx.scan_all() # 全量扫描所有目录
stats = idx.scan_dir("materials") # 扫描单个子目录
print(idx.summarize()) # 打印统计
AssetIndex 发现策略:
- 只扫描已知目录 (materials/kling/wan2_2/bgm/bgm_library/lipsync/character_sheet)
- 按 hash 去重: 同一文件即使出现在不同目录也只入库一次
- 增量模式: 只扫描 mtime > 上次扫描时间的文件
"""
import hashlib
import json
import os
import sqlite3
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from lib.logger import get_logger
logger = get_logger("asset_index")
# ── 全局路径 ───────────────────────────────────────────────
_LOCAL_ROOT = Path(os.environ.get(
"AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local"),
))
_CACHE_DIR = _LOCAL_ROOT / "tools" / "ave" / "cache"
_DB_PATH = _LOCAL_ROOT / "tools" / "ave" / "data" / "ave.db"
# ── 已知素材目录 (相对于 cache) ──────────────────────────
WATCHED_SUBDIRS: dict[str, str] = {
"materials": "pexels 下载素材",
"kling": "Kling 生成视频",
"wan2_2": "Wan2.2 数字人视频",
"bgm": "BGM 缓存",
"bgm_library": "BGM 素材库",
"lipsync": "唇形同步产出",
"character_sheet": "定妆照/角色图",
"outputs": "最终成片",
"beat_sync": "节拍分析缓存",
}
# ── 已知扩展名 → asset_type ─────────────────────────────
EXT_TYPE_MAP = {
".mp4": "video",
".mov": "video",
".webm": "video",
".avi": "video",
".mkv": "video",
".wav": "audio",
".mp3": "audio",
".aac": "audio",
".m4a": "audio",
".flac": "audio",
".ogg": "audio",
".png": "image",
".jpg": "image",
".jpeg": "image",
".webp": "image",
".gif": "image",
}
# ═══════════════════════════════════════════════════════════
# 核心类
# ═══════════════════════════════════════════════════════════
class AssetIndex:
"""素材索引引擎"""
def __init__(self, cache_dir: Union[str, Path] = _CACHE_DIR,
db_path: Union[str, Path] = _DB_PATH):
self.cache_dir = Path(cache_dir)
self.db_path = Path(db_path)
self._init_db()
# ── DB 初始化 ──────────────────────────────────────────
def _init_db(self):
"""确保 assets 相关表存在(复用 dashboard.py 的 schema)"""
try:
conn = sqlite3.connect(str(self.db_path))
conn.executescript("""
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_type TEXT NOT NULL,
name TEXT,
file_path TEXT,
file_size INTEGER,
hash TEXT,
source TEXT,
meta_json TEXT,
production_id INTEGER,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS asset_tags (
asset_id INTEGER NOT NULL REFERENCES assets(id),
tag TEXT NOT NULL,
PRIMARY KEY (asset_id, tag)
);
CREATE TABLE IF NOT EXISTS production_assets (
production_id INTEGER NOT NULL REFERENCES productions(id),
asset_id INTEGER NOT NULL REFERENCES assets(id),
role TEXT,
PRIMARY KEY (production_id, asset_id)
);
CREATE INDEX IF NOT EXISTS idx_assets_hash ON assets(hash);
CREATE INDEX IF NOT EXISTS idx_assets_type ON assets(asset_type);
CREATE INDEX IF NOT EXISTS idx_assets_path ON assets(file_path);
CREATE INDEX IF NOT EXISTS idx_asset_tags_tag ON asset_tags(tag);
""")
conn.commit()
conn.close()
except Exception as e:
logger.error(f"DB 初始化失败: {e}")
# ── 文件级操作 ────────────────────────────────────────
def _compute_hash(self, file_path: str) -> str:
"""快速 hash: 读首 64KB + 文件大小 (比全量 MD5 快 100x)"""
try:
sz = os.path.getsize(file_path)
with open(file_path, "rb") as f:
head = f.read(65536) # 64KB
h = hashlib.md5(head).hexdigest()[:16]
return f"{h}_{sz}"
except OSError:
return ""
def _extract_media_info(self, file_path: str) -> dict:
"""通过 ffprobe 提取媒体元数据"""
meta: dict = {}
ext = Path(file_path).suffix.lower()
# 非视频/音频文件跳过 ffprobe
if ext not in (".mp4", ".mov", ".webm", ".avi", ".mkv",
".wav", ".mp3", ".aac", ".m4a", ".flac", ".ogg"):
return meta
try:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", file_path],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return meta
data = json.loads(result.stdout)
# 格式信息
fmt = data.get("format", {})
meta["format"] = fmt.get("format_name", "")
meta["bitrate"] = int(fmt.get("bit_rate", 0))
meta["duration_sec"] = round(float(fmt.get("duration", 0)), 2)
meta["size_bytes"] = int(fmt.get("size", 0))
# 视频流
for stream in data.get("streams", []):
if stream.get("codec_type") == "video":
meta["video_codec"] = stream.get("codec_name", "")
meta["width"] = stream.get("width", 0)
meta["height"] = stream.get("height", 0)
meta["fps"] = _parse_fps(stream.get("r_frame_rate", ""))
meta["pixel_format"] = stream.get("pix_fmt", "")
break
# 音频流
for stream in data.get("streams", []):
if stream.get("codec_type") == "audio":
meta["audio_codec"] = stream.get("codec_name", "")
meta["sample_rate"] = int(stream.get("sample_rate", 0))
meta["channels"] = stream.get("channels", 0)
break
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
logger.debug(f"ffprobe 失败 {file_path}: {e}")
return meta
def _source_from_path(self, file_path: str) -> str:
"""从文件路径推断来源"""
p = Path(file_path)
try:
rel = p.relative_to(self.cache_dir)
parts = rel.parts
if len(parts) >= 1:
return parts[0] # 顶级子目录名 = source
except ValueError:
pass
return "unknown"
def _infer_tags(self, file_path: str, meta: dict, source: str) -> list[str]:
"""基于文件属性自动生成标签"""
tags: list[str] = [source]
ext = Path(file_path).suffix.lower()
# 文件类型标签
asset_type = EXT_TYPE_MAP.get(ext, "other")
tags.append(asset_type)
# 尺寸标签 (视频)
w = meta.get("width", 0)
h = meta.get("height", 0)
if w and h:
if w >= 3840 or h >= 2160:
tags.append("4k")
elif w >= 1920 or h >= 1080:
tags.append("1080p")
elif w >= 1280 or h >= 720:
tags.append("720p")
else:
tags.append("sd")
# 时长标签
dur = meta.get("duration_sec", 0)
if dur > 0:
if dur < 2:
tags.append("ultra_short")
elif dur < 5:
tags.append("short")
elif dur < 15:
tags.append("medium")
elif dur < 60:
tags.append("long")
else:
tags.append("extra_long")
# 编码标签
vcodec = meta.get("video_codec", "")
if vcodec:
tags.append(f"codec_{vcodec}")
return tags
# ── 单文件入库 ────────────────────────────────────────
def index_file(self, file_path: str, production_id: int = 0,
force: bool = False) -> int:
"""
索引单个文件并入库。
参数:
file_path: 文件绝对路径
production_id: 关联的 production ID (0 = 不关联)
force: 强制重新索引 (即使 hash 已存在)
返回:
asset_id (失败返回 -1, 已存在且未 force 返回 -2)
"""
if not os.path.isfile(file_path):
logger.warning(f"文件不存在: {file_path}")
return -1
ext = Path(file_path).suffix.lower()
asset_type = EXT_TYPE_MAP.get(ext, "other")
name = Path(file_path).name
file_size = os.path.getsize(file_path)
file_hash = self._compute_hash(file_path)
source = self._source_from_path(file_path)
meta = self._extract_media_info(file_path)
tags = self._infer_tags(file_path, meta, source)
if not file_hash:
return -1
conn = sqlite3.connect(str(self.db_path))
try:
# 去重检查
if not force and file_hash:
existing = conn.execute(
"SELECT id FROM assets WHERE hash = ? LIMIT 1",
(file_hash,),
).fetchone()
if existing:
conn.close()
return -2 # 已存在
# 插入资产
cur = conn.execute(
"""INSERT INTO assets
(asset_type, name, file_path, file_size, hash, source, meta_json, production_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(asset_type, name, file_path, file_size, file_hash, source,
json.dumps(meta, ensure_ascii=False),
production_id if production_id > 0 else None),
)
aid = cur.lastrowid
# 标签
for tag in set(tags): # set 去重
conn.execute(
"INSERT OR IGNORE INTO asset_tags (asset_id, tag) VALUES (?, ?)",
(aid, tag),
)
# 关联 production
if production_id > 0:
conn.execute(
"""INSERT OR IGNORE INTO production_assets
(production_id, asset_id, role) VALUES (?, ?, ?)""",
(production_id, aid, "output" if source in ("materials", "kling", "wan2_2")
else "cache"),
)
conn.commit()
logger.info(f" 入库: {name} (type={asset_type}, hash={file_hash[:12]}...)")
return aid
except Exception as e:
logger.error(f"index_file 失败: {file_path}: {e}")
return -1
finally:
conn.close()
# ── 目录扫描 ──────────────────────────────────────────
def scan_dir(self, subdir: str, production_id: int = 0,
progress: bool = True) -> dict:
"""
扫描一个子目录, 入库所有新文件。
参数:
subdir: WATCHED_SUBDIRS 中的 key (如 "materials")
production_id: 可选关联 production
progress: 打印进度
返回:
{"dir": str, "total": int, "new": int, "skipped": int, "errors": int}
"""
target_dir = self.cache_dir / subdir
stat = {"dir": subdir, "total": 0, "new": 0, "skipped": 0, "errors": 0}
if not target_dir.is_dir():
logger.warning(f"目录不存在: {target_dir}")
return stat
files = sorted(target_dir.rglob("*"))
valid_exts = set(EXT_TYPE_MAP.keys())
for i, fpath in enumerate(files):
if not fpath.is_file():
continue
if fpath.suffix.lower() not in valid_exts:
continue
stat["total"] += 1
ret = self.index_file(str(fpath), production_id)
if ret == -1:
stat["errors"] += 1
elif ret == -2:
stat["skipped"] += 1
else:
stat["new"] += 1
if progress and i % 20 == 0 and i > 0:
logger.info(f" [{subdir}] {i} 文件已处理...")
logger.info(f" [{subdir}] 共 {stat['total']} 文件, "
f"新增 {stat['new']}, 跳过 {stat['skipped']}, 错误 {stat['errors']}")
return stat
def scan_all(self, production_id: int = 0, progress: bool = True) -> dict:
"""
扫描所有已知素材目录。
返回:
{"total_new": int, "dirs": {subdir: stat_dict}}
"""
results: dict = {"total_new": 0, "dirs": {}}
for subdir in WATCHED_SUBDIRS:
stat = self.scan_dir(subdir, production_id, progress)
results["dirs"][subdir] = stat
results["total_new"] += stat["new"]
logger.info(f"全量扫描完成, 新增 {results['total_new']} 个素材")
return results
# ── 统计查询 ──────────────────────────────────────────
def summarize(self) -> dict:
"""汇总当前素材库统计信息"""
conn = sqlite3.connect(str(self.db_path))
try:
total = conn.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
by_type = conn.execute(
"SELECT asset_type, COUNT(*) as cnt FROM assets GROUP BY asset_type"
).fetchall()
by_source = conn.execute(
"SELECT source, COUNT(*) as cnt FROM assets GROUP BY source"
).fetchall()
total_size = conn.execute(
"SELECT COALESCE(SUM(file_size), 0) FROM assets"
).fetchone()[0]
orphan = conn.execute(
"SELECT COUNT(*) FROM assets WHERE production_id IS NULL"
).fetchone()[0]
return {
"total_assets": total,
"by_type": dict(by_type),
"by_source": dict(by_source),
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 1),
"orphan_count": orphan,
"watched_dirs": list(WATCHED_SUBDIRS.keys()),
}
finally:
conn.close()
def print_summary(self):
"""打印人类可读的统计"""
s = self.summarize()
print(f"\n{'='*50}")
print(f" 素材库统计")
print(f"{'='*50}")
print(f" 素材总数: {s['total_assets']}")
print(f" 总大小: {s['total_size_mb']} MB")
print(f" 无关联素材: {s['orphan_count']}")
print(f"\n ─ 按类型 ─")
for t, c in sorted(s["by_type"].items(), key=lambda x: -x[1]):
print(f" {t:12s} {c}")
print(f"\n ─ 按来源 ─")
for src, c in sorted(s["by_source"].items(), key=lambda x: -x[1]):
print(f" {src:16s} {c}")
print(f"\n ─ 监控目录 ─")
for d in s["watched_dirs"]:
print(f" {d}")
print(f"{'='*50}\n")
# ═══════════════════════════════════════════════════════════
# 辅助函数
# ═══════════════════════════════════════════════════════════
def _parse_fps(r_frame_rate: str) -> float:
"""解析 ffprobe 的 r_frame_rate (如 '30000/1001' → 29.97)"""
try:
if "/" in r_frame_rate:
num, den = r_frame_rate.split("/")
return round(float(num) / float(den), 2)
return float(r_frame_rate)
except (ValueError, ZeroDivisionError):
return 0.0
def _human_size(bytes_: int) -> str:
if bytes_ < 1024:
return f"{bytes_} B"
elif bytes_ < 1024**2:
return f"{bytes_/1024:.1f} KB"
elif bytes_ < 1024**3:
return f"{bytes_/(1024**2):.1f} MB"
return f"{bytes_/(1024**3):.2f} GB"
@@ -0,0 +1,317 @@
"""
asset_manager/tags — 标签系统 + 素材搜索
职责:
1. 自动标签: 根据文件属性和路径生成标签 (复用 index.py 的 _infer_tags)
2. 标签管理: 查看所有标签、标签频次、批量标记
3. 搜素引擎: 按名称/路径/标签/类型/来源 多维度过滤
4. 搜索结果格式化输出 (表格/JSON)
用法:
from asset_manager.tags import AssetSearch
srch = AssetSearch()
# 搜索
results = srch.search("人物", tags=["video"])
results = srch.search_by_tag("pexels", limit=20)
# 标签管理
srch.tag_stats() # 所有标签频次
srch.tag_asset(42, ["人物", "户外"]) # 给资产打标签
"""
import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from lib.logger import get_logger
logger = get_logger("asset_tags")
# ── 全局路径 ─────────────────────────────────────────────
_LOCAL_ROOT = Path(os.environ.get(
"AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local"),
))
_DB_PATH = _LOCAL_ROOT / "tools" / "ave" / "data" / "ave.db"
# ═══════════════════════════════════════════════════════════
# 搜索类
# ═══════════════════════════════════════════════════════════
class AssetSearch:
"""素材搜索引擎 (基于 SQLite LIKE + 标签过滤)"""
def __init__(self, db_path: Union[str, Path] = _DB_PATH):
self.db_path = Path(db_path)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
return conn
# ── 搜索 ─────────────────────────────────────────────
def search(
self,
keyword: str = "",
*,
asset_type: Optional[str] = None,
source: Optional[str] = None,
tags: Optional[list[str]] = None,
min_size: int = 0,
max_size: int = 0,
min_duration: float = 0.0,
max_duration: float = 0.0,
production_id: Optional[int] = None,
limit: int = 50,
offset: int = 0,
order_by: str = "id DESC",
) -> list[dict]:
"""
多维度素材搜索。
参数:
keyword: 关键字 (模糊匹配 name/file_path)
asset_type: 文件类型 (video/audio/image/other)
source: 来源 (materials/kling/wan2_2/bgm/...)
tags: 标签列表 (AND 逻辑, 需同时命中)
min_size: 最小文件大小 (bytes)
max_size: 最大文件大小 (0 = 不限制)
min_duration: 最小时长 (秒)
max_duration: 最大时长 (0 = 不限制)
production_id:关联 production ID
limit/offset: 分页
order_by: 排序 (SQL 片段, 如 "file_size DESC")
返回:
[{
"id", "asset_type", "name", "file_path", "file_size",
"hash", "source", "meta", "tags", "created_at",
"production_id"
}, ...]
"""
conn = self._connect()
try:
where = []
params: list = []
# 关键字
if keyword:
like = f"%{keyword}%"
where.append("(a.name LIKE ? OR a.file_path LIKE ?)")
params.extend([like, like])
# 类型
if asset_type:
where.append("a.asset_type = ?")
params.append(asset_type)
# 来源
if source:
where.append("a.source = ?")
params.append(source)
# 标签 (AND: 需要所有指定标签)
if tags:
tag_conditions = []
for tag in tags:
tag_conditions.append(
"EXISTS (SELECT 1 FROM asset_tags WHERE asset_id = a.id AND tag = ?)"
)
params.append(tag.lower())
where.append("(" + " AND ".join(tag_conditions) + ")")
# 文件大小
if min_size > 0:
where.append("a.file_size >= ?")
params.append(min_size)
if max_size > 0:
where.append("a.file_size <= ?")
params.append(max_size)
# 时长 (meta_json 中的 duration_sec)
if min_duration > 0:
where.append(
"json_extract(a.meta_json, '$.duration_sec') >= ?"
)
params.append(min_duration)
if max_duration > 0:
where.append(
"json_extract(a.meta_json, '$.duration_sec') <= ?"
)
params.append(max_duration)
# production
if production_id is not None:
where.append("a.production_id = ?")
params.append(production_id)
# 排除内部记录
where.append("a.asset_type != '__meta__'")
where_clause = " AND ".join(where) if where else "1=1"
# 安全校验 order_by (防止 SQL 注入)
allowed_orders = {
"id", "id DESC", "id ASC",
"file_size", "file_size DESC", "file_size ASC",
"created_at", "created_at DESC", "created_at ASC",
"name", "name DESC", "name ASC",
}
safe_order = order_by if order_by in allowed_orders else "id DESC"
rows = conn.execute(
f"""SELECT a.*, GROUP_CONCAT(at.tag, ',') as tag_list
FROM assets a
LEFT JOIN asset_tags at ON at.asset_id = a.id
WHERE {where_clause}
GROUP BY a.id
ORDER BY {safe_order}
LIMIT ? OFFSET ?""",
params + [limit, offset],
).fetchall()
results = []
for r in rows:
d = dict(r)
# 解析 meta_json
try:
d["meta"] = json.loads(d.pop("meta_json", "{}") or "{}")
except json.JSONDecodeError:
d["meta"] = {}
# 标签列表
d["tags"] = d.pop("tag_list", "").split(",") if d.get("tag_list") else []
d["file_size_human"] = _human_size(d.get("file_size", 0))
results.append(d)
return results
except Exception as e:
logger.error(f"search 失败: {e}")
return []
finally:
conn.close()
# ── 标签搜索 ─────────────────────────────────────────
def search_by_tag(self, tag: str, limit: int = 50) -> list[dict]:
"""按单个标签搜索"""
return self.search(tags=[tag], limit=limit)
def search_by_type(self, asset_type: str, limit: int = 50) -> list[dict]:
"""按类型搜索"""
return self.search(asset_type=asset_type, limit=limit)
# ── 标签管理 ─────────────────────────────────────────
def tag_stats(self) -> list[dict]:
"""返回所有标签及出现频次, 按频次降序"""
conn = self._connect()
try:
rows = conn.execute(
"""SELECT tag, COUNT(*) as count
FROM asset_tags
GROUP BY tag
ORDER BY count DESC"""
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def tag_asset(self, asset_id: int, tags: list[str]) -> bool:
"""给指定资产打标签"""
conn = self._connect()
try:
for tag in tags:
tag_clean = tag.strip().lower()
if tag_clean:
conn.execute(
"INSERT OR IGNORE INTO asset_tags (asset_id, tag) VALUES (?, ?)",
(asset_id, tag_clean),
)
conn.commit()
logger.info(f"资产 {asset_id} 标记: {tags}")
return True
except Exception as e:
logger.error(f"标记失败 asset {asset_id}: {e}")
return False
finally:
conn.close()
def untag_asset(self, asset_id: int, tags: list[str]) -> bool:
"""移除标签"""
conn = self._connect()
try:
for tag in tags:
conn.execute(
"DELETE FROM asset_tags WHERE asset_id = ? AND tag = ?",
(asset_id, tag.strip().lower()),
)
conn.commit()
return True
except Exception as e:
logger.error(f"移除标签失败: {e}")
return False
finally:
conn.close()
def print_results(self, results: list[dict], show_meta: bool = False):
"""打印搜索结果表格"""
if not results:
print(" 未找到匹配的素材")
return
print(f"\n {'ID':>4s} {'类型':8s} {'来源':12s} {'大小':10s} {'名称'}")
print(f" {'-'*80}")
for r in results:
print(f" {r['id']:>4d} {r['asset_type']:8s} {r['source'] or '?':12s} "
f"{r.get('file_size_human', ''):>10s} {r['name'] or '?'}")
if show_meta and r.get("meta"):
meta = r["meta"]
parts = []
if meta.get("duration_sec"):
parts.append(f"dur={meta['duration_sec']}s")
if meta.get("width"):
parts.append(f"{meta['width']}x{meta['height']}")
if meta.get("fps"):
parts.append(f"{meta['fps']}fps")
if parts:
print(f" {'':>4s} {'':8s} {'':12s} {'':10s} [{', '.join(parts)}]")
if r.get("tags"):
print(f" {'':>4s} {'':8s} {'':12s} {'':10s} #{', #'.join(r['tags'][:5])}")
print(f" {'-'*80}")
print(f" 共 {len(results)} 条结果\n")
def print_tag_cloud(self):
"""打印标签云"""
stats = self.tag_stats()
if not stats:
print(" 暂无标签")
return
max_count = max(s["count"] for s in stats) if stats else 1
print(f"\n {'标签':24s} {'频次':>6s} 分布")
print(f" {'-'*60}")
for s in stats:
bar_len = int(s["count"] / max_count * 20)
bar = "█" * bar_len + "░" * (20 - bar_len)
print(f" {s['tag']:24s} {s['count']:>6d} {bar}")
print(f" 共 {len(stats)} 个标签\n")
# ═══════════════════════════════════════════════════════════
# 辅助函数
# ═══════════════════════════════════════════════════════════
def _human_size(bytes_: int) -> str:
if bytes_ < 1024:
return f"{bytes_} B"
elif bytes_ < 1024**2:
return f"{bytes_/1024:.1f} KB"
elif bytes_ < 1024**3:
return f"{bytes_/(1024**2):.1f} MB"
return f"{bytes_/(1024**3):.2f} GB"