TradingView Optimizer

TradingView 优化:隐藏付费弹窗 Toast 提示,提供 Watchlist 批量添加、Pine Log 复制、Symbol 快捷键,以及 Pine Editor/Pine Log 快捷切换。

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         TradingView Optimizer
// @namespace    https://github.com/rainocean
// @author       rainocean
// @version      1.2.3
// @description  TradingView 优化:隐藏付费弹窗 Toast 提示,提供 Watchlist 批量添加、Pine Log 复制、Symbol 快捷键,以及 Pine Editor/Pine Log 快捷切换。
// @license      MIT
// @match        *://*.tradingview.com/*
// @match        *://tradingview.com/*
// @grant        GM_setClipboard
// @grant        GM.setClipboard
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  const TIMING = { inputDelay: 1200, clickDelay: 700, between: 1200, maxWait: 15000 };
  const TOAST_TEXT = '付费功能';
  const TOAST_MS = 2000;
  const CLEAN_INTERVAL = 2000;
  const OBSERVED_ATTRS = ['class', 'style'];
  const log = (...a) => console.log('[TV-OPT]', ...a);
  const sleep = ms => new Promise(r => setTimeout(r, ms));

  async function copyText(text) {
    if (typeof GM_setClipboard === 'function') {
      GM_setClipboard(text, 'text');
      return;
    }

    if (typeof GM !== 'undefined' && typeof GM.setClipboard === 'function') {
      await GM.setClipboard(text, 'text');
      return;
    }

    try {
      await navigator.clipboard.writeText(text);
      return;
    } catch (e) {
      log('Clipboard API 复制失败,尝试回退复制:', e);
    }

    const textarea = document.createElement('textarea');
    textarea.value = text;
    textarea.setAttribute('readonly', '');
    textarea.style.position = 'fixed';
    textarea.style.inset = '0 auto auto 0';
    textarea.style.inlineSize = '1px';
    textarea.style.blockSize = '1px';
    textarea.style.opacity = '0';
    document.body.appendChild(textarea);
    textarea.focus();
    textarea.select();

    try {
      if (!document.execCommand('copy')) throw new Error('execCommand copy returned false');
    } finally {
      textarea.remove();
    }
  }

  const CLICK_PAY_SELECTORS = [
    '.tv-floating-toolbar__widget--go-pro',
    '.tv-header__area--right .tv-header__link--pro',
    '[data-overflow-tooltip-text*="upgrade" i]',
    '[data-overflow-tooltip-text*="premium" i]',
    '[data-overflow-tooltip-text*="pro" i]',
    '[title*="upgrade" i]',
    '[title*="go pro" i]',
    '[title*="premium" i]',
    'a[href*="upgrade" i]',
    'a[href*="premium" i]'
  ];

  const DIALOG_SELECTORS = [
    '[data-dialog-name="gopro"]',
    '.tv-dialog--pro-plan',
    '.tv-dialog--upgrade',
    '[data-name="upgrade-dialog"]',
    '[data-name="pro-features-dialog"]',
    '[class*="gopro-"]'
  ];

  const BACKDROP_SELECTORS = [
    '.tv-dialog__modal-wrap',
    '.tv-dialog-manager__modal-container',
    '[class*="modal"]',
    '[class*="backdrop"]'
  ];

  const UPGRADE_TEXT_RE = /(upgrade|go\s*pro|premium|试用|升级|高级|尊享|会员)/i;
  let toastBox = null;
  let paywallInitialized = false;
  let clickFence = 0;
  const handledDialogs = new WeakSet();

  function appendStyle(css) {
    const style = document.createElement('style');
    style.textContent = css;
    (document.head || document.documentElement).appendChild(style);
    return style;
  }

  function injectPaywallStyles() {
    appendStyle(`
      #tv-opt-toast-box {
        position: fixed;
        inset-inline-end: 16px;
        inset-block-end: 16px;
        z-index: 2147483647;
        display: flex;
        flex-direction: column;
        gap: 8px;
        pointer-events: none;
      }
      .tv-opt-toast {
        background: rgba(0, 0, 0, 0.82);
        color: #fff;
        padding: 6px 10px;
        font-size: 11px;
        line-height: 1.4;
        border-radius: 6px;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
        max-inline-size: 44ch;
        opacity: 0;
        transition: opacity 120ms ease;
        pointer-events: none;
        user-select: none;
      }
      [data-dialog-name="gopro"],
      .tv-dialog--pro-plan,
      .tv-dialog--upgrade,
      [data-name="upgrade-dialog"],
      [data-name="pro-features-dialog"] {
        display: none !important;
      }
      body {
        overflow: auto !important;
      }
    `);
  }

  function ensureToastBox() {
    if (toastBox) return toastBox;
    toastBox = document.createElement('div');
    toastBox.id = 'tv-opt-toast-box';
    (document.documentElement || document.body).appendChild(toastBox);
    return toastBox;
  }

  function showToast(text = TOAST_TEXT) {
    const box = ensureToastBox();
    const t = document.createElement('div');
    t.className = 'tv-opt-toast';
    t.textContent = text;
    box.appendChild(t);
    requestAnimationFrame(() => { t.style.opacity = '1'; });
    setTimeout(() => {
      t.style.opacity = '0';
      setTimeout(() => t.remove(), 180);
    }, TOAST_MS);
  }

  function safeRemove(node) {
    try { node?.remove?.(); } catch (_) {}
  }

  function findDialogRoot(node) {
    if (!node || node.nodeType !== 1) return null;
    if (node.matches?.(DIALOG_SELECTORS.join(', '))) return node;
    for (const sel of DIALOG_SELECTORS) {
      const hit = node.closest?.(sel);
      if (hit) return hit;
    }
    return null;
  }

  function removeDialogPack(node) {
    const root = findDialogRoot(node);
    if (!root) return false;

    let container = null;
    for (const sel of BACKDROP_SELECTORS) {
      const hit = root.closest?.(sel);
      if (hit) { container = hit; break; }
    }
    if (container?.parentElement) {
      container.parentElement.querySelectorAll(BACKDROP_SELECTORS.join(', ')).forEach(safeRemove);
    }
    safeRemove(root);

    document.querySelectorAll('div[style*="position: fixed"]').forEach(el => {
      const z = Number(el.style.zIndex || 0);
      if (z >= 120 && UPGRADE_TEXT_RE.test(el.textContent || '')) safeRemove(el);
    });
    return true;
  }

  function isOwnUI(el) {
    return Boolean(el?.closest?.('#tv-opt-wlc, #tv-opt-toast-box, #tv-opt-pine-log-copy-btn'));
  }

  function isExplicitPayTarget(el) {
    if (isOwnUI(el)) return false;
    for (let i = 0, n = el; i < 6 && n; i++, n = n.parentElement) {
      if (isOwnUI(n)) return false;
      if (n.matches?.(CLICK_PAY_SELECTORS.join(', '))) return true;
      const label = (
        n.getAttribute?.('aria-label') ||
        n.getAttribute?.('title') ||
        n.getAttribute?.('data-overflow-tooltip-text') ||
        n.textContent || ''
      ).trim();
      if (label && UPGRADE_TEXT_RE.test(label)) return true;
      if (n.tagName === 'A' && /upgrade|premium/i.test(n.getAttribute('href') || '')) return true;
    }
    return false;
  }

  function removeExistingPaywall(silent = false) {
    let hit = false;
    for (const sel of DIALOG_SELECTORS) {
      document.querySelectorAll(sel).forEach(node => {
        if (handledDialogs.has(node)) return;
        handledDialogs.add(node);
        if (removeDialogPack(node)) hit = true;
      });
    }
    if (hit && !silent) showToast();
    return hit;
  }

  function handlePaywallMutations(mutations) {
    for (const m of mutations) {
      if (m.type === 'childList') {
        m.addedNodes.forEach(node => {
          if (node.nodeType !== 1) return;
          if (!node.matches?.(DIALOG_SELECTORS.join(', ')) && !node.querySelector?.(DIALOG_SELECTORS.join(', '))) return;
          if (handledDialogs.has(node)) return;
          handledDialogs.add(node);
          if (removeDialogPack(node)) showToast();
        });
      } else if (m.type === 'attributes') {
        const target = m.target;
        if (target.nodeType !== 1 || handledDialogs.has(target)) continue;
        if (!target.matches?.(DIALOG_SELECTORS.join(', ')) && !UPGRADE_TEXT_RE.test(target.textContent || '')) continue;
        handledDialogs.add(target);
        if (removeDialogPack(target)) showToast();
      }
    }
  }

  function isEditableTarget(el) {
    return Boolean(el?.closest?.('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
  }

  function findSymbolSearchButton() {
    return document.querySelector('#header-toolbar-symbol-search') ||
      document.querySelector('button[aria-label*="symbol search" i]') ||
      document.querySelector('button[data-tooltip*="symbol search" i]') ||
      document.querySelector('button[id*="symbol-search" i]');
  }

  function findCompareSymbolsButton() {
    return document.querySelector('#header-toolbar-compare') ||
      document.querySelector('button[aria-label="Compare symbols" i]') ||
      document.querySelector('button[data-tooltip="Compare symbols" i]') ||
      document.querySelector('button[id*="compare" i][aria-haspopup="dialog"]');
  }

  function findOpenPineLogPanel() {
    return [...document.querySelectorAll('[data-test-id-widget-type="pine_logs"], .widgetbar-widget-pine_logs')]
      .find(isVisible);
  }

  function findPineLogButton() {
    const direct = [
      '[data-qa-id="open-pine-logs"]',
      'button[aria-label*="Pine Log" i]',
      'button[aria-label*="Pine Logs" i]',
      'button[data-tooltip*="Pine Log" i]',
      'button[title*="Pine Log" i]',
      '[role="tab"][aria-label*="Pine Log" i]',
      '[role="tab"][title*="Pine Log" i]'
    ].map(sel => document.querySelector(sel)).find(isVisible);
    if (direct) return direct;

    return [...document.querySelectorAll('button, [role="button"], [role="tab"], [role="menuitem"]')]
      .find(el => isVisible(el) && /^pine logs?$/i.test(getText(el).replace(/Click to learn more/i, '').trim()));
  }

  function findPineEditorButton() {
    return [...document.querySelectorAll('[data-name="pine-dialog-button"], button[aria-label="Pine" i], button[data-tooltip="Pine" i]')]
      .find(isVisible);
  }

  function findPineScriptMoreOptionsButton() {
    return [...document.querySelectorAll('[data-qa-id="script-more-options"]')]
      .find(isVisible);
  }

  function getPineEditorRoots() {
    const moreButton = findPineScriptMoreOptionsButton();
    return [
      moreButton?.closest('[class*="widget" i]'),
      moreButton?.closest('[class*="dialog" i]'),
      moreButton?.closest('[class*="container" i]'),
      moreButton?.closest('[class*="pane" i]'),
      document
    ].filter(Boolean);
  }

  function findPineEditorCloseButton() {
    const moreButton = findPineScriptMoreOptionsButton();
    for (const root of getPineEditorRoots()) {
      const closeButton = [...root.querySelectorAll('button[aria-label="Close" i], button[title="Close" i]')]
        .filter(isVisible)
        .find(btn => btn !== moreButton);
      if (closeButton) return closeButton;
    }
    return null;
  }

  function findPineLogCloseButton() {
    const panel = findOpenPineLogPanel();
    if (!panel) return null;
    const root = panel.closest('.widgetbar-page') || panel;
    return [...root.querySelectorAll('button[aria-label="Close" i], button[title="Close" i], [data-name*="close" i]')]
      .find(isVisible);
  }

  function togglePineEditor() {
    const closeButton = findPineEditorCloseButton();
    if (closeButton) {
      safeClick(closeButton);
      return true;
    }

    const pineEditorButton = findPineEditorButton();
    if (!pineEditorButton) {
      showToast('未找到 Pine Editor 入口');
      return false;
    }
    safeClick(pineEditorButton);
    return true;
  }

  async function openPineLogPanel() {
    if (findOpenPineLogPanel()) return true;

    let pineLogButton = findPineLogButton();
    if (!pineLogButton) {
      const pineEditorButton = findPineEditorButton();
      if (pineEditorButton) {
        safeClick(pineEditorButton);
        await sleep(250);
      }

      const moreButton = await waitFor(findPineScriptMoreOptionsButton, 2000, 100);
      if (moreButton) {
        safeClick(moreButton);
        await sleep(150);
      }

      pineLogButton = await waitFor(findPineLogButton, 2000, 100);
    }

    if (!pineLogButton) {
      showToast('未找到 Pine Log 入口');
      return false;
    }
    safeClick(pineLogButton);
    await waitFor(findOpenPineLogPanel, 2000, 100);
    return true;
  }

  async function togglePineLogPanel() {
    const closeButton = findPineLogCloseButton();
    if (closeButton) {
      safeClick(closeButton);
      return true;
    }
    return openPineLogPanel();
  }

  function triggerNativeToolbarButton(findButton, fallbackMessage) {
    const button = findButton();
    if (!button) {
      showToast(fallbackMessage);
      return false;
    }
    safeClick(button);
    return true;
  }

  function initToolbarShortcuts() {
    if (!/\/chart\//.test(location.pathname)) return;
    window.addEventListener('keydown', e => {
      if (e.metaKey || e.shiftKey || e.repeat) return;
      const key = e.key.toLowerCase();
      const editable = isEditableTarget(e.target);
      const altOnly = e.altKey && !e.ctrlKey;
      const ctrlAlt = e.ctrlKey && e.altKey;
      if (altOnly && key === 's' && !editable) {
        e.preventDefault();
        e.stopPropagation();
        triggerNativeToolbarButton(findSymbolSearchButton, '未找到 Symbol Search 入口');
      } else if (altOnly && key === 'c' && !editable) {
        e.preventDefault();
        e.stopPropagation();
        triggerNativeToolbarButton(findCompareSymbolsButton, '未找到 Compare Symbols 入口');
      } else if (ctrlAlt && key === 'l') {
        e.preventDefault();
        e.stopPropagation();
        togglePineLogPanel().catch(err => {
          console.error('[TV-OPT Pine Log]', err);
          showToast('切换 Pine Log 失败');
        });
      } else if (ctrlAlt && key === 'e') {
        e.preventDefault();
        e.stopPropagation();
        togglePineEditor();
      }
    }, true);
    log('Toolbar shortcuts 已加载: Alt+S Symbol Search, Alt+C Compare, Ctrl+Alt+L Pine Log, Ctrl+Alt+E Pine Editor');
  }

  function initPaywallToast() {
    if (paywallInitialized) return;
    paywallInitialized = true;
    injectPaywallStyles();
    removeExistingPaywall();

    const root = document.body || document.documentElement;
    const observer = new MutationObserver(handlePaywallMutations);
    observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: OBSERVED_ATTRS });

    window.addEventListener('click', e => {
      if (isOwnUI(e.target)) return;
      clickFence++;
      if (isExplicitPayTarget(e.target)) showToast();
      const fenceAtClick = clickFence;
      [0, 150, 300, 600, 900, 1200].forEach(delay => {
        setTimeout(() => {
          if (fenceAtClick === clickFence) removeExistingPaywall(true);
        }, delay);
      });
    }, true);

    window.addEventListener('keydown', () => {
      setTimeout(removeExistingPaywall, 0);
      setTimeout(removeExistingPaywall, 180);
    }, true);

    setInterval(removeExistingPaywall, CLEAN_INTERVAL);
  }

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

  function extractFromDOM() {
    const result = { sections: [], flatSymbols: [] };
    const listWrap =
      document.querySelector('[class*="contentWrap-"] [class*="wrapper-"] > [class*="wrap-"]') ||
      document.querySelector('[class*="wrapper-"] > [class*="wrap-"]');
    if (!listWrap) return result;

    let cur = null;
    for (const child of listWrap.children) {
      const cls = child.className || '';
      if (cls.includes('locator-') || !cls.includes('listItem-')) continue;
      if (cls.includes('noBorder-')) {
        const title = child.querySelector('span[class*="title-"][class*="toggleable-"]');
        if (title) {
          const name = title.textContent.replace(/[⁤]/g, '').trim();
          if (name) { cur = { name, symbols: [] }; result.sections.push(cur); }
        }
        continue;
      }
      const link = child.querySelector('a[href*="/symbols/"]');
      if (!link) continue;
      const m = link.getAttribute('href').match(/\/symbols\/([A-Z]+)-([A-Z0-9]+)\/?/);
      if (!m) continue;
      const sym = `${m[1]}:${m[2]}`;
      result.flatSymbols.push(sym);
      if (cur) cur.symbols.push(sym);
    }
    return result;
  }

  function guessASharePrefix(code) {
    return /^[569]/.test(code) ? 'SSE:' : 'SZSE:';
  }

  function normalizeManualSymbol(raw) {
    let s = raw.trim().toUpperCase();
    if (!s) return '';
    s = s.replace(/\s+/g, '').replace(/,/g, ',').replace(/;/g, ';');

    if (/^(SSE|SH|XSHG):\d{6}$/.test(s)) return 'SSE:' + s.split(':')[1];
    if (/^(SZSE|SZ|XSHE):\d{6}$/.test(s)) return 'SZSE:' + s.split(':')[1];
    if (/^\d{6}$/.test(s)) return guessASharePrefix(s) + s;

    const m = s.match(/^(\d{6})\.(SH|SZ)$/);
    if (m) return (m[2] === 'SH' ? 'SSE:' : 'SZSE:') + m[1];

    return s;
  }

  function parseManualSymbols(text) {
    return text.split(/[\n,; ]+/).map(normalizeManualSymbol).filter(Boolean);
  }

  function isVisible(el) {
    if (!el) return false;
    const style = getComputedStyle(el);
    const rect = el.getBoundingClientRect();
    return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
  }

  function getText(el) {
    return (el?.innerText || el?.textContent || '').trim();
  }

  function safeClick(el) {
    if (!el) return;
    try { el.scrollIntoView({ block: 'center' }); } catch (_) {}
    try { el.click(); return; } catch (_) {}
    el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
    el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
    el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
  }

  function setNativeValue(input, value) {
    const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value');
    if (desc?.set) desc.set.call(input, value);
    else input.value = value;
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.dispatchEvent(new Event('change', { bubbles: true }));
  }

  async function waitFor(fn, timeout = TIMING.maxWait, interval = 250) {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      const result = fn();
      if (result) return result;
      await sleep(interval);
    }
    return null;
  }

  function findAddSymbolButton() {
    for (const sel of [
      'button[data-name="add-symbol-button"][aria-label="Add symbol"]',
      'button[data-name="add-symbol-button"][data-tooltip="Add symbol"]',
      'button[data-name="add-symbol-button"]'
    ]) {
      const el = [...document.querySelectorAll(sel)].find(isVisible);
      if (el) return el;
    }
    return null;
  }

  function getCandidateDialogRoots() {
    return [...new Set([
      ...document.querySelectorAll('[role="dialog"]'),
      ...document.querySelectorAll('[class*="dialog"]'),
      ...document.querySelectorAll('[class*="popup"]'),
      ...document.querySelectorAll('[class*="modal"]')
    ].filter(isVisible))];
  }

  function findSearchInput(root) {
    for (const sel of [
      'input[placeholder*="Search" i]',
      'input[aria-label*="Search" i]',
      'input[placeholder*="symbol" i]',
      'input[aria-label*="symbol" i]',
      'input[type="text"]',
      'input:not([type])'
    ]) {
      const el = [...root.querySelectorAll(sel)].find(isVisible);
      if (el) return el;
    }
    return null;
  }

  function findOpenedAddDialog() {
    for (const root of getCandidateDialogRoots()) {
      const text = getText(root).toLowerCase();
      if (text.includes('compare') || text.includes('comparison') || text.includes('对比') || text.includes('比较')) continue;
      const input = findSearchInput(root);
      if (!input) continue;
      const addIcons = [
        ...root.querySelectorAll('span[role="img"][class*="addAction-"]'),
        ...root.querySelectorAll('[class*="actionsCell-"] span[role="img"]')
      ].filter(isVisible);
      if (addIcons.length > 0) return { root, input };
    }
    return null;
  }

  async function openAddDialog() {
    const panelBtn = [...document.querySelectorAll(
      'button[aria-label="Watchlist, details and news"], button[data-tooltip="Watchlist, details and news"]'
    )].find(isVisible);
    if (panelBtn && panelBtn.getAttribute('aria-pressed') !== 'true') {
      safeClick(panelBtn);
      await sleep(TIMING.clickDelay);
    }

    const addBtn = await waitFor(findAddSymbolButton);
    if (!addBtn) throw new Error('找不到 Add symbol 按钮');
    safeClick(addBtn);
    await sleep(TIMING.clickDelay);

    const dialog = await waitFor(findOpenedAddDialog);
    if (!dialog) throw new Error('Add symbol 弹窗没出现');
    return dialog;
  }

  function findResultAddButton(root, symbol) {
    const upper = symbol.toUpperCase();
    const code = upper.includes(':') ? upper.split(':')[1] : upper;
    const addBtns = [
      ...root.querySelectorAll('span[role="img"][class*="addAction-"]'),
      ...root.querySelectorAll('[class*="actionsCell-"] span[role="img"]')
    ].filter(isVisible);

    for (const btn of addBtns) {
      const row = btn.closest('[role="row"]') || btn.closest('[class*="wrap-"]') ||
        btn.closest('[class*="itemRow-"]') || btn.parentElement?.parentElement?.parentElement;
      if (!row) continue;
      const rowText = getText(row).toUpperCase();
      if (rowText.includes(upper) || rowText.includes(code)) return btn;
    }
    return addBtns.length === 1 ? addBtns[0] : null;
  }

  async function closeDialog(root) {
    if (!root) return;
    const close = [...root.querySelectorAll('button, [role="button"]')].find(el => {
      if (!isVisible(el)) return false;
      const text = [el.getAttribute('aria-label'), el.getAttribute('title'), el.getAttribute('data-tooltip'), getText(el)]
        .join(' ')
        .toLowerCase();
      return text.includes('close') || text.includes('关闭');
    });
    if (close) { safeClick(close); await sleep(300); return; }
    document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape', code: 'Escape' }));
    await sleep(300);
  }

  async function clearInput(input) {
    input.focus();
    setNativeValue(input, '');
    await sleep(100);
    for (const [key, code, ctrl] of [['a', 'KeyA', true], ['Backspace', 'Backspace', false]]) {
      input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key, code, ctrlKey: !!ctrl }));
      input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key, code, ctrlKey: !!ctrl }));
    }
    setNativeValue(input, '');
    await sleep(100);
  }

  async function addOneSymbol(symbol) {
    log('添加:', symbol);
    const { root, input } = await openAddDialog();
    await clearInput(input);
    input.focus();
    setNativeValue(input, symbol);
    await sleep(TIMING.inputDelay);
    const addBtn = await waitFor(() => findResultAddButton(root, symbol), 5000, 250);
    if (!addBtn) { await closeDialog(root); throw new Error(`搜索结果没找到 ${symbol}`); }
    safeClick(addBtn);
    await sleep(TIMING.clickDelay);
    await closeDialog(root);
    log('添加完成:', symbol);
  }

  function injectWatchlistStyles() {
    appendStyle(`
      #tv-opt-wlc {
        position: fixed;
        inset-block-start: 35%;
        inset-inline-end: 8px;
        transform: translateY(-50%);
        z-index: 999999;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        font-size: 13px;
      }
      #tv-opt-wlc-fab {
        inline-size: 36px;
        block-size: 36px;
        padding: 0;
        border-radius: 6.75px;
        background: #131722 url('https://i.postimg.cc/XvW9Q04k/tvopt-icon.png') center / cover no-repeat;
        border: 1px solid #363a45;
        cursor: pointer;
        box-shadow: 0 4px 14px rgba(19, 23, 34, .45);
        transition: transform .15s;
      }
      #tv-opt-wlc-fab:hover { transform: scale(1.08); }
      #tv-opt-wlc-panel {
        display: none;
        position: absolute;
        inset-block-start: 0;
        inset-inline-end: 48px;
        inline-size: min(380px, calc(100vw - 72px));
        max-block-size: calc(100vh - 24px);
        background: #1e222d;
        border: 1px solid #363a45;
        border-radius: 10px;
        box-shadow: 0 8px 30px rgba(0, 0, 0, .55);
        color: #d1d4dc;
        overflow-y: auto;
        scrollbar-width: thin;
        scrollbar-color: #434651 transparent;
      }
      #tv-opt-wlc-panel::-webkit-scrollbar { inline-size: 6px; }
      #tv-opt-wlc-panel::-webkit-scrollbar-track { background: transparent; }
      #tv-opt-wlc-panel::-webkit-scrollbar-thumb {
        background: #434651;
        border-radius: 999px;
      }
      #tv-opt-wlc-panel::-webkit-scrollbar-thumb:hover { background: #5a5f70; }
      #tv-opt-wlc-panel.open { display: block; }
      .tv-opt-wlc-header {
        padding: 12px 16px;
        background: #131722;
        border-block-end: 1px solid #363a45;
        font-weight: 600;
        font-size: 14px;
        color: #fff;
        display: flex;
        justify-content: space-between;
      }
      .tv-opt-wlc-shortcuts {
        padding: 8px 10px;
        border: 1px solid #363a45;
        border-radius: 6px;
        background: #131722;
        color: #b2b5be;
        font-size: 12px;
        line-height: 1.6;
      }
      .tv-opt-kbd {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-inline-size: 16px;
        margin-inline: 2px;
        padding: 1px 5px;
        border: 1px solid #434651;
        border-radius: 4px;
        background: #2a2e39;
        color: #fff;
        font-family: 'SF Mono', Monaco, Consolas, monospace;
        font-size: 11px;
      }
      .tv-opt-wlc-manual { padding: 12px 16px 0; }
      #tv-opt-wlc-input {
        inline-size: 100%;
        block-size: 132px;
        box-sizing: border-box;
        resize: vertical;
        background: #131722;
        color: #d1d4dc;
        border: 1px solid #363a45;
        border-radius: 6px;
        padding: 8px;
        font-size: 12px;
        line-height: 1.45;
        font-family: 'SF Mono', Monaco, Consolas, monospace;
        scrollbar-width: thin;
        scrollbar-color: #434651 transparent;
      }
      #tv-opt-wlc-input::-webkit-scrollbar { inline-size: 6px; }
      #tv-opt-wlc-input::-webkit-scrollbar-track { background: transparent; }
      #tv-opt-wlc-input::-webkit-scrollbar-thumb {
        background: #434651;
        border-radius: 999px;
      }
      #tv-opt-wlc-input::-webkit-scrollbar-thumb:hover { background: #5a5f70; }
      .tv-opt-wlc-body {
        padding: 12px 16px;
        max-block-size: 280px;
        overflow-y: auto;
      }
      .tv-opt-wlc-tag {
        display: inline-block;
        background: #363a45;
        color: #b2b5be;
        padding: 2px 8px;
        border-radius: 4px;
        font-size: 11px;
        font-weight: 600;
        margin: 6px 0 3px;
      }
      .tv-opt-wlc-symbols {
        font-family: 'SF Mono', Monaco, Consolas, monospace;
        font-size: 11px;
        line-height: 1.7;
        word-break: break-all;
        margin-block-end: 4px;
      }
      .tv-opt-wlc-item {
        display: inline-block;
        margin: 2px 4px 2px 0;
        padding: 2px 6px;
        border-radius: 3px;
        background: #2a2e39;
        transition: all .15s;
      }
      .tv-opt-wlc-done { background: #1b3a26; color: #26a69a; text-decoration: line-through; opacity: .6; }
      .tv-opt-wlc-active { background: #2962ff; color: #fff; font-weight: 600; }
      .tv-opt-wlc-error { background: #3a1b1b; color: #ef5350; }
      .tv-opt-wlc-footer {
        padding: 12px 16px;
        border-block-start: 1px solid #363a45;
        display: flex;
        flex-direction: column;
        gap: 8px;
      }
      .tv-opt-wlc-row { display: flex; gap: 6px; }
      .tv-opt-btn {
        flex: 1;
        padding: 9px;
        border: none;
        border-radius: 6px;
        font-size: 12px;
        font-weight: 600;
        cursor: pointer;
        transition: background .15s;
      }
      .tv-opt-btn-primary { background: #2962ff; color: #fff; }
      .tv-opt-btn-primary:hover { background: #1e88e5; }
      .tv-opt-btn-primary:disabled { background: #555; cursor: wait; }
      .tv-opt-btn-secondary { background: #363a45; color: #d1d4dc; }
      .tv-opt-btn-secondary:hover { background: #434651; }
      .tv-opt-btn-success { background: #26a69a; color: #fff; }
      .tv-opt-btn-success:hover { background: #2bb5a8; }
      .tv-opt-status { font-size: 12px; min-block-size: 18px; line-height: 1.5; }
      .tv-opt-status-ok { color: #26a69a; }
      .tv-opt-status-er { color: #ef5350; }
      .tv-opt-status-i { color: #b2b5be; }
    `);
  }

  let extracted = null;
  let manualText = '';
  let addIdx = 0;
  let addRunning = false;
  let addCancelled = false;
  let addErrors = new Set();

  function buildWatchlistUI() {
    document.getElementById('tv-opt-wlc')?.remove();
    const root = document.createElement('div');
    root.id = 'tv-opt-wlc';
    root.innerHTML = '<div id="tv-opt-wlc-panel"></div><button id="tv-opt-wlc-fab" type="button" aria-label="TradingView Optimizer"></button>';
    document.body.appendChild(root);

    root.querySelector('#tv-opt-wlc-fab').onclick = () => {
      const panel = root.querySelector('#tv-opt-wlc-panel');
      if (panel.classList.toggle('open')) drawWatchlistPanel(panel);
      else panel.style.insetBlockStart = '0px';
    };
    log('Watchlist UI 已创建');
  }

  function renderSymbolItems(symbols) {
    return symbols.map((sym, i) => {
      const cls = addErrors.has(i) ? 'tv-opt-wlc-error' : i < addIdx ? 'tv-opt-wlc-done' : i === addIdx ? 'tv-opt-wlc-active' : '';
      return `<span class="tv-opt-wlc-item ${cls}">${escapeHTML(sym)}</span>`;
    }).join('');
  }

  function fitWatchlistPanel(panel) {
    panel.style.insetBlockStart = '0px';
    requestAnimationFrame(() => {
      const rect = panel.getBoundingClientRect();
      const margin = 12;
      const overflow = rect.bottom + margin - window.innerHeight;
      panel.style.insetBlockStart = overflow > 0 ? `${-overflow}px` : '0px';
    });
  }

  function drawWatchlistPanel(panel = document.querySelector('#tv-opt-wlc-panel')) {
    if (!panel) return;

    const hasData = extracted && extracted.flatSymbols.length > 0;
    const symbols = hasData ? extracted.flatSymbols : [];
    const total = symbols.length;
    const done = addIdx >= total && hasData;

    let listHTML = '';
    if (!hasData) {
      listHTML = '<div class="tv-opt-status-i" style="text-align:center;padding:20px 0">点击“提取页面”读取当前 watchlist,<br>或粘贴 A 股代码后点“匹配前缀”。</div>';
    } else if (extracted.sections.some(s => s.symbols.length)) {
      for (const section of extracted.sections) {
        if (!section.symbols.length) continue;
        listHTML += `<div class="tv-opt-wlc-tag">${escapeHTML(section.name)}</div><div class="tv-opt-wlc-symbols">`;
        listHTML += section.symbols.map(sym => {
          const globalIndex = symbols.indexOf(sym);
          const cls = addErrors.has(globalIndex) ? 'tv-opt-wlc-error' : globalIndex < addIdx ? 'tv-opt-wlc-done' : globalIndex === addIdx ? 'tv-opt-wlc-active' : '';
          return `<span class="tv-opt-wlc-item ${cls}">${escapeHTML(sym)}</span>`;
        }).join('');
        listHTML += '</div>';
      }
    } else {
      listHTML = `<div class="tv-opt-wlc-symbols">${renderSymbolItems(symbols)}</div>`;
    }

    panel.innerHTML = `
      <div class="tv-opt-wlc-header">
        <span>TradingView Optimizer</span>
        <span style="font-weight:400;color:#b2b5be;font-size:12px">${hasData ? addIdx + '/' + total : ''}</span>
      </div>
      <div class="tv-opt-wlc-manual">
        <textarea id="tv-opt-wlc-input" placeholder="手动贴 A 股代码,一行一个或逗号分隔,例如:&#10;000001&#10;300308&#10;300750">${escapeHTML(manualText)}</textarea>
      </div>
      <div class="tv-opt-wlc-body">${listHTML}</div>
      <div class="tv-opt-wlc-footer">
        <div class="tv-opt-wlc-row">
          <button class="tv-opt-btn tv-opt-btn-primary" id="tv-opt-extract" type="button">提取页面</button>
          <button class="tv-opt-btn tv-opt-btn-secondary" id="tv-opt-preview" type="button">匹配前缀</button>
          <button class="tv-opt-btn tv-opt-btn-success" id="tv-opt-add" type="button" ${!hasData ? 'disabled' : ''}>${addRunning ? '暂停' : done ? '完成' : '开始添加'}</button>
        </div>
        <div class="tv-opt-wlc-row">
          <button class="tv-opt-btn tv-opt-btn-secondary" id="tv-opt-skip" type="button" ${!hasData || done ? 'disabled' : ''}>跳过</button>
          <button class="tv-opt-btn tv-opt-btn-secondary" id="tv-opt-copy-one" type="button" ${!hasData || done ? 'disabled' : ''}>复制当前</button>
          <button class="tv-opt-btn tv-opt-btn-secondary" id="tv-opt-copy-all" type="button" ${!hasData ? 'disabled' : ''}>复制全部</button>
        </div>
        <div class="tv-opt-wlc-shortcuts">
          快捷键:<br>
            <span class="tv-opt-kbd">Alt</span> + <span class="tv-opt-kbd">S</span> 打开 Symbol Search;<br>
            <span class="tv-opt-kbd">Alt</span> + <span class="tv-opt-kbd">C</span> 打开 Compare Symbols;<br>
            <span class="tv-opt-kbd">Ctrl</span> + <span class="tv-opt-kbd">Alt</span> + <span class="tv-opt-kbd">E</span> 打开/关闭 Pine Editor;<br>
            <span class="tv-opt-kbd">Ctrl</span> + <span class="tv-opt-kbd">Alt</span> + <span class="tv-opt-kbd">L</span> 打开/关闭 Pine Log。
        </div>
        <div class="tv-opt-status tv-opt-status-i" id="tv-opt-status">v1.2.0</div>
      </div>`;

    fitWatchlistPanel(panel);

    const status = (msg, type = 'ok') => {
      const el = panel.querySelector('#tv-opt-status');
      if (el) { el.textContent = msg; el.className = 'tv-opt-status tv-opt-status-' + type; }
    };

    const manualInput = panel.querySelector('#tv-opt-wlc-input');
    manualInput.oninput = () => { manualText = manualInput.value; };

    panel.querySelector('#tv-opt-extract').onclick = () => {
      manualText = manualInput.value;
      extracted = extractFromDOM();
      addIdx = 0;
      addErrors = new Set();
      const count = extracted.flatSymbols.length;
      if (count) status(`提取到 ${count} 个 symbol,${extracted.sections.length} 个分组`, 'ok');
      else status('未找到 symbols,页面可能没加载完', 'er');
      drawWatchlistPanel(panel);
    };

    panel.querySelector('#tv-opt-preview').onclick = () => {
      manualText = manualInput.value;
      const parsed = parseManualSymbols(manualText);
      extracted = parsed.length ? { sections: [{ name: '手动输入', symbols: parsed }], flatSymbols: parsed } : null;
      addIdx = 0;
      addErrors = new Set();
      if (parsed.length) status(`已匹配前缀 ${parsed.length} 个,确认后点“开始添加”`, 'ok');
      else status('没有识别到代码', 'er');
      drawWatchlistPanel(panel);
    };

    panel.querySelector('#tv-opt-add').onclick = async () => {
      if (!hasData) return;
      if (addRunning) { addCancelled = true; return; }
      if (done) return;
      manualText = manualInput.value;

      addRunning = true;
      addCancelled = false;
      drawWatchlistPanel(panel);

      while (addIdx < total && !addCancelled) {
        const sym = symbols[addIdx];
        status(`(${addIdx + 1}/${total}) 添加 ${sym}...`, 'i');
        drawWatchlistPanel(panel);
        try {
          await addOneSymbol(sym);
          addIdx++;
          status(`${sym} 已添加`, 'ok');
        } catch (e) {
          addErrors.add(addIdx);
          addIdx++;
          status(`${sym}: ${e.message}`, 'er');
          log('添加失败:', sym, e);
        }
        await sleep(TIMING.between);
      }

      addRunning = false;
      if (addIdx >= total) {
        const failed = addErrors.size;
        status(failed ? `完成: ${total - failed} 成功, ${failed} 失败` : `全部 ${total} 个添加完成`, failed ? 'er' : 'ok');
      }
      drawWatchlistPanel(panel);
    };

    panel.querySelector('#tv-opt-skip').onclick = () => {
      if (addIdx < total) { addIdx++; drawWatchlistPanel(panel); }
    };
    panel.querySelector('#tv-opt-copy-one').onclick = () => {
      if (addIdx < total) { copyText(symbols[addIdx]); status(`已复制 ${symbols[addIdx]}`); }
    };
    panel.querySelector('#tv-opt-copy-all').onclick = () => {
      copyText(symbols.join(', '));
      status(`已复制全部 ${total} 个`);
    };
  }

  function initWatchlistOpt() {
    if (!/\/(watchlists|chart)\//.test(location.pathname)) return;
    injectWatchlistStyles();
    buildWatchlistUI();
  }

  const PINE_COPY_BTN_ID = 'tv-opt-pine-log-copy-btn';
  const PINE_COPY_ICON = '<svg viewBox="0 0 24 24"><path d="M16 1H4C2.9 1 2 1.9 2 3v14h2V3h12V1zm3 4H8C6.9 5 6 5.9 6 7v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>';
  const PINE_CHECK_ICON = '<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';

  function injectPineLogStyles() {
    appendStyle(`
      #${PINE_COPY_BTN_ID} {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        inline-size: 28px;
        block-size: 28px;
        border: none;
        border-radius: 4px;
        background: transparent;
        color: var(--tv-color-toolbar-button-text, #b2b5be);
        cursor: pointer;
        user-select: none;
        transition: color .12s, background .12s;
        padding: 0;
      }
      #${PINE_COPY_BTN_ID}:hover {
        color: var(--tv-color-toolbar-button-text-hover, #d1d4dc);
        background: var(--tv-color-toolbar-button-background-hover, rgba(255, 255, 255, .06));
      }
      #${PINE_COPY_BTN_ID}:active { color: var(--tv-color-toolbar-button-text-active, #fff); }
      #${PINE_COPY_BTN_ID} svg {
        inline-size: 20px;
        block-size: 20px;
        fill: currentColor;
      }
      #${PINE_COPY_BTN_ID}.copied { color: #26a69a !important; }
      #${PINE_COPY_BTN_ID}.fail { color: #ef5350 !important; }
    `);
  }

  function createPineLogButton() {
    const btn = document.createElement('div');
    btn.id = PINE_COPY_BTN_ID;
    btn.setAttribute('data-role', 'button');
    btn.setAttribute('title', '复制 Pine Log');
    btn.className = 'apply-common-tooltip';
    btn.innerHTML = PINE_COPY_ICON;
    btn.addEventListener('click', onCopyPineLog);
    return btn;
  }

  function tryInjectPineLogButton() {
    if (document.getElementById(PINE_COPY_BTN_ID)) return;
    const searchBtn = document.querySelector('[data-name="button-open-search-input"]');
    if (!searchBtn?.parentNode) return;
    searchBtn.parentNode.insertBefore(createPineLogButton(), searchBtn);
  }

  function findPineLogScrollableAncestor() {
    const virtualScroll = document.querySelector('div[class*="virtualScroll"]');
    if (!virtualScroll) return null;
    let el = virtualScroll.parentElement;
    while (el && el !== document.body) {
      const style = getComputedStyle(el);
      if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 5) return el;
      el = el.parentElement;
    }
    return null;
  }

  function readVisiblePineLogMessages() {
    const msgs = [];
    document.querySelectorAll('div[data-index]').forEach(row => {
      const span = row.querySelector('span[class*="msg-"]');
      if (!span) return;
      const index = parseInt(row.getAttribute('data-index'), 10);
      const text = span.textContent.trim();
      if (Number.isFinite(index) && text) msgs.push({ index, text });
    });
    return msgs;
  }

  async function waitForPineLogRender(prevMaxIndex, timeout = 500) {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      await sleep(30);
      const msgs = readVisiblePineLogMessages();
      const curMax = msgs.reduce((max, row) => Math.max(max, row.index), -1);
      if (curMax > prevMaxIndex) return;
    }
  }

  async function collectAllPineLogs() {
    const scrollEl = findPineLogScrollableAncestor();
    if (!scrollEl) {
      const msgs = readVisiblePineLogMessages();
      return msgs.length ? msgs.sort((a, b) => a.index - b.index).map(m => m.text).join('\n') : null;
    }

    const collected = new Map();
    const originalTop = scrollEl.scrollTop;
    scrollEl.scrollTop = 0;
    await sleep(200);
    readVisiblePineLogMessages().forEach(m => collected.set(m.index, m.text));

    const step = Math.max(Math.floor(scrollEl.clientHeight * 0.4), 20);
    let noNewCount = 0;

    for (let i = 0; i < 2000; i++) {
      const prevSize = collected.size;
      const prevMaxIndex = [...collected.keys()].reduce((max, index) => Math.max(max, index), -1);

      scrollEl.scrollTop += step;
      await waitForPineLogRender(prevMaxIndex, 400);
      readVisiblePineLogMessages().forEach(m => collected.set(m.index, m.text));

      const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight;
      if (scrollEl.scrollTop >= maxScroll - 2) {
        await sleep(150);
        readVisiblePineLogMessages().forEach(m => collected.set(m.index, m.text));
        break;
      }

      if (collected.size === prevSize) {
        noNewCount++;
        if (noNewCount > 8) break;
      } else {
        noNewCount = 0;
      }
    }

    const indices = [...collected.keys()].sort((a, b) => a - b);
    const gaps = [];
    for (let i = 0; i < indices.length - 1; i++) {
      if (indices[i + 1] - indices[i] > 1) gaps.push({ from: indices[i], to: indices[i + 1] });
    }

    if (gaps.length > 0) {
      log('Pine Log 检测到缺口,补扫:', gaps);
      const firstRow = document.querySelector('div[data-index]');
      const rowHeight = firstRow ? firstRow.getBoundingClientRect().height : 56;
      for (const gap of gaps) {
        scrollEl.scrollTop = gap.from * rowHeight;
        await sleep(200);
        readVisiblePineLogMessages().forEach(m => collected.set(m.index, m.text));
      }
    }

    scrollEl.scrollTop = originalTop;
    return collected.size
      ? [...collected.entries()].sort((a, b) => a[0] - b[0]).map(entry => entry[1]).join('\n')
      : null;
  }

  function resetPineLogButton(btn) {
    if (!btn) return;
    btn.classList.remove('copied', 'fail');
    btn.innerHTML = PINE_COPY_ICON;
    btn.title = '复制 Pine Log';
  }

  async function onCopyPineLog(event) {
    event?.stopPropagation?.();
    const btn = document.getElementById(PINE_COPY_BTN_ID);
    if (!btn || btn.classList.contains('copied')) return;

    btn.classList.add('copied');
    btn.innerHTML = PINE_CHECK_ICON;
    btn.title = '收集中...';

    try {
      const text = await collectAllPineLogs();
      if (!text) {
        btn.classList.remove('copied');
        btn.classList.add('fail');
        btn.title = '未找到 Log 内容';
        setTimeout(() => resetPineLogButton(btn), 2000);
        return;
      }

      await copyText(text);
      btn.title = `已复制 ${text.split('\n').length} 行`;
      showToast(btn.title);
    } catch (e) {
      console.error('[TV-OPT Pine Log]', e);
      btn.classList.remove('copied');
      btn.classList.add('fail');
      btn.title = '复制失败: ' + e.message;
    }

    setTimeout(() => resetPineLogButton(btn), 2500);
  }

  function initPineLogCopier() {
    if (!/\/chart\//.test(location.pathname)) return;
    injectPineLogStyles();
    let debounce = null;
    const observer = new MutationObserver(() => {
      if (document.getElementById(PINE_COPY_BTN_ID)) return;
      clearTimeout(debounce);
      debounce = setTimeout(tryInjectPineLogButton, 500);
    });
    observer.observe(document.body, { childList: true, subtree: true });
    [2000, 4000, 7000, 12000].forEach(ms => setTimeout(tryInjectPineLogButton, ms));
    log('Pine Log Copier 已加载');
  }

  function onReady(fn) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', fn, { once: true });
    } else {
      fn();
    }
  }

  onReady(initPaywallToast);
  window.addEventListener('load', () => {
    setTimeout(initToolbarShortcuts, 1000);
    setTimeout(initWatchlistOpt, 2000);
    setTimeout(initPineLogCopier, 2000);
  }, { once: true });
})();