MyFans Downloader

Scan and download subscribed MyFans plan videos.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Advertisement:

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

Advertisement:

// ==UserScript==
// @name         MyFans Downloader
// @namespace    https://github.com/myfans-downloader
// @version      0.1.6
// @description  Scan and download subscribed MyFans plan videos.
// @author       myfans-downloader
// @match        https://myfans.jp/*
// @license      MIT
// @grant        GM_xmlhttpRequest
// @connect      api.myfans.jp
// @connect      content.mfcdn.jp
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/mux.min.js
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const API_BASE = 'https://api.myfans.jp/api';
  const DEFAULT_KEYWORDS = 'demo,sample,preview,teaser,trial,サンプル,プレビュー,予告,トレーラー,無料,お試し,日常';
  const RESERVED_ROUTES = new Set([
    'account', 'auth', 'award_pages', 'events', 'external_notifications', 'feature',
    'feed', 'gachas', 'genres', 'identify', 'messages', 'otoshidama-creators',
    'passwords', 'payment_failure', 'payments', 'policies', 'posts', 'privacy',
    'ranking', 's', 'sign_in', 'sign_up', 'terms', 'tokenize', 'trade', 'unlimited'
  ]);

  const STYLES = `
    #mfd-fab {
      position: fixed; right: 24px; bottom: 24px; z-index: 2147483647;
      width: 52px; height: 52px; border: 0; border-radius: 50%;
      background: linear-gradient(135deg, #0f766e, #2563eb);
      color: #fff; cursor: pointer; box-shadow: 0 8px 24px rgba(15,118,110,.36);
      display: flex; align-items: center; justify-content: center;
      font: 700 18px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }
    #mfd-fab:hover { transform: translateY(-1px); }
    #mfd-panel {
      position: fixed; right: 24px; bottom: 88px; z-index: 2147483646;
      width: 430px; max-height: 86vh; overflow-y: auto; display: none;
      background: #14171f; color: #d9e2f2; border: 1px solid #2b3140;
      border-radius: 12px; padding: 16px; box-shadow: 0 12px 40px rgba(0,0,0,.48);
      font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }
    #mfd-panel.open { display: block; }
    #mfd-panel h3 { margin: 0 0 12px; font-size: 16px; color: #7dd3fc; }
    #mfd-panel label { display: block; margin: 0 0 4px; color: #9aa8bd; font-size: 12px; }
    #mfd-panel input[type=text], #mfd-panel input[type=number], #mfd-panel select {
      width: 100%; box-sizing: border-box; padding: 7px 9px; margin: 0 0 10px;
      background: #1f2430; color: #d9e2f2; border: 1px solid #394252; border-radius: 7px;
      font-size: 13px;
    }
    #mfd-panel input:focus, #mfd-panel select:focus { outline: none; border-color: #38bdf8; }
    .mfd-row { display: flex; gap: 8px; align-items: flex-start; }
    .mfd-row > div { flex: 1; min-width: 0; }
    .mfd-check { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; color: #cbd5e1; }
    .mfd-check input { accent-color: #0f766e; }
    .mfd-btn {
      width: 100%; padding: 8px 10px; border-radius: 7px; border: 1px solid transparent;
      cursor: pointer; font-weight: 650; font-size: 13px;
    }
    .mfd-btn:disabled { opacity: .45; cursor: not-allowed; }
    .mfd-btn-primary { background: linear-gradient(135deg, #0f766e, #2563eb); color: #fff; }
    .mfd-btn-green { background: #16a34a; color: #fff; }
    .mfd-btn-outline { background: transparent; color: #d9e2f2; border-color: #394252; }
    .mfd-btn-sm { width: auto; padding: 5px 8px; font-size: 11px; }
    #mfd-status { min-height: 18px; margin: 10px 0 4px; color: #9aa8bd; font-size: 12px; }
    #mfd-stats { min-height: 18px; color: #9aa8bd; font-size: 12px; }
    #mfd-progress-wrap { display: none; height: 6px; overflow: hidden; border-radius: 4px; background: #253044; margin: 10px 0; }
    #mfd-progress-bar { height: 100%; width: 0%; background: linear-gradient(90deg, #14b8a6, #38bdf8); }
    #mfd-results { display: none; margin-top: 12px; }
    #mfd-results.show { display: block; }
    .mfd-select-bar { display: flex; gap: 6px; align-items: center; margin: 8px 0; }
    .mfd-select-bar span { margin-left: auto; color: #9aa8bd; font-size: 11px; }
    #mfd-list { max-height: 300px; overflow: auto; border: 1px solid #2b3140; border-radius: 8px; }
    #mfd-list table { width: 100%; border-collapse: collapse; table-layout: fixed; }
    #mfd-list th { position: sticky; top: 0; background: #202838; color: #9aa8bd; padding: 5px; text-align: left; }
    #mfd-list td { padding: 5px; border-top: 1px solid #252b38; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    #mfd-list tr:hover { background: #1b2230; }
    #mfd-list input[type=checkbox] { accent-color: #0f766e; }
    .mfd-status.ok { color: #22c55e; }
    .mfd-status.err { color: #fb7185; }
    .mfd-status.busy { color: #fbbf24; }
    .mfd-muted { color: #9aa8bd; }
    .mfd-actions { display: flex; gap: 8px; margin-top: 10px; }
    .mfd-actions .mfd-btn { flex: 1; }
  `;

  let results = { items: [] };
  let selectedIndices = new Set();
  let scanning = false;
  let downloading = false;
  let abortController = null;
  let filenameCounts = {};
  let bridgeSeq = 0;

  function injectStyles() {
    if (document.getElementById('mfd-styles')) return;
    const style = document.createElement('style');
    style.id = 'mfd-styles';
    style.textContent = STYLES;
    document.head.appendChild(style);
  }

  function escapeHtml(value) {
    return String(value ?? '').replace(/[&<>"']/g, ch => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[ch]));
  }

  function sanitizeFilename(value) {
    return String(value || 'item')
      .replace(/[\\/:*?"<>|]+/g, '_')
      .replace(/[()()]+/g, '_')
      .replace(/\s+/g, ' ')
      .replace(/_+/g, '_')
      .replace(/^[ _]+|[ _]+$/g, '')
      .slice(0, 120) || 'item';
  }

  function resetFilenameCounts() {
    filenameCounts = {};
  }

  function shortId(value) {
    const raw = String(value || '').replace(/[^a-zA-Z0-9]/g, '');
    return raw.slice(0, 8) || 'item';
  }

  function extractFileId(post, fallback) {
    const keys = [
      'content_id', 'contentId', 'post_id', 'postId', 'display_id', 'displayId',
      'serial_id', 'serialId', 'legacy_id', 'legacyId', 'number', 'id'
    ];
    for (const key of keys) {
      const value = post?.[key];
      if (value !== undefined && value !== null && /^\d+$/.test(String(value))) return String(value);
    }
    return shortId(fallback || post?.id || post?.uuid);
  }

  function creatorFromUrl() {
    const parts = location.pathname.split('/').filter(Boolean);
    if (!parts[0] || RESERVED_ROUTES.has(parts[0])) return '';
    return parts[0];
  }

  function planIdFromUrl() {
    const match = location.pathname.match(/^\/[^/]+\/plans\/([0-9a-f-]{20,})/i);
    return match ? match[1] : '';
  }

  function postIdFromUrl() {
    const match = location.pathname.match(/^\/posts\/([0-9a-f-]{20,})/i);
    return match ? match[1] : '';
  }

  function getCookieValue(name) {
    const parts = document.cookie.split(';').map(part => part.trim());
    const hit = parts.find(part => part.startsWith(name + '='));
    if (!hit) return '';
    return decodeURIComponent(hit.slice(name.length + 1));
  }

  function authHeaders() {
    const token = getCookieValue('_mfans_token');
    const headers = {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'X-Mf-Locale': 'ja',
      'x-vercel-env': 'production',
      'google-ga-data': 'event328'
    };
    if (token) headers.Authorization = `Token token=${token}`;
    return headers;
  }

  function setupPageBridge() {
    if (document.getElementById('mfd-page-bridge')) return;
    const script = document.createElement('script');
    script.id = 'mfd-page-bridge';
    script.textContent = `
      (() => {
        if (window.__mfdApiBridge) return;
        window.__mfdApiBridge = true;

        const cookieValue = (name) => {
          const hit = document.cookie.split(';').map(v => v.trim()).find(v => v.startsWith(name + '='));
          return hit ? decodeURIComponent(hit.slice(name.length + 1)) : '';
        };

        const defaultHeaders = () => {
          const token = cookieValue('_mfans_token');
          const headers = {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
            'X-Mf-Locale': 'ja',
            'x-vercel-env': 'production',
            'google-ga-data': 'event328'
          };
          if (token) headers.Authorization = 'Token token=' + token;
          return headers;
        };

        window.addEventListener('message', (event) => {
          if (event.source !== window) return;
          const msg = event.data;
          if (!msg || msg.type !== 'MFD_API_REQUEST' || !msg.id || !msg.url) return;
          if (!/^https:\\/\\/api\\.myfans\\.jp\\/api\\//.test(msg.url)) {
            window.postMessage({ type: 'MFD_API_RESPONSE', id: msg.id, ok: false, status: 0, error: 'Blocked URL' }, '*');
            return;
          }

          const xhr = new XMLHttpRequest();
          xhr.open(msg.method || 'GET', msg.url, true);
          xhr.responseType = msg.responseType || 'text';
          xhr.withCredentials = true;
          const headers = { ...defaultHeaders(), ...(msg.headers || {}) };
          for (const [key, value] of Object.entries(headers)) {
            try { xhr.setRequestHeader(key, value); } catch (_) {}
          }
          xhr.onload = () => {
            window.postMessage({
              type: 'MFD_API_RESPONSE',
              id: msg.id,
              ok: xhr.status >= 200 && xhr.status < 300,
              status: xhr.status,
              statusText: xhr.statusText,
              response: xhr.response
            }, '*');
          };
          xhr.onerror = () => window.postMessage({ type: 'MFD_API_RESPONSE', id: msg.id, ok: false, status: xhr.status || 0, error: 'XHR failed' }, '*');
          xhr.onabort = () => window.postMessage({ type: 'MFD_API_RESPONSE', id: msg.id, ok: false, status: xhr.status || 0, error: 'XHR aborted' }, '*');
          xhr.send(msg.body || null);
        });
      })();
    `;
    document.documentElement.appendChild(script);
    script.remove();
  }

  function pageApiRequest(url, options = {}) {
    setupPageBridge();
    const id = `mfd-${Date.now()}-${++bridgeSeq}`;
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        window.removeEventListener('message', onMessage);
        reject(new Error(`API bridge timeout: ${url}`));
      }, options.timeout || 60000);

      function onMessage(event) {
        if (event.source !== window) return;
        const msg = event.data;
        if (!msg || msg.type !== 'MFD_API_RESPONSE' || msg.id !== id) return;
        clearTimeout(timeout);
        window.removeEventListener('message', onMessage);
        if (msg.error) {
          reject(new Error(`${msg.error}: ${url}`));
        } else {
          resolve(msg);
        }
      }

      window.addEventListener('message', onMessage);
      window.postMessage({
        type: 'MFD_API_REQUEST',
        id,
        url,
        method: options.method || 'GET',
        headers: options.headers || {},
        responseType: options.responseType || 'text',
        body: options.body || null
      }, '*');
    });
  }

  function xhrRequest(url, options = {}) {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open(options.method || 'GET', url, true);
      xhr.responseType = options.responseType || 'text';
      xhr.withCredentials = options.withCredentials !== false;
      for (const [key, value] of Object.entries(options.headers || {})) {
        xhr.setRequestHeader(key, value);
      }
      xhr.onload = () => {
        resolve({
          ok: xhr.status >= 200 && xhr.status < 300,
          status: xhr.status,
          statusText: xhr.statusText,
          response: xhr.response
        });
      };
      xhr.onerror = () => reject(new Error(`XHR failed: ${url}`));
      xhr.onabort = () => reject(new Error(options.signal?.aborted ? 'Scan stopped.' : 'XHR aborted'));
      if (options.signal) {
        options.signal.addEventListener('abort', () => {
          try { xhr.abort(); } catch (_) {}
        }, { once: true });
      }
      xhr.send(options.body || null);
    });
  }

  function gmRequest(url, options = {}) {
    return new Promise((resolve, reject) => {
      if (typeof GM_xmlhttpRequest !== 'function') {
        reject(new Error('GM_xmlhttpRequest is unavailable'));
        return;
      }
      if (options.signal?.aborted) {
        reject(new Error('Scan stopped.'));
        return;
      }
      let requestHandle = null;
      const abortRequest = () => {
        try { requestHandle?.abort?.(); } catch (_) {}
      };
      const cleanup = () => {
        if (options.signal) options.signal.removeEventListener('abort', abortRequest);
      };
      if (options.signal) options.signal.addEventListener('abort', abortRequest, { once: true });
      requestHandle = GM_xmlhttpRequest({
        method: options.method || 'GET',
        url,
        headers: options.headers || {},
        data: options.body || undefined,
        responseType: options.responseType || 'text',
        timeout: options.timeout || 60000,
        anonymous: options.anonymous === true,
        onload: resp => {
          cleanup();
          resolve({
            ok: resp.status >= 200 && resp.status < 300,
            status: resp.status,
            statusText: resp.statusText,
            response: resp.response
          });
        },
        onerror: err => {
          cleanup();
          reject(new Error(`GM request failed: ${url} ${err?.error || ''}`.trim()));
        },
        ontimeout: () => {
          cleanup();
          reject(new Error(`GM request timeout: ${url}`));
        },
        onabort: () => {
          cleanup();
          reject(new Error('Scan stopped.'));
        }
      });
    });
  }

  function request(url, options = {}) {
    if (typeof GM_xmlhttpRequest === 'function') {
      return gmRequest(url, options);
    }
    return xhrRequest(url, options);
  }

  function throwIfAborted(signal) {
    if (signal?.aborted) throw new Error('Scan stopped.');
  }

  function isScanStopped(err) {
    return String(err?.message || err) === 'Scan stopped.';
  }

  async function apiFetch(path, params, signal) {
    throwIfAborted(signal);
    const url = new URL(path.replace(/^\//, ''), API_BASE.endsWith('/') ? API_BASE : API_BASE + '/');
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value);
      });
    }
    const resp = await request(url.toString(), {
      headers: authHeaders(),
      timeout: 60000,
      signal
    });
    throwIfAborted(signal);
    const text = String(resp.response || '');
    let json = null;
    if (text) {
      try { json = JSON.parse(text); } catch (_) { json = null; }
    }
    if (!resp.ok) {
      const detail = json?.message || json?.error || text.slice(0, 160) || resp.statusText;
      throw new Error(`API ${resp.status}: ${detail}`);
    }
    return json;
  }

  function arrayFromPayload(payload, names) {
    if (Array.isArray(payload)) return payload;
    const root = payload?.data ?? payload;
    if (Array.isArray(root)) return root;
    for (const name of names) {
      if (Array.isArray(root?.[name])) return root[name];
      if (Array.isArray(root?.data?.[name])) return root.data[name];
    }
    return [];
  }

  function postsFromPayload(payload) {
    return arrayFromPayload(payload, ['posts', 'items', 'results', 'list']);
  }

  function nextPageFromPayload(payload) {
    const root = payload?.data ?? payload;
    return root?.pagination?.next
      ?? root?.pagination?.next_page
      ?? root?.meta?.next
      ?? root?.meta?.next_page
      ?? payload?.pagination?.next
      ?? null;
  }

  function firstTitleLine(value) {
    const lines = String(value || '')
      .replace(/\r/g, '\n')
      .split('\n')
      .map(line => line.trim())
      .filter(Boolean)
      .filter(line => !/^https?:\/\//i.test(line))
      .filter(line => !/^※/.test(line));
    const first = lines[0] || String(value || '').trim();
    return first.split(' | ')[0].trim();
  }

  function postTitle(post) {
    return choosePostTitle(post).title;
  }

  function cleanTitle(value) {
    return firstTitleLine(value);
  }

  function normalizeTitleKey(value) {
    return String(value || '')
      .normalize('NFKC')
      .replace(/^\s*【[^】]*限定[^】]*】\s*/u, '')
      .replace(/^\s*\[[^\]]*限定[^\]]*\]\s*/u, '')
      .replace(/\s+/g, '')
      .toLowerCase();
  }

  function isGenericPlanTitle(value) {
    return /プラン.*です[!!。]?$/u.test(String(value || ''));
  }

  function titleScore(value) {
    const title = cleanTitle(value);
    if (!title) return -1000;
    let score = Math.min(title.length, 90);
    if (/【[^】]+】/u.test(title)) score += 18;
    if (/限定/u.test(title)) score += 24;
    if (/未公開|先行/u.test(title)) score += 20;
    if (isGenericPlanTitle(title)) score -= 55;
    if (/^(MyFans|マイファンズ)$/i.test(title)) score -= 120;
    if (title.length < 6) score -= 40;
    return score;
  }

  function titleCandidatesFromPost(post) {
    const candidates = [];
    const add = (source, value) => {
      const title = cleanTitle(value);
      if (title) candidates.push({ source, title, score: titleScore(title) });
    };
    const explicit = [
      'displayTitle', 'display_title', 'postTitle', 'post_title', 'headline',
      'subject', 'name', 'title', 'body', 'content', 'description', 'text'
    ];
    for (const key of explicit) add(key, post?.[key]);

    const nested = [
      ['plan.title', post?.plan?.title],
      ['plan.name', post?.plan?.name],
      ['product.title', post?.product?.title],
      ['product.name', post?.product?.name],
      ['article.title', post?.article?.title],
      ['article.name', post?.article?.name]
    ];
    for (const [source, value] of nested) add(source, value);

    const seen = new Set();
    return candidates.filter(item => {
      const key = `${item.source}:${item.title}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  }

  function choosePostTitle(post, fallback) {
    const candidates = titleCandidatesFromPost(post);
    if (fallback) candidates.push({ source: 'fallback', title: cleanTitle(fallback), score: titleScore(fallback) });
    if (!candidates.length) return { title: cleanTitle(`post_${post?.id || ''}`), source: 'fallback', candidates: [] };
    const best = candidates.slice().sort((a, b) => b.score - a.score)[0];
    return { title: best.title, source: best.source, candidates };
  }

  function preferTitle(base, extra) {
    const current = cleanTitle(base?.title || base || '');
    const next = cleanTitle(extra?.title || extra || '');
    if (!next) return base;
    if (!current) return typeof extra === 'string' ? { title: next, source: 'extra' } : extra;
    const sameBase = normalizeTitleKey(current) === normalizeTitleKey(next);
    const nextHasLimitedPrefix = /^【[^】]*限定[^】]*】/u.test(next) || /^\[[^\]]*限定[^\]]*\]/u.test(next);
    const currentHasLimitedPrefix = /^【[^】]*限定[^】]*】/u.test(current) || /^\[[^\]]*限定[^\]]*\]/u.test(current);
    if (sameBase && nextHasLimitedPrefix && !currentHasLimitedPrefix) {
      return typeof extra === 'string' ? { title: next, source: 'extra' } : extra;
    }
    const nextScore = titleScore(next);
    const currentScore = titleScore(current);
    if (nextScore > currentScore + 12) return typeof extra === 'string' ? { title: next, source: 'extra' } : extra;
    return typeof base === 'string' ? { title: current, source: 'base' } : base;
  }

  function extractPostObject(payload) {
    const root = payload?.data ?? payload;
    return root?.post ?? root?.item ?? root?.result ?? root;
  }

  function cleanPageTitle(value) {
    return cleanTitle(String(value || '')
      .replace(/\s*[-|]\s*MyFans.*$/i, '')
      .replace(/\s*[-|]\s*マイファンズ.*$/i, ''));
  }

  async function fetchPostPageTitle(postId, signal) {
    throwIfAborted(signal);
    const resp = await fetch(`/posts/${encodeURIComponent(postId)}`, {
      credentials: 'include',
      headers: { 'Accept': 'text/html,application/xhtml+xml' },
      signal
    });
    throwIfAborted(signal);
    if (!resp.ok) throw new Error(`Page ${resp.status}`);
    const html = await resp.text();
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const metaTitle = doc.querySelector('meta[property="og:title"], meta[name="twitter:title"]')?.getAttribute('content');
    return cleanPageTitle(metaTitle || doc.querySelector('title')?.textContent || '');
  }

  function titleFromVisibleCard(card, apiTitle) {
    if (!card) return '';
    const lines = String(card.textContent || '')
      .replace(/\r/g, '\n')
      .split('\n')
      .map(line => cleanTitle(line))
      .filter(Boolean)
      .filter(line => !/^\d+:\d{2}/.test(line))
      .filter(line => !/^(いいね|コメント|シェア|保存|購入|動画|画像|もっと見る)$/u.test(line));
    const limited = lines.find(line => /^【[^】]*限定[^】]*】/u.test(line) || /^\[[^\]]*限定[^\]]*\]/u.test(line));
    if (limited) return limited;
    const apiKey = normalizeTitleKey(apiTitle);
    if (apiKey) {
      const same = lines.find(line => normalizeTitleKey(line).includes(apiKey) || apiKey.includes(normalizeTitleKey(line)));
      if (same) return same;
    }
    return lines.sort((a, b) => titleScore(b) - titleScore(a))[0] || '';
  }

  function visiblePostTitles() {
    const posts = collectVisiblePosts();
    const map = new Map();
    for (const [postId, post] of posts) {
      if (post.visibleTitle) map.set(postId, post.visibleTitle);
    }
    return map;
  }

  function durationFromText(text) {
    const match = String(text || '').match(/(?:^|\n)\s*(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s*(?:\n|$)/);
    if (!match) return 0;
    const hours = Number(match[1] || 0);
    const minutes = Number(match[2] || 0);
    const seconds = Number(match[3] || 0);
    return hours * 3600 + minutes * 60 + seconds;
  }

  function collectVisiblePosts() {
    const map = new Map();
    const links = Array.from(document.querySelectorAll('a[href*="/posts/"]'));
    for (const link of links) {
      const match = String(link.href || link.getAttribute('href') || '').match(/\/posts\/([0-9a-f-]{20,})/i);
      if (!match || map.has(match[1])) continue;
      let card = link;
      let hops = 0;
      while (card && card !== document.body) {
        const text = String(card.textContent || '');
        const postLinks = card.querySelectorAll?.('a[href*="/posts/"]').length || 0;
        if (text.length >= 8 && text.length <= 1800 && postLinks <= 3) break;
        card = card.parentElement;
        hops += 1;
        if (hops >= 8) break;
      }
      const text = String(card?.textContent || link.textContent || '').trim();
      const title = titleFromVisibleCard(card, link.textContent || '');
      if (title || text) {
        map.set(match[1], {
          postId: match[1],
          postUrl: `https://myfans.jp/posts/${match[1]}`,
          visibleTitle: title,
          visibleText: text,
          visibleDuration: durationFromText(text),
          hasBracketLimited: text.includes('【限定】'),
          hasAnyLimited: text.includes('限定'),
          hasUnpublishedFirst: text.includes('未公開先行版')
        });
      }
    }
    return map;
  }

  async function autoLoadVisiblePosts(onProgress, signal) {
    let stable = 0;
    let lastHeight = 0;
    for (let i = 0; i < 80; i++) {
      throwIfAborted(signal);
      window.scrollTo(0, document.body.scrollHeight);
      await new Promise(resolve => setTimeout(resolve, 800));
      const height = document.body.scrollHeight;
      const count = document.querySelectorAll('a[href*="/posts/"]').length;
      if (onProgress && i % 4 === 0) onProgress(`Loading visible posts (${count} links)...`);
      if (height === lastHeight) stable += 1;
      else stable = 0;
      lastHeight = height;
      if (stable >= 6) break;
    }
  }

  function extractDurationSec(obj) {
    const candidates = [
      obj?.duration,
      obj?.duration_sec,
      obj?.duration_seconds,
      obj?.duration_s,
      obj?.video_duration,
      obj?.attachment_length,
      obj?.metadata?.duration,
      obj?.metadata?.video?.duration,
      obj?.metadata?.video?.duration_sec
    ];
    for (const value of candidates) {
      const n = Number(value);
      if (Number.isFinite(n) && n > 0) return n;
    }
    const msCandidates = [
      obj?.duration_ms,
      obj?.metadata?.duration_ms,
      obj?.metadata?.video?.duration_ms
    ];
    for (const value of msCandidates) {
      const n = Number(value);
      if (Number.isFinite(n) && n > 0) return n / 1000;
    }
    return 0;
  }

  function normalizeVideos(payload) {
    const root = payload?.data ?? payload;
    const videos = [];
    const pushGroup = (source, arr) => {
      if (!Array.isArray(arr)) return;
      for (const item of arr) {
        if (item && item.url) videos.push({ ...item, source });
      }
    };
    pushGroup('main', root?.main);
    pushGroup('trial', root?.trial);
    pushGroup('main', root?.videos);
    pushGroup('main', root?.items);
    pushGroup('main', root?.data);
    if (Array.isArray(root)) pushGroup('main', root);
    return videos;
  }

  function resolutionValue(video) {
    const resolution = String(video?.resolution || '');
    const match = resolution.match(/(\d{3,4})/);
    if (match) return Number(match[1]);
    const height = Number(video?.height);
    if (Number.isFinite(height)) return height;
    return 0;
  }

  function chooseVideo(videos, quality) {
    const usable = videos.filter(v => v.url && v.source !== 'trial');
    const pool = usable.length ? usable : videos.filter(v => v.url);
    if (!pool.length) return null;

    if (quality && quality !== 'best') {
      const target = Number(quality.replace(/\D/g, ''));
      const exact = pool.find(v => resolutionValue(v) === target);
      if (exact) return exact;
      const below = pool.filter(v => resolutionValue(v) > 0 && resolutionValue(v) <= target)
        .sort((a, b) => resolutionValue(b) - resolutionValue(a))[0];
      if (below) return below;
    }

    return pool.slice().sort((a, b) => {
      const byResolution = resolutionValue(b) - resolutionValue(a);
      if (byResolution) return byResolution;
      return (Number(b.bitrate) || 0) - (Number(a.bitrate) || 0);
    })[0];
  }

  async function fetchPostTitleDetails(postId, skipDetailApi = false, signal) {
    throwIfAborted(signal);
    const details = {
      detailTitle: '',
      detailTitleSource: '',
      pageTitle: ''
    };

    if (!skipDetailApi) {
      try {
        const payload = await apiFetch(`/v2/posts/${postId}`, null, signal);
        const picked = choosePostTitle(extractPostObject(payload));
        details.detailTitle = picked.title;
        details.detailTitleSource = picked.source;
      } catch (err) {
        if (isScanStopped(err)) throw err;
        details.detailError = String(err.message || err).slice(0, 120);
      }
    }

    try {
      details.pageTitle = await fetchPostPageTitle(postId, signal);
    } catch (err) {
      if (isScanStopped(err)) throw err;
      details.pageError = String(err.message || err).slice(0, 120);
    }

    return details;
  }

  function isDemoLike(post, video, options) {
    const duration = extractDurationSec(video) || extractDurationSec(post);
    if (duration > 0 && duration < options.minDuration) return true;
    const keywords = String(options.demoKeywords || '')
      .split(',')
      .map(v => v.trim().toLowerCase())
      .filter(Boolean);
    if (!keywords.length) return false;
    const haystack = [
      postTitle(post),
      post?.body,
      post?.content,
      post?.description,
      post?.text
    ].filter(Boolean).join(' ').toLowerCase();
    return keywords.some(keyword => haystack.includes(keyword));
  }

  function buildFilename(item) {
    const fileId = sanitizeFilename(item.fileId || item.postId || 'item');
    const title = sanitizeFilename(item.title || '');
    const stem = title ? `${fileId}_${title}` : fileId;
    const base = stem.slice(0, 150).replace(/_+$/g, '');
    filenameCounts[base] = (filenameCounts[base] || 0) + 1;
    return `${base}${filenameCounts[base] > 1 ? '_' + filenameCounts[base] : ''}.mp4`;
  }

  function formatDuration(sec) {
    if (!sec) return '-';
    const total = Math.round(sec);
    const h = Math.floor(total / 3600);
    const m = Math.floor((total % 3600) / 60);
    const s = total % 60;
    if (h) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
    return `${m}:${String(s).padStart(2, '0')}`;
  }

  function formatBytes(bytes) {
    if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
    if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
    return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
  }

  async function scanPlan(options, onProgress) {
    const items = [];
    const skipped = [];
    const seen = new Set();
    throwIfAborted(options.signal);
    if (options.includeVisiblePosts) {
      await autoLoadVisiblePosts(onProgress, options.signal);
    }
    const visiblePosts = collectVisiblePosts();
    let page = 1;
    let detailApiUnavailable = false;
    resetFilenameCounts();

    while (page && page <= options.maxPages) {
      throwIfAborted(options.signal);
      onProgress(`Scanning page ${page}...`);
      const payload = await apiFetch(`/v2/plans/${options.planId}/posts`, {
        kind: 'video',
        sort_key: options.sortKey,
        page
      }, options.signal);
      const posts = postsFromPayload(payload);
      if (!posts.length) break;

      for (let idx = 0; idx < posts.length; idx++) {
        throwIfAborted(options.signal);
        const post = posts[idx];
        const postId = post?.id || post?.post_id || post?.uuid;
        if (!postId || seen.has(postId)) continue;
        seen.add(postId);
        if (post.kind && post.kind !== 'video') continue;

        const postDur = extractDurationSec(post);
        if (options.skipDemos && postDur > 0 && isDemoLike(post, { duration: postDur }, options)) {
          skipped.push({ postId, reason: 'demo/short' });
          continue;
        }

        onProgress(`Fetching video ${items.length + 1} (${idx + 1}/${posts.length})...`);
        let videoPayload;
        try {
          videoPayload = await apiFetch(`/v2/posts/${postId}/videos`, null, options.signal);
        } catch (err) {
          if (isScanStopped(err)) throw err;
          skipped.push({ postId, reason: err.message });
          continue;
        }

        const videoChoices = normalizeVideos(videoPayload);
        const chosen = chooseVideo(videoChoices, options.quality);
        if (!chosen) {
          skipped.push({ postId, reason: 'no main video url' });
          continue;
        }
        const duration = extractDurationSec(chosen) || postDur;
        const demo = isDemoLike(post, chosen, options);
        if (options.skipDemos && demo) {
          skipped.push({ postId, reason: 'demo/short' });
          continue;
        }

        const apiPicked = choosePostTitle(post);
        const apiTitle = apiPicked.title;
        let chosenTitle = { title: apiTitle, source: `api:${apiPicked.source}` };
        const visiblePost = visiblePosts.get(String(postId));
        const visibleTitle = visiblePost?.visibleTitle || '';
        if (visibleTitle) chosenTitle = preferTitle(chosenTitle, { title: visibleTitle, source: 'visible' });

        let titleDetails = { detailTitle: '', detailTitleSource: '', pageTitle: '' };
        if (options.fetchDetails) {
          onProgress(`Checking title ${items.length + 1} (${idx + 1}/${posts.length})...`);
          titleDetails = await fetchPostTitleDetails(postId, detailApiUnavailable, options.signal);
          if (/API 404|API 405|API 501/.test(titleDetails.detailError || '')) detailApiUnavailable = true;
        if (!visibleTitle && !detailApiUnavailable && titleDetails.detailTitle) {
          chosenTitle = preferTitle(chosenTitle, { title: titleDetails.detailTitle, source: `detail:${titleDetails.detailTitleSource || 'title'}` });
        }
          if (!visibleTitle && titleDetails.pageTitle) chosenTitle = preferTitle(chosenTitle, { title: titleDetails.pageTitle, source: 'page' });
        }

        const title = chosenTitle.title;
        const fileId = extractFileId(post, postId);
        items.push({
          postId,
          fileId,
          postUrl: `https://myfans.jp/posts/${postId}`,
          title,
          titleSource: chosenTitle.source,
          apiTitle,
          visibleTitle,
          visibleLimited: visiblePost?.hasBracketLimited || false,
          detailTitle: titleDetails.detailTitle,
          pageTitle: titleDetails.pageTitle,
          detailTitleSource: titleDetails.detailTitleSource,
          creator: options.creator,
          duration,
          resolution: chosen.resolution || (chosen.height ? `${chosen.height}p` : ''),
          width: chosen.width || '',
          height: chosen.height || '',
          url: chosen.url,
          source: chosen.source,
          demo,
          filename: buildFilename({ postId, fileId, title })
        });
      }

      const next = nextPageFromPayload(payload);
      if (!next || Number(next) === page) break;
      page = Number(next);
      if (!Number.isFinite(page)) break;
    }

    if (options.includeVisiblePosts) {
      const extraPosts = [...visiblePosts.values()].filter(post => !seen.has(post.postId));
      for (let idx = 0; idx < extraPosts.length; idx++) {
        throwIfAborted(options.signal);
        const visiblePost = extraPosts[idx];
        const postId = visiblePost.postId;
        if (!postId || seen.has(postId)) continue;
        seen.add(postId);

        const postLike = {
          id: postId,
          title: visiblePost.visibleTitle,
          body: visiblePost.visibleText,
          content: visiblePost.visibleText,
          duration: visiblePost.visibleDuration
        };
        if (options.skipDemos && isDemoLike(postLike, { duration: visiblePost.visibleDuration }, options)) {
          skipped.push({ postId, reason: 'visible demo/short' });
          continue;
        }

        onProgress(`Fetching visible post ${idx + 1}/${extraPosts.length}...`);
        let videoPayload;
        try {
          videoPayload = await apiFetch(`/v2/posts/${postId}/videos`, null, options.signal);
        } catch (err) {
          if (isScanStopped(err)) throw err;
          skipped.push({ postId, reason: `visible ${err.message}` });
          continue;
        }

        const videoChoices = normalizeVideos(videoPayload);
        const chosen = chooseVideo(videoChoices, options.quality);
        if (!chosen) {
          skipped.push({ postId, reason: 'visible no main video url' });
          continue;
        }
        const duration = extractDurationSec(chosen) || visiblePost.visibleDuration;
        const demo = isDemoLike(postLike, chosen, options);
        if (options.skipDemos && demo) {
          skipped.push({ postId, reason: 'visible demo/short' });
          continue;
        }

        let chosenTitle = { title: visiblePost.visibleTitle || `post_${postId}`, source: 'visible' };
        let titleDetails = { detailTitle: '', detailTitleSource: '', pageTitle: '' };
        if (options.fetchDetails) {
          titleDetails = await fetchPostTitleDetails(postId, detailApiUnavailable, options.signal);
          if (/API 404|API 405|API 501/.test(titleDetails.detailError || '')) detailApiUnavailable = true;
          if (!visiblePost.visibleTitle && !detailApiUnavailable && titleDetails.detailTitle) {
            chosenTitle = preferTitle(chosenTitle, { title: titleDetails.detailTitle, source: `detail:${titleDetails.detailTitleSource || 'title'}` });
          }
          if (!visiblePost.visibleTitle && titleDetails.pageTitle) chosenTitle = preferTitle(chosenTitle, { title: titleDetails.pageTitle, source: 'page' });
        }

        const title = cleanTitle(chosenTitle.title);
        const fileId = extractFileId(null, postId);
        items.push({
          postId,
          fileId,
          postUrl: visiblePost.postUrl,
          title,
          titleSource: chosenTitle.source,
          apiTitle: '',
          visibleTitle: visiblePost.visibleTitle,
          visibleLimited: visiblePost.hasBracketLimited || false,
          detailTitle: titleDetails.detailTitle,
          pageTitle: titleDetails.pageTitle,
          detailTitleSource: titleDetails.detailTitleSource,
          creator: options.creator,
          duration,
          resolution: chosen.resolution || (chosen.height ? `${chosen.height}p` : ''),
          width: chosen.width || '',
          height: chosen.height || '',
          url: chosen.url,
          source: `visible:${chosen.source || 'main'}`,
          demo,
          filename: buildFilename({ postId, fileId, title })
        });
      }
    }

    return { creator: options.creator, planId: options.planId, items, skipped };
  }

  function generateTSV(data, indices) {
    const selected = indices ? data.items.filter((_, i) => indices.has(i)) : data.items;
    const header = 'post_id\tduration_sec\tresolution\tfilename\tdemo\tvisible_limited\ttitle\ttitle_source\tapi_title\tvisible_title\tdetail_title\tpage_title\turl\tpost_url';
    const rows = selected.map(item => [
      item.postId,
      Math.round(item.duration || 0),
      item.resolution || '',
      item.filename,
      item.demo ? 'Y' : '',
      item.visibleLimited ? 'Y' : '',
      String(item.title || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.titleSource || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.apiTitle || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.visibleTitle || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.detailTitle || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      String(item.pageTitle || '').replace(/\t/g, ' ').replace(/\r?\n/g, ' '),
      item.url,
      item.postUrl
    ].join('\t'));
    return [header, ...rows].join('\n');
  }

  function triggerTextDownload(content, filename) {
    const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
  }

  function resolveUrl(base, value) {
    if (/^https?:\/\//i.test(value)) return value;
    return new URL(value, base).toString();
  }

  function streamResolution(line) {
    const match = line.match(/RESOLUTION=(\d+)x(\d+)/i);
    if (match) return Number(match[2]);
    return 0;
  }

  async function parseM3U8(m3u8Url, quality, signal) {
    const resp = await request(m3u8Url, { signal, withCredentials: false, timeout: 60000 });
    if (!resp.ok) throw new Error(`M3U8 ${resp.status}`);
    const text = String(resp.response || '');
    const lines = text.split('\n').map(line => line.trim()).filter(Boolean);
    const base = m3u8Url.slice(0, m3u8Url.lastIndexOf('/') + 1);

    if (lines.some(line => /^#EXT-X-KEY/i.test(line) && !/METHOD=NONE/i.test(line))) {
      throw new Error('Encrypted HLS is not supported');
    }

    const variants = [];
    for (let i = 0; i < lines.length; i++) {
      if (!lines[i].startsWith('#EXT-X-STREAM-INF')) continue;
      const bandwidth = Number((lines[i].match(/BANDWIDTH=(\d+)/i) || [])[1] || 0);
      const height = streamResolution(lines[i]);
      if (i + 1 < lines.length) {
        variants.push({ url: resolveUrl(base, lines[i + 1]), bandwidth, height });
      }
    }
    if (variants.length) {
      let chosen = null;
      if (quality && quality !== 'best') {
        const target = Number(quality.replace(/\D/g, ''));
        chosen = variants.find(v => v.height === target)
          || variants.filter(v => v.height > 0 && v.height <= target).sort((a, b) => b.height - a.height)[0];
      }
      chosen = chosen || variants.sort((a, b) => (b.height - a.height) || (b.bandwidth - a.bandwidth))[0];
      return parseM3U8(chosen.url, quality, signal);
    }

    return lines.filter(line => !line.startsWith('#')).map(line => resolveUrl(base, line));
  }

  function transmuxTsToMp4(tsData) {
    return new Promise((resolve, reject) => {
      try {
        if (typeof muxjs === 'undefined') throw new Error('mux.js is unavailable');
        const transmuxer = new muxjs.mp4.Transmuxer({ remux: true });
        const out = [];
        transmuxer.on('data', segment => {
          const buf = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength);
          buf.set(segment.initSegment, 0);
          buf.set(segment.data, segment.initSegment.byteLength);
          out.push(buf);
        });
        transmuxer.on('done', () => {
          const total = out.reduce((sum, chunk) => sum + chunk.byteLength, 0);
          const merged = new Uint8Array(total);
          let offset = 0;
          for (const chunk of out) {
            merged.set(chunk, offset);
            offset += chunk.byteLength;
          }
          resolve(merged);
        });
        transmuxer.push(tsData);
        transmuxer.flush();
      } catch (err) {
        reject(err);
      }
    });
  }

  async function downloadHLS(item, options, onStatus, signal) {
    onStatus('busy', 'playlist');
    const segments = await parseM3U8(item.url, options.quality, signal);
    if (!segments.length) throw new Error('empty playlist');

    const chunks = [];
    let totalBytes = 0;
    for (let i = 0; i < segments.length; i++) {
      if (signal?.aborted) throw new Error('cancelled');
      onStatus('busy', `${i + 1}/${segments.length} ${formatBytes(totalBytes)}`);
      const resp = await request(segments[i], {
        signal,
        responseType: 'arraybuffer',
        withCredentials: false
      });
      if (!resp.ok) throw new Error(`segment ${i + 1}: ${resp.status}`);
      const buf = resp.response;
      chunks.push(buf);
      totalBytes += buf.byteLength;
    }

    onStatus('busy', `remux ${formatBytes(totalBytes)}`);
    const tsBuffer = new Uint8Array(totalBytes);
    let offset = 0;
    for (const chunk of chunks) {
      tsBuffer.set(new Uint8Array(chunk), offset);
      offset += chunk.byteLength;
    }

    let blob;
    try {
      const mp4Data = await transmuxTsToMp4(tsBuffer);
      blob = new Blob([mp4Data], { type: 'video/mp4' });
    } catch (err) {
      console.warn('[MyFans DL] Remux failed, saving TS data instead:', err);
      blob = new Blob(chunks, { type: 'video/mp2t' });
      item.filename = item.filename.replace(/\.mp4$/i, '.ts');
    }

    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = item.filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(a.href), 10000);
    onStatus('ok', formatBytes(blob.size));
  }

  function setSelection(panel, videos, mode) {
    if (mode === 'all') selectedIndices = new Set(videos.map((_, i) => i));
    if (mode === 'none') selectedIndices.clear();
    if (mode === 'invert') {
      const next = new Set();
      videos.forEach((_, i) => { if (!selectedIndices.has(i)) next.add(i); });
      selectedIndices = next;
    }
    panel.querySelectorAll('.mfd-row-cb').forEach(cb => {
      cb.checked = selectedIndices.has(Number(cb.dataset.idx));
    });
    const master = panel.querySelector('#mfd-select-all');
    if (master) master.checked = videos.length > 0 && selectedIndices.size === videos.length;
    updateSelectionCount(panel, videos);
  }

  function updateSelectionCount(panel, videos) {
    const target = panel.querySelector('#mfd-sel-count');
    if (target) target.textContent = `${selectedIndices.size}/${videos.length} selected`;
  }

  function renderRows(panel) {
    const list = panel.querySelector('#mfd-list');
    const videos = results.items || [];
    if (!videos.length) {
      list.innerHTML = '<div class="mfd-muted" style="padding:10px;">No videos after filters.</div>';
      return;
    }
    selectedIndices = new Set(videos.map((_, i) => i));
    let html = '<table><thead><tr>';
    html += '<th style="width:28px;"><input type="checkbox" id="mfd-select-all" checked></th>';
    html += '<th style="width:34px;">#</th><th style="width:78px;">Post</th><th style="width:62px;">Dur</th><th style="width:62px;">Res</th><th>Title</th><th style="width:82px;">Status</th>';
    html += '</tr></thead><tbody>';
    videos.forEach((item, i) => {
      html += `<tr id="mfd-row-${i}">`;
      html += `<td><input type="checkbox" class="mfd-row-cb" data-idx="${i}" checked></td>`;
      html += `<td>${i + 1}</td>`;
      html += `<td title="${escapeHtml(item.postId)}"><a href="${escapeHtml(item.postUrl)}" target="_blank" style="color:#7dd3fc;">${escapeHtml(shortId(item.postId))}</a></td>`;
      html += `<td>${escapeHtml(formatDuration(item.duration))}</td>`;
      html += `<td>${escapeHtml(item.resolution || '-')}</td>`;
      html += `<td title="${escapeHtml([item.titleSource, item.title].filter(Boolean).join(': '))}">${escapeHtml(item.title)}</td>`;
      html += `<td class="mfd-status" id="mfd-st-${i}">-</td>`;
      html += '</tr>';
    });
    html += '</tbody></table>';
    list.innerHTML = html;

    const master = panel.querySelector('#mfd-select-all');
    master?.addEventListener('change', event => setSelection(panel, videos, event.target.checked ? 'all' : 'none'));
    panel.querySelectorAll('.mfd-row-cb').forEach(cb => {
      cb.addEventListener('change', event => {
        const idx = Number(event.target.dataset.idx);
        if (event.target.checked) selectedIndices.add(idx);
        else selectedIndices.delete(idx);
        if (master) master.checked = selectedIndices.size === videos.length;
        updateSelectionCount(panel, videos);
      });
    });
    updateSelectionCount(panel, videos);
  }

  function updateExportData(panel) {
    const target = panel.querySelector('#mfd-export-json');
    if (target) target.value = JSON.stringify(results);
  }

  function collectOptions(panel) {
    return {
      creator: sanitizeFilename(panel.querySelector('#mfd-creator').value.trim() || creatorFromUrl() || 'myfans'),
      planId: panel.querySelector('#mfd-plan').value.trim() || planIdFromUrl(),
      sortKey: panel.querySelector('#mfd-sort').value,
      quality: panel.querySelector('#mfd-quality').value,
      maxPages: Math.max(1, Number(panel.querySelector('#mfd-pages').value) || 20),
      minDuration: Math.max(0, Number(panel.querySelector('#mfd-min-dur').value) || 0),
      skipDemos: panel.querySelector('#mfd-skip-demo').checked,
      fetchDetails: panel.querySelector('#mfd-fetch-details').checked,
      includeVisiblePosts: panel.querySelector('#mfd-include-visible').checked,
      demoKeywords: panel.querySelector('#mfd-demo-keywords').value
    };
  }

  function updateRowStatus(panel, idx, cls, text) {
    const el = panel.querySelector(`#mfd-st-${idx}`);
    if (!el) return;
    el.className = `mfd-status ${cls || ''}`;
    el.textContent = text || '';
  }

  async function downloadSelected(panel) {
    if (downloading) return;
    const videos = results.items || [];
    const selected = [...selectedIndices].sort((a, b) => a - b);
    if (!selected.length) return;

    const options = collectOptions(panel);
    const status = panel.querySelector('#mfd-status');
    const startBtn = panel.querySelector('#mfd-download');
    const stopBtn = panel.querySelector('#mfd-stop');
    downloading = true;
    abortController = new AbortController();
    startBtn.disabled = true;
    stopBtn.style.display = '';

    let done = 0;
    for (const idx of selected) {
      if (abortController.signal.aborted) break;
      done += 1;
      status.textContent = `Downloading ${done}/${selected.length}...`;
      try {
        await downloadHLS(videos[idx], options, (cls, msg) => updateRowStatus(panel, idx, cls, msg), abortController.signal);
      } catch (err) {
        updateRowStatus(panel, idx, 'err', String(err.message || err).slice(0, 80));
      }
      if (done < selected.length && !abortController.signal.aborted) {
        await new Promise(resolve => setTimeout(resolve, 400));
      }
    }

    downloading = false;
    startBtn.disabled = false;
    stopBtn.style.display = 'none';
    status.textContent = abortController.signal.aborted ? 'Download stopped.' : 'Download complete.';
  }

  function buildPanel() {
    if (document.getElementById('mfd-fab')) return;
    injectStyles();

    const fab = document.createElement('button');
    fab.id = 'mfd-fab';
    fab.title = 'MyFans Downloader';
    fab.textContent = 'MF';
    document.body.appendChild(fab);

    const panel = document.createElement('div');
    panel.id = 'mfd-panel';
    panel.innerHTML = `
      <h3>MyFans Downloader</h3>

      <div class="mfd-row">
        <div>
          <label>Creator</label>
          <input type="text" id="mfd-creator" value="${escapeHtml(creatorFromUrl())}" placeholder="creator username" />
        </div>
        <div>
          <label>Plan ID</label>
          <input type="text" id="mfd-plan" value="${escapeHtml(planIdFromUrl())}" placeholder="plan UUID" />
        </div>
      </div>

      <div class="mfd-row">
        <div>
          <label>Quality</label>
          <select id="mfd-quality">
            <option value="best">Best available</option>
            <option value="2160p">2160p or lower</option>
            <option value="1080p">1080p or lower</option>
            <option value="720p">720p or lower</option>
            <option value="480p">480p or lower</option>
          </select>
        </div>
        <div>
          <label>Sort</label>
          <select id="mfd-sort">
            <option value="publish_start_at">Newest</option>
            <option value="publish_start_at_asc">Oldest</option>
            <option value="views">Views</option>
            <option value="likes">Likes</option>
            <option value="last_viewed_at">Last viewed</option>
          </select>
        </div>
      </div>

      <div class="mfd-row">
        <div>
          <label>Min duration (sec)</label>
          <input type="number" id="mfd-min-dur" value="60" min="0" step="10" />
        </div>
        <div>
          <label>Max pages</label>
          <input type="number" id="mfd-pages" value="20" min="1" step="1" />
        </div>
      </div>

      <label class="mfd-check"><input type="checkbox" id="mfd-skip-demo" checked> Skip demo/short videos</label>
      <label class="mfd-check"><input type="checkbox" id="mfd-fetch-details" checked> Fetch detail/page titles</label>
      <label class="mfd-check"><input type="checkbox" id="mfd-include-visible"> Include visible page posts (may scan many)</label>

      <label>Demo keywords</label>
      <input type="text" id="mfd-demo-keywords" value="${escapeHtml(DEFAULT_KEYWORDS)}" />

      <button class="mfd-btn mfd-btn-primary" id="mfd-scan">Scan plan</button>
      <button class="mfd-btn mfd-btn-outline" id="mfd-scan-post" style="margin-top:8px;">Scan current post</button>
      <button class="mfd-btn mfd-btn-outline" id="mfd-stop" style="display:none;margin-top:8px;">Stop</button>

      <div id="mfd-progress-wrap"><div id="mfd-progress-bar"></div></div>
      <div id="mfd-status"></div>
      <div id="mfd-stats"></div>

      <div id="mfd-results">
        <textarea id="mfd-export-json" style="display:none;"></textarea>
        <div class="mfd-select-bar">
          <button class="mfd-btn mfd-btn-outline mfd-btn-sm" id="mfd-sel-all">All</button>
          <button class="mfd-btn mfd-btn-outline mfd-btn-sm" id="mfd-sel-none">None</button>
          <button class="mfd-btn mfd-btn-outline mfd-btn-sm" id="mfd-sel-invert">Invert</button>
          <span id="mfd-sel-count"></span>
        </div>
        <div id="mfd-list"></div>
        <div class="mfd-actions">
          <button class="mfd-btn mfd-btn-green" id="mfd-download">Download selected</button>
        </div>
        <button class="mfd-btn mfd-btn-outline" id="mfd-export" style="margin-top:8px;">Export selected TSV</button>
        <button class="mfd-btn mfd-btn-outline" id="mfd-export-json-file" style="margin-top:8px;">Export JSON manifest</button>
      </div>
    `;
    document.body.appendChild(panel);

    const status = panel.querySelector('#mfd-status');
    const stats = panel.querySelector('#mfd-stats');
    const progressWrap = panel.querySelector('#mfd-progress-wrap');
    const progressBar = panel.querySelector('#mfd-progress-bar');
    const resultsWrap = panel.querySelector('#mfd-results');
    const scanBtn = panel.querySelector('#mfd-scan');
    const stopBtn = panel.querySelector('#mfd-stop');

    fab.addEventListener('click', () => panel.classList.toggle('open'));

    scanBtn.addEventListener('click', async () => {
      if (scanning) return;
      const options = collectOptions(panel);
      if (!options.planId) {
        status.textContent = 'Plan ID is required.';
        return;
      }

      abortController = new AbortController();
      options.signal = abortController.signal;
      scanning = true;
      scanBtn.disabled = true;
      scanBtn.textContent = 'Scanning...';
      stopBtn.style.display = '';
      progressWrap.style.display = 'block';
      progressBar.style.width = '4%';
      resultsWrap.classList.remove('show');
      status.textContent = 'Starting scan...';
      stats.textContent = '';
      results = { items: [] };
      selectedIndices.clear();

      try {
        results = await scanPlan(options, msg => {
          status.textContent = msg;
          const current = Number(progressBar.style.width.replace('%', '')) || 4;
          progressBar.style.width = `${Math.min(95, current + 3)}%`;
        });
        progressBar.style.width = '100%';
        const totalDur = results.items.reduce((sum, item) => sum + (item.duration || 0), 0);
        status.textContent = 'Scan complete.';
        stats.innerHTML = `<b>${escapeHtml(results.creator)}</b> - ${results.items.length} videos (${formatDuration(totalDur)}), skipped ${results.skipped.length}`;
        updateExportData(panel);
        renderRows(panel);
        resultsWrap.classList.add('show');
      } catch (err) {
        if (isScanStopped(err)) {
          status.textContent = 'Scan stopped.';
        } else {
          status.textContent = `Error: ${err.message || err}`;
          console.error('[MyFans DL]', err);
        }
      } finally {
        scanning = false;
        scanBtn.disabled = false;
        scanBtn.textContent = 'Scan plan';
        stopBtn.style.display = 'none';
        abortController = null;
      }
    });

    panel.querySelector('#mfd-scan-post').addEventListener('click', async () => {
      const postId = postIdFromUrl();
      if (!postId) {
        status.textContent = 'Open a /posts/... page first.';
        return;
      }
      const options = collectOptions(panel);
      status.textContent = 'Fetching current post video...';
      results = { creator: options.creator, planId: options.planId, items: [], skipped: [] };
      resetFilenameCounts();
      try {
        const videoPayload = await apiFetch(`/v2/posts/${postId}/videos`);
        const chosen = chooseVideo(normalizeVideos(videoPayload), options.quality);
        if (!chosen) throw new Error('No video URL found.');
        results.items.push({
          postId,
          fileId: extractFileId(null, postId),
          postUrl: `https://myfans.jp/posts/${postId}`,
          title: cleanTitle(document.title || `post_${postId}`),
          creator: options.creator,
          duration: extractDurationSec(chosen),
          resolution: chosen.resolution || (chosen.height ? `${chosen.height}p` : ''),
          width: chosen.width || '',
          height: chosen.height || '',
          url: chosen.url,
          source: chosen.source,
          demo: false,
          filename: buildFilename({ postId, fileId: extractFileId(null, postId), title: cleanTitle(document.title || '') })
        });
        status.textContent = 'Current post ready.';
        stats.innerHTML = `<b>${escapeHtml(options.creator)}</b> - 1 video`;
        updateExportData(panel);
        renderRows(panel);
        resultsWrap.classList.add('show');
      } catch (err) {
        status.textContent = `Error: ${err.message || err}`;
      }
    });

    panel.querySelector('#mfd-sel-all').addEventListener('click', () => setSelection(panel, results.items || [], 'all'));
    panel.querySelector('#mfd-sel-none').addEventListener('click', () => setSelection(panel, results.items || [], 'none'));
    panel.querySelector('#mfd-sel-invert').addEventListener('click', () => setSelection(panel, results.items || [], 'invert'));
    panel.querySelector('#mfd-download').addEventListener('click', () => downloadSelected(panel));
    panel.querySelector('#mfd-stop').addEventListener('click', () => abortController?.abort());
    panel.querySelector('#mfd-export').addEventListener('click', () => {
      const creator = collectOptions(panel).creator;
      const tsv = generateTSV(results, selectedIndices);
      triggerTextDownload(tsv, `${creator}_myfans_manifest.tsv`);
    });
    panel.querySelector('#mfd-export-json-file').addEventListener('click', () => {
      const creator = collectOptions(panel).creator;
      triggerTextDownload(JSON.stringify(results, null, 2), `${creator}_myfans_manifest.json`);
    });

    const refreshFields = () => {
      const creator = creatorFromUrl();
      const plan = planIdFromUrl();
      if (creator) panel.querySelector('#mfd-creator').value = sanitizeFilename(creator);
      if (plan) panel.querySelector('#mfd-plan').value = plan;
    };
    let lastUrl = location.href;
    setInterval(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        refreshFields();
      }
    }, 1000);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', buildPanel);
  } else {
    buildPanel();
  }
})();