#!/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()">×</button>
<button class="lightbox-nav lightbox-prev" id="lb-prev" onclick="lbNavigate(-1)">‹</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)">›</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()
volume目录下会按用户存放大量视频/图片文件,时间久了想回顾某个用户的某条作品时只能在文件管理器里翻找,预览不方便,也无法一目了然地浏览所有内容。
建议内容:增加一个可选功能,在下载完成后自动生成一套静态 HTML 存档页面。
我用ai写了一个Python脚本,作者自己加入的此功能就更好
每个用户一个 index.html 页面(放在抖音存档HTML/昵称/)
一个总索引页 存档总索引.html,每个用户目录下存放标题文案的 metadata/*.json 元数据
还有一个最好加一个提取用户抖音号当文件夹名字的建议,UID是无法搜索到抖音的