[优化建议] 为已下载的抖音媒体自动生成可浏览的静态HTML存档页面

Author: lvSaVCreated May 15, 2026Updated Sep 5, 2026
Labels未跟进问题(Stale)

volume目录下会按用户存放大量视频/图片文件,时间久了想回顾某个用户的某条作品时只能在文件管理器里翻找,预览不方便,也无法一目了然地浏览所有内容。

建议内容:增加一个可选功能,在下载完成后自动生成一套静态 HTML 存档页面。 我用ai写了一个Python脚本,作者自己加入的此功能就更好 每个用户一个 index.html 页面(放在抖音存档HTML/昵称/) 一个总索引页 存档总索引.html,每个用户目录下存放标题文案的 metadata/*.json 元数据

还有一个最好加一个提取用户抖音号当文件夹名字的建议,UID是无法搜索到抖音的

ImageImage
python
#!/usr/bin/env python3
"""
Douyin Media Archive Generator
Reads downloaded media files from Volume/, generates:
  1. Per-user metadata JSON (stored in {user_dir}/metadata/)
  2. Per-user static HTML gallery pages (stored in 抖音存档HTML/{nickname}/)
  3. Master index page ( 抖音存档HTML/存档总索引.html )
"""
import json
import os
import re
import shutil
import html as html_mod
from datetime import datetime
from pathlib import Path
from collections import defaultdict
from urllib.parse import quote

# ── Configuration ──────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent
VOLUME_DIR = BASE_DIR / "Volume"
ARCHIVE_ROOT = BASE_DIR / "抖音存档HTML"

MEDIA_EXTENSIONS = {".mp4", ".jpeg", ".jpg", ".png", ".webp", ".gif", ".mov"}

TYPE_MAP = {
    "视频": "video",
    "图集": "image",
    "实况": "mixed",
}

# ── Helpers ────────────────────────────────────────────────────

def safe_filename(s: str) -> str:
    """Strip characters unsafe for filenames."""
    return re.sub(r'[<>:"/\\|?*]', '_', s)


def parse_filename(filename: str) -> dict | None:
    """
    Parse a downloaded media filename.
    Format: YYYY-MM-DD HH.MM.SS-TYPE-NICKNAME-DESC[_PART].ext
    Returns dict with keys: datetime, type_cn, type_en, nickname, desc, part, ext, filename
    """
    stem, ext = os.path.splitext(filename)
    ext = ext.lower()
    if ext not in MEDIA_EXTENSIONS:
        return None

    # Extract part number: _N at end of stem
    part = 1
    m = re.match(r'^(.+)_(\d+)$', stem)
    if m:
        stem = m.group(1)
        part = int(m.group(2))

    # Split by '-' separator
    parts = stem.split("-")
    if len(parts) < 5:
        return None

    year = parts[0]
    month = parts[1]
    day_time = parts[2]       # "DD HH.MM.SS"
    type_cn = parts[3]
    nickname = parts[4]
    desc = "-".join(parts[5:]) if len(parts) > 5 else ""

    if type_cn not in TYPE_MAP:
        return None

    try:
        dt = datetime.strptime(f"{year}-{month}-{day_time}", "%Y-%m-%d %H.%M.%S")
    except ValueError:
        return None

    return {
        "datetime": dt,
        "datetime_str": dt.strftime("%Y-%m-%d %H:%M:%S"),
        "datetime_iso": dt.strftime("%Y-%m-%dT%H:%M:%S"),
        "type_cn": type_cn,
        "type_en": TYPE_MAP[type_cn],
        "nickname": nickname,
        "desc": desc,
        "part": part,
        "ext": ext,
        "filename": filename,
    }


def scan_user_directories(volume_dir: Path) -> list[dict]:
    """Find all user directories matching UID*_*_发布作品 pattern."""
    users = []
    if not volume_dir.exists():
        return users
    for d in sorted(volume_dir.iterdir()):
        if not d.is_dir():
            continue
        m = re.match(r'^UID(\d+)_(.+)_发布作品$', d.name)
        if m:
            users.append({
                "uid": m.group(1),
                "nickname": m.group(2),
                "dir_name": d.name,
                "dir_path": d,
            })
    return users


def group_posts(media_files: list[dict]) -> list[dict]:
    """
    Group parsed media files into posts.
    Files sharing the same (datetime, type_cn, nickname, desc) belong to one post.
    Sorted reverse-chronological.
    """
    groups = defaultdict(list)
    for mf in media_files:
        key = (mf["datetime"], mf["type_cn"], mf["nickname"], mf["desc"])
        groups[key].append(mf)

    posts = []
    for (_dt, type_cn, nickname, desc), files in groups.items():
        # Keep original type_cn for post-level type (图集→image, 视频→video, 实况→mixed)
        post_type = TYPE_MAP[type_cn]

        media = []
        for f in sorted(files, key=lambda x: x["part"]):
            media.append({
                "type": "video" if f["ext"] in (".mp4", ".mov") else "image",
                "filename": f["filename"],
                "part": f["part"],
            })

        posts.append({
            "create_time": files[0]["datetime_iso"],
            "create_time_display": files[0]["datetime_str"],
            "desc": desc,
            "media_type_cn": type_cn,
            "media_type": post_type,
            "media": media,
            "media_count": len(media),
        })

    posts.sort(key=lambda p: p["create_time"], reverse=True)
    return posts


# ── JSON generation ────────────────────────────────────────────

def generate_metadata_json(user: dict, posts: list[dict], output_dir: Path) -> Path:
    """Write metadata JSON for a single user."""
    output_dir.mkdir(parents=True, exist_ok=True)

    metadata = {
        "user": {
            "uid": user["uid"],
            "nickname": user["nickname"],
            "directory": user["dir_name"],
        },
        "post_count": len(posts),
        "generated_at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
        "posts": [],
    }

    for i, post in enumerate(posts):
        entry = {
            "aweme_id": f"{user['uid']}_{i:05d}",
            "create_time": post["create_time"],
            "create_time_display": post["create_time_display"],
            "desc": post["desc"],
            "media_type": post["media_type"],
            "media_type_cn": post["media_type_cn"],
            "media": post["media"],
        }
        metadata["posts"].append(entry)

    json_path = output_dir / f"{safe_filename(user['nickname'])}.json"
    json_path.write_text(
        json.dumps(metadata, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    return json_path


# ── HTML generation ────────────────────────────────────────────

HTML_CSS = """\
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans SC",sans-serif;background:#f5f5f5;color:#333;line-height:1.6;min-height:100vh}
a{color:#fe2c55;text-decoration:none}a:hover{text-decoration:underline}
.container{max-width:720px;margin:0 auto;padding:20px}
.header{background:#fff;border-bottom:1px solid #e8e8e8;padding:24px 0;text-align:center;position:sticky;top:0;z-index:10;box-shadow:0 1px 3px rgba(0,0,0,.05)}
.header h1{font-size:1.4em;color:#111;margin-bottom:4px}
.header .uid{font-size:.8em;color:#999}
.header .stats{font-size:.85em;color:#888;margin-top:6px}
.back-link{display:inline-block;margin-top:8px;font-size:.85em;color:#fe2c55}
.post-card{background:#fff;border-radius:12px;margin:16px 0;overflow:hidden;border:1px solid #e8e8e8;box-shadow:0 1px 4px rgba(0,0,0,.04)}
.post-header{padding:14px 16px 8px;display:flex;justify-content:space-between;align-items:flex-start}
.post-time{font-size:.8em;color:#999}
.post-type{font-size:.75em;color:#fe2c55;background:rgba(254,44,85,.08);padding:2px 8px;border-radius:10px}
.post-desc{padding:0 16px 12px;font-size:.92em;color:#444;word-break:break-word;white-space:pre-wrap}
.post-desc .hashtag{color:#fe2c55}
.media-grid{display:grid;gap:2px}
.media-grid.cols-1{grid-template-columns:1fr}
.media-grid.cols-2{grid-template-columns:1fr 1fr}
.media-grid.cols-3{grid-template-columns:1fr 1fr 1fr}
.media-grid.cols-4{grid-template-columns:1fr 1fr}
.media-item{position:relative;background:#eee;cursor:pointer;overflow:hidden;border-radius:4px}
.media-item img{width:100%;height:100%;object-fit:cover;display:block}
.media-item video{width:100%;height:auto;display:block;max-height:75vh;background:#000}
.empty-state{text-align:center;padding:60px 20px;color:#999}
.empty-state h2{font-size:1.2em;margin-bottom:8px;color:#666}
.footer{text-align:center;padding:30px;color:#bbb;font-size:.75em}
/* Lightbox */
.lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.9);z-index:1000;justify-content:center;align-items:center;overflow:hidden}
.lightbox.active{display:flex}
.lightbox-inner{position:relative;display:flex;justify-content:center;align-items:center;width:100%;height:100%;overflow:hidden;cursor:grab}
.lightbox-inner.dragging{cursor:grabbing}
.lightbox img{max-width:90vw;max-height:90vh;object-fit:contain;transform-origin:center center;transition:transform .1s ease;user-select:none;pointer-events:none}
.lightbox video{max-width:90vw;max-height:90vh;object-fit:contain}
.lightbox-close{position:absolute;top:20px;right:20px;width:36px;height:36px;background:rgba(255,255,255,.1);border:none;border-radius:50%;color:#fff;font-size:24px;cursor:pointer;z-index:10}
.lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;background:rgba(255,255,255,.15);border:none;border-radius:50%;color:#fff;font-size:22px;cursor:pointer;z-index:10;display:flex;align-items:center;justify-content:center;transition:background .2s}
.lightbox-nav:hover{background:rgba(255,255,255,.3)}
.lightbox-nav:disabled{opacity:.2;cursor:default}
.lightbox-prev{left:16px}
.lightbox-next{right:16px}
.lightbox-counter{position:absolute;top:20px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,.8);font-size:14px;pointer-events:none;z-index:10}
.lightbox-zoom-hint{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,.5);font-size:13px;pointer-events:none}
/* Index page */
.index-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;padding:20px 0}
.user-card{background:#fff;border-radius:12px;padding:20px;text-align:center;border:1px solid #e8e8e8;transition:border-color .2s,box-shadow .2s;display:block;color:inherit;text-decoration:none;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.user-card:hover{border-color:#fe2c55;text-decoration:none;box-shadow:0 2px 8px rgba(254,44,85,.1)}
.user-card .avatar{width:60px;height:60px;border-radius:50%;background:#f0f0f0;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:24px;color:#fe2c55;overflow:hidden}
.user-card .avatar img{width:100%;height:100%;object-fit:cover}
.user-card .nickname{font-size:1em;color:#222;margin-bottom:4px}
.user-card .meta{font-size:.78em;color:#999}
.index-header{text-align:center;padding:30px 0 10px}
.index-header h1{font-size:1.6em;color:#222}
.index-header p{color:#999;margin-top:6px}
.search-bar{padding:12px 16px;position:sticky;top:0;background:#f5f5f5;z-index:99}
.search-input{width:100%;background:#fff;border:1px solid #e8e8e8;border-radius:9999px;padding:12px 16px;color:#333;font-size:15px;outline:none}
.search-input:focus{border-color:#fe2c55}
@media(max-width:600px){
.container{padding:12px}
.post-card{margin:10px 0;border-radius:8px}
.index-grid{grid-template-columns:repeat(2,1fr);gap:10px}
}
"""

HTML_LIGHTBOX_JS = """\
<script>
var lbScale=1,lbX=0,lbY=0;
var isDragging=false,dragStartX=0,dragStartY=0,dragOriginX=0,dragOriginY=0;
var dragMoved=false;
var lastPinchDist=0;
var lbGroup=[],lbIndex=0;

function applyTransform(){
  var img=document.querySelector('#lightbox-content img');
  if(img)img.style.transform='translate('+lbX+'px,'+lbY+'px) scale('+lbScale+')';
}

function isImageItem(){
  return lbGroup[lbIndex]&&lbGroup[lbIndex].type==='image';
}

function lbShowImage(idx){
  lbIndex=idx;
  lbScale=1;lbX=0;lbY=0;
  var item=lbGroup[idx];
  var content=document.getElementById('lightbox-content');
  if(item.type==='video'){
    content.innerHTML='<video src="'+item.src+'" controls style="max-width:90vw;max-height:90vh;"></video>';
  }else{
    content.innerHTML='<img src="'+item.src+'" style="max-width:90vw;max-height:90vh;object-fit:contain;transform-origin:center center;user-select:none;pointer-events:none;">';
  }
  var counter=document.getElementById('lb-counter');
  counter.textContent=lbGroup.length>1?(idx+1)+' / '+lbGroup.length:'';
  document.getElementById('lb-prev').disabled=(idx===0);
  document.getElementById('lb-next').disabled=(idx===lbGroup.length-1);
  var ns=lbGroup.length>1?'flex':'none';
  document.getElementById('lb-prev').style.display=ns;
  document.getElementById('lb-next').style.display=ns;
}

function lbNavigate(dir){
  var next=lbIndex+dir;
  if(next>=0&&next<lbGroup.length)lbShowImage(next);
}

function lbOpen(el){
  lbGroup=JSON.parse(el.dataset.group);
  document.getElementById('lightbox').classList.add('active');
  document.body.style.overflow='hidden';
  lbShowImage(parseInt(el.dataset.idx));
}

function closeLightbox(){
  document.getElementById('lightbox').classList.remove('active');
  document.getElementById('lightbox-content').innerHTML='';
  document.body.style.overflow='';
  lbScale=1;lbX=0;lbY=0;
  lbGroup=[];lbIndex=0;
}

document.getElementById('lightbox').addEventListener('click',function(e){
  if(e.target===this)closeLightbox();
});

document.addEventListener('keydown',function(e){
  if(!document.getElementById('lightbox').classList.contains('active'))return;
  if(e.key==='Escape')closeLightbox();
  else if(e.key==='ArrowLeft')lbNavigate(-1);
  else if(e.key==='ArrowRight')lbNavigate(1);
});

document.getElementById('lightbox').addEventListener('wheel',function(e){
  if(!isImageItem())return;
  e.preventDefault();
  var delta=e.deltaY>0?0.9:1.1;
  lbScale=Math.min(Math.max(lbScale*delta,0.5),8);
  applyTransform();
},{passive:false});

var lbInner=document.getElementById('lightbox-inner');
lbInner.addEventListener('mousedown',function(e){
  if(e.button!==0||!isImageItem())return;
  isDragging=true;dragMoved=false;
  dragStartX=e.clientX;dragStartY=e.clientY;
  dragOriginX=lbX;dragOriginY=lbY;
  this.classList.add('dragging');
});
document.addEventListener('mousemove',function(e){
  if(!isDragging)return;
  dragMoved=true;
  lbX=dragOriginX+(e.clientX-dragStartX);
  lbY=dragOriginY+(e.clientY-dragStartY);
  applyTransform();
});
document.addEventListener('mouseup',function(){
  isDragging=false;
  var inner=document.getElementById('lightbox-inner');
  if(inner)inner.classList.remove('dragging');
});

lbInner.addEventListener('click',function(e){
  if(e.target===this&&!dragMoved)closeLightbox();
});

lbInner.addEventListener('touchstart',function(e){
  if(e.touches.length===2){
    lastPinchDist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
  }else if(e.touches.length===1&&isImageItem()){
    isDragging=true;
    dragStartX=e.touches[0].clientX;dragStartY=e.touches[0].clientY;
    dragOriginX=lbX;dragOriginY=lbY;
  }
},{passive:true});
lbInner.addEventListener('touchmove',function(e){
  if(e.touches.length===2){
    e.preventDefault();
    var dist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
    if(lastPinchDist>0){
      lbScale=Math.min(Math.max(lbScale*(dist/lastPinchDist),0.5),8);
      applyTransform();
    }
    lastPinchDist=dist;
  }else if(e.touches.length===1&&isDragging){
    lbX=dragOriginX+(e.touches[0].clientX-dragStartX);
    lbY=dragOriginY+(e.touches[0].clientY-dragStartY);
    applyTransform();
  }
},{passive:false});
lbInner.addEventListener('touchend',function(){isDragging=false;lastPinchDist=0;});
</script>"""


def _url_encode_path(path: str) -> str:
    """URL-encode each segment of a relative path, preserving / separators."""
    parts = path.split("/")
    return "/".join(quote(p, safe="") for p in parts)


def _highlight_hashtags(text: str) -> str:
    """Wrap #tag text in highlight spans."""
    text = html_mod.escape(text)
    text = re.sub(r'(#[\w一-鿿぀-ゟ゠-ヿ가-힯]+)', r'<span class="hashtag">\1</span>', text)
    return text


def _media_grid_class(count: int) -> str:
    if count == 1:
        return "cols-1"
    elif count == 2:
        return "cols-2"
    elif count == 3:
        return "cols-3"
    else:
        return "cols-4"


def _render_media_items(media_list: list, media_base_rel: str) -> str:
    """Render media HTML for a post with lightbox data attributes."""
    if not media_list:
        return ""

    # Build lightbox group data for all items (images + videos)
    lb_items = []
    for m in media_list:
        fname = m["filename"]
        src_path = _url_encode_path(f"{media_base_rel}/{fname}")
        lb_items.append({"type": m["type"], "src": src_path})

    lb_json = json.dumps(lb_items, ensure_ascii=False)
    lb_json_escaped = html_mod.escape(lb_json, quote=True)

    items_html = []
    for i, m in enumerate(media_list):
        fname = m["filename"]
        src_path = _url_encode_path(f"{media_base_rel}/{fname}")
        quoted_src = html_mod.escape(src_path, quote=True)

        if m["type"] == "video":
            items_html.append(
                f'<div class="media-item"><video src="{quoted_src}" controls '
                f'preload="metadata" poster=""></video></div>'
            )
        else:
            items_html.append(
                f'<div class="media-item" data-group="{lb_json_escaped}" '
                f'data-idx="{i}" onclick="lbOpen(this); event.stopPropagation();">'
                f'<img src="{quoted_src}" loading="lazy" alt=""></div>'
            )

    grid_class = _media_grid_class(len(media_list))
    return f'<div class="media-grid {grid_class}">{"".join(items_html)}</div>'


def generate_user_html(user: dict, posts: list[dict], media_rel: str, output_dir: Path) -> Path:
    """Generate the per-user static HTML gallery page."""
    output_dir.mkdir(parents=True, exist_ok=True)

    user_nick = html_mod.escape(user["nickname"])
    user_uid = html_mod.escape(user["uid"])

    post_cards = []

    for post in posts:
        time_str = html_mod.escape(post["create_time_display"])
        type_label = html_mod.escape(post["media_type_cn"])
        desc_html = _highlight_hashtags(post["desc"]) if post["desc"] else '<span style="color:#bbb">(no description)</span>'

        grid_html = _render_media_items(post["media"], media_rel)

        post_cards.append(f"""\
<div class="post-card">
  <div class="post-header">
    <span class="post-time">{time_str}</span>
    <span class="post-type">{type_label}</span>
  </div>
  <div class="post-desc">{desc_html}</div>
  {grid_html}
</div>""")

    html = f"""\
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{user_nick} - 抖音作品存档</title>
<style>{HTML_CSS}</style>
</head>
<body>
<div class="header">
  <h1>@{user_nick}</h1>
  <div class="uid">UID: {user_uid}</div>
  <div class="stats">共 {len(posts)} 条作品</div>
  <a class="back-link" href="../存档总索引.html">← 返回总索引</a>
</div>
<div class="container">
  {''.join(post_cards) if post_cards else '<div class="empty-state"><h2>暂无作品</h2><p>该用户目录下未找到媒体文件</p></div>'}
</div>

<!-- Lightbox -->
<div class="lightbox" id="lightbox">
  <button class="lightbox-close" onclick="closeLightbox()">&times;</button>
  <button class="lightbox-nav lightbox-prev" id="lb-prev" onclick="lbNavigate(-1)">&#8249;</button>
  <div class="lightbox-inner" id="lightbox-inner">
    <div id="lightbox-content"></div>
  </div>
  <button class="lightbox-nav lightbox-next" id="lb-next" onclick="lbNavigate(1)">&#8250;</button>
  <div class="lightbox-counter" id="lb-counter"></div>
  <div class="lightbox-zoom-hint">滚轮缩放 · 拖拽移动 · ← → 切换 · ESC关闭</div>
</div>

{HTML_LIGHTBOX_JS}
</body>
</html>"""

    html_path = output_dir / "index.html"
    html_path.write_text(html, encoding="utf-8")
    return html_path


def generate_index_html(user_pages: list[dict], output_path: Path) -> Path:
    """Generate the master archive index page."""
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # user_pages: list of {nickname, uid, post_count, html_rel_path, first_thumb, thumb_dir}
    cards = []
    for u in user_pages:
        nn = html_mod.escape(u["nickname"])
        uid = html_mod.escape(u["uid

Source: JoeanAmier/TikTokDownloader