TikTok Link Collector

Collects original TikTok urls and also converts to TikWM HD links. Edge-hover UI with auto-scroll and idle-stop.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         TikTok Link Collector
// @namespace    https://www.tiktok.com/
// @version      0.4.0
// @description  Collects original TikTok urls and also converts to TikWM HD links. Edge-hover UI with auto-scroll and idle-stop.
// @author       Gemini 3 Pro (previously ChatGPT 5.2 Thinking)
// @icon         https://www.tiktok.com/favicon.ico
// @match        https://www.tiktok.com/@*
// @match        https://tiktok.com/@*
// @match        https://www.tiktok.com/music/*
// @match        https://www.tiktok.com/tag/*
// @match        https://www.tiktok.com/search/*
// @exclude      https://www.tiktok.com/
// @exclude      https://www.tiktok.com/foryou*
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ---------- Config ----------
  const DEFAULT_IDLE_SECONDS = 33;
  const SCROLL_INTERVAL_MS = 2000; // How often to scroll

  // ---------- State ----------
  const seenIds = new Set();
  const originalLinks = new Set();
  const tikwmLinks = new Set();

  let scrollTimer = null;
  let countdownTimer = null;
  let hideTimer = null;

  let idleCounter = 0;
  let lastHeight = 0;

  let pinnedUntil = 0;
  let isShown = false;

  // ---------- Utils ----------
  function nowMs() { return Date.now(); }

  function pinBriefly(ms) {
    pinnedUntil = nowMs() + (ms || 2000);
    showPanel(true);
  }

  function shouldBlockHide() {
    // Prevent hiding if pinned OR if currently scrolling/counting down
    return nowMs() <= pinnedUntil || scrollTimer !== null || countdownTimer !== null;
  }

  function extractId(href) {
    const m = href && href.match(/\/video\/(\d{6,})/);
    return m ? m[1] : null;
  }

  function normalize(href) {
    try {
      const u = new URL(href, location.origin);
      return location.origin + u.pathname.replace(/\/$/, '');
    } catch {
      return href;
    }
  }

  function tikwm(id) {
    return 'https://www.tikwm.com/video/media/hdplay/' + id + '.mp4';
  }

  function getUsername() {
    const m = location.pathname.match(/^\/@([^/]+)/);
    return m ? m[1] : 'tiktok_user';
  }

  function getFormattedDate() {
    const d = new Date();
    const year = d.getFullYear();
    const month = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    return `${year}-${month}-${day}`;
  }

  // ---------- Logic ----------

  // Returns true if new links were found
  function scanLinks() {
    let newFound = false;
    document.querySelectorAll('a[href*="/video/"]').forEach(a => {
      const id = extractId(a.href);
      if (!id || seenIds.has(id)) return;

      seenIds.add(id);
      originalLinks.add(normalize(a.href));
      tikwmLinks.add(tikwm(id));
      newFound = true;
    });

    if (newFound) {
        updateUI();
    }
    return newFound;
  }

  function autoSave() {
    // Auto-save (defaults to original TikTok links)
    if (originalLinks.size === 0) return;

    const filename = `${getUsername()}_tiktok-urls_${getFormattedDate()}.txt`;
    download([...originalLinks].join('\n'), filename);
  }

  function stopScroll(reason) {
    if (scrollTimer) {
      clearInterval(scrollTimer);
      scrollTimer = null;
    }
    if (countdownTimer) {
        clearInterval(countdownTimer);
        countdownTimer = null;
    }

    const statusEl = document.getElementById('tt_status');
    if (statusEl) statusEl.textContent = reason || "Stopped";

    updateUI();
  }

  function scrollTick() {
    const currentHeight = document.body.scrollHeight;
    window.scrollBy(0, window.innerHeight); // Scroll by viewport height

    const newLinksFound = scanLinks();

    // Read user-defined idle limit (seconds)
    const idleInput = document.getElementById('tt_idle');
    const idleSeconds = parseInt(idleInput.value, 10) || DEFAULT_IDLE_SECONDS;
    // Calculate threshold ticks based on interval
    const idleThresholdTicks = Math.max(1, Math.floor(idleSeconds / (SCROLL_INTERVAL_MS / 1000)));

    // Reset counter if: new links found OR page height increased
    if (newLinksFound || currentHeight > lastHeight) {
        idleCounter = 0;
        lastHeight = currentHeight;
    } else {
        idleCounter++;
    }

    if (idleCounter >= idleThresholdTicks) {
        stopScroll("Auto-stopped (idle)");
        autoSave();
    }
  }

  function startScroll() {
    if (scrollTimer || countdownTimer) return;

    let countdown = 3;
    const statusEl = document.getElementById('tt_status');
    statusEl.textContent = "Starting in " + countdown + "...";
    pinBriefly(3500);

    countdownTimer = setInterval(() => {
        countdown--;
        if (countdown > 0) {
            statusEl.textContent = "Starting in " + countdown + "...";
        } else {
            clearInterval(countdownTimer);
            countdownTimer = null;

            // Start actual scrolling
            lastHeight = document.body.scrollHeight;
            idleCounter = 0;
            statusEl.textContent = "Scrolling...";
            scanLinks(); // Scan once before start
            scrollTimer = setInterval(scrollTick, SCROLL_INTERVAL_MS);
        }
    }, 1000);
  }

  // ---------- UI Construction ----------
  const PANEL_W = 340;

  GM_addStyle(
    '#tt_tab{' +
    'position:fixed;left:0;top:50%;width:18px;height:44px;margin-top:-22px;' +
    'background:#bdc5c8;border:1px solid #abb0b3;border-left:none;' +
    'border-radius:0 6px 6px 0;cursor:pointer;z-index:2147483646;' +
    'display:flex;align-items:center;justify-content:center;opacity:0.7;' +
    '}' +
    '#tt_tab:hover{opacity:1;}' +
    '#tt_panel{' +
    'position:fixed;left:0;top:50%;' +
    'transform:translate(-100%, -50%);' +
    'width:' + PANEL_W + 'px;' +
    'background:rgba(0,0,0,0.78);color:#fff;' +
    'padding:10px 10px 10px 28px;' +
    'border-radius:0 8px 8px 0;' +
    'box-shadow:0 2px 10px rgba(0,0,0,0.4);' +
    'transition:transform 140ms linear,opacity 140ms linear;' +
    'opacity:0.92;z-index:2147483645;' +
    'font-family:system-ui,Segoe UI,Arial;font-size:12px;' +
    '}' +
    '#tt_panel.show{transform:translate(0, -50%);opacity:1;}' +
    '#tt_header{margin-bottom:8px;}' +
    '#tt_title{font-weight:700;font-size:13px;margin-bottom:2px;}' +
    '#tt_info_row{display:flex;justify-content:space-between;opacity:0.9;font-size:11px;}' +
    '.tt_row{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;align-items:center;}' +
    '.tt_btn{' +
    'border:1px solid rgba(255,255,255,0.25);background:rgba(255,255,255,0.08);' +
    'color:#fff;padding:4px 10px;border-radius:999px;cursor:pointer;font-size:12px;line-height:1.2;' +
    '}' +
    '.tt_btn:hover{background:rgba(255,255,255,0.18);}' +
    '#tt_start{border-color:rgba(100,220,120,0.6);}' +
    '#tt_stop{border-color:rgba(220,100,100,0.6);}' +
    '#tt_clear{border-color:rgba(220,200,100,0.6);}' +
    '#tt_output{width:100%;height:140px;margin-top:8px;background:rgba(255,255,255,0.1);border:none;color:#fff;font-size:11px;resize:vertical;}' +
    '#tt_idle_wrap{display:flex;align-items:center;gap:6px;margin-left:auto;}' +
    '#tt_idle_wrap span{font-size:11px;opacity:0.9;}' +
    // CHANGE: width set to 50px to fit double digits comfortably
    '#tt_idle{width:50px;font-size:12px;padding:2px 4px;border-radius:4px;border:1px solid rgba(255,255,255,0.25);background:rgba(0,0,0,0.35);color:#fff;text-align:center;}'
  );

  const tab = document.createElement('div');
  tab.id = 'tt_tab';
  tab.innerHTML = '<svg width="12" height="12"><path id="tt_arrow" d="M4 2 L8 6 L4 10 Z" fill="#2b2f33"/></svg>';
  document.body.appendChild(tab);

  const panel = document.createElement('div');
  panel.id = 'tt_panel';
  panel.innerHTML =
    '<div id="tt_header">' +
        '<div id="tt_title">TikTok Link Collector</div>' +
        '<div id="tt_info_row">' +
            '<span id="tt_count">0 links</span>' +
            '<span id="tt_status">Idle</span>' +
        '</div>' +
    '</div>' +
    '<div class="tt_row">' +
        '<button class="tt_btn" id="tt_start">Start</button>' +
        '<button class="tt_btn" id="tt_stop">Stop</button>' +
        '<button class="tt_btn" id="tt_copy">Copy</button>' +
        '<button class="tt_btn" id="tt_clear">Clear</button>' +
    '</div>' +
    '<div class="tt_row">' +
        '<button class="tt_btn" id="tt_dl_orig">DL Orig .txt</button>' +
        '<button class="tt_btn" id="tt_dl_tikwm">DL TikWM .txt</button>' +
        '<div id="tt_idle_wrap">' +
            '<span>Idle (s)</span>' +
            '<input id="tt_idle" type="number" min="2" value="' + DEFAULT_IDLE_SECONDS + '">' +
        '</div>' +
    '</div>' +
    '<textarea id="tt_output" readonly></textarea>';
  document.body.appendChild(panel);

  const arrow = panel.previousSibling.querySelector('#tt_arrow');

  function setArrow(open) {
    arrow.setAttribute('d', open ? 'M8 2 L4 6 L8 10 Z' : 'M4 2 L8 6 L4 10 Z');
  }

  function showPanel(force) {
    if (hideTimer) clearTimeout(hideTimer);
    if (isShown && !force) return;
    isShown = true;
    panel.classList.add('show');
    setArrow(true);
  }

  function scheduleHide() {
    if (shouldBlockHide()) return;
    hideTimer = setTimeout(() => {
      if (shouldBlockHide()) return;
      isShown = false;
      panel.classList.remove('show');
      setArrow(false);
    }, 140);
  }

  tab.addEventListener('mouseenter', () => showPanel(false));
  tab.addEventListener('mouseleave', scheduleHide);
  panel.addEventListener('mouseenter', () => showPanel(true));
  panel.addEventListener('mouseleave', scheduleHide);

  // ---------- Actions ----------
  function updateUI() {
    const countEl = document.getElementById('tt_count');
    const outputEl = document.getElementById('tt_output');

    if (countEl) countEl.textContent = originalLinks.size + ' links';
    if (outputEl) {
        outputEl.value = [...originalLinks].join('\n');
    }
  }

  function download(text, name) {
    const blob = new Blob([text], { type: 'text/plain' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = name;
    a.click();
    URL.revokeObjectURL(a.href);
  }

  document.getElementById('tt_start').onclick = e => {
    e.preventDefault();
    startScroll();
  };

  document.getElementById('tt_stop').onclick = e => {
    e.preventDefault();
    pinBriefly(3000);
    stopScroll("Stopped");
  };

  document.getElementById('tt_copy').onclick = async e => {
    e.preventDefault();
    pinBriefly(2000);
    GM_setClipboard(
      [...originalLinks].join('\n'),
      { type: 'text' }
    );
    const s = document.getElementById('tt_status');
    const old = s.textContent;
    s.textContent = "Copied!";
    setTimeout(() => s.textContent = old, 1000);
  };

  document.getElementById('tt_clear').onclick = e => {
      e.preventDefault();
      seenIds.clear();
      originalLinks.clear();
      tikwmLinks.clear();
      updateUI();
      const s = document.getElementById('tt_status');
      s.textContent = "Cleared";
  };

  document.getElementById('tt_dl_orig').onclick = e => {
    e.preventDefault();
    pinBriefly(2000);
    const filename = `${getUsername()}_tiktok-urls_${getFormattedDate()}.txt`;
    download([...originalLinks].join('\n'), filename);
  };

  document.getElementById('tt_dl_tikwm').onclick = e => {
    e.preventDefault();
    pinBriefly(2000);
    const filename = `${getUsername()}_tikwm-urls_${getFormattedDate()}.txt`;
    download([...tikwmLinks].join('\n'), filename);
  };

  // Init scan
  scanLinks();
})();