Skip to content

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

Description

@lvSaV

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

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

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

Image Image
#!/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"])
        pc = u["post_count"]
        href = html_mod.escape(u["html_rel_path"], quote=True)
        thumb_html = ""
        if u.get("first_thumb"):
            # Index page is at 抖音存档HTML/ level, so relative to Volume is ../Volume/
            thumb_path = _url_encode_path(f"../Volume/{u['thumb_dir']}/{u['first_thumb']}")
            thumb_html = f'<img src="{html_mod.escape(thumb_path, quote=True)}" alt="">'
        else:
            thumb_html = nn[0] if nn else "?"

        cards.append(f"""\
<a class="user-card" href="{href}">
  <div class="avatar">{thumb_html}</div>
  <div class="nickname">{nn}</div>
  <div class="meta">UID: {uid} · {pc} 条作品</div>
</a>""")

    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>抖音作品存档 · 总索引</title>
<style>{HTML_CSS}</style>
</head>
<body>
<div class="container">
  <div class="index-header">
    <h1>抖音作品存档</h1>
    <p>共 {len(user_pages)} 位用户 · 生成于 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
  </div>
  <div class="search-bar">
    <input type="text" id="search-input" class="search-input" placeholder="搜索用户..." oninput="filterUsers()">
  </div>
  <div class="index-grid">
    {''.join(cards) if cards else '<div class="empty-state"><h2>暂无存档</h2><p>请先运行脚本生成用户存档</p></div>'}
  </div>
</div>
<div class="footer">Douyin Media Archive · Generated by generate_douyin_archive.py</div>
<script>
function filterUsers(){{
  var f=document.getElementById('search-input').value.toLowerCase();
  document.querySelectorAll('.user-card').forEach(function(c){{
    var name=c.querySelector('.nickname').textContent.toLowerCase();
    var meta=c.querySelector('.meta').textContent.toLowerCase();
    c.style.display=(name.includes(f)||meta.includes(f))?'':'none';
  }});
}}
</script>
</body>
</html>"""

    output_path.write_text(html, encoding="utf-8")
    return output_path


# ── Main ───────────────────────────────────────────────────────

def main():
    # Fix Windows console encoding
    import sys
    if sys.platform == "win32":
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")

    print("=" * 60)
    print("  抖音媒体存档生成器")
    print("=" * 60)

    ARCHIVE_ROOT.mkdir(parents=True, exist_ok=True)

    # ── Step 1: Scan user directories ──
    users = scan_user_directories(VOLUME_DIR)
    print(f"\n发现 {len(users)} 个用户目录")

    if not users:
        print("未找到任何用户目录,退出。")
        return

    user_page_entries = []
    total_posts = 0
    total_errors = 0

    for user in users:
        print(f"\n{'─' * 40}")
        print(f"处理: {user['nickname']} (UID: {user['uid']})")

        # Collect & parse all media files
        media_dir = user["dir_path"]
        media_files = []
        for f in sorted(media_dir.iterdir()):
            if not f.is_file():
                continue
            parsed = parse_filename(f.name)
            if parsed:
                media_files.append(parsed)

        if not media_files:
            print(f"  [WARN] 未找到可解析的媒体文件,跳过")
            # Still add to index with 0 posts
            user_page_entries.append({
                "nickname": user["nickname"],
                "uid": user["uid"],
                "post_count": 0,
                "html_rel_path": f"{safe_filename(user['nickname'])}/index.html",
                "first_thumb": None,
            })
            continue

        print(f"  找到 {len(media_files)} 个媒体文件")

        # Group into posts
        posts = group_posts(media_files)
        print(f"  分为 {len(posts)} 条作品")
        total_posts += len(posts)

        # ── Step 2: Generate metadata JSON ──
        metadata_dir = media_dir / "metadata"
        json_path = generate_metadata_json(user, posts, metadata_dir)
        print(f"  [OK] 元数据JSON: {json_path.name}")

        # ── Step 3: Generate user HTML ──
        # Relative path from HTML to media: ../../Volume/{user_dir}/
        media_rel = f"../../Volume/{user['dir_name']}"
        html_dir = ARCHIVE_ROOT / safe_filename(user["nickname"])
        html_path = generate_user_html(user, posts, media_rel, html_dir)
        print(f"  [OK] 用户页面: {safe_filename(user['nickname'])}/index.html")

        # Find first image for thumbnail (store filename + dir for index path computation)
        first_thumb = None
        first_thumb_dir = user["dir_name"]
        for post in posts:
            for m in post["media"]:
                if m["type"] == "image":
                    first_thumb = m["filename"]
                    break
            if first_thumb:
                break

        user_page_entries.append({
            "nickname": user["nickname"],
            "uid": user["uid"],
            "post_count": len(posts),
            "html_rel_path": f"{safe_filename(user['nickname'])}/index.html",
            "first_thumb": first_thumb,
            "thumb_dir": first_thumb_dir,
        })

    # ── Step 4: Generate index ──
    print(f"\n{'=' * 60}")
    index_path = generate_index_html(user_page_entries, ARCHIVE_ROOT / "存档总索引.html")
    print(f"[OK] 总索引页面: {index_path}")
    print(f"\n完成! 共处理 {len(users)} 位用户, {total_posts} 条作品。")
    print(f"输出目录: {ARCHIVE_ROOT}")


if __name__ == "__main__":
    main()

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions