﻿#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Personal Life OS - 本地服务
- 托管 life-dashboard.html 等静态文件
- 提供 /sync (POST) 接收来自微信读书的书签按钮同步数据
- 提供 /sync.json (GET) 供工作台读取最新同步结果
"""
import http.server
import socketserver
import copy
import html
import json
import math
import os
import re
import ssl
import subprocess
import sys
import time
import shutil
import datetime
import threading
import urllib.parse
import urllib.request
from urllib.parse import parse_qs, urlparse

WORKSPACE = os.path.dirname(os.path.abspath(__file__))
SYNC_PATH = os.path.join(WORKSPACE, "sync.json")
KEY_PATH = os.path.join(WORKSPACE, ".weread", "apikey.txt")
GW_URL = "https://i.weread.qq.com/api/agent/gateway"
LAST_NOTE_PATH = os.path.join(WORKSPACE, ".weread", "tmp", "last_reading_note.json")
READING_AUTO_SCRIPT = os.path.join(WORKSPACE, ".weread", "tmp", "run_reading_auto.py")
READING_NOTES_DIR = os.path.join(WORKSPACE, "读书收获")
READING_BOOKS_DIR = os.path.join(READING_NOTES_DIR, "书籍")
WEREAD_SHELF_CACHE_PATH = os.path.join(WORKSPACE, ".weread", "tmp", "shelf.json")
HOT_VIDEOS_PATH = os.path.join(WORKSPACE, "learning-hot-videos.json")
HOT_VIDEO_AUTO_SCRIPT = os.path.join(WORKSPACE, "hot_video_auto.py")
AI_LEARNING_VIDEOS_PATH = os.path.join(WORKSPACE, "ai-learning-videos.json")
AI_LEARNING_VIDEO_AUTO_SCRIPT = os.path.join(WORKSPACE, "ai_learning_video_auto.py")
LIFE_DATA_PATH = os.path.join(WORKSPACE, "life-data.json")
FAVORITE_COLLECTIONS_PATH = os.path.join(WORKSPACE, "favorite-collections.json")
FAVORITE_MEDIA_DIR = os.path.join(WORKSPACE, ".favorites", "media")
FAVORITE_SYNC_SCRIPT = os.path.join(WORKSPACE, "sync-favorites.js")
NODE_EXE = os.path.join(os.path.expanduser("~"), ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "node", "bin", "node.exe")
NODE_MODULES = os.path.join(os.path.expanduser("~"), ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "node", "node_modules")
FAVORITE_LOG_DIR = os.path.join(WORKSPACE, ".favorites")
DAILY_ENGLISH_EVENTS_DB = os.path.join(os.path.expanduser("~"), "AppData", "Roaming", "ting_en", "events.db")
DAILY_ENGLISH_CONFIG_JSON = os.path.join(os.path.expanduser("~"), "AppData", "Roaming", "ting_en", "config.json")
CLOUD_SERVER_ORIGIN = os.environ.get("WORKBUDDY_CLOUD_ORIGIN", "https://xm.cloudmall.fun").rstrip("/")
READING_SYNC_TOKEN = os.environ.get("WORKBUDDY_READING_SYNC_TOKEN", "workbuddy-reading-sync-20260910")
PORT = 8765
HOT_VIDEO_AUTO_HOUR = 7
READING_AUTO_HOUR = 8
ALWAYS_DAILY_PRESET_IDS = {"exercise", "read", "english", "ailearn"}
SUNDAY_PRESET_ID = "readingNote"
DAILY_PRESET_IDS = ALWAYS_DAILY_PRESET_IDS | {SUNDAY_PRESET_ID}
PRESET_DISPLAY_NAMES = {
    "exercise": "💃",
    "read": "📖",
    "english": "🎧",
    "ailearn": "Ai",
    "readingNote": "📖🪙",
}

# Simple in-memory cache for readtimes
_readtimes_cache = {"ts": 0, "data": None}
_READTIMES_TTL = 60  # seconds
_reading_auto_lock = threading.Lock()
_hot_video_auto_lock = threading.Lock()
_ai_learning_video_auto_lock = threading.Lock()
_reading_cloud_sync_lock = threading.Lock()
_english_cloud_sync_lock = threading.Lock()

READING_SYNC_PREFIXES = (
    "读书收获/书籍/",
    "读书收获/reading-insights.json",
    ".weread/tmp/shelf.json",
    ".weread/tmp/last_reading_note.json",
    "reading-note-status.json",
)


def _local_today_str():
    tz8 = datetime.timezone(datetime.timedelta(hours=8))
    return datetime.datetime.now(tz8).strftime("%Y-%m-%d")


def _last_reading_note_date():
    if not os.path.exists(LAST_NOTE_PATH):
        return ""
    try:
        with open(LAST_NOTE_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        return str(data.get("date") or "")
    except Exception:
        return ""


def _last_hot_video_date():
    if not os.path.exists(HOT_VIDEOS_PATH):
        return ""
    try:
        with open(HOT_VIDEOS_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        updated_at = str(data.get("updatedAt") or "")
        videos = data.get("videos") if isinstance(data, dict) else []
        if len(videos or []) < 3:
            return ""
        return updated_at[:10]
    except Exception:
        return ""


def _is_allowed_reading_sync_path(rel_path):
    rel_path = (rel_path or "").replace("\\", "/").lstrip("/")
    return any(rel_path == prefix.rstrip("/") or rel_path.startswith(prefix) for prefix in READING_SYNC_PREFIXES)


def _collect_reading_sync_files():
    files = []
    candidates = [
        os.path.join(WORKSPACE, "reading-note-status.json"),
        os.path.join(READING_NOTES_DIR, "reading-insights.json"),
        os.path.join(WORKSPACE, ".weread", "tmp", "shelf.json"),
        os.path.join(WORKSPACE, ".weread", "tmp", "last_reading_note.json"),
    ]
    if os.path.exists(READING_BOOKS_DIR):
        for root, _, names in os.walk(READING_BOOKS_DIR):
            for name in names:
                if name not in ("书籍档案.md", "metadata.json"):
                    continue
                candidates.append(os.path.join(root, name))
    seen = set()
    for path in candidates:
        if not path or not os.path.exists(path) or not os.path.isfile(path):
            continue
        rel_path = os.path.relpath(path, WORKSPACE).replace("\\", "/")
        if rel_path in seen or not _is_allowed_reading_sync_path(rel_path):
            continue
        seen.add(rel_path)
        try:
            with open(path, "r", encoding="utf-8") as f:
                content = f.read()
            files.append({
                "path": rel_path,
                "content": content,
                "mtime": int(os.path.getmtime(path)),
            })
        except Exception:
            continue
    return files


def sync_reading_artifacts_to_cloud(timeout=20):
    if os.name != "nt":
        return {"ok": True, "skipped": True, "reason": "cloud sync runs from the Windows desktop service"}
    if not _reading_cloud_sync_lock.acquire(blocking=False):
        return {"ok": False, "error": "读书档案云同步正在运行"}
    try:
        files = _collect_reading_sync_files()
        if not files:
            return {"ok": True, "skipped": True, "files": 0}
        payload = json.dumps({
            "token": READING_SYNC_TOKEN,
            "updatedAt": int(time.time()),
            "files": files,
        }, ensure_ascii=False).encode("utf-8")
        req = urllib.request.Request(
            f"{CLOUD_SERVER_ORIGIN}/api/reading-sync/import",
            data=payload,
            headers={"Content-Type": "application/json; charset=utf-8"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            result = json.loads(resp.read().decode("utf-8", "ignore"))
        return result
    except Exception as e:
        return {"ok": False, "error": str(e)}
    finally:
        _reading_cloud_sync_lock.release()


def read_daily_english_listening_stats():
    if not os.path.exists(DAILY_ENGLISH_EVENTS_DB):
        fallback = read_daily_english_stats_from_life_data()
        if fallback.get("ok"):
            fallback["warning"] = "当前服务无法读取每日英语听力 Windows 客户端日志，已使用云端工作台同步记录。"
            return fallback
        return {
            "ok": False,
            "error": "未找到每日英语听力 Windows 客户端日志 events.db",
            "sourcePath": DAILY_ENGLISH_EVENTS_DB,
        }
    checkin_info = {}
    plan_duration = 0
    if os.path.exists(DAILY_ENGLISH_CONFIG_JSON):
        try:
            with open(DAILY_ENGLISH_CONFIG_JSON, "r", encoding="utf-8") as f:
                cfg = json.load(f)
            raw_checkin = cfg.get("Key_User_CheckIn_Info")
            if isinstance(raw_checkin, str) and raw_checkin.strip():
                checkin_info = json.loads(raw_checkin)
            plan_duration = int(float(cfg.get("planDuration") or 0))
        except Exception:
            checkin_info = {}
            plan_duration = 0
    try:
        import sqlite3
        con = sqlite3.connect(f"file:{DAILY_ENGLISH_EVENTS_DB}?mode=ro", uri=True)
        con.row_factory = sqlite3.Row
        cur = con.cursor()
        rows = cur.execute(
            """
            select epoch,
                   count(*) as count,
                   sum(coalesce(duration, 0)) as listenSeconds,
                   sum(coalesce(read_duration, 0)) as readSeconds,
                   min(timestamp) as firstTimestamp,
                   max(timestamp) as lastTimestamp
            from Logs
            where type = 'LISTEN_DURATION'
            group by epoch
            order by epoch desc
            """
        ).fetchall()
        con.close()
    except Exception as e:
        return {"ok": False, "error": str(e), "sourcePath": DAILY_ENGLISH_EVENTS_DB}

    read_times = {}
    listen_times = {}
    records = []
    for row in rows:
        raw_epoch = str(row["epoch"] or "")
        if not re.fullmatch(r"\d{8}", raw_epoch):
            continue
        date_key = f"{raw_epoch[:4]}-{raw_epoch[4:6]}-{raw_epoch[6:8]}"
        listen_seconds = int(row["listenSeconds"] or 0)
        read_seconds = int(row["readSeconds"] or 0)
        study_seconds = max(0, listen_seconds + read_seconds)
        listen_times[date_key] = listen_seconds
        read_times[date_key] = study_seconds
        records.append({
            "date": date_key,
            "listenSeconds": listen_seconds,
            "listenMinutes": int(round(listen_seconds / 60)),
            "readSeconds": read_seconds,
            "studySeconds": study_seconds,
            "studyMinutes": int(round(study_seconds / 60)),
            "count": int(row["count"] or 0),
            "firstTimestamp": int(row["firstTimestamp"] or 0),
            "lastTimestamp": int(row["lastTimestamp"] or 0),
        })
    fallback_date = str(checkin_info.get("checkinDateLocal") or checkin_info.get("checkinDate") or "")
    fallback_seconds = max(0, plan_duration * 60)
    if re.fullmatch(r"\d{4}-\d{2}-\d{2}", fallback_date) and fallback_seconds > int(read_times.get(fallback_date) or 0):
        read_times[fallback_date] = fallback_seconds
        listen_times[fallback_date] = max(int(listen_times.get(fallback_date) or 0), fallback_seconds)
        if not any(item.get("date") == fallback_date for item in records):
            records.insert(0, {
                "date": fallback_date,
                "listenSeconds": fallback_seconds,
                "listenMinutes": int(round(fallback_seconds / 60)),
                "readSeconds": 0,
                "studySeconds": fallback_seconds,
                "studyMinutes": int(round(fallback_seconds / 60)),
                "count": 0,
                "firstTimestamp": 0,
                "lastTimestamp": 0,
                "sourceDetail": "checkin-planDuration",
            })
    life_fallback = read_daily_english_stats_from_life_data()
    if life_fallback.get("ok"):
        existing_records = {item.get("date"): item for item in records if isinstance(item, dict)}
        for date_key, seconds in (life_fallback.get("readTimes") or {}).items():
            try:
                seconds = int(seconds or 0)
            except Exception:
                seconds = 0
            if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(date_key or "")) or seconds <= int(read_times.get(date_key) or 0):
                continue
            minutes = int(round(seconds / 60))
            read_times[date_key] = seconds
            listen_times[date_key] = max(int(listen_times.get(date_key) or 0), seconds)
            existing = existing_records.get(date_key)
            if existing:
                existing.update({
                    "listenSeconds": max(int(existing.get("listenSeconds") or 0), seconds),
                    "listenMinutes": minutes,
                    "studySeconds": seconds,
                    "studyMinutes": minutes,
                    "sourceDetail": "life-data-englishMinutes-merged",
                })
            else:
                records.append({
                    "date": date_key,
                    "listenSeconds": seconds,
                    "listenMinutes": minutes,
                    "readSeconds": 0,
                    "studySeconds": seconds,
                    "studyMinutes": minutes,
                    "count": 1,
                    "firstTimestamp": 0,
                    "lastTimestamp": 0,
                    "sourceDetail": "life-data-englishMinutes-merged",
                })
        records.sort(key=lambda item: item.get("date") or "", reverse=True)
    return {
        "ok": True,
        "source": "每日英语听力 Windows 客户端 events.db",
        "sourcePath": DAILY_ENGLISH_EVENTS_DB,
        "configPath": DAILY_ENGLISH_CONFIG_JSON,
        "checkinInfo": checkin_info,
        "planDurationMinutes": plan_duration,
        "readTimes": read_times,
        "listenTimes": listen_times,
        "records": records,
    }


def _load_life_data_file():
    if not os.path.exists(LIFE_DATA_PATH):
        return {}
    try:
        with open(LIFE_DATA_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def read_daily_english_stats_from_life_data():
    data = _load_life_data_file()
    daily = data.get("dailyPlan") if isinstance(data, dict) else {}
    if not isinstance(daily, dict):
        daily = {}
    read_times = {}
    records = []
    for date_key, plan in daily.items():
        if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(date_key or "")):
            continue
        presets = plan.get("presets") if isinstance(plan, dict) else []
        if not isinstance(presets, list):
            continue
        task = next((item for item in presets if isinstance(item, dict) and item.get("id") == "english"), None)
        minutes = int(round(float(task.get("englishMinutes") or 0))) if isinstance(task, dict) else 0
        if minutes <= 0:
            continue
        seconds = minutes * 60
        read_times[date_key] = seconds
        records.append({
            "date": date_key,
            "listenSeconds": seconds,
            "listenMinutes": minutes,
            "readSeconds": 0,
            "studySeconds": seconds,
            "studyMinutes": minutes,
            "count": 1,
            "firstTimestamp": int(task.get("completedAt") or 0) if isinstance(task, dict) else 0,
            "lastTimestamp": int(task.get("completedAt") or 0) if isinstance(task, dict) else 0,
            "sourceDetail": "life-data-englishMinutes",
        })
    records.sort(key=lambda item: item.get("date") or "", reverse=True)
    return {
        "ok": bool(read_times),
        "source": "云端工作台 life-data 英语打卡记录",
        "sourcePath": LIFE_DATA_PATH,
        "planDurationMinutes": 5,
        "readTimes": read_times,
        "listenTimes": read_times,
        "records": records,
    }


def _ensure_life_plan_for_date(data, date_key):
    daily = data.setdefault("dailyPlan", {})
    plan = daily.setdefault(date_key, {"presets": [], "customs": []})
    presets = plan.setdefault("presets", [])
    plan.setdefault("customs", [])
    for preset_id in ("exercise", "read", "english", "ailearn"):
        if not any(isinstance(item, dict) and item.get("id") == preset_id for item in presets):
            presets.append({
                "id": preset_id,
                "name": PRESET_DISPLAY_NAMES.get(preset_id, preset_id),
                "checked": False,
                "type": "preset",
            })
    return plan


def _upsert_english_session(data, date_key, minutes, timestamp=0):
    english = data.setdefault("englishLearn", {})
    sessions = english.setdefault("sessions", [])
    if not isinstance(sessions, list):
        sessions = []
        english["sessions"] = sessions
    session_id = f"daily_english_listening_{date_key}"
    existing = next((item for item in sessions if isinstance(item, dict) and item.get("id") == session_id), None)
    payload = {
        "id": session_id,
        "date": date_key,
        "skill": "每日英语听力",
        "minutes": int(minutes),
        "detail": "每日英语听力 Windows 客户端同步",
        "key": "daily-english-listening",
        "time": time.strftime("%H:%M", time.localtime((timestamp or time.time() * 1000) / 1000)),
        "timestamp": int(timestamp or time.time() * 1000),
    }
    if existing:
        if int(minutes) >= int(existing.get("minutes") or 0):
            existing.update(payload)
    else:
        sessions.append(payload)


def sync_daily_english_listening_to_life_data(timeout=20):
    if os.name != "nt":
        return {"ok": True, "skipped": True, "reason": "每日英语听力同步只在 Windows 桌面服务读取本机客户端日志"}
    if not _english_cloud_sync_lock.acquire(blocking=False):
        return {"ok": False, "error": "每日英语听力云同步正在运行"}
    try:
        stats = read_daily_english_listening_stats()
        if not stats.get("ok"):
            return stats
        data = _load_life_data_file()
        changed = False
        cutoff = datetime.datetime.now().date() - datetime.timedelta(days=30)
        for record in stats.get("records") or []:
            date_key = str(record.get("date") or "")
            if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date_key):
                continue
            try:
                if datetime.datetime.strptime(date_key, "%Y-%m-%d").date() < cutoff:
                    continue
            except ValueError:
                continue
            minutes = int(round((record.get("studySeconds") or 0) / 60))
            if minutes <= 0:
                continue
            plan = _ensure_life_plan_for_date(data, date_key)
            task = next((item for item in plan.get("presets", []) if isinstance(item, dict) and item.get("id") == "english"), None)
            if not task:
                continue
            previous = int(task.get("englishMinutes") or 0)
            if minutes > previous:
                task["englishMinutes"] = minutes
                task["autoCheckin"] = "dailyEnglishListening"
                task["thresholdMinutes"] = 5
                task["checked"] = minutes >= 5 or bool(task.get("checked"))
                if task["checked"] and not task.get("completedAt"):
                    task["completedAt"] = int(record.get("lastTimestamp") or time.time() * 1000)
                task["statusText"] = f"每日英语听力 {minutes} 分钟，已自动打卡" if task["checked"] else f"每日英语听力 {minutes} 分钟，满 5 分钟自动打卡"
                _upsert_english_session(data, date_key, minutes, record.get("lastTimestamp") or 0)
                changed = True
        if not changed:
            return {"ok": True, "changed": False, "stats": stats}
        data["__syncUpdatedAt"] = int(time.time() * 1000)
        data["__syncSavedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
        with open(LIFE_DATA_PATH, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        payload = json.dumps({"data": data}, ensure_ascii=False).encode("utf-8")
        req = urllib.request.Request(
            f"{CLOUD_SERVER_ORIGIN}/api/life-data",
            data=payload,
            headers={"Content-Type": "application/json; charset=utf-8"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            cloud = json.loads(resp.read().decode("utf-8", "ignore"))
        return {"ok": True, "changed": True, "cloud": cloud, "stats": stats}
    except Exception as e:
        return {"ok": False, "error": str(e)}
    finally:
        _english_cloud_sync_lock.release()


def start_english_listening_sync_scheduler():
    if os.name != "nt":
        return
    def loop():
        time.sleep(3)
        while True:
            sync_daily_english_listening_to_life_data(timeout=20)
            time.sleep(90)
    threading.Thread(target=loop, daemon=True, name="english-listening-cloud-sync").start()


def run_hot_video_auto_job(timeout=180):
    if not os.path.exists(HOT_VIDEO_AUTO_SCRIPT):
        return {"ok": False, "error": "hot_video_auto.py not found"}
    if not _hot_video_auto_lock.acquire(blocking=False):
        return {"ok": False, "error": "热门视频抓取正在运行，请稍后再试。"}
    try:
        proc = subprocess.run(
            [sys.executable, HOT_VIDEO_AUTO_SCRIPT],
            cwd=WORKSPACE,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=timeout,
        )
        stdout = (proc.stdout or "").strip()
        stderr = (proc.stderr or "").strip()
        result = json.loads(stdout.splitlines()[-1]) if stdout else {"ok": proc.returncode == 0}
        if proc.returncode != 0:
            result["ok"] = False
            result["error"] = stderr or stdout or f"exit code {proc.returncode}"
        result["stdout"] = stdout
        if stderr:
            result["stderr"] = stderr
        return result
    except subprocess.TimeoutExpired:
        return {"ok": False, "error": "热门视频抓取超时，请稍后重试。"}
    except Exception as e:
        return {"ok": False, "error": str(e)}
    finally:
        _hot_video_auto_lock.release()


def run_ai_learning_video_job(timeout=180, completed_count=None, refresh_offset=0):
    if not os.path.exists(AI_LEARNING_VIDEO_AUTO_SCRIPT):
        return {"ok": False, "error": "ai_learning_video_auto.py not found"}
    if not _ai_learning_video_auto_lock.acquire(blocking=False):
        return {"ok": False, "error": "AI学习视频推荐正在生成中，请稍后再试。"}
    try:
        cmd = [sys.executable, AI_LEARNING_VIDEO_AUTO_SCRIPT]
        if completed_count is not None:
            cmd.append(f"--completed-count={int(completed_count)}")
        if refresh_offset:
            cmd.append(f"--refresh-offset={max(0, int(refresh_offset))}")
        proc = subprocess.run(
            cmd,
            cwd=WORKSPACE,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=timeout,
        )
        stdout = (proc.stdout or "").strip()
        stderr = (proc.stderr or "").strip()
        result = json.loads(stdout.splitlines()[-1]) if stdout else {"ok": proc.returncode == 0}
        if proc.returncode != 0:
            result["ok"] = False
            result["error"] = stderr or stdout or f"exit code {proc.returncode}"
        result["stdout"] = stdout
        if stderr:
            result["stderr"] = stderr
        return result
    except subprocess.TimeoutExpired:
        return {"ok": False, "error": "AI学习视频推荐生成超时，请稍后重试。"}
    except Exception as e:
        return {"ok": False, "error": str(e)}
    finally:
        _ai_learning_video_auto_lock.release()


def run_reading_auto_job(timeout=180):
    if not os.path.exists(READING_AUTO_SCRIPT):
        return {
            "ok": False,
            "error": "reading note generator script not found",
            "script": READING_AUTO_SCRIPT,
        }
    if not _reading_auto_lock.acquire(blocking=False):
        return {"ok": False, "error": "读书收获正在生成中，请稍后查看。"}
    try:
        proc = subprocess.run(
            [sys.executable, READING_AUTO_SCRIPT],
            cwd=WORKSPACE,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=timeout,
        )
        if proc.returncode != 0:
            return {
                "ok": False,
                "error": "生成脚本执行失败",
                "stdout": proc.stdout,
                "stderr": proc.stderr,
                "returncode": proc.returncode,
            }
        global _readtimes_cache
        _readtimes_cache = {"ts": 0, "data": None}
        status_data = {"ok": True}
        if os.path.exists(LAST_NOTE_PATH):
            try:
                with open(LAST_NOTE_PATH, "r", encoding="utf-8") as f:
                    status_data = json.load(f)
            except Exception as e:
                status_data = {"ok": True, "warning": f"笔记已生成，但状态文件读取失败：{e}"}
        status_data["ok"] = True
        status_data["stdout"] = proc.stdout.strip()
        status_data["cloudSync"] = sync_reading_artifacts_to_cloud(timeout=20)
        return status_data
    except subprocess.TimeoutExpired:
        return {"ok": False, "error": "生成超时，请稍后重试。"}
    except Exception as e:
        return {"ok": False, "error": str(e)}
    finally:
        _reading_auto_lock.release()


def start_reading_auto_scheduler():
    def loop():
        while True:
            try:
                tz8 = datetime.timezone(datetime.timedelta(hours=8))
                now = datetime.datetime.now(tz8)
                if now.hour >= READING_AUTO_HOUR and _last_reading_note_date() != now.strftime("%Y-%m-%d"):
                    result = run_reading_auto_job(timeout=180)
                    log_dir = os.path.join(WORKSPACE, ".weread", "tmp")
                    os.makedirs(log_dir, exist_ok=True)
                    with open(os.path.join(log_dir, "reading-auto.log"), "a", encoding="utf-8") as f:
                        f.write(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] scheduled 08:00 result={json.dumps(result, ensure_ascii=False)}\n")
            except Exception as e:
                try:
                    log_dir = os.path.join(WORKSPACE, ".weread", "tmp")
                    os.makedirs(log_dir, exist_ok=True)
                    with open(os.path.join(log_dir, "reading-auto.log"), "a", encoding="utf-8") as f:
                        f.write(f"[scheduler-error] {e}\n")
                except Exception:
                    pass
            time.sleep(60)
    threading.Thread(target=loop, daemon=True, name="reading-auto-08").start()


def start_hot_video_auto_scheduler():
    def loop():
        while True:
            try:
                tz8 = datetime.timezone(datetime.timedelta(hours=8))
                now = datetime.datetime.now(tz8)
                if now.hour >= HOT_VIDEO_AUTO_HOUR and _last_hot_video_date() != now.strftime("%Y-%m-%d"):
                    result = run_hot_video_auto_job(timeout=180)
                    log_dir = os.path.join(WORKSPACE, ".workbuddy")
                    os.makedirs(log_dir, exist_ok=True)
                    with open(os.path.join(log_dir, "hot-video-auto.log"), "a", encoding="utf-8") as f:
                        f.write(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] scheduled 07:00 result={json.dumps(result, ensure_ascii=False)}\n")
            except Exception as e:
                try:
                    log_dir = os.path.join(WORKSPACE, ".workbuddy")
                    os.makedirs(log_dir, exist_ok=True)
                    with open(os.path.join(log_dir, "hot-video-auto.log"), "a", encoding="utf-8") as f:
                        f.write(f"[scheduler-error] {e}\n")
                except Exception:
                    pass
            time.sleep(60)
    threading.Thread(target=loop, daemon=True, name="hot-video-auto-07").start()


class Handler(http.server.SimpleHTTPRequestHandler):
    def guess_type(self, path):
        content_type = super().guess_type(path)
        if content_type.startswith("text/") and "charset=" not in content_type:
            return f"{content_type}; charset=utf-8"
        return content_type

    def _cors(self):
        existing = b"".join(getattr(self, "_headers_buffer", [])).lower()
        if b"access-control-allow-origin:" not in existing:
            self.send_header("Access-Control-Allow-Origin", "*")
        if b"access-control-allow-methods:" not in existing:
            self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        if b"access-control-allow-headers:" not in existing:
            self.send_header("Access-Control-Allow-Headers", "Content-Type")

    def do_OPTIONS(self):
        self.send_response(200)
        self._cors()
        self.end_headers()

    def _read_key(self):
        if not os.path.exists(KEY_PATH):
            return None
        try:
            with open(KEY_PATH, "r", encoding="utf-8") as f:
                return f.read().strip()
        except Exception:
            return None

    def _gw_post(self, api_name, payload, timeout=10):
        key = self._read_key()
        if not key:
            raise RuntimeError("WEREAD_API_KEY not configured")
        body = json.dumps({"skill_version": "1.0.4", "api_name": api_name, **payload}, ensure_ascii=False).encode("utf-8")
        req = urllib.request.Request(
            GW_URL,
            data=body,
            headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
            method="POST",
        )
        ctx = ssl.create_default_context()
        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
            return json.loads(resp.read().decode("utf-8"))

    def do_POST(self):
        if self.path.startswith("/api/generate-reading-note"):
            self._generate_reading_note()
        elif self.path.startswith("/api/delete-reading-note"):
            self._delete_reading_note()
        elif self.path.startswith("/api/reading-sync/import"):
            self._import_reading_sync()
        elif self.path.startswith("/api/hot-videos/run"):
            self._run_hot_videos()
        elif self.path.startswith("/api/ai-learning-videos/run"):
            self._run_ai_learning_videos()
        elif self.path.startswith("/api/viral-video/analyze"):
            self._analyze_viral_video()
        elif self.path.startswith("/api/life-data"):
            self._save_life_data()
        elif self.path.startswith("/api/favorite-collections/import"):
            self._import_favorite_collections()
        elif self.path.startswith("/api/favorite-collections/login"):
            self._login_favorite_collections()
        elif self.path.startswith("/api/favorite-collections/sync"):
            self._sync_favorite_collections()
        elif self.path.startswith("/api/favorite-collections/analyze"):
            self._analyze_favorite_collection_item()
        elif self.path.startswith("/sync"):
            try:
                length = int(self.headers.get("Content-Length", 0))
                body = self.rfile.read(length) if length else b"{}"
                data = json.loads(body.decode("utf-8"))
            except Exception as e:
                self.send_response(400)
                self._cors()
                self.end_headers()
                self.wfile.write(json.dumps({"ok": False, "error": str(e)}).encode("utf-8"))
                return
            books = data.get("books", []) if isinstance(data, dict) else []
            snapshot = {"updated": data.get("updated", "") if isinstance(data, dict) else "", "books": books}
            try:
                with open(SYNC_PATH, "w", encoding="utf-8") as f:
                    json.dump(snapshot, f, ensure_ascii=False, indent=2)
            except Exception as e:
                self.send_response(500)
                self._cors()
                self.end_headers()
                self.wfile.write(json.dumps({"ok": False, "error": str(e)}).encode("utf-8"))
                return
            self.send_response(200)
            self._cors()
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.end_headers()
            self.wfile.write(json.dumps({"ok": True, "count": len(books)}).encode("utf-8"))
        else:
            self.send_response(404)
            self._cors()
            self.end_headers()

    def _generate_reading_note(self):
        status_data = run_reading_auto_job(timeout=180)
        self._json_response(status_data, status=200 if status_data.get("ok") else 500)

    def _delete_reading_note(self):
        try:
            payload = self._read_json_body()
            date_str = str(payload.get("date") or "")
        except Exception as e:
            self._json_response({"ok": False, "error": f"请求格式错误：{e}"}, status=400)
            return
        note_path = self._reading_note_file_for_date(date_str)
        if not note_path:
            self._json_response({"ok": False, "error": "日期格式错误"}, status=400)
            return
        deleted = False
        if os.path.exists(note_path):
            os.remove(note_path)
            deleted = True
        if _last_reading_note_date() == date_str:
            for status_path in (
                LAST_NOTE_PATH,
                os.path.join(WORKSPACE, "reading-note-status.json"),
            ):
                try:
                    if os.path.exists(status_path):
                        os.remove(status_path)
                except Exception:
                    pass
        self._json_response({"ok": True, "deleted": deleted, "date": date_str})

    def _import_reading_sync(self):
        try:
            payload = self._read_json_body()
            if not isinstance(payload, dict) or payload.get("token") != READING_SYNC_TOKEN:
                self._json_response({"ok": False, "error": "unauthorized"}, status=403)
                return
            files = payload.get("files")
            if not isinstance(files, list):
                self._json_response({"ok": False, "error": "files must be a list"}, status=400)
                return
            written = 0
            skipped = 0
            for item in files:
                if not isinstance(item, dict):
                    skipped += 1
                    continue
                rel_path = str(item.get("path") or "").replace("\\", "/").lstrip("/")
                content = item.get("content")
                if not rel_path or not _is_allowed_reading_sync_path(rel_path) or not isinstance(content, str):
                    skipped += 1
                    continue
                target = os.path.abspath(os.path.join(WORKSPACE, *rel_path.split("/")))
                if not target.startswith(os.path.abspath(WORKSPACE) + os.sep):
                    skipped += 1
                    continue
                os.makedirs(os.path.dirname(target), exist_ok=True)
                with open(target, "w", encoding="utf-8") as f:
                    f.write(content)
                mtime = item.get("mtime")
                try:
                    if mtime:
                        os.utime(target, (int(mtime), int(mtime)))
                except Exception:
                    pass
                written += 1
            self._json_response({"ok": True, "written": written, "skipped": skipped})
        except Exception as e:
            self._json_response({"ok": False, "error": str(e)}, status=500)

    def _run_hot_videos(self):
        result = run_hot_video_auto_job(timeout=180)
        self._json_response(result, status=200 if result.get("ok") else 500)

    def _run_ai_learning_videos(self):
        completed_count = None
        refresh_offset = 0
        try:
            payload = self._read_json_body()
            if isinstance(payload, dict) and payload.get("completedCount") is not None:
                completed_count = int(payload.get("completedCount"))
            if isinstance(payload, dict) and payload.get("refreshOffset") is not None:
                refresh_offset = max(0, int(payload.get("refreshOffset")))
        except Exception:
            completed_count = None
            refresh_offset = 0
        result = run_ai_learning_video_job(timeout=180, completed_count=completed_count, refresh_offset=refresh_offset)
        self._json_response(result, status=200 if result.get("ok") else 500)

    def _extract_first_url(self, text):
        match = re.search(r"https?://[^\s，。)）]+", text or "", re.I)
        return match.group(0) if match else ""

    def _load_hot_videos(self):
        if not os.path.exists(HOT_VIDEOS_PATH):
            return []
        try:
            with open(HOT_VIDEOS_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
            return data.get("videos", []) if isinstance(data, dict) else data if isinstance(data, list) else []
        except Exception:
            return []

    def _normalize_video_url(self, url):
        url = str(url or "").strip().lower()
        url = re.sub(r"^https?://", "", url)
        url = re.sub(r"[?#].*$", "", url)
        return url.rstrip("/")

    def _match_hot_video(self, url):
        target = self._normalize_video_url(url)
        if not target:
            return None
        for video in self._load_hot_videos():
            current = self._normalize_video_url(video.get("url") or video.get("link") or "")
            if current and (current == target or current in target or target in current):
                return video
        return None

    def _fetch_json_url(self, url, referer="https://www.bilibili.com/", timeout=12):
        req = urllib.request.Request(
            url,
            headers={
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
                "Referer": referer,
                "Accept": "application/json, text/plain, */*",
                "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
            },
        )
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8", "ignore"))

    def _fetch_bilibili_video_meta(self, url):
        text = str(url or "")
        bvid_match = re.search(r"(BV[0-9A-Za-z]+)", text)
        aid_match = re.search(r"(?:/video/|av)(\d+)", text)
        if bvid_match:
            api = "https://api.bilibili.com/x/web-interface/view?" + urllib.parse.urlencode({"bvid": bvid_match.group(1)})
        elif aid_match:
            api = "https://api.bilibili.com/x/web-interface/view?" + urllib.parse.urlencode({"aid": aid_match.group(1)})
        else:
            raise RuntimeError("没有识别到 B站 BV 号或 av 号")
        data = self._fetch_json_url(api)
        if data.get("code") != 0:
            raise RuntimeError(data.get("message") or "B站接口未返回视频信息")
        info = data.get("data") or {}
        stat = info.get("stat") or {}
        owner = info.get("owner") or {}
        fans = None
        if owner.get("mid"):
            try:
                rel = self._fetch_json_url(
                    "https://api.bilibili.com/x/relation/stat?" + urllib.parse.urlencode({"vmid": owner.get("mid")})
                )
                fans = (rel.get("data") or {}).get("follower")
            except Exception:
                fans = None
        tags = []
        try:
            tag_data = self._fetch_json_url(
                "https://api.bilibili.com/x/tag/archive/tags?" + urllib.parse.urlencode({"bvid": info.get("bvid")})
            )
            tags = [item.get("tag_name") for item in (tag_data.get("data") or []) if item.get("tag_name")]
        except Exception:
            tags = []
        plays = stat.get("view")
        ratio = round(plays / fans, 2) if isinstance(plays, int) and isinstance(fans, int) and fans > 0 else None
        return {
            "platform": "B站",
            "title": info.get("title") or "",
            "url": f"https://www.bilibili.com/video/{info.get('bvid')}/" if info.get("bvid") else url,
            "plays": plays,
            "playCount": plays,
            "likes": stat.get("like"),
            "likeCount": stat.get("like"),
            "fans": fans,
            "followerCount": fans,
            "playFanRatio": ratio,
            "tags": tags[:8] or ["学习视频"],
            "author": owner.get("name") or "",
            "duration": info.get("duration"),
            "description": info.get("desc") or "",
            "analysisBasis": "B站公开接口：标题、作者、播放、点赞、粉丝、标签",
        }

    def _openai_api_key(self):
        key = os.environ.get("OPENAI_API_KEY", "").strip()
        if key:
            return key
        for path in (
            os.path.join(WORKSPACE, ".openai", "api_key.txt"),
            os.path.join(os.path.expanduser("~"), ".openai", "api_key.txt"),
        ):
            try:
                if os.path.exists(path):
                    with open(path, "r", encoding="utf-8") as f:
                        key = f.read().strip()
                    if key:
                        return key
            except Exception:
                pass
        return ""

    def _analyze_viral_images(self, images, raw_text="", timeout=45):
        valid = []
        for item in images if isinstance(images, list) else []:
            if not isinstance(item, dict):
                continue
            data_url = str(item.get("dataUrl") or "")
            if not data_url.startswith("data:image/"):
                continue
            if len(data_url) > 4_500_000:
                continue
            valid.append({
                "name": str(item.get("name") or "image"),
                "type": str(item.get("type") or "image"),
                "dataUrl": data_url,
            })
            if len(valid) >= 8:
                break
        if not valid:
            return None
        key = self._openai_api_key()
        if not key:
            return {
                "ok": False,
                "error": "缺少视觉模型 API Key：请配置环境变量 OPENAI_API_KEY，或把 Key 写入 .openai/api_key.txt。",
                "imageCount": len(valid),
            }
        prompt = (
            "你是短视频爆款拆解助手。请只根据用户上传的截图/图片和输入文本提取可见证据，"
            "不要编造看不到的画面、字幕、评论或数据。输出中文，结构为："
            "1. 画面事实；2. 可见文字/OCR；3. 视频可能类型；4. 可用于拆解的内容结构线索；"
            "5. 仍缺少的信息。用户输入文本如下：\n"
            f"{raw_text[:1200]}"
        )
        content = [{"type": "input_text", "text": prompt}]
        for item in valid:
            content.append({"type": "input_image", "image_url": item["dataUrl"]})
        body = json.dumps({
            "model": os.environ.get("OPENAI_VISION_MODEL", "gpt-4o-mini"),
            "input": [{"role": "user", "content": content}],
            "max_output_tokens": 900,
        }, ensure_ascii=False).encode("utf-8")
        req = urllib.request.Request(
            "https://api.openai.com/v1/responses",
            data=body,
            headers={
                "Authorization": f"Bearer {key}",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                result = json.loads(resp.read().decode("utf-8", "ignore"))
        except Exception as e:
            return {"ok": False, "error": f"视觉识别失败：{e}", "imageCount": len(valid)}
        text = str(result.get("output_text") or "").strip()
        if not text:
            parts = []
            for out in result.get("output", []) if isinstance(result.get("output"), list) else []:
                for part in out.get("content", []) if isinstance(out, dict) and isinstance(out.get("content"), list) else []:
                    if isinstance(part, dict) and part.get("text"):
                        parts.append(str(part.get("text")))
            text = "\n".join(parts).strip()
        return {"ok": True, "imageCount": len(valid), "analysis": text}

    def _analyze_viral_video(self):
        try:
            payload = self._read_json_body()
            raw = str(payload.get("url") or payload.get("text") or "") if isinstance(payload, dict) else ""
        except Exception as e:
            self._json_response({"ok": False, "error": f"请求格式错误：{e}"}, status=400)
            return
        url = self._extract_first_url(raw) or raw.strip()
        platform = self._detect_video_platform(url or raw)
        matched = self._match_hot_video(url)
        video = dict(matched or {})
        limitations = []
        supplement_text = str(payload.get("supplementText") or "") if isinstance(payload, dict) else ""
        images = payload.get("images") if isinstance(payload, dict) else []
        vision = self._analyze_viral_images(images, supplement_text or raw)
        if vision:
            if vision.get("ok") and vision.get("analysis"):
                video["imageAnalysis"] = vision.get("analysis")
                video["description"] = (str(video.get("description") or "") + "\n\n图片识别结果：\n" + vision.get("analysis")).strip()
                video["analysisBasis"] = (str(video.get("analysisBasis") or "").strip("；") + "；图片视觉识别").strip("；")
                video["mediaReadable"] = True
            else:
                limitations.append(vision.get("error") or "图片已收到，但视觉识别失败。")
        if platform == "B站":
            try:
                video = {**video, **self._fetch_bilibili_video_meta(url)}
                if vision and vision.get("ok") and vision.get("analysis"):
                    video["imageAnalysis"] = vision.get("analysis")
                    video["description"] = (str(video.get("description") or "") + "\n\n图片识别结果：\n" + vision.get("analysis")).strip()
                    video["analysisBasis"] = (str(video.get("analysisBasis") or "").strip("；") + "；图片视觉识别").strip("；")
                    video["mediaReadable"] = True
            except Exception as e:
                limitations.append(f"B站详情接口读取失败：{e}")
        elif platform in ("抖音", "小红书"):
            limitations.append(f"{platform}视频详情、画面、音轨通常需要登录态和平台前端授权；当前本地服务拿不到你的平台登录 Cookie，所以只能使用分享文本和榜单已抓到的公开字段。")
        else:
            limitations.append("未识别平台，无法调用平台公开接口。")
        tools = self._tool_status()
        missing = [name for name, ok in tools.items() if not ok]
        if missing:
            limitations.append("本机缺少 yt-dlp / ffmpeg / Whisper 中的部分组件，无法下载视频、提取音频并转逐字稿。")
        if not video:
            video = {"platform": platform, "url": url, "tags": []}
        video["matchedHotVideo"] = bool(matched)
        video["mediaReadable"] = bool(video.get("mediaReadable"))
        self._json_response({"ok": True, "video": video, "toolStatus": tools, "limitations": limitations})

    def _json_response(self, payload, status=200):
        self.send_response(status)
        self._cors()
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.end_headers()
        self.wfile.write(json.dumps(payload, ensure_ascii=False).encode("utf-8"))

    def _daily_english_listening_stats(self):
        return read_daily_english_listening_stats()

    def _reading_note_file_for_date(self, date_str):
        if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date_str or ""):
            return None
        year = date_str[:4]
        return os.path.join(WORKSPACE, "读书收获", year, f"今日读书收获_{date_str}.md")

    def _markdown_to_html(self, text):
        out = []
        in_ol = False
        in_ul = False

        def close_ol():
            nonlocal in_ol
            if in_ol:
                out.append("</ol>")
                in_ol = False

        def close_ul():
            nonlocal in_ul
            if in_ul:
                out.append("</ul>")
                in_ul = False

        for raw in text.splitlines():
            line = raw.strip()
            if not line:
                close_ol()
                close_ul()
                continue
            safe = html.escape(line)
            safe = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", safe)
            safe = re.sub(r"\*(.+?)\*", r"<em>\1</em>", safe)
            if safe.startswith("# "):
                close_ol()
                close_ul()
                heading = safe[2:]
                if "｜作者：" in heading:
                    book_title, author = heading.split("｜作者：", 1)
                    heading = f'<span class="book-title-main">{book_title}</span><span class="book-title-author">作者：{author}</span>'
                out.append(f"<h1>{heading}</h1>")
            elif safe.startswith("## "):
                close_ol()
                close_ul()
                out.append(f"<h2>{safe[3:]}</h2>")
            elif safe.startswith("### "):
                close_ol()
                close_ul()
                out.append(f"<h3>{safe[4:]}</h3>")
            elif safe.startswith("&gt; "):
                close_ol()
                close_ul()
                out.append(f"<blockquote>{safe[5:]}</blockquote>")
            elif re.match(r"^\d+\. ", safe):
                close_ul()
                if not in_ol:
                    out.append("<ol>")
                    in_ol = True
                item_text = re.sub(r"^\d+\. ", "", safe)
                out.append(f"<li>{item_text}</li>")
            elif safe.startswith("- "):
                close_ol()
                if not in_ul:
                    out.append("<ul>")
                    in_ul = True
                out.append(f"<li>{safe[2:]}</li>")
            elif safe == "---":
                close_ol()
                close_ul()
                out.append("<hr>")
            else:
                close_ol()
                close_ul()
                out.append(f"<p>{safe}</p>")
        close_ol()
        close_ul()
        return "\n".join(out)

    def _serve_reading_note_view(self):
        qs = parse_qs(urlparse(self.path).query)
        date_str = (qs.get("date") or [""])[0]
        note_path = self._reading_note_file_for_date(date_str)
        if not note_path or not os.path.exists(note_path):
            self.send_response(404)
            self._cors()
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write("<h1>读书笔记不存在</h1>".encode("utf-8"))
            return

        with open(note_path, "r", encoding="utf-8") as f:
            body = self._markdown_to_html(f.read())
        page = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>今日读书收获 {html.escape(date_str)}</title>
<style>
body{{margin:0;background:#fdf2f4;color:#4a3b3e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;line-height:1.75}}
main{{max-width:860px;margin:0 auto;padding:42px 22px 72px}}
h1{{font-size:30px;line-height:1.25;margin:0 0 18px;color:#b55d76}}
.book-title-main{{display:inline;font-weight:800}}
.book-title-author{{display:inline-block;margin-left:14px;font-size:15px;font-weight:600;color:#9a8288;vertical-align:middle}}
h2{{font-size:20px;margin:34px 0 12px;color:#b55d76}}
h3{{font-size:16px;margin:22px 0 8px;color:#7a5961}}
p{{margin:10px 0}}
blockquote{{margin:12px 0;padding:12px 16px;border-left:4px solid #d97a94;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(217,122,148,.10)}}
ol,ul{{padding-left:24px}}
li{{margin:8px 0}}
hr{{border:0;border-top:1px solid #f3d6de;margin:28px 0 14px}}
a{{color:#b55d76}}
.back{{display:inline-block;margin-bottom:20px;color:#9a8288;text-decoration:none;font-size:14px}}
</style>
</head>
<body>
<main>
<a class="back" href="/life-dashboard.html">← 返回工作台</a>
{body}
</main>
</body>
</html>"""
        self.send_response(200)
        self._cors()
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(page.encode("utf-8"))

    def _safe_book_slug(self, text):
        clean = str(text or "")
        if not clean or clean in (".", ".."):
            return ""
        if "\\" in clean or "/" in clean:
            return ""
        return clean

    def _book_archive_path(self, slug):
        slug = self._safe_book_slug(slug)
        if not slug:
            return None
        path = os.path.abspath(os.path.join(READING_BOOKS_DIR, slug, "书籍档案.md"))
        root = os.path.abspath(READING_BOOKS_DIR)
        if not path.startswith(root + os.sep):
            return None
        return path

    def _load_weread_shelf_books(self):
        if not os.path.exists(WEREAD_SHELF_CACHE_PATH):
            return {"byId": {}, "byTitle": {}}
        try:
            with open(WEREAD_SHELF_CACHE_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
        except Exception:
            return {"byId": {}, "byTitle": {}}
        by_id = {}
        by_title = {}
        for item in data.get("books", []) if isinstance(data, dict) else []:
            book = item.get("book") if isinstance(item.get("book"), dict) else item
            book_id = str(book.get("bookId") or item.get("bookId") or "")
            title = str(book.get("title") or item.get("title") or "")
            if not book_id or not title:
                continue
            shelf_item = {
                "bookId": book_id,
                "title": title,
                "readUpdateTime": int(book.get("readUpdateTime") or item.get("readUpdateTime") or book.get("updateTime") or item.get("updateTime") or 0),
                "finishReading": int(book.get("finishReading") or item.get("finishReading") or 0),
                "isTop": bool(book.get("isTop") or item.get("isTop")),
            }
            by_id[book_id] = shelf_item
            by_title[title] = shelf_item
        return {"byId": by_id, "byTitle": by_title}

    def _book_archive_meta(self, slug):
        archive_path = self._book_archive_path(slug)
        if not archive_path or not os.path.exists(archive_path):
            return None
        meta_path = os.path.join(os.path.dirname(archive_path), "metadata.json")
        data = {}
        try:
            if os.path.exists(meta_path):
                with open(meta_path, "r", encoding="utf-8") as f:
                    data = json.load(f)
        except Exception:
            data = {}
        title = data.get("title") or slug
        quotes = data.get("quotes") if isinstance(data.get("quotes"), list) else []
        quote_preview = ""
        if quotes:
            quote_preview = str(quotes[0].get("text") or "")[:180]
        if not quote_preview:
            try:
                with open(archive_path, "r", encoding="utf-8") as f:
                    for line in f:
                        if line.strip().startswith(">"):
                            quote_preview = self._strip_markdown(line.strip())[:180]
                            break
            except Exception:
                pass
        return {
            "slug": slug,
            "bookId": str(data.get("bookId") or ""),
            "title": title,
            "author": data.get("author") or "",
            "status": data.get("status") or "",
            "progress": data.get("progress"),
            "readTs": int(data.get("readTs") or data.get("read_ts") or 0),
            "quoteCount": data.get("quoteCount") if data.get("quoteCount") is not None else len(quotes),
            "maxQuoteCount": data.get("maxQuoteCount") or 20,
            "summary": data.get("evidenceLimit") or "基于微信读书可见数据整理。",
            "quoteText": quote_preview,
            "path": os.path.relpath(archive_path, WORKSPACE),
            "viewUrl": f"/reading-book-view?book={urllib.parse.quote(slug)}",
            "modifiedAt": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(archive_path))),
        }

    def _serve_reading_book_archive_view(self):
        qs = parse_qs(urlparse(self.path).query)
        slug = (qs.get("book") or [""])[0]
        archive_path = self._book_archive_path(slug)
        if not archive_path or not os.path.exists(archive_path):
            self.send_response(404)
            self._cors()
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write("<h1>书籍档案不存在</h1>".encode("utf-8"))
            return

        with open(archive_path, "r", encoding="utf-8") as f:
            body = self._markdown_to_html(f.read())
        meta = self._book_archive_meta(slug)
        title = meta.get("title") if meta else slug
        page = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)} 读书档案</title>
<style>
body{{margin:0;background:#fdf2f4;color:#4a3b3e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;line-height:1.75}}
main{{max-width:900px;margin:0 auto;padding:42px 22px 72px}}
h1{{font-size:30px;line-height:1.25;margin:0 0 18px;color:#b55d76}}
.book-title-main{{display:inline;font-weight:800}}
.book-title-author{{display:inline-block;margin-left:14px;font-size:15px;font-weight:600;color:#9a8288;vertical-align:middle}}
h2{{font-size:20px;margin:34px 0 12px;color:#b55d76}}
h3{{font-size:16px;margin:22px 0 8px;color:#7a5961}}
p{{margin:10px 0}}
blockquote{{margin:12px 0;padding:12px 16px;border-left:4px solid #d97a94;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(217,122,148,.10)}}
ol,ul{{padding-left:24px}}
li{{margin:8px 0}}
a{{color:#b55d76}}
.back{{display:inline-block;margin-bottom:20px;color:#9a8288;text-decoration:none;font-size:14px}}
</style>
</head>
<body>
<main>
<a class="back" href="/life-dashboard.html">← 返回工作台</a>
{body}
</main>
</body>
</html>"""
        self.send_response(200)
        self._cors()
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(page.encode("utf-8"))

    def _serve_reading_book_archives(self):
        archives = []
        shelf = self._load_weread_shelf_books()
        if os.path.exists(READING_BOOKS_DIR):
            for name in os.listdir(READING_BOOKS_DIR):
                if name.startswith("."):
                    continue
                meta = self._book_archive_meta(name)
                if not meta:
                    continue
                shelf_item = shelf["byId"].get(meta.get("bookId") or "") or shelf["byTitle"].get(meta.get("title") or "")
                if not shelf_item:
                    continue
                try:
                    progress = int(meta.get("progress") or 0)
                except Exception:
                    progress = 0
                if progress <= 0:
                    continue
                meta["readTs"] = max(int(meta.get("readTs") or 0), int(shelf_item.get("readUpdateTime") or 0))
                meta["isCurrentReading"] = False
                meta["shelfBookId"] = shelf_item.get("bookId") or ""
                archives.append(meta)
        if archives:
            newest = max(archives, key=lambda item: int(item.get("readTs") or 0))
            newest["isCurrentReading"] = True
        archives.sort(
            key=lambda item: (
                1 if item.get("isCurrentReading") else 0,
                int(item.get("readTs") or 0),
                int(item.get("progress") or 0),
                item.get("modifiedAt") or "",
            ),
            reverse=True,
        )
        self._json_response({"ok": True, "archives": archives})

    def _serve_reading_book_archive(self):
        qs = parse_qs(urlparse(self.path).query)
        slug = (qs.get("book") or [""])[0]
        archive_path = self._book_archive_path(slug)
        if not archive_path or not os.path.exists(archive_path):
            self._json_response({"ok": False, "error": "书籍档案不存在"}, status=404)
            return
        with open(archive_path, "r", encoding="utf-8") as f:
            markdown = f.read()
        self._json_response({
            "ok": True,
            "book": slug,
            "meta": self._book_archive_meta(slug),
            "html": self._markdown_to_html(markdown),
        })

    def _strip_markdown(self, text):
        text = re.sub(r"^#+\s*", "", text)
        text = re.sub(r"^>\s*", "", text)
        text = re.sub(r"^\d+\.\s*", "", text)
        text = re.sub(r"[*_`#]", "", text)
        return re.sub(r"\s+", " ", text).strip()

    def _note_summary(self, lines):
        sections = ("## 深度解读", "## 核心观点", "## 个人思考", "## 金句摘录")
        for section in sections:
            try:
                start = next(i for i, line in enumerate(lines) if line.strip() == section)
            except StopIteration:
                continue
            parts = []
            for line in lines[start + 1:]:
                stripped = line.strip()
                if stripped.startswith("## "):
                    break
                clean = self._strip_markdown(stripped)
                if clean and clean != "---":
                    parts.append(clean)
                if len(" ".join(parts)) >= 120:
                    break
            if parts:
                return " ".join(parts)[:140]
        for line in lines:
            clean = self._strip_markdown(line.strip())
            if clean and not clean.startswith("今日读书收获"):
                return clean[:140]
        return "暂无摘要"

    def _note_quote(self, lines):
        quote_sections = ("## 今日金句", "## 金句摘录")
        status_prefixes = ("阅读打卡", "连续打卡", "已保存")
        for section in quote_sections:
            try:
                start = next(i for i, line in enumerate(lines) if line.strip() == section)
            except StopIteration:
                continue
            for line in lines[start + 1:]:
                stripped = line.strip()
                if stripped.startswith("## "):
                    break
                if not stripped.startswith(">"):
                    continue
                clean = self._strip_markdown(stripped)
                if clean and not clean.startswith(status_prefixes):
                    return clean[:220]
            for line in lines[start + 1:]:
                stripped = line.strip()
                if stripped.startswith("## "):
                    break
                clean = self._strip_markdown(stripped)
                if clean and clean != "---" and not clean.startswith(status_prefixes):
                    return clean[:220]
        for line in lines:
            stripped = line.strip()
            if not stripped.startswith(">"):
                continue
            clean = self._strip_markdown(stripped)
            if clean and not clean.startswith(status_prefixes):
                return clean[:220]
        return ""

    def _note_meta(self, path):
        name = os.path.basename(path)
        match = re.search(r"(\d{4}-\d{2}-\d{2})", name)
        date_str = match.group(1) if match else time.strftime("%Y-%m-%d", time.localtime(os.path.getmtime(path)))
        with open(path, "r", encoding="utf-8") as f:
            text = f.read()
        lines = text.splitlines()
        heading = next((self._strip_markdown(line) for line in lines if line.startswith("# ")), f"读书收获（{date_str}）")
        primary_match = re.search(r"(?:今日书籍|书名)：\s*《([^》]+)》", text)
        if not primary_match:
            primary_match = re.search(r"——\s*《([^》]+)》", text)
        if not primary_match:
            primary_match = re.search(r"《([^》]+)》", text)
        book_title = primary_match.group(1) if primary_match else "综合读书笔记"
        return {
            "date": date_str,
            "title": heading,
            "bookTitle": book_title,
            "summary": self._note_summary(lines),
            "quoteText": self._note_quote(lines),
            "path": os.path.relpath(path, WORKSPACE),
            "viewUrl": f"/reading-note-view?date={date_str}",
            "modifiedAt": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path))),
        }

    def _serve_reading_notes(self):
        notes = []
        if os.path.exists(READING_NOTES_DIR):
            for root, _, files in os.walk(READING_NOTES_DIR):
                for name in files:
                    if not re.fullmatch(r"今日读书收获_\d{4}-\d{2}-\d{2}\.md", name):
                        continue
                    if not re.fullmatch(r"\d{4}", os.path.basename(root)):
                        continue
                    path = os.path.join(root, name)
                    try:
                        notes.append(self._note_meta(path))
                    except Exception as e:
                        notes.append({
                            "date": "",
                            "title": name,
                            "bookTitle": "读取失败",
                            "summary": str(e),
                            "path": os.path.relpath(path, WORKSPACE),
                            "viewUrl": "",
                            "modifiedAt": "",
                        })
        notes.sort(key=lambda item: (item.get("date") or "", item.get("modifiedAt") or ""), reverse=True)
        self._json_response({"ok": True, "notes": notes})

    def _serve_reading_note(self):
        qs = parse_qs(urlparse(self.path).query)
        date_str = (qs.get("date") or [""])[0]
        note_path = self._reading_note_file_for_date(date_str)
        if not note_path or not os.path.exists(note_path):
            self._json_response({"ok": False, "error": "读书笔记不存在"}, status=404)
            return
        with open(note_path, "r", encoding="utf-8") as f:
            markdown = f.read()
        self._json_response({
            "ok": True,
            "date": date_str,
            "meta": self._note_meta(note_path),
            "html": self._markdown_to_html(markdown),
        })

    def _serve_hot_videos(self):
        default_data = {
            "ok": True,
            "updatedAt": "",
            "source": "daily automation",
            "track": "学习类视频雷达：AI技能 / 剪辑技术 / AI+剪辑",
            "minDailyVideos": 5,
            "targetDailyVideos": 7,
            "videos": [],
            "error": "",
        }
        if not os.path.exists(HOT_VIDEOS_PATH):
            self._json_response(default_data)
            return
        try:
            with open(HOT_VIDEOS_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
            if isinstance(data, list):
                data = {**default_data, "videos": data}
            elif isinstance(data, dict):
                data = {**default_data, **data}
            else:
                data = {**default_data, "error": "learning-hot-videos.json format is not supported"}
            data["ok"] = not bool(data.get("error"))
            self._json_response(data)
        except Exception as e:
            self._json_response({**default_data, "ok": False, "error": str(e)}, status=500)

    def _serve_ai_learning_videos(self):
        default_data = {
            "ok": True,
            "updatedAt": "",
            "source": "daily automation via public web search",
            "track": "AI新手变现实操路线",
            "stage": "第1阶段：AI基础使用与提示词",
            "stageIndex": 0,
            "goal": "用 ChatGPT、豆包、Kimi 等工具完成清晰提问、改写、总结和模板化输出。",
            "minDailyVideos": 8,
            "videos": [],
            "notes": "",
            "error": "",
        }
        if not os.path.exists(AI_LEARNING_VIDEOS_PATH):
            self._json_response(default_data)
            return
        try:
            with open(AI_LEARNING_VIDEOS_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
            if isinstance(data, list):
                data = {**default_data, "videos": data}
            elif isinstance(data, dict):
                data = {**default_data, **data}
            else:
                data = {**default_data, "error": "ai-learning-videos.json format is not supported"}
            data["ok"] = not bool(data.get("error"))
            self._json_response(data)
        except Exception as e:
            self._json_response({**default_data, "ok": False, "error": str(e)}, status=500)

    def _serve_life_data(self):
        if not os.path.exists(LIFE_DATA_PATH):
            self._json_response({"ok": True, "exists": False, "data": None, "updatedAt": 0})
            return
        try:
            with open(LIFE_DATA_PATH, "r", encoding="utf-8-sig") as f:
                data = json.load(f)
            self._json_response({
                "ok": True,
                "exists": True,
                "data": data,
                "updatedAt": data.get("__syncUpdatedAt", 0) if isinstance(data, dict) else 0,
            })
        except Exception as e:
            self._json_response({"ok": False, "error": str(e)}, status=500)

    def _life_preset_has_value(self, task):
        if not isinstance(task, dict):
            return False
        return bool(
            task.get("checked")
            or task.get("sleepTime")
            or task.get("statusText")
            or float(task.get("count") or 0) > 0
            or float(task.get("readMinutes") or 0) > 0
            or float(task.get("aiMinutes") or 0) > 0
            or float(task.get("englishMinutes") or 0) > 0
        )

    def _merge_life_preset(self, target, source):
        if not isinstance(target, dict) or not isinstance(source, dict):
            return
        if self._life_preset_has_value(source) and not self._life_preset_has_value(target):
            target.update(source)
            return
        if not self._life_preset_has_value(source):
            return
        task_id = source.get("id")
        if task_id == "sleep":
            if not target.get("sleepTime") and source.get("sleepTime"):
                target["sleepTime"] = source.get("sleepTime")
            target["checked"] = bool(target.get("checked") or source.get("checked"))
        elif task_id == "read":
            if float(source.get("readMinutes") or 0) > float(target.get("readMinutes") or 0):
                target["readMinutes"] = source.get("readMinutes")
                if source.get("statusText"):
                    target["statusText"] = source.get("statusText")
            target["checked"] = bool(target.get("checked") or source.get("checked"))
            if source.get("checked") and source.get("completedAt") and not target.get("completedAt"):
                target["completedAt"] = source.get("completedAt")
            for key in ("autoCheckin", "thresholdMinutes"):
                if source.get(key) and not target.get(key):
                    target[key] = source.get(key)
        elif task_id == "ailearn":
            if float(source.get("aiMinutes") or 0) > float(target.get("aiMinutes") or 0):
                target["aiMinutes"] = source.get("aiMinutes")
                if source.get("statusText"):
                    target["statusText"] = source.get("statusText")
            target["checked"] = bool(target.get("checked") or source.get("checked"))
            if source.get("checked") and source.get("completedAt") and not target.get("completedAt"):
                target["completedAt"] = source.get("completedAt")
            for key in ("autoCheckin", "thresholdMinutes"):
                if source.get(key) and not target.get(key):
                    target[key] = source.get(key)
        elif task_id == "english":
            if float(source.get("englishMinutes") or 0) > float(target.get("englishMinutes") or 0):
                target["englishMinutes"] = source.get("englishMinutes")
                if source.get("statusText"):
                    target["statusText"] = source.get("statusText")
            target["checked"] = bool(target.get("checked") or source.get("checked"))
            if source.get("checked") and source.get("completedAt") and not target.get("completedAt"):
                target["completedAt"] = source.get("completedAt")
            for key in ("autoCheckin", "thresholdMinutes"):
                if source.get(key) and not target.get(key):
                    target[key] = source.get(key)
        elif task_id == "water":
            target["count"] = max(float(target.get("count") or 0), float(source.get("count") or 0))
            target["checked"] = bool(target.get("checked") or source.get("checked"))
        else:
            target["checked"] = bool(target.get("checked") or source.get("checked"))

    def _merge_life_daily_plan(self, incoming, existing):
        incoming_daily = incoming.setdefault("dailyPlan", {})
        existing_daily = existing.get("dailyPlan") if isinstance(existing, dict) else {}
        if not isinstance(existing_daily, dict):
            return
        for date_key, existing_plan in existing_daily.items():
            if not isinstance(existing_plan, dict):
                continue
            target_plan = incoming_daily.setdefault(date_key, {"presets": [], "customs": []})
            target_plan.setdefault("presets", [])
            target_plan.setdefault("customs", [])
            target_presets = target_plan["presets"]
            if not isinstance(target_presets, list):
                target_presets = []
                target_plan["presets"] = target_presets
            by_id = {p.get("id"): p for p in target_presets if isinstance(p, dict) and p.get("id")}
            for source_task in existing_plan.get("presets") or []:
                if not isinstance(source_task, dict) or not source_task.get("id"):
                    continue
                allowed_ids = set(ALWAYS_DAILY_PRESET_IDS)
                try:
                    if datetime.datetime.strptime(str(date_key), "%Y-%m-%d").weekday() == 6:
                        allowed_ids.add(SUNDAY_PRESET_ID)
                except Exception:
                    allowed_ids = set(DAILY_PRESET_IDS)
                if source_task.get("id") not in allowed_ids:
                    continue
                target_task = by_id.get(source_task.get("id"))
                if target_task is None:
                    target_presets.append(dict(source_task))
                    by_id[source_task.get("id")] = target_presets[-1]
                else:
                    self._merge_life_preset(target_task, source_task)
            target_customs = target_plan["customs"] if isinstance(target_plan.get("customs"), list) else []
            target_plan["customs"] = target_customs
            seen = {("id:" + str(c.get("id"))) if isinstance(c, dict) and c.get("id") else ("name:" + str(c.get("name", ""))) for c in target_customs if isinstance(c, dict)}
            for custom in existing_plan.get("customs") or []:
                if not isinstance(custom, dict):
                    continue
                key = ("id:" + str(custom.get("id"))) if custom.get("id") else ("name:" + str(custom.get("name", "")))
                if key not in seen:
                    target_customs.append(dict(custom))
                    seen.add(key)

    def _normalize_life_preset_names(self, data):
        if not isinstance(data, dict):
            return
        daily = data.get("dailyPlan") if isinstance(data.get("dailyPlan"), dict) else {}
        for plan in daily.values():
            if not isinstance(plan, dict):
                continue
            presets = plan.get("presets") if isinstance(plan.get("presets"), list) else []
            for task in presets:
                if not isinstance(task, dict):
                    continue
                task_id = task.get("id")
                if task_id in PRESET_DISPLAY_NAMES:
                    task["name"] = PRESET_DISPLAY_NAMES[task_id]

    def _exercise_key(self, entry):
        if not isinstance(entry, dict):
            return ""
        if entry.get("id"):
            return "id:" + str(entry.get("id"))
        return "|".join(str(entry.get(k, "")) for k in ("type", "duration", "seconds", "time", "timestamp", "source"))

    def _merge_life_exercises(self, incoming, existing):
        incoming_exercises = incoming.setdefault("exercises", {})
        incoming_deleted = incoming.setdefault("deletedExerciseKeys", {})
        existing_deleted = existing.get("deletedExerciseKeys") if isinstance(existing, dict) else {}
        if not isinstance(incoming_deleted, dict):
            incoming_deleted = {}
            incoming["deletedExerciseKeys"] = incoming_deleted
        if isinstance(existing_deleted, dict):
            for date_key, keys in existing_deleted.items():
                if not isinstance(keys, list) or not keys:
                    continue
                target_keys = incoming_deleted.setdefault(date_key, [])
                if not isinstance(target_keys, list):
                    target_keys = []
                    incoming_deleted[date_key] = target_keys
                for key in keys:
                    if key and key not in target_keys:
                        target_keys.append(key)
        existing_exercises = existing.get("exercises") if isinstance(existing, dict) else {}
        if not isinstance(existing_exercises, dict):
            return
        for date_key, entries in existing_exercises.items():
            if not isinstance(entries, list) or not entries:
                continue
            target_entries = incoming_exercises.setdefault(date_key, [])
            if not isinstance(target_entries, list):
                target_entries = []
                incoming_exercises[date_key] = target_entries
            deleted = set(incoming_deleted.get(date_key) or [])
            target_entries[:] = [e for e in target_entries if self._exercise_key(e) not in deleted]
            seen = {self._exercise_key(e) for e in target_entries if self._exercise_key(e)}
            for entry in entries:
                key = self._exercise_key(entry)
                if key and key not in deleted and key not in seen:
                    target_entries.append(dict(entry))
                    seen.add(key)

    def _exercise_minutes(self, entry):
        if not isinstance(entry, dict):
            return 0
        try:
            seconds = float(entry.get("seconds")) if entry.get("seconds") is not None else float(entry.get("duration") or 0) * 60
        except (TypeError, ValueError):
            seconds = 0
        if seconds <= 0:
            return 0
        return max(1, math.ceil(seconds / 60))

    def _apply_life_exercise_presets(self, data):
        if not isinstance(data, dict):
            return
        exercises = data.get("exercises") if isinstance(data.get("exercises"), dict) else {}
        daily = data.get("dailyPlan") if isinstance(data.get("dailyPlan"), dict) else {}
        for date_key, plan in daily.items():
            if not isinstance(plan, dict):
                continue
            presets = plan.get("presets") if isinstance(plan.get("presets"), list) else []
            task = next((p for p in presets if isinstance(p, dict) and p.get("id") == "exercise"), None)
            if task is None:
                continue
            entries = exercises.get(date_key) if isinstance(exercises.get(date_key), list) else []
            minutes = sum(self._exercise_minutes(entry) for entry in entries)
            task["exerciseMinutes"] = minutes
            if minutes > 0:
                task["checked"] = True
                task["statusText"] = f"跳舞/运动 {minutes} 分钟，已自动打卡"
            else:
                task["checked"] = False
                task.pop("completedAt", None)
                task["statusText"] = "跳舞/运动 0 分钟"

    def _value_weight(self, value):
        if value in (None, "", [], {}):
            return 0
        if isinstance(value, dict):
            return len(value)
        if isinstance(value, list):
            return len(value)
        return 1

    def _merge_unique_list(self, incoming_list, existing_list, key_func):
        if not isinstance(incoming_list, list):
            incoming_list = []
        if not isinstance(existing_list, list):
            return incoming_list
        seen = {key_func(item) for item in incoming_list if key_func(item)}
        for item in existing_list:
            key = key_func(item)
            if key and key not in seen:
                incoming_list.append(copy.deepcopy(item))
                seen.add(key)
        return incoming_list

    def _merge_true_reading_bank(self, incoming_list, existing_list):
        def valid_items(items):
            return [
                copy.deepcopy(item)
                for item in (items or [])
                if isinstance(item, dict)
                and item.get("title")
                and item.get("source")
                and "??" not in str(item.get("source", ""))
            ]

        merged_by_title = {}
        order = []
        for item in valid_items(existing_list) + valid_items(incoming_list):
            title = str(item.get("title", "")).strip()
            if not title:
                continue
            current = merged_by_title.get(title)
            if current is None:
                merged_by_title[title] = item
                order.append(title)
                continue
            current_images = current.get("pageImages") if isinstance(current.get("pageImages"), list) else []
            item_images = item.get("pageImages") if isinstance(item.get("pageImages"), list) else []
            if len(item_images) > len(current_images):
                base = copy.deepcopy(item)
                for key, value in current.items():
                    if key not in base or base.get(key) in (None, "", []):
                        base[key] = copy.deepcopy(value)
                merged_by_title[title] = base
            else:
                for key, value in item.items():
                    if key not in current or current.get(key) in (None, "", []):
                        current[key] = copy.deepcopy(value)
        return [merged_by_title[title] for title in order if title in merged_by_title]

    def _merge_map_non_destructive(self, incoming_map, existing_map, merger=None):
        if not isinstance(incoming_map, dict):
            incoming_map = {}
        if not isinstance(existing_map, dict):
            return incoming_map
        for key, existing_value in existing_map.items():
            incoming_value = incoming_map.get(key)
            if self._value_weight(incoming_value) == 0:
                incoming_map[key] = copy.deepcopy(existing_value)
            elif merger:
                incoming_map[key] = merger(incoming_value, existing_value)
        return incoming_map

    def _merge_indexed_records(self, incoming_items, existing_items):
        target = incoming_items[:] if isinstance(incoming_items, list) else []
        if not isinstance(existing_items, list):
            return target
        for idx, existing_item in enumerate(existing_items):
            if not existing_item:
                continue
            if idx >= len(target) or not target[idx]:
                while idx >= len(target):
                    target.append(None)
                target[idx] = copy.deepcopy(existing_item)
                continue
            current = target[idx]
            if isinstance(current, dict) and isinstance(existing_item, dict):
                if existing_item.get("done") and not current.get("done"):
                    current.update(copy.deepcopy(existing_item))
                elif int(existing_item.get("checkedAt") or 0) > int(current.get("checkedAt") or 0):
                    current.update(copy.deepcopy(existing_item))
        return target

    def _merge_attempt_records(self, incoming_attempts, existing_attempts):
        target = copy.deepcopy(incoming_attempts) if isinstance(incoming_attempts, list) else []
        if not isinstance(existing_attempts, list):
            return target
        for task_index, attempts in enumerate(existing_attempts):
            if not isinstance(attempts, list) or not attempts:
                continue
            while task_index >= len(target):
                target.append([])
            if not isinstance(target[task_index], list):
                target[task_index] = []
            seen = {
                "|".join([
                    str(a.get("checkedAt", "")),
                    ",".join(a.get("answers", []) if isinstance(a.get("answers"), list) else []),
                    str(a.get("correct", "")),
                    str(a.get("total", "")),
                ])
                for a in target[task_index]
                if isinstance(a, dict)
            }
            for attempt in attempts:
                if not isinstance(attempt, dict):
                    continue
                key = "|".join([
                    str(attempt.get("checkedAt", "")),
                    ",".join(attempt.get("answers", []) if isinstance(attempt.get("answers"), list) else []),
                    str(attempt.get("correct", "")),
                    str(attempt.get("total", "")),
                ])
                if key not in seen:
                    target[task_index].append(copy.deepcopy(attempt))
                    seen.add(key)
        return target

    def _merge_english_record(self, incoming_record, existing_record, record_type="generic"):
        if not isinstance(existing_record, dict):
            return incoming_record
        if not isinstance(incoming_record, dict) or not incoming_record:
            return copy.deepcopy(existing_record)
        merged = copy.deepcopy(incoming_record)
        for key, existing_value in existing_record.items():
            incoming_value = merged.get(key)
            if key in ("done", "passed"):
                merged[key] = bool(incoming_value) or bool(existing_value)
            elif key == "checkedAt":
                merged[key] = max(int(incoming_value or 0), int(existing_value or 0))
            elif key in ("taskResults", "firstTaskResults"):
                merged[key] = self._merge_indexed_records(incoming_value, existing_value)
            elif key == "taskAttempts":
                merged[key] = self._merge_attempt_records(incoming_value, existing_value)
            elif self._value_weight(incoming_value) == 0:
                merged[key] = copy.deepcopy(existing_value)
            elif isinstance(incoming_value, list) and isinstance(existing_value, list) and len(existing_value) > len(incoming_value):
                merged[key] = copy.deepcopy(existing_value)
            elif isinstance(incoming_value, dict) and isinstance(existing_value, dict) and len(existing_value) > len(incoming_value):
                merged[key] = copy.deepcopy(existing_value)
        if record_type == "reading" and isinstance(merged.get("taskResults"), list):
            task_results = [r for r in merged["taskResults"] if isinstance(r, dict)]
            if task_results:
                merged["correct"] = sum(int(r.get("correct") or 0) for r in task_results)
                merged["total"] = sum(int(r.get("total") or 0) for r in task_results)
                merged["wrongs"] = [w for r in task_results for w in (r.get("wrongs") or []) if isinstance(w, dict)]
                merged["done"] = len(task_results) >= 2 and all(r.get("done") for r in task_results)
        return merged

    def _is_legacy_fake_bec_reading(self, record):
        if not isinstance(record, dict):
            return False
        has_current_answers = bool(record.get("checkedAt")) or bool(record.get("taskResults")) or bool(record.get("taskAnswers"))
        if has_current_answers:
            return True
        sources = []
        if isinstance(record.get("sources"), list):
            sources.extend(str(x) for x in record.get("sources") if x)
        if isinstance(record.get("taskResults"), list):
            sources.extend(str(x.get("source", "")) for x in record.get("taskResults") if isinstance(x, dict))
        return "\u771f\u9898\u9898\u578b" in " ".join(sources)

    def _strip_legacy_fake_bec_reading(self, english):
        if not isinstance(english, dict):
            return
        if english.get("trueReadingBank"):
            return
        reading = english.get("reading")
        if not isinstance(reading, dict):
            return
        legacy = english.setdefault("legacyReadingPractice", {})
        for date_key, record in list(reading.items()):
            if str(date_key) < "2026-07-30":
                continue
            if not self._is_legacy_fake_bec_reading(record):
                continue
            legacy_key = f"{date_key}-fake-bec"
            if legacy_key not in legacy:
                legacy[legacy_key] = copy.deepcopy(record)
            reading[date_key] = {
                "done": False,
                "taskAnswers": [],
                "taskResults": [],
                "answers": [],
                "correct": 0,
                "total": 0,
                "wrongs": [],
                "checkedAt": 0,
                "sourceStatus": "fake practice removed from current strict BEC reading",
            }
            checked = english.setdefault("checkedIn", {})
            checked[date_key] = False

    def _merge_life_english(self, incoming, existing):
        incoming_english = incoming.setdefault("englishLearn", {})
        existing_english = existing.get("englishLearn") if isinstance(existing, dict) else {}
        if not isinstance(existing_english, dict):
            return
        self._strip_legacy_fake_bec_reading(incoming_english)
        self._strip_legacy_fake_bec_reading(existing_english)
        incoming_sessions = incoming_english.setdefault("sessions", [])
        if isinstance(incoming_sessions, list):
            seen = {
                s.get("id") or f"{s.get('date')}|{s.get('skill')}|{s.get('key')}|{s.get('timestamp')}"
                for s in incoming_sessions
                if isinstance(s, dict)
            }
            for session in existing_english.get("sessions") or []:
                if not isinstance(session, dict):
                    continue
                key = session.get("id") or f"{session.get('date')}|{session.get('skill')}|{session.get('key')}|{session.get('timestamp')}"
                if key not in seen:
                    incoming_sessions.append(dict(session))
                    seen.add(key)
        incoming_english["trueReadingBank"] = self._merge_true_reading_bank(
            incoming_english.get("trueReadingBank"),
            existing_english.get("trueReadingBank"),
        )
        incoming_english["trueWritingBank"] = self._merge_unique_list(
            incoming_english.get("trueWritingBank"),
            existing_english.get("trueWritingBank"),
            lambda item: (str(item.get("source", "")) + "|" + str(item.get("text", ""))) if isinstance(item, dict) and item.get("source") and item.get("text") else "",
        )
        existing_video_collections = existing_english.get("videoCollections") or {}
        if isinstance(existing_video_collections, dict):
            incoming_video_collections = incoming_english.setdefault("videoCollections", {})
            if not isinstance(incoming_video_collections, dict):
                incoming_video_collections = {}
                incoming_english["videoCollections"] = incoming_video_collections
            for key, existing_value in existing_video_collections.items():
                if not isinstance(existing_value, dict):
                    continue
                incoming_value = incoming_video_collections.get(key)
                if not isinstance(incoming_value, dict):
                    incoming_video_collections[key] = copy.deepcopy(existing_value)
                    continue
                incoming_ts = float(incoming_value.get("updatedAt") or incoming_value.get("pagesFetchedAt") or 0)
                existing_ts = float(existing_value.get("updatedAt") or existing_value.get("pagesFetchedAt") or 0)
                if existing_ts > incoming_ts:
                    incoming_video_collections[key] = copy.deepcopy(existing_value)
        for field in ("checkedIn", "cards", "reading", "writing", "tasks", "speaking"):
            existing_map = existing_english.get(field) or {}
            if not isinstance(existing_map, dict):
                continue
            incoming_map = incoming_english.setdefault(field, {})
            if not isinstance(incoming_map, dict):
                incoming_map = {}
                incoming_english[field] = incoming_map
            for date_key, existing_value in existing_map.items():
                incoming_value = incoming_map.get(date_key)
                if field == "checkedIn":
                    incoming_map[date_key] = bool(incoming_value) or bool(existing_value)
                elif field == "tasks":
                    if not isinstance(incoming_value, list) or not incoming_value:
                        incoming_map[date_key] = copy.deepcopy(existing_value)
                    elif isinstance(existing_value, list):
                        by_id = {task.get("id"): task for task in incoming_value if isinstance(task, dict) and task.get("id")}
                        for task in existing_value:
                            if not isinstance(task, dict) or not task.get("id"):
                                continue
                            if task.get("id") not in by_id:
                                incoming_value.append(copy.deepcopy(task))
                            elif task.get("checked"):
                                by_id[task.get("id")]["checked"] = True
                elif field == "reading":
                    incoming_map[date_key] = self._merge_english_record(incoming_value, existing_value, "reading")
                elif field == "writing":
                    incoming_map[date_key] = self._merge_english_record(incoming_value, existing_value, "writing")
                else:
                    incoming_map[date_key] = self._merge_english_record(incoming_value, existing_value, field)

    def _merge_life_generic_modules(self, incoming, existing):
        if not isinstance(existing, dict):
            return
        incoming["memos"] = self._merge_unique_list(
            incoming.get("memos"),
            existing.get("memos"),
            lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("memo:" + str(item.get("text", "")) + "|" + str(item.get("createdAt", "")) + "|" + str(item.get("reminderAt", ""))) if isinstance(item, dict) else "",
        )
        incoming["focusRecords"] = self._merge_unique_list(
            incoming.get("focusRecords"),
            existing.get("focusRecords"),
            lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("focus:" + str(item.get("date", "")) + "|" + str(item.get("time", "")) + "|" + str(item.get("minutes", item.get("duration", ""))) + "|" + str(item.get("timestamp", ""))) if isinstance(item, dict) else "",
        )
        incoming["books"] = self._merge_unique_list(
            incoming.get("books"),
            existing.get("books"),
            lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("book:" + str(item.get("title", "")) + "|" + str(item.get("author", ""))) if isinstance(item, dict) else "",
        )
        incoming["moods"] = self._merge_map_non_destructive(incoming.get("moods"), existing.get("moods"))
        incoming["sleepReminderShown"] = self._merge_map_non_destructive(
            incoming.get("sleepReminderShown"),
            existing.get("sleepReminderShown"),
            lambda current, old: bool(current) or bool(old),
        )
        incoming["reminderState"] = self._merge_map_non_destructive(incoming.get("reminderState"), existing.get("reminderState"))
        existing_viral = existing.get("viralVideoAnalysis") if isinstance(existing.get("viralVideoAnalysis"), dict) else {}
        incoming_viral = incoming.setdefault("viralVideoAnalysis", {})
        if isinstance(incoming_viral, dict) and isinstance(existing_viral, dict):
            incoming_viral["history"] = self._merge_unique_list(
                incoming_viral.get("history"),
                existing_viral.get("history"),
                lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("viral:" + str(item.get("url", "")) + "|" + str(item.get("analyzedAt", "")) + "|" + str(item.get("title", ""))) if isinstance(item, dict) else "",
            )
        existing_ai = existing.get("aiLearn") if isinstance(existing.get("aiLearn"), dict) else {}
        incoming_ai = incoming.setdefault("aiLearn", {})
        if isinstance(incoming_ai, dict) and isinstance(existing_ai, dict):
            incoming_ai["currentIndex"] = max(int(incoming_ai.get("currentIndex") or 0), int(existing_ai.get("currentIndex") or 0))
            incoming_ai["history"] = self._merge_unique_list(
                incoming_ai.get("history"),
                existing_ai.get("history"),
                lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("ai:" + str(item.get("date", "")) + "|" + str(item.get("topicIndex", "")) + "|" + str(item.get("theme", ""))) if isinstance(item, dict) else "",
            )
            incoming_ai["history"] = [
                item for item in (incoming_ai.get("history") or [])
                if isinstance(item, dict) and (item.get("id") or isinstance(item.get("completedVideos"), list))
            ]
            incoming_ai["recommendationHistory"] = self._merge_unique_list(
                incoming_ai.get("recommendationHistory"),
                existing_ai.get("recommendationHistory"),
                lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("ai-rec:" + str(item.get("date", "")) + "|" + str(item.get("batchId", "")) + "|" + str(item.get("stage", ""))) if isinstance(item, dict) else "",
            )
            incoming_ai["manualSessions"] = self._merge_unique_list(
                incoming_ai.get("manualSessions"),
                existing_ai.get("manualSessions"),
                lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("ai-manual:" + str(item.get("date", "")) + "|" + str(item.get("minutes", "")) + "|" + str(item.get("timestamp", ""))) if isinstance(item, dict) else "",
            )
            existing_refresh = existing_ai.get("recommendationRefreshCount") if isinstance(existing_ai.get("recommendationRefreshCount"), dict) else {}
            incoming_refresh = incoming_ai.setdefault("recommendationRefreshCount", {})
            if isinstance(incoming_refresh, dict):
                for key, value in existing_refresh.items():
                    incoming_refresh[key] = max(int(incoming_refresh.get(key) or 0), int(value or 0))
            for map_field in ("dailyVideos", "videoCompletions", "hotVideoCompletions"):
                existing_map = existing_ai.get(map_field) if isinstance(existing_ai.get(map_field), dict) else {}
                incoming_map = incoming_ai.setdefault(map_field, {})
                if isinstance(incoming_map, dict):
                    for key, value in existing_map.items():
                        if key not in incoming_map:
                            incoming_map[key] = copy.deepcopy(value)
                        elif map_field == "dailyVideos" and isinstance(incoming_map.get(key), dict) and isinstance(value, dict):
                            current = incoming_map[key]
                            current_stamp = str(current.get("updatedAt") or current.get("batchId") or "")
                            old_stamp = str(value.get("updatedAt") or value.get("batchId") or "")
                            if old_stamp and old_stamp > current_stamp:
                                incoming_map[key] = copy.deepcopy(value)
                        elif map_field in ("videoCompletions", "hotVideoCompletions") and isinstance(incoming_map.get(key), dict) and isinstance(value, dict):
                            current = incoming_map[key]
                            current_ts = int(current.get("statusUpdatedAt") or current.get("completedAtTs") or 0)
                            old_ts = int(value.get("statusUpdatedAt") or value.get("completedAtTs") or 0)
                            if old_ts > current_ts:
                                incoming_map[key] = copy.deepcopy(value)
                            elif old_ts == current_ts:
                                if value.get("completed") and not current.get("completed") and not current.get("cancelledAt"):
                                    incoming_map[key] = copy.deepcopy(value)
                                elif current.get("completed") and not current.get("completedAt") and value.get("completedAt"):
                                    current["completedAt"] = value.get("completedAt")
            existing_courses = existing_ai.get("courses") if isinstance(existing_ai.get("courses"), dict) else {}
            incoming_courses = incoming_ai.setdefault("courses", {})
            if isinstance(incoming_courses, dict):
                for course_id, old_course in existing_courses.items():
                    if not isinstance(old_course, dict):
                        continue
                    current_course = incoming_courses.get(course_id)
                    if not isinstance(current_course, dict):
                        incoming_courses[course_id] = copy.deepcopy(old_course)
                        continue
                    current_course["sessions"] = self._merge_unique_list(
                        current_course.get("sessions"),
                        old_course.get("sessions"),
                        lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("course:" + str(item.get("date", "")) + "|" + str(item.get("episodeId", "")) + "|" + str(item.get("timestamp", ""))) if isinstance(item, dict) else "",
                    )
                    current_done = current_course.get("completedEpisodeIds") if isinstance(current_course.get("completedEpisodeIds"), list) else []
                    old_done = old_course.get("completedEpisodeIds") if isinstance(old_course.get("completedEpisodeIds"), list) else []
                    current_course["completedEpisodeIds"] = list(dict.fromkeys([str(item) for item in current_done + old_done]))
                    current_ts = int(current_course.get("updatedAt") or 0)
                    old_ts = int(old_course.get("updatedAt") or 0)
                    if old_ts > current_ts:
                        current_course["currentEpisodeId"] = old_course.get("currentEpisodeId") or current_course.get("currentEpisodeId")
                        current_course["updatedAt"] = old_ts
                    current_episodes = current_course.get("episodes") if isinstance(current_course.get("episodes"), list) else []
                    old_episodes = old_course.get("episodes") if isinstance(old_course.get("episodes"), list) else []
                    if len(old_episodes) > len(current_episodes):
                        current_course["episodes"] = copy.deepcopy(old_episodes)
                    for field in ("title", "owner", "subtitle", "cover", "entryUrl", "seasonId", "totalParts", "totalMinutes", "catalogFetchedAt"):
                        if current_course.get(field) in (None, "") and old_course.get(field) not in (None, ""):
                            current_course[field] = copy.deepcopy(old_course.get(field))
        existing_reading = existing.get("readingAuto") if isinstance(existing.get("readingAuto"), dict) else {}
        incoming_reading = incoming.setdefault("readingAuto", {})
        if isinstance(incoming_reading, dict) and isinstance(existing_reading, dict):
            if "enabled" not in incoming_reading and "enabled" in existing_reading:
                incoming_reading["enabled"] = existing_reading.get("enabled")
            if not incoming_reading.get("currentBook") and existing_reading.get("currentBook"):
                incoming_reading["currentBook"] = existing_reading.get("currentBook")
            incoming_insights = incoming_reading.setdefault("insights", {})
            existing_insights = existing_reading.get("insights") if isinstance(existing_reading.get("insights"), dict) else {}
            if isinstance(incoming_insights, dict):
                for book_title, old_items in existing_insights.items():
                    incoming_insights[book_title] = self._merge_unique_list(
                        incoming_insights.get(book_title),
                        old_items,
                        lambda item: ("id:" + str(item.get("id"))) if isinstance(item, dict) and item.get("id") else ("insight:" + str(item.get("date", "")) + "|" + str(item.get("sentence", "")) + "|" + str(item.get("insight", ""))) if isinstance(item, dict) else "",
                    )

    def _merge_life_data_for_save(self, incoming):
        if isinstance(incoming, dict) and isinstance(incoming.get("englishLearn"), dict):
            self._strip_legacy_fake_bec_reading(incoming.get("englishLearn"))
        if not os.path.exists(LIFE_DATA_PATH):
            return incoming
        try:
            with open(LIFE_DATA_PATH, "r", encoding="utf-8-sig") as f:
                existing = json.load(f)
        except Exception:
            return incoming
        if not isinstance(existing, dict):
            return incoming
        self._merge_life_daily_plan(incoming, existing)
        self._normalize_life_preset_names(incoming)
        self._merge_life_exercises(incoming, existing)
        self._apply_life_exercise_presets(incoming)
        self._merge_life_english(incoming, existing)
        self._merge_life_generic_modules(incoming, existing)
        self._normalize_life_preset_names(incoming)
        incoming["__syncUpdatedAt"] = max(
            int(incoming.get("__syncUpdatedAt") or 0),
            int(existing.get("__syncUpdatedAt") or 0),
            int(time.time() * 1000),
        )
        return incoming

    def _save_life_data(self):
        try:
            payload = self._read_json_body()
            data = payload.get("data") if isinstance(payload, dict) else None
            if not isinstance(data, dict):
                self._json_response({"ok": False, "error": "data must be an object"}, status=400)
                return
            data = self._merge_life_data_for_save(data)
            data["__syncUpdatedAt"] = int(data.get("__syncUpdatedAt") or time.time() * 1000)
            data["__syncSavedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
            with open(LIFE_DATA_PATH, "w", encoding="utf-8") as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
            self._json_response({"ok": True, "updatedAt": data["__syncUpdatedAt"]})
        except Exception as e:
            self._json_response({"ok": False, "error": str(e)}, status=500)

    def _favorite_default_data(self):
        return {
            "ok": True,
            "updatedAt": "",
            "sources": ["抖音", "小红书"],
            "categories": ["知识技能", "生活灵感", "搞笑娱乐", "未分类"],
            "knowledgeSubcategories": ["编程/AI", "职场沟通", "理财", "其他知识"],
            "syncStatus": {
                "douyin": "未同步",
                "xiaohongshu": "未同步",
                "message": "抖音/小红书收藏需要登录态或导出的分享链接。当前本地服务已准备好入库、分类、转写和分析流程。",
            },
            "videos": [],
        }

    def _load_favorite_data(self):
        default = self._favorite_default_data()
        if not os.path.exists(FAVORITE_COLLECTIONS_PATH):
            return default
        try:
            with open(FAVORITE_COLLECTIONS_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
            if not isinstance(data, dict):
                return {**default, "syncStatus": {**default["syncStatus"], "message": "收藏数据格式不正确，已使用空数据。"}}
            data = {**default, **data}
            if not isinstance(data.get("videos"), list):
                data["videos"] = []
            data["ok"] = True
            return data
        except Exception as e:
            return {**default, "ok": False, "syncStatus": {**default["syncStatus"], "message": str(e)}}

    def _save_favorite_data(self, data):
        data["updatedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
        with open(FAVORITE_COLLECTIONS_PATH, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

    def _serve_favorite_collections(self):
        self._json_response(self._load_favorite_data())

    def _read_json_body(self):
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length) if length else b"{}"
        return json.loads(body.decode("utf-8"))

    def _detect_video_platform(self, text):
        lower = (text or "").lower()
        if "douyin.com" in lower or "iesdouyin" in lower:
            return "抖音"
        if "xiaohongshu.com" in lower or "xhslink.com" in lower:
            return "小红书"
        if "bilibili.com" in lower or "b23.tv" in lower:
            return "B站"
        return "未知平台"

    def _extract_favorite_tags(self, text):
        tags = re.findall(r"#([^#\s，。,.!！?？]+)", text or "")
        keywords = ["AI", "编程", "职场", "沟通", "理财", "基金", "省钱", "家居", "穿搭", "美食", "搞笑", "段子", "宠物"]
        for keyword in keywords:
            if keyword in (text or "") and keyword not in tags:
                tags.append(keyword)
        return tags[:8]

    def _clean_favorite_title(self, text, url):
        title = re.sub(r"https?://\S+", " ", text or "")
        title = re.sub(r"#[^#\s，。,.!！?？]+", " ", title)
        title = re.sub(r"^[\d.:\sA-Za-z@/]+", " ", title)
        title = re.sub(r"\s+", " ", title).strip(" ，。,.!！?？")
        if not title:
            title = url or "未命名收藏视频"
        return title[:90]

    def _classify_favorite_video(self, title, tags):
        text = f"{title} {' '.join(tags)}"
        knowledge = ["AI", "编程", "代码", "Python", "Cursor", "效率", "教程", "学习", "职场", "沟通", "表达", "理财", "基金", "股票", "存钱", "副业"]
        funny = ["搞笑", "段子", "沙雕", "哈哈", "离谱", "整活", "娱乐"]
        life = ["生活", "家居", "穿搭", "美食", "旅行", "收纳", "做饭", "灵感", "装修", "护肤"]
        if any(k in text for k in knowledge):
            if any(k in text for k in ["AI", "编程", "代码", "Python", "Cursor", "豆包", "Coze"]):
                return "知识技能", "编程/AI"
            if any(k in text for k in ["职场", "沟通", "表达", "汇报", "面试"]):
                return "知识技能", "职场沟通"
            if any(k in text for k in ["理财", "基金", "股票", "存钱", "省钱", "投资"]):
                return "知识技能", "理财"
            return "知识技能", "其他知识"
        if any(k in text for k in funny):
            return "搞笑娱乐", ""
        if any(k in text for k in life):
            return "生活灵感", ""
        return "未分类", ""

    def _favorite_analysis(self, item):
        title = item.get("title") or "未命名收藏视频"
        transcript = item.get("transcript") or ""
        category = item.get("category") or "未分类"
        subcategory = item.get("subcategory") or ""
        tags = "、".join(item.get("tags") or [])
        material = transcript if transcript else f"{title} {tags}"
        if category == "知识技能":
            focus = f"这条内容适合沉淀为一张知识卡片，重点关注“问题、方法、例子、行动”四层结构。{('子类：' + subcategory + '。') if subcategory else ''}"
            action = "提炼 3 个可执行步骤，并把其中最容易当天实践的一步加入待办。"
        elif category == "生活灵感":
            focus = "这条内容的价值在于场景启发，适合记录触发灵感的画面、物品、地点或生活方式选择。"
            action = "拆出可复用元素：场景、预算、准备物、替代方案。"
        elif category == "搞笑娱乐":
            focus = "这条内容主要依靠情绪释放和反差制造记忆点，适合分析包袱铺垫、节奏停顿和结尾反转。"
            action = "记录笑点公式：铺垫对象、预期方向、反差动作、结尾回扣。"
        else:
            focus = "当前信息较少，建议先补充标题、标签或逐字稿，再做更精确归类。"
            action = "先判断它是知识、灵感还是娱乐，再决定是否收藏进长期合集。"
        summary = self._strip_markdown(material)[:180] or "暂无可分析文本。"
        return {
            "summary": summary,
            "contentInsight": focus,
            "actionSuggestion": action,
            "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
        }

    def _import_favorite_collections(self):
        try:
            payload = self._read_json_body()
            raw = payload.get("text", "") if isinstance(payload, dict) else ""
            snippets = re.split(r"\n{2,}|\r?\n(?=.*https?://)", raw.strip())
            data = self._load_favorite_data()
            existing_urls = {item.get("url") for item in data.get("videos", []) if item.get("url")}
            added = []
            for snippet in snippets:
                snippet = snippet.strip()
                if not snippet:
                    continue
                url_match = re.search(r"https?://[^\s，。)）]+", snippet)
                if not url_match:
                    continue
                url = url_match.group(0)
                if url in existing_urls:
                    continue
                tags = self._extract_favorite_tags(snippet)
                title = self._clean_favorite_title(snippet, url)
                category, subcategory = self._classify_favorite_video(title, tags)
                item = {
                    "id": f"fav_{int(time.time() * 1000)}_{len(data.get('videos', [])) + len(added)}",
                    "platform": self._detect_video_platform(url),
                    "title": title,
                    "url": url,
                    "category": category,
                    "subcategory": subcategory,
                    "tags": tags,
                    "collectedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
                    "transcriptStatus": "待转写",
                    "transcript": "",
                    "analysisStatus": "待分析",
                    "analysis": {},
                }
                item["analysis"] = self._favorite_analysis(item)
                item["analysisStatus"] = "已生成初步分析"
                added.append(item)
                existing_urls.add(url)
            data["videos"] = added + data.get("videos", [])
            if added:
                data["syncStatus"]["message"] = f"已导入 {len(added)} 条收藏链接，并完成自动分类与初步分析。"
            self._save_favorite_data(data)
            self._json_response({"ok": True, "added": len(added), "data": data})
        except Exception as e:
            self._json_response({"ok": False, "error": str(e)}, status=500)

    def _node_command(self):
        if os.path.exists(NODE_EXE):
            return NODE_EXE
        return shutil.which("node")

    def _node_env(self):
        env = os.environ.copy()
        paths = []
        if os.path.exists(NODE_MODULES):
            paths.append(NODE_MODULES)
        if env.get("NODE_PATH"):
            paths.append(env["NODE_PATH"])
        if paths:
            env["NODE_PATH"] = os.pathsep.join(paths)
        return env

    def _favorite_item_from_sync(self, raw_item, index=0):
        url = raw_item.get("url", "")
        title = raw_item.get("title") or self._clean_favorite_title("", url)
        tags = self._extract_favorite_tags(title)
        category, subcategory = self._classify_favorite_video(title, tags)
        item = {
            "id": f"fav_{int(time.time() * 1000)}_{index}",
            "platform": raw_item.get("platform") or self._detect_video_platform(url),
            "title": title,
            "url": url,
            "category": category,
            "subcategory": subcategory,
            "tags": tags,
            "collectedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            "transcriptStatus": "待转写",
            "transcript": "",
            "analysisStatus": "待分析",
            "analysis": {},
            "source": raw_item.get("source") or "login-sync",
        }
        item["analysis"] = self._favorite_analysis(item)
        item["analysisStatus"] = "已生成初步分析"
        return item

    def _merge_favorite_videos(self, data, raw_videos):
        existing_urls = {item.get("url") for item in data.get("videos", []) if item.get("url")}
        added = []
        for raw in raw_videos:
            url = raw.get("url")
            if not url or url in existing_urls:
                continue
            item = self._favorite_item_from_sync(raw, len(data.get("videos", [])) + len(added))
            added.append(item)
            existing_urls.add(url)
        if added:
            data["videos"] = added + data.get("videos", [])
        return added

    def _login_favorite_collections(self):
        try:
            payload = self._read_json_body()
        except Exception:
            payload = {}
        platform = payload.get("platform", "all") if isinstance(payload, dict) else "all"
        node = self._node_command()
        if not node:
            self._json_response({"ok": False, "error": "未找到 Node.js，无法打开登录窗口。"}, status=500)
            return
        if not os.path.exists(FAVORITE_SYNC_SCRIPT):
            self._json_response({"ok": False, "error": "sync-favorites.js 不存在。"}, status=500)
            return
        try:
            os.makedirs(FAVORITE_LOG_DIR, exist_ok=True)
            profile_dir = os.path.join(FAVORITE_LOG_DIR, "browser-profile")
            os.makedirs(profile_dir, exist_ok=True)
            log_path = os.path.join(FAVORITE_LOG_DIR, "login.log")
            with open(log_path, "a", encoding="utf-8") as log_file:
                log_file.write(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] open login chrome {platform}\n")
            chrome = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
            if not os.path.exists(chrome):
                chrome = shutil.which("chrome") or shutil.which("chrome.exe")
            if not chrome:
                self._json_response({"ok": False, "error": "未找到 Chrome，无法打开登录窗口。"}, status=500)
                return
            urls = []
            if platform in ("all", "douyin"):
                urls.append("https://www.douyin.com/")
            if platform in ("all", "xiaohongshu"):
                urls.append("https://www.xiaohongshu.com/")
            subprocess.Popen(
                [chrome, f"--user-data-dir={profile_dir}", "--remote-debugging-port=9223", "--new-window", *urls],
                cwd=WORKSPACE,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
            )
            data = self._load_favorite_data()
            data["syncStatus"] = {
                "douyin": "登录窗口已打开",
                "xiaohongshu": "登录窗口已打开",
                "message": "已打开独立 Chrome 登录窗口。请扫码登录抖音和小红书；登录完成后关闭这个登录窗口，再回到工作台点击“同步收藏”。",
                "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            }
            self._save_favorite_data(data)
            self._json_response({"ok": True, "data": data, "message": data["syncStatus"]["message"]})
        except Exception as e:
            self._json_response({"ok": False, "error": str(e)}, status=500)

    def _sync_favorite_collections(self):
        node = self._node_command()
        data = self._load_favorite_data()
        if not node:
            data["syncStatus"] = {
                "douyin": "同步失败",
                "xiaohongshu": "同步失败",
                "message": "未找到 Node.js，无法启动浏览器同步脚本。",
                "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            }
            self._save_favorite_data(data)
            self._json_response({"ok": False, "data": data, "error": data["syncStatus"]["message"]}, status=500)
            return
        try:
            proc = subprocess.run(
                [node, FAVORITE_SYNC_SCRIPT, "sync", "all"],
                cwd=WORKSPACE,
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=180,
                env=self._node_env(),
            )
            stdout = (proc.stdout or "").strip()
            result = json.loads(stdout) if stdout else {"ok": False, "error": proc.stderr or "同步脚本无输出", "statuses": [], "videos": []}
            added = self._merge_favorite_videos(data, result.get("videos") or [])
            statuses = result.get("statuses") or []
            status_map = {s.get("platform"): s for s in statuses if isinstance(s, dict)}
            data["syncStatus"] = {
                "douyin": status_map.get("抖音", {}).get("message", "未返回状态"),
                "xiaohongshu": status_map.get("小红书", {}).get("message", "未返回状态"),
                "message": f"同步完成，新增 {len(added)} 条收藏。若新增为 0，请确认登录窗口里已经进入收藏页并能看到收藏内容。",
                "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            }
            if result.get("error"):
                data["syncStatus"]["message"] = f"同步脚本异常：{result.get('error')}"
            self._save_favorite_data(data)
            self._json_response({"ok": len(added) > 0, "added": len(added), "result": result, "data": data})
        except subprocess.TimeoutExpired:
            data["syncStatus"] = {
                "douyin": "同步超时",
                "xiaohongshu": "同步超时",
                "message": "同步超过 3 分钟未完成。请确认登录窗口没有卡在验证码或权限页。",
                "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            }
            self._save_favorite_data(data)
            self._json_response({"ok": False, "data": data, "error": data["syncStatus"]["message"]}, status=504)
        except Exception as e:
            data["syncStatus"] = {
                "douyin": "同步失败",
                "xiaohongshu": "同步失败",
                "message": str(e),
                "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            }
            self._save_favorite_data(data)
            self._json_response({"ok": False, "data": data, "error": str(e)}, status=500)

    def _tool_status(self):
        return {
            "ytDlp": bool(shutil.which("yt-dlp")),
            "ffmpeg": bool(shutil.which("ffmpeg")),
            "whisperCli": bool(shutil.which("whisper")),
        }

    def _transcribe_favorite_with_whisper(self, item):
        os.makedirs(FAVORITE_MEDIA_DIR, exist_ok=True)
        base = re.sub(r"[^a-zA-Z0-9_-]", "_", item.get("id") or str(int(time.time())))
        output_tpl = os.path.join(FAVORITE_MEDIA_DIR, base + ".%(ext)s")
        subprocess.run(
            ["yt-dlp", "-x", "--audio-format", "mp3", "-o", output_tpl, item.get("url", "")],
            cwd=WORKSPACE,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=180,
            check=True,
        )
        audio_path = os.path.join(FAVORITE_MEDIA_DIR, base + ".mp3")
        if not os.path.exists(audio_path):
            matches = [os.path.join(FAVORITE_MEDIA_DIR, name) for name in os.listdir(FAVORITE_MEDIA_DIR) if name.startswith(base + ".")]
            audio_path = matches[0] if matches else audio_path
        if not os.path.exists(audio_path):
            raise RuntimeError("音频提取失败：未找到输出音频文件")
        subprocess.run(
            ["whisper", audio_path, "--language", "Chinese", "--model", "base", "--output_format", "txt", "--output_dir", FAVORITE_MEDIA_DIR],
            cwd=WORKSPACE,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=600,
            check=True,
        )
        transcript_path = os.path.splitext(audio_path)[0] + ".txt"
        if not os.path.exists(transcript_path):
            raise RuntimeError("Whisper 已运行，但未找到逐字稿文件")
        with open(transcript_path, "r", encoding="utf-8", errors="replace") as f:
            return f.read().strip(), os.path.relpath(transcript_path, WORKSPACE)

    def _analyze_favorite_collection_item(self):
        try:
            payload = self._read_json_body()
            item_id = payload.get("id", "") if isinstance(payload, dict) else ""
            data = self._load_favorite_data()
            item = next((v for v in data.get("videos", []) if v.get("id") == item_id), None)
            if not item:
                self._json_response({"ok": False, "error": "收藏视频不存在"}, status=404)
                return
            tools = self._tool_status()
            missing = [name for name, ok in tools.items() if not ok]
            if missing and not item.get("transcript"):
                item["transcriptStatus"] = "缺少转写组件"
                item["transcriptNote"] = "需要安装 yt-dlp、ffmpeg 和 Whisper 后才能从平台链接提取音频并转逐字稿。当前已先基于标题/标签完成内容分析。"
            else:
                try:
                    if not item.get("transcript"):
                        item["transcriptStatus"] = "转写中"
                        transcript, transcript_path = self._transcribe_favorite_with_whisper(item)
                        item["transcript"] = transcript
                        item["transcriptPath"] = transcript_path
                    item["transcriptStatus"] = "已转写"
                    item["transcriptNote"] = "已提取音频并完成 Whisper 逐字稿。"
                except Exception as e:
                    item["transcriptStatus"] = "转写失败"
                    item["transcriptNote"] = f"音频提取或 Whisper 转写失败：{e}。当前已先基于标题/标签完成内容分析。"
            item["analysis"] = self._favorite_analysis(item)
            item["analysisStatus"] = "已分析"
            item["analysis"]["toolStatus"] = tools
            item["analysis"]["transcriptBasis"] = "逐字稿" if item.get("transcript") else "标题/标签"
            self._save_favorite_data(data)
            self._json_response({"ok": True, "item": item, "data": data})
        except Exception as e:
            self._json_response({"ok": False, "error": str(e)}, status=500)

    def do_GET(self):
        req_path = urlparse(self.path).path
        if req_path == "/api/hot-videos":
            self._serve_hot_videos()
        elif req_path == "/api/ai-learning-videos":
            self._serve_ai_learning_videos()
        elif req_path == "/api/life-data":
            self._serve_life_data()
        elif req_path == "/api/favorite-collections":
            self._serve_favorite_collections()
        elif req_path == "/api/reading-note":
            self._serve_reading_note()
        elif req_path == "/api/reading-notes":
            self._serve_reading_notes()
        elif req_path == "/api/reading-book-archives":
            self._serve_reading_book_archives()
        elif req_path == "/api/reading-book-archive":
            self._serve_reading_book_archive()
        elif req_path == "/reading-note-view":
            self._serve_reading_note_view()
        elif req_path == "/reading-book-view":
            self._serve_reading_book_archive_view()
        elif req_path == "/sync.json":
            self.send_response(200)
            self._cors()
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.end_headers()
            if os.path.exists(SYNC_PATH):
                with open(SYNC_PATH, "r", encoding="utf-8") as f:
                    self.wfile.write(f.read().encode("utf-8"))
            else:
                self.wfile.write(json.dumps({"books": []}).encode("utf-8"))
        elif self.path.startswith("/api/weread-readtimes"):
            global _readtimes_cache
            try:
                now = time.time()
                if _readtimes_cache["data"] is not None and now - _readtimes_cache["ts"] < _READTIMES_TTL:
                    data = _readtimes_cache["data"]
                else:
                    data = self._gw_post("/readdata/detail", {"mode": "weekly"}, timeout=8)
                    _readtimes_cache = {"ts": now, "data": data}
                self.send_response(200)
                self._cors()
                self.send_header("Content-Type", "application/json; charset=utf-8")
                self.end_headers()
                self.wfile.write(json.dumps({"ok": True, "data": data}, ensure_ascii=False).encode("utf-8"))
            except Exception as e:
                self.send_response(200)
                self._cors()
                self.send_header("Content-Type", "application/json; charset=utf-8")
                self.end_headers()
                self.wfile.write(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8"))
        elif self.path.startswith("/api/daily-english-listening"):
            result = self._daily_english_listening_stats()
            self._json_response(result, status=200 if result.get("ok") else 500)
        elif self.path.startswith("/api/last-reading-note"):
            self.send_response(200)
            self._cors()
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.end_headers()
            if os.path.exists(LAST_NOTE_PATH):
                with open(LAST_NOTE_PATH, "r", encoding="utf-8") as f:
                    self.wfile.write(f.read().encode("utf-8"))
            else:
                self.wfile.write(json.dumps({"ok": False, "error": "not generated yet"}).encode("utf-8"))
        else:
            super().do_GET()

    def end_headers(self):
        self.send_header("Cache-Control", "no-store")
        self._cors()
        super().end_headers()

    def log_message(self, fmt, *args):
        pass  # 静默日志


def main():
    # 初始化空同步文件
    if not os.path.exists(SYNC_PATH):
        with open(SYNC_PATH, "w", encoding="utf-8") as f:
            json.dump({"books": []}, f, ensure_ascii=False)
    if not os.path.exists(FAVORITE_COLLECTIONS_PATH):
        with open(FAVORITE_COLLECTIONS_PATH, "w", encoding="utf-8") as f:
            json.dump(Handler._favorite_default_data(Handler), f, ensure_ascii=False, indent=2)
    os.chdir(WORKSPACE)
    start_hot_video_auto_scheduler()
    start_reading_auto_scheduler()
    start_english_listening_sync_scheduler()
    if os.name == "nt":
        threading.Thread(target=sync_reading_artifacts_to_cloud, daemon=True, name="reading-cloud-sync-startup").start()
        threading.Thread(target=sync_daily_english_listening_to_life_data, daemon=True, name="english-cloud-sync-startup").start()
    socketserver.ThreadingTCPServer.allow_reuse_address = True
    with socketserver.ThreadingTCPServer(("", PORT), Handler) as httpd:
        print(f"Personal Life OS server running at http://localhost:{PORT}")
        httpd.serve_forever()


if __name__ == "__main__":
    main()

